Skip to content

Commit 7c89137

Browse files
committed
ENH: Added more options for formats.style.bar
You can now have the bar be centered on zero or midpoint value (in addition to the already existing way of having the min value at the left side of the cell)
1 parent ca8ef49 commit 7c89137

File tree

2 files changed

+206
-10
lines changed

2 files changed

+206
-10
lines changed

pandas/formats/style.py

Lines changed: 121 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"or `pip install Jinja2`"
1818
raise ImportError(msg)
1919

20-
from pandas.types.common import is_float, is_string_like
20+
from pandas.types.common import is_float, is_string_like, is_list_like
2121

2222
import numpy as np
2323
import pandas as pd
@@ -857,38 +857,151 @@ def set_properties(self, subset=None, **kwargs):
857857
return self.applymap(f, subset=subset)
858858

859859
@staticmethod
860-
def _bar(s, color, width):
861-
normed = width * (s - s.min()) / (s.max() - s.min())
860+
def _bar_left(s, color, width):
861+
"""
862+
The minimum value is aligned at the left of the cell
863+
.. versionadded:: 0.17.1
862864
865+
Parameters
866+
----------
867+
color: 2-tuple/list, of [``color_negative``, ``color_positive``]
868+
869+
Returns
870+
-------
871+
self : Styler
872+
"""
873+
normed = width * (s - s.min()) / (s.max() - s.min())
874+
zero_normed = width * (0 - s.min()) / (s.max() - s.min())
863875
base = 'width: 10em; height: 80%;'
864876
attrs = (base + 'background: linear-gradient(90deg,{c} {w}%, '
865877
'transparent 0%)')
866-
return [attrs.format(c=color, w=x) if x != 0 else base for x in normed]
867878

868-
def bar(self, subset=None, axis=0, color='#d65f5f', width=100):
879+
return [base if x == 0 else attrs.format(c=color[0], w=x)
880+
if x < zero_normed
881+
else attrs.format(c=color[1], w=x) if x >= zero_normed
882+
else base for x in normed]
883+
884+
@staticmethod
885+
def _bar_center_zero(s, color, width):
886+
"""
887+
Creates a bar chart where the zero is centered in the cell
888+
.. versionadded:: 0.19.2
889+
890+
Parameters
891+
----------
892+
color: 2-tuple/list, of [``color_negative``, ``color_positive``]
893+
894+
Returns
895+
-------
896+
self : Styler
897+
"""
898+
899+
# Either the min or the max should reach the edge
900+
# (50%, centered on zero)
901+
m = max(abs(s.min()), abs(s.max()))
902+
903+
normed = s * 50 * width / (100 * m)
904+
905+
base = 'width: 10em; height: 80%;'
906+
907+
attrs_neg = (base + 'background: linear-gradient(90deg, transparent 0%, transparent {w}%, {c} {w}%, '
908+
'{c} 50%, transparent 50%)')
909+
910+
attrs_pos = (base + 'background: linear-gradient(90deg, transparent 0%, transparent 50%, {c} 50%, {c} {w}%, '
911+
'transparent {w}%)')
912+
913+
return [attrs_pos.format(c=color[1], w=(50 + x)) if x >= 0
914+
else attrs_neg.format(c=color[0], w=(50 + x))
915+
for x in normed]
916+
917+
@staticmethod
918+
def _bar_center_mid(s, color, width):
919+
"""
920+
Creates a bar chart where the midpoint is centered in the cell
921+
.. versionadded:: 0.19.2
922+
923+
Parameters
924+
----------
925+
color: 2-tuple/list, of [``color_negative``, ``color_positive``]
926+
927+
Returns
928+
-------
929+
self : Styler
930+
"""
931+
932+
if s.min() >= 0:
933+
# In this case, we place the zero at the left, and the max() should
934+
# be at width
935+
zero = 0
936+
slope = width / s.max()
937+
elif s.max() <= 0:
938+
# In this case, we place the zero at the right, and the min()
939+
# should be at 100-width
940+
zero = 100
941+
slope = width / -s.min()
942+
else:
943+
slope = width / (s.max() - s.min())
944+
zero = (100 + width) / 2 - slope * s.max()
945+
946+
normed = zero + slope * s
947+
948+
base = 'width: 10em; height: 80%;'
949+
950+
attrs_neg = (base + 'background: linear-gradient(90deg, transparent 0%, transparent {w}%, {c} {w}%, '
951+
'{c} {zero}%, transparent {zero}%)')
952+
953+
attrs_pos = (base + 'background: linear-gradient(90deg, transparent 0%, transparent {zero}%, {c} {zero}%, {c} {w}%, '
954+
'transparent {w}%)')
955+
956+
return [attrs_pos.format(c=color[1], zero=zero, w=x) if x > zero
957+
else attrs_neg.format(c=color[0], zero=zero, w=x)
958+
for x in normed]
959+
960+
def bar(self, subset=None, align='left', axis=0, color='#d65f5f', width=100):
869961
"""
870962
Color the background ``color`` proptional to the values in each column.
871963
Excludes non-numeric data by default.
872-
873964
.. versionadded:: 0.17.1
874965
875966
Parameters
876967
----------
877968
subset: IndexSlice, default None
878969
a valid slice for ``data`` to limit the style application to
879970
axis: int
880-
color: str
971+
color: str (for align='left') or 2-tuple/list (for align='zero', 'mid')
972+
If a str is passed, the color is the same for both
973+
negative and positive numbers. If 2-tuple/list is used, the
974+
first element is the color_negative and the second is the
975+
color_positive (eg: ['d65f5f', '5fba7d'])
881976
width: float
882977
A number between 0 or 100. The largest value will cover ``width``
883978
percent of the cell's width
979+
align : str, default 'left'
980+
.. versionadded:: 0.19.2
981+
- 'left' : the min value starts at the left of the cell
982+
- 'zero' : a value of zero is located at the center of the cell
983+
- 'mid' : the center of the cell is at (max-min)/2, or
984+
if values are all negative (positive) the zero is aligned
985+
at the right (left) of the cell
884986
885987
Returns
886988
-------
887989
self : Styler
888990
"""
889991
subset = _maybe_numeric_slice(self.data, subset)
890992
subset = _non_reducing_slice(subset)
891-
self.apply(self._bar, subset=subset, axis=axis, color=color,
993+
994+
if not(is_list_like(color)):
995+
color = [color, color]
996+
997+
if align == 'left':
998+
self.apply(self._bar_left, subset=subset, axis=axis, color=color,
999+
width=width)
1000+
elif align == 'zero':
1001+
self.apply(self._bar_center_zero, subset=subset, axis=axis, color=color,
1002+
width=width)
1003+
elif align == 'mid':
1004+
self.apply(self._bar_center_mid, subset=subset, axis=axis, color=color,
8921005
width=width)
8931006
return self
8941007

pandas/tests/formats/test_style.py

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ def test_empty(self):
265265
{'props': [['', '']], 'selector': 'row1_col0'}]
266266
self.assertEqual(result, expected)
267267

268-
def test_bar(self):
268+
def test_bar_align_left(self):
269269
df = pd.DataFrame({'A': [0, 1, 2]})
270270
result = df.style.bar()._compute().ctx
271271
expected = {
@@ -298,7 +298,7 @@ def test_bar(self):
298298
result = df.style.bar(color='red', width=50)._compute().ctx
299299
self.assertEqual(result, expected)
300300

301-
def test_bar_0points(self):
301+
def test_bar_align_left_0points(self):
302302
df = pd.DataFrame([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
303303
result = df.style.bar()._compute().ctx
304304
expected = {(0, 0): ['width: 10em', ' height: 80%'],
@@ -348,6 +348,89 @@ def test_bar_0points(self):
348348
', transparent 0%)']}
349349
self.assertEqual(result, expected)
350350

351+
def test_bar_align_zero_pos_and_neg(self):
352+
df = pd.DataFrame({'A': [-10, 0, 20, 90]})
353+
354+
result = df.style.bar(align='zero', color=[
355+
'#d65f5f', '#5fba7d'], width=90)._compute().ctx
356+
357+
expected = {(0, 0): ['width: 10em',
358+
' height: 80%',
359+
'background: linear-gradient(90deg, transparent 0%, transparent 45.0%, #d65f5f 45.0%, #d65f5f 50%, transparent 50%)'],
360+
(1, 0): ['width: 10em',
361+
' height: 80%',
362+
'background: linear-gradient(90deg, transparent 0%, transparent 50%, #5fba7d 50%, #5fba7d 50.0%, transparent 50.0%)'],
363+
(2, 0): ['width: 10em',
364+
' height: 80%',
365+
'background: linear-gradient(90deg, transparent 0%, transparent 50%, #5fba7d 50%, #5fba7d 60.0%, transparent 60.0%)'],
366+
(3, 0): ['width: 10em',
367+
' height: 80%',
368+
'background: linear-gradient(90deg, transparent 0%, transparent 50%, #5fba7d 50%, #5fba7d 95.0%, transparent 95.0%)']}
369+
self.assertEqual(result, expected)
370+
371+
def test_bar_align_mid_pos_and_neg(self):
372+
df = pd.DataFrame({'A': [-10, 0, 20, 90]})
373+
374+
result = df.style.bar(align='mid', color=[
375+
'#d65f5f', '#5fba7d'])._compute().ctx
376+
377+
expected = {(0, 0): ['width: 10em',
378+
' height: 80%',
379+
'background: linear-gradient(90deg, transparent 0%, transparent 0.0%, #d65f5f 0.0%, #d65f5f 10.0%, transparent 10.0%)'],
380+
(1, 0): ['width: 10em',
381+
' height: 80%',
382+
'background: linear-gradient(90deg, transparent 0%, transparent 10.0%, #d65f5f 10.0%, #d65f5f 10.0%, transparent 10.0%)'],
383+
(2, 0): ['width: 10em',
384+
' height: 80%',
385+
'background: linear-gradient(90deg, transparent 0%, transparent 10.0%, #5fba7d 10.0%, #5fba7d 30.0%, transparent 30.0%)'],
386+
(3, 0): ['width: 10em',
387+
' height: 80%',
388+
'background: linear-gradient(90deg, transparent 0%, transparent 10.0%, #5fba7d 10.0%, #5fba7d 100.0%, transparent 100.0%)']}
389+
390+
self.assertEqual(result, expected)
391+
392+
def test_bar_align_mid_all_pos(self):
393+
df = pd.DataFrame({'A': [10, 20, 50, 100]})
394+
395+
result = df.style.bar(align='mid', color=[
396+
'#d65f5f', '#5fba7d'])._compute().ctx
397+
398+
expected = {(0, 0): ['width: 10em',
399+
' height: 80%',
400+
'background: linear-gradient(90deg, transparent 0%, transparent 0%, #5fba7d 0%, #5fba7d 10.0%, transparent 10.0%)'],
401+
(1, 0): ['width: 10em',
402+
' height: 80%',
403+
'background: linear-gradient(90deg, transparent 0%, transparent 0%, #5fba7d 0%, #5fba7d 20.0%, transparent 20.0%)'],
404+
(2, 0): ['width: 10em',
405+
' height: 80%',
406+
'background: linear-gradient(90deg, transparent 0%, transparent 0%, #5fba7d 0%, #5fba7d 50.0%, transparent 50.0%)'],
407+
(3, 0): ['width: 10em',
408+
' height: 80%',
409+
'background: linear-gradient(90deg, transparent 0%, transparent 0%, #5fba7d 0%, #5fba7d 100.0%, transparent 100.0%)']}
410+
411+
self.assertEqual(result, expected)
412+
413+
def test_bar_align_mid_all_neg(self):
414+
df = pd.DataFrame({'A': [-100, -60, -30, -20]})
415+
416+
result = df.style.bar(align='mid', color=[
417+
'#d65f5f', '#5fba7d'])._compute().ctx
418+
419+
expected = {(0, 0): ['width: 10em',
420+
' height: 80%',
421+
'background: linear-gradient(90deg, transparent 0%, transparent 0.0%, #d65f5f 0.0%, #d65f5f 100%, transparent 100%)'],
422+
(1, 0): ['width: 10em',
423+
' height: 80%',
424+
'background: linear-gradient(90deg, transparent 0%, transparent 40.0%, #d65f5f 40.0%, #d65f5f 100%, transparent 100%)'],
425+
(2, 0): ['width: 10em',
426+
' height: 80%',
427+
'background: linear-gradient(90deg, transparent 0%, transparent 70.0%, #d65f5f 70.0%, #d65f5f 100%, transparent 100%)'],
428+
(3, 0): ['width: 10em',
429+
' height: 80%',
430+
'background: linear-gradient(90deg, transparent 0%, transparent 80.0%, #d65f5f 80.0%, #d65f5f 100%, transparent 100%)']}
431+
432+
self.assertEqual(result, expected)
433+
351434
def test_highlight_null(self, null_color='red'):
352435
df = pd.DataFrame({'A': [0, np.nan]})
353436
result = df.style.highlight_null()._compute().ctx

0 commit comments

Comments
 (0)