Skip to content

Commit d1eafbb

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 7b84eb6 commit d1eafbb

File tree

2 files changed

+206
-10
lines changed

2 files changed

+206
-10
lines changed

pandas/formats/style.py

+121-8
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
@@ -851,38 +851,151 @@ def set_properties(self, subset=None, **kwargs):
851851
return self.applymap(f, subset=subset)
852852

853853
@staticmethod
854-
def _bar(s, color, width):
855-
normed = width * (s - s.min()) / (s.max() - s.min())
854+
def _bar_left(s, color, width):
855+
"""
856+
The minimum value is aligned at the left of the cell
857+
.. versionadded:: 0.17.1
856858
859+
Parameters
860+
----------
861+
color: 2-tuple/list, of [``color_negative``, ``color_positive``]
862+
863+
Returns
864+
-------
865+
self : Styler
866+
"""
867+
normed = width * (s - s.min()) / (s.max() - s.min())
868+
zero_normed = width * (0 - s.min()) / (s.max() - s.min())
857869
base = 'width: 10em; height: 80%;'
858870
attrs = (base + 'background: linear-gradient(90deg,{c} {w}%, '
859871
'transparent 0%)')
860-
return [attrs.format(c=color, w=x) if x != 0 else base for x in normed]
861872

862-
def bar(self, subset=None, axis=0, color='#d65f5f', width=100):
873+
return [base if x == 0 else attrs.format(c=color[0], w=x)
874+
if x < zero_normed
875+
else attrs.format(c=color[1], w=x) if x >= zero_normed
876+
else base for x in normed]
877+
878+
@staticmethod
879+
def _bar_center_zero(s, color, width):
880+
"""
881+
Creates a bar chart where the zero is centered in the cell
882+
.. versionadded:: 0.19.2
883+
884+
Parameters
885+
----------
886+
color: 2-tuple/list, of [``color_negative``, ``color_positive``]
887+
888+
Returns
889+
-------
890+
self : Styler
891+
"""
892+
893+
# Either the min or the max should reach the edge
894+
# (50%, centered on zero)
895+
m = max(abs(s.min()), abs(s.max()))
896+
897+
normed = s * 50 * width / (100 * m)
898+
899+
base = 'width: 10em; height: 80%;'
900+
901+
attrs_neg = (base + 'background: linear-gradient(90deg, transparent 0%, transparent {w}%, {c} {w}%, '
902+
'{c} 50%, transparent 50%)')
903+
904+
attrs_pos = (base + 'background: linear-gradient(90deg, transparent 0%, transparent 50%, {c} 50%, {c} {w}%, '
905+
'transparent {w}%)')
906+
907+
return [attrs_pos.format(c=color[1], w=(50 + x)) if x >= 0
908+
else attrs_neg.format(c=color[0], w=(50 + x))
909+
for x in normed]
910+
911+
@staticmethod
912+
def _bar_center_mid(s, color, width):
913+
"""
914+
Creates a bar chart where the midpoint is centered in the cell
915+
.. versionadded:: 0.19.2
916+
917+
Parameters
918+
----------
919+
color: 2-tuple/list, of [``color_negative``, ``color_positive``]
920+
921+
Returns
922+
-------
923+
self : Styler
924+
"""
925+
926+
if s.min() >= 0:
927+
# In this case, we place the zero at the left, and the max() should
928+
# be at width
929+
zero = 0
930+
slope = width / s.max()
931+
elif s.max() <= 0:
932+
# In this case, we place the zero at the right, and the min()
933+
# should be at 100-width
934+
zero = 100
935+
slope = width / -s.min()
936+
else:
937+
slope = width / (s.max() - s.min())
938+
zero = (100 + width) / 2 - slope * s.max()
939+
940+
normed = zero + slope * s
941+
942+
base = 'width: 10em; height: 80%;'
943+
944+
attrs_neg = (base + 'background: linear-gradient(90deg, transparent 0%, transparent {w}%, {c} {w}%, '
945+
'{c} {zero}%, transparent {zero}%)')
946+
947+
attrs_pos = (base + 'background: linear-gradient(90deg, transparent 0%, transparent {zero}%, {c} {zero}%, {c} {w}%, '
948+
'transparent {w}%)')
949+
950+
return [attrs_pos.format(c=color[1], zero=zero, w=x) if x > zero
951+
else attrs_neg.format(c=color[0], zero=zero, w=x)
952+
for x in normed]
953+
954+
def bar(self, subset=None, align='left', axis=0, color='#d65f5f', width=100):
863955
"""
864956
Color the background ``color`` proptional to the values in each column.
865957
Excludes non-numeric data by default.
866-
867958
.. versionadded:: 0.17.1
868959
869960
Parameters
870961
----------
871962
subset: IndexSlice, default None
872963
a valid slice for ``data`` to limit the style application to
873964
axis: int
874-
color: str
965+
color: str (for align='left') or 2-tuple/list (for align='zero', 'mid')
966+
If a str is passed, the color is the same for both
967+
negative and positive numbers. If 2-tuple/list is used, the
968+
first element is the color_negative and the second is the
969+
color_positive (eg: ['d65f5f', '5fba7d'])
875970
width: float
876971
A number between 0 or 100. The largest value will cover ``width``
877972
percent of the cell's width
973+
align : str, default 'left'
974+
.. versionadded:: 0.19.2
975+
- 'left' : the min value starts at the left of the cell
976+
- 'zero' : a value of zero is located at the center of the cell
977+
- 'mid' : the center of the cell is at (max-min)/2, or
978+
if values are all negative (positive) the zero is aligned
979+
at the right (left) of the cell
878980
879981
Returns
880982
-------
881983
self : Styler
882984
"""
883985
subset = _maybe_numeric_slice(self.data, subset)
884986
subset = _non_reducing_slice(subset)
885-
self.apply(self._bar, subset=subset, axis=axis, color=color,
987+
988+
if not(is_list_like(color)):
989+
color = [color, color]
990+
991+
if align == 'left':
992+
self.apply(self._bar_left, subset=subset, axis=axis, color=color,
993+
width=width)
994+
elif align == 'zero':
995+
self.apply(self._bar_center_zero, subset=subset, axis=axis, color=color,
996+
width=width)
997+
elif align == 'mid':
998+
self.apply(self._bar_center_mid, subset=subset, axis=axis, color=color,
886999
width=width)
8871000
return self
8881001

pandas/tests/formats/test_style.py

+85-2
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)