Skip to content

Commit c9eda0e

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 c5f219a commit c9eda0e

File tree

2 files changed

+207
-11
lines changed

2 files changed

+207
-11
lines changed

pandas/formats/style.py

Lines changed: 122 additions & 9 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
@@ -781,7 +781,7 @@ def background_gradient(self, cmap='PuBu', low=0, high=0, axis=0,
781781
low, high: float
782782
compress the range by these values.
783783
axis: int or str
784-
1 or 'columns' for colunwise, 0 or 'index' for rowwise
784+
1 or 'columns' for columnwise, 0 or 'index' for rowwise
785785
subset: IndexSlice
786786
a valid slice for ``data`` to limit the style application to
787787
@@ -846,38 +846,151 @@ def set_properties(self, subset=None, **kwargs):
846846
return self.applymap(f, subset=subset)
847847

848848
@staticmethod
849-
def _bar(s, color, width):
850-
normed = width * (s - s.min()) / (s.max() - s.min())
849+
def _bar_left(s, color, width):
850+
"""
851+
The minimum value is aligned at the left of the cell
852+
.. versionadded:: 0.17.1
851853
854+
Parameters
855+
----------
856+
color: 2-tuple/list, of [``color_negative``, ``color_positive``]
857+
858+
Returns
859+
-------
860+
self : Styler
861+
"""
862+
normed = width * (s - s.min()) / (s.max() - s.min())
863+
zero_normed = width * (0 - s.min()) / (s.max() - s.min())
852864
base = 'width: 10em; height: 80%;'
853865
attrs = (base + 'background: linear-gradient(90deg,{c} {w}%, '
854866
'transparent 0%)')
855-
return [attrs.format(c=color, w=x) if x != 0 else base for x in normed]
856867

857-
def bar(self, subset=None, axis=0, color='#d65f5f', width=100):
868+
return [base if x == 0 else attrs.format(c=color[0], w=x)
869+
if x < zero_normed
870+
else attrs.format(c=color[1], w=x) if x >= zero_normed
871+
else base for x in normed]
872+
873+
@staticmethod
874+
def _bar_center_zero(s, color, width):
875+
"""
876+
Creates a bar chart where the zero is centered in the cell
877+
.. versionadded:: 0.19.2
878+
879+
Parameters
880+
----------
881+
color: 2-tuple/list, of [``color_negative``, ``color_positive``]
882+
883+
Returns
884+
-------
885+
self : Styler
886+
"""
887+
888+
# Either the min or the max should reach the edge
889+
# (50%, centered on zero)
890+
m = max(abs(s.min()), abs(s.max()))
891+
892+
normed = s * 50 * width / (100 * m)
893+
894+
base = 'width: 10em; height: 80%;'
895+
896+
attrs_neg = (base + 'background: linear-gradient(90deg, transparent 0%, transparent {w}%, {c} {w}%, '
897+
'{c} 50%, transparent 50%)')
898+
899+
attrs_pos = (base + 'background: linear-gradient(90deg, transparent 0%, transparent 50%, {c} 50%, {c} {w}%, '
900+
'transparent {w}%)')
901+
902+
return [attrs_pos.format(c=color[1], w=(50 + x)) if x >= 0
903+
else attrs_neg.format(c=color[0], w=(50 + x))
904+
for x in normed]
905+
906+
@staticmethod
907+
def _bar_center_mid(s, color, width):
908+
"""
909+
Creates a bar chart where the midpoint is centered in the cell
910+
.. versionadded:: 0.19.2
911+
912+
Parameters
913+
----------
914+
color: 2-tuple/list, of [``color_negative``, ``color_positive``]
915+
916+
Returns
917+
-------
918+
self : Styler
919+
"""
920+
921+
if s.min() >= 0:
922+
# In this case, we place the zero at the left, and the max() should
923+
# be at width
924+
zero = 0
925+
slope = width / s.max()
926+
elif s.max() <= 0:
927+
# In this case, we place the zero at the right, and the min()
928+
# should be at 100-width
929+
zero = 100
930+
slope = width / -s.min()
931+
else:
932+
slope = width / (s.max() - s.min())
933+
zero = (100 + width) / 2 - slope * s.max()
934+
935+
normed = zero + slope * s
936+
937+
base = 'width: 10em; height: 80%;'
938+
939+
attrs_neg = (base + 'background: linear-gradient(90deg, transparent 0%, transparent {w}%, {c} {w}%, '
940+
'{c} {zero}%, transparent {zero}%)')
941+
942+
attrs_pos = (base + 'background: linear-gradient(90deg, transparent 0%, transparent {zero}%, {c} {zero}%, {c} {w}%, '
943+
'transparent {w}%)')
944+
945+
return [attrs_pos.format(c=color[1], zero=zero, w=x) if x > zero
946+
else attrs_neg.format(c=color[0], zero=zero, w=x)
947+
for x in normed]
948+
949+
def bar(self, subset=None, align='left', axis=0, color='#d65f5f', width=100):
858950
"""
859951
Color the background ``color`` proptional to the values in each column.
860952
Excludes non-numeric data by default.
861-
862953
.. versionadded:: 0.17.1
863954
864955
Parameters
865956
----------
866957
subset: IndexSlice, default None
867958
a valid slice for ``data`` to limit the style application to
868959
axis: int
869-
color: str
960+
color: str (for align='left') or 2-tuple/list (for align='zero', 'mid')
961+
If a str is passed, the color is the same for both
962+
negative and positive numbers. If 2-tuple/list is used, the
963+
first element is the color_negative and the second is the
964+
color_positive (eg: ['d65f5f', '5fba7d'])
870965
width: float
871966
A number between 0 or 100. The largest value will cover ``width``
872967
percent of the cell's width
968+
align : str, default 'left'
969+
.. versionadded:: 0.19.2
970+
- 'left' : the min value starts at the left of the cell
971+
- 'zero' : a value of zero is located at the center of the cell
972+
- 'mid' : the center of the cell is at (max-min)/2, or
973+
if values are all negative (positive) the zero is aligned
974+
at the right (left) of the cell
873975
874976
Returns
875977
-------
876978
self : Styler
877979
"""
878980
subset = _maybe_numeric_slice(self.data, subset)
879981
subset = _non_reducing_slice(subset)
880-
self.apply(self._bar, subset=subset, axis=axis, color=color,
982+
983+
if not(is_list_like(color)):
984+
color = [color, color]
985+
986+
if align == 'left':
987+
self.apply(self._bar_left, subset=subset, axis=axis, color=color,
988+
width=width)
989+
elif align == 'zero':
990+
self.apply(self._bar_center_zero, subset=subset, axis=axis, color=color,
991+
width=width)
992+
elif align == 'mid':
993+
self.apply(self._bar_center_mid, subset=subset, axis=axis, color=color,
881994
width=width)
882995
return self
883996

pandas/tests/formats/test_style.py

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

289-
def test_bar(self):
289+
def test_bar_align_left(self):
290290
df = pd.DataFrame({'A': [0, 1, 2]})
291291
result = df.style.bar()._compute().ctx
292292
expected = {
@@ -319,7 +319,7 @@ def test_bar(self):
319319
result = df.style.bar(color='red', width=50)._compute().ctx
320320
self.assertEqual(result, expected)
321321

322-
def test_bar_0points(self):
322+
def test_bar_align_left_0points(self):
323323
df = pd.DataFrame([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
324324
result = df.style.bar()._compute().ctx
325325
expected = {(0, 0): ['width: 10em', ' height: 80%'],
@@ -369,6 +369,89 @@ def test_bar_0points(self):
369369
', transparent 0%)']}
370370
self.assertEqual(result, expected)
371371

372+
def test_bar_align_zero_pos_and_neg(self):
373+
df = pd.DataFrame({'A': [-10, 0, 20, 90]})
374+
375+
result = df.style.bar(align='zero', color=[
376+
'#d65f5f', '#5fba7d'], width=90)._compute().ctx
377+
378+
expected = {(0, 0): ['width: 10em',
379+
' height: 80%',
380+
'background: linear-gradient(90deg, transparent 0%, transparent 45.0%, #d65f5f 45.0%, #d65f5f 50%, transparent 50%)'],
381+
(1, 0): ['width: 10em',
382+
' height: 80%',
383+
'background: linear-gradient(90deg, transparent 0%, transparent 50%, #5fba7d 50%, #5fba7d 50.0%, transparent 50.0%)'],
384+
(2, 0): ['width: 10em',
385+
' height: 80%',
386+
'background: linear-gradient(90deg, transparent 0%, transparent 50%, #5fba7d 50%, #5fba7d 60.0%, transparent 60.0%)'],
387+
(3, 0): ['width: 10em',
388+
' height: 80%',
389+
'background: linear-gradient(90deg, transparent 0%, transparent 50%, #5fba7d 50%, #5fba7d 95.0%, transparent 95.0%)']}
390+
self.assertEqual(result, expected)
391+
392+
def test_bar_align_mid_pos_and_neg(self):
393+
df = pd.DataFrame({'A': [-10, 0, 20, 90]})
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.0%, #d65f5f 0.0%, #d65f5f 10.0%, transparent 10.0%)'],
401+
(1, 0): ['width: 10em',
402+
' height: 80%',
403+
'background: linear-gradient(90deg, transparent 0%, transparent 10.0%, #d65f5f 10.0%, #d65f5f 10.0%, transparent 10.0%)'],
404+
(2, 0): ['width: 10em',
405+
' height: 80%',
406+
'background: linear-gradient(90deg, transparent 0%, transparent 10.0%, #5fba7d 10.0%, #5fba7d 30.0%, transparent 30.0%)'],
407+
(3, 0): ['width: 10em',
408+
' height: 80%',
409+
'background: linear-gradient(90deg, transparent 0%, transparent 10.0%, #5fba7d 10.0%, #5fba7d 100.0%, transparent 100.0%)']}
410+
411+
self.assertEqual(result, expected)
412+
413+
def test_bar_align_mid_all_pos(self):
414+
df = pd.DataFrame({'A': [10, 20, 50, 100]})
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%, #5fba7d 0%, #5fba7d 10.0%, transparent 10.0%)'],
422+
(1, 0): ['width: 10em',
423+
' height: 80%',
424+
'background: linear-gradient(90deg, transparent 0%, transparent 0%, #5fba7d 0%, #5fba7d 20.0%, transparent 20.0%)'],
425+
(2, 0): ['width: 10em',
426+
' height: 80%',
427+
'background: linear-gradient(90deg, transparent 0%, transparent 0%, #5fba7d 0%, #5fba7d 50.0%, transparent 50.0%)'],
428+
(3, 0): ['width: 10em',
429+
' height: 80%',
430+
'background: linear-gradient(90deg, transparent 0%, transparent 0%, #5fba7d 0%, #5fba7d 100.0%, transparent 100.0%)']}
431+
432+
self.assertEqual(result, expected)
433+
434+
def test_bar_align_mid_all_neg(self):
435+
df = pd.DataFrame({'A': [-100, -60, -30, -20]})
436+
437+
result = df.style.bar(align='mid', color=[
438+
'#d65f5f', '#5fba7d'])._compute().ctx
439+
440+
expected = {(0, 0): ['width: 10em',
441+
' height: 80%',
442+
'background: linear-gradient(90deg, transparent 0%, transparent 0.0%, #d65f5f 0.0%, #d65f5f 100%, transparent 100%)'],
443+
(1, 0): ['width: 10em',
444+
' height: 80%',
445+
'background: linear-gradient(90deg, transparent 0%, transparent 40.0%, #d65f5f 40.0%, #d65f5f 100%, transparent 100%)'],
446+
(2, 0): ['width: 10em',
447+
' height: 80%',
448+
'background: linear-gradient(90deg, transparent 0%, transparent 70.0%, #d65f5f 70.0%, #d65f5f 100%, transparent 100%)'],
449+
(3, 0): ['width: 10em',
450+
' height: 80%',
451+
'background: linear-gradient(90deg, transparent 0%, transparent 80.0%, #d65f5f 80.0%, #d65f5f 100%, transparent 100%)']}
452+
453+
self.assertEqual(result, expected)
454+
372455
def test_highlight_null(self, null_color='red'):
373456
df = pd.DataFrame({'A': [0, np.nan]})
374457
result = df.style.highlight_null()._compute().ctx

0 commit comments

Comments
 (0)