Skip to content

Commit f1e4fb0

Browse files
authored
ENH: Styler.bar extended to allow centering about the mean, value or callable (#42301)
1 parent 3fb6d21 commit f1e4fb0

File tree

4 files changed

+325
-123
lines changed

4 files changed

+325
-123
lines changed

doc/source/user_guide/style.ipynb

+13-9
Original file line numberDiff line numberDiff line change
@@ -1190,9 +1190,9 @@
11901190
"cell_type": "markdown",
11911191
"metadata": {},
11921192
"source": [
1193-
"In version 0.20.0 the ability to customize the bar chart further was given. You can now have the `df.style.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), and you can pass a list of `[color_negative, color_positive]`.\n",
1193+
"Additional keyword arguments give more control on centering and positioning, and you can pass a list of `[color_negative, color_positive]` to highlight lower and higher values.\n",
11941194
"\n",
1195-
"Here's how you can change the above with the new `align='mid'` option:"
1195+
"Here's how you can change the above with the new `align` option, combined with setting `vmin` and `vmax` limits, the `width` of the figure, and underlying css `props` of cells, leaving space to display the text and the bars:"
11961196
]
11971197
},
11981198
{
@@ -1201,7 +1201,8 @@
12011201
"metadata": {},
12021202
"outputs": [],
12031203
"source": [
1204-
"df2.style.bar(subset=['A', 'B'], align='mid', color=['#d65f5f', '#5fba7d'])"
1204+
"df2.style.bar(align=0, vmin=-2.5, vmax=2.5, color=['#d65f5f', '#5fba7d'],\n",
1205+
" width=60, props=\"width: 120px; border-right: 1px solid black;\").format('{:.3f}', na_rep=\"\")"
12051206
]
12061207
},
12071208
{
@@ -1225,28 +1226,31 @@
12251226
"\n",
12261227
"# Test series\n",
12271228
"test1 = pd.Series([-100,-60,-30,-20], name='All Negative')\n",
1228-
"test2 = pd.Series([10,20,50,100], name='All Positive')\n",
1229-
"test3 = pd.Series([-10,-5,0,90], name='Both Pos and Neg')\n",
1229+
"test2 = pd.Series([-10,-5,0,90], name='Both Pos and Neg')\n",
1230+
"test3 = pd.Series([10,20,50,100], name='All Positive')\n",
1231+
"test4 = pd.Series([100, 103, 101, 102], name='Large Positive')\n",
1232+
"\n",
12301233
"\n",
12311234
"head = \"\"\"\n",
12321235
"<table>\n",
12331236
" <thead>\n",
12341237
" <th>Align</th>\n",
12351238
" <th>All Negative</th>\n",
1236-
" <th>All Positive</th>\n",
12371239
" <th>Both Neg and Pos</th>\n",
1240+
" <th>All Positive</th>\n",
1241+
" <th>Large Positive</th>\n",
12381242
" </thead>\n",
12391243
" </tbody>\n",
12401244
"\n",
12411245
"\"\"\"\n",
12421246
"\n",
1243-
"aligns = ['left','zero','mid']\n",
1247+
"aligns = ['left', 'right', 'zero', 'mid', 'mean', 99]\n",
12441248
"for align in aligns:\n",
12451249
" row = \"<tr><th>{}</th>\".format(align)\n",
1246-
" for series in [test1,test2,test3]:\n",
1250+
" for series in [test1,test2,test3, test4]:\n",
12471251
" s = series.copy()\n",
12481252
" s.name=''\n",
1249-
" row += \"<td>{}</td>\".format(s.to_frame().style.bar(align=align, \n",
1253+
" row += \"<td>{}</td>\".format(s.to_frame().style.hide_index().bar(align=align, \n",
12501254
" color=['#d65f5f', '#5fba7d'], \n",
12511255
" width=100).render()) #testn['width']\n",
12521256
" row += '</tr>'\n",

doc/source/whatsnew/v1.4.0.rst

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ enhancement2
3030
Other enhancements
3131
^^^^^^^^^^^^^^^^^^
3232
- :meth:`Series.sample`, :meth:`DataFrame.sample`, and :meth:`.GroupBy.sample` now accept a ``np.random.Generator`` as input to ``random_state``. A generator will be more performant, especially with ``replace=False`` (:issue:`38100`)
33+
- Additional options added to :meth:`.Styler.bar` to control alignment and display (:issue:`26070`)
3334
- :meth:`Series.ewm`, :meth:`DataFrame.ewm`, now support a ``method`` argument with a ``'table'`` option that performs the windowing operation over an entire :class:`DataFrame`. See :ref:`Window Overview <window.overview>` for performance and functional benefits (:issue:`42273`)
3435
-
3536

pandas/io/formats/style.py

+192-75
Original file line numberDiff line numberDiff line change
@@ -2042,75 +2042,16 @@ def set_properties(self, subset: Subset | None = None, **kwargs) -> Styler:
20422042
values = "".join(f"{p}: {v};" for p, v in kwargs.items())
20432043
return self.applymap(lambda x: values, subset=subset)
20442044

2045-
@staticmethod
2046-
def _bar(
2047-
s,
2048-
align: str,
2049-
colors: list[str],
2050-
width: float = 100,
2051-
vmin: float | None = None,
2052-
vmax: float | None = None,
2053-
):
2054-
"""
2055-
Draw bar chart in dataframe cells.
2056-
"""
2057-
# Get input value range.
2058-
smin = np.nanmin(s.to_numpy()) if vmin is None else vmin
2059-
smax = np.nanmax(s.to_numpy()) if vmax is None else vmax
2060-
if align == "mid":
2061-
smin = min(0, smin)
2062-
smax = max(0, smax)
2063-
elif align == "zero":
2064-
# For "zero" mode, we want the range to be symmetrical around zero.
2065-
smax = max(abs(smin), abs(smax))
2066-
smin = -smax
2067-
# Transform to percent-range of linear-gradient
2068-
normed = width * (s.to_numpy(dtype=float) - smin) / (smax - smin + 1e-12)
2069-
zero = -width * smin / (smax - smin + 1e-12)
2070-
2071-
def css_bar(start: float, end: float, color: str) -> str:
2072-
"""
2073-
Generate CSS code to draw a bar from start to end.
2074-
"""
2075-
css = "width: 10em; height: 80%;"
2076-
if end > start:
2077-
css += "background: linear-gradient(90deg,"
2078-
if start > 0:
2079-
css += f" transparent {start:.1f}%, {color} {start:.1f}%, "
2080-
e = min(end, width)
2081-
css += f"{color} {e:.1f}%, transparent {e:.1f}%)"
2082-
return css
2083-
2084-
def css(x):
2085-
if pd.isna(x):
2086-
return ""
2087-
2088-
# avoid deprecated indexing `colors[x > zero]`
2089-
color = colors[1] if x > zero else colors[0]
2090-
2091-
if align == "left":
2092-
return css_bar(0, x, color)
2093-
else:
2094-
return css_bar(min(x, zero), max(x, zero), color)
2095-
2096-
if s.ndim == 1:
2097-
return [css(x) for x in normed]
2098-
else:
2099-
return DataFrame(
2100-
[[css(x) for x in row] for row in normed],
2101-
index=s.index,
2102-
columns=s.columns,
2103-
)
2104-
21052045
def bar(
21062046
self,
21072047
subset: Subset | None = None,
21082048
axis: Axis | None = 0,
21092049
color="#d65f5f",
21102050
width: float = 100,
2111-
align: str = "left",
2051+
align: str | float | int | Callable = "mid",
21122052
vmin: float | None = None,
21132053
vmax: float | None = None,
2054+
props: str = "width: 10em;",
21142055
) -> Styler:
21152056
"""
21162057
Draw bar chart in the cell backgrounds.
@@ -2131,16 +2072,26 @@ def bar(
21312072
first element is the color_negative and the second is the
21322073
color_positive (eg: ['#d65f5f', '#5fba7d']).
21332074
width : float, default 100
2134-
A number between 0 or 100. The largest value will cover `width`
2135-
percent of the cell's width.
2136-
align : {'left', 'zero',' mid'}, default 'left'
2137-
How to align the bars with the cells.
2138-
2139-
- 'left' : the min value starts at the left of the cell.
2075+
The percentage of the cell, measured from the left, in which to draw the
2076+
bars, in [0, 100].
2077+
align : str, int, float, callable, default 'mid'
2078+
How to align the bars within the cells relative to a width adjusted center.
2079+
If string must be one of:
2080+
2081+
- 'left' : bars are drawn rightwards from the minimum data value.
2082+
- 'right' : bars are drawn leftwards from the maximum data value.
21402083
- 'zero' : a value of zero is located at the center of the cell.
2141-
- 'mid' : the center of the cell is at (max-min)/2, or
2142-
if values are all negative (positive) the zero is aligned
2143-
at the right (left) of the cell.
2084+
- 'mid' : a value of (max-min)/2 is located at the center of the cell,
2085+
or if all values are negative (positive) the zero is
2086+
aligned at the right (left) of the cell.
2087+
- 'mean' : the mean value of the data is located at the center of the cell.
2088+
2089+
If a float or integer is given this will indicate the center of the cell.
2090+
2091+
If a callable should take a 1d or 2d array and return a scalar.
2092+
2093+
.. versionchanged:: 1.4.0
2094+
21442095
vmin : float, optional
21452096
Minimum bar value, defining the left hand limit
21462097
of the bar drawing range, lower values are clipped to `vmin`.
@@ -2149,14 +2100,16 @@ def bar(
21492100
Maximum bar value, defining the right hand limit
21502101
of the bar drawing range, higher values are clipped to `vmax`.
21512102
When None (default): the maximum value of the data will be used.
2103+
props : str, optional
2104+
The base CSS of the cell that is extended to add the bar chart. Defaults to
2105+
`"width: 10em;"`
2106+
2107+
.. versionadded:: 1.4.0
21522108
21532109
Returns
21542110
-------
21552111
self : Styler
21562112
"""
2157-
if align not in ("left", "zero", "mid"):
2158-
raise ValueError("`align` must be one of {'left', 'zero',' mid'}")
2159-
21602113
if not (is_list_like(color)):
21612114
color = [color, color]
21622115
elif len(color) == 1:
@@ -2172,14 +2125,15 @@ def bar(
21722125
subset = self.data.select_dtypes(include=np.number).columns
21732126

21742127
self.apply(
2175-
self._bar,
2128+
_bar,
21762129
subset=subset,
21772130
axis=axis,
21782131
align=align,
21792132
colors=color,
2180-
width=width,
2133+
width=width / 100,
21812134
vmin=vmin,
21822135
vmax=vmax,
2136+
base_css=props,
21832137
)
21842138

21852139
return self
@@ -2830,3 +2784,166 @@ def _highlight_between(
28302784
else np.full(data.shape, True, dtype=bool)
28312785
)
28322786
return np.where(g_left & l_right, props, "")
2787+
2788+
2789+
def _bar(
2790+
data: FrameOrSeries,
2791+
align: str | float | int | Callable,
2792+
colors: list[str],
2793+
width: float,
2794+
vmin: float | None,
2795+
vmax: float | None,
2796+
base_css: str,
2797+
):
2798+
"""
2799+
Draw bar chart in data cells using HTML CSS linear gradient.
2800+
2801+
Parameters
2802+
----------
2803+
data : Series or DataFrame
2804+
Underling subset of Styler data on which operations are performed.
2805+
align : str in {"left", "right", "mid", "zero", "mean"}, int, float, callable
2806+
Method for how bars are structured or scalar value of centre point.
2807+
colors : list-like of str
2808+
Two listed colors as string in valid CSS.
2809+
width : float in [0,1]
2810+
The percentage of the cell, measured from left, where drawn bars will reside.
2811+
vmin : float, optional
2812+
Overwrite the minimum value of the window.
2813+
vmax : float, optional
2814+
Overwrite the maximum value of the window.
2815+
base_css : str
2816+
Additional CSS that is included in the cell before bars are drawn.
2817+
"""
2818+
2819+
def css_bar(start: float, end: float, color: str) -> str:
2820+
"""
2821+
Generate CSS code to draw a bar from start to end in a table cell.
2822+
2823+
Uses linear-gradient.
2824+
2825+
Parameters
2826+
----------
2827+
start : float
2828+
Relative positional start of bar coloring in [0,1]
2829+
end : float
2830+
Relative positional end of the bar coloring in [0,1]
2831+
color : str
2832+
CSS valid color to apply.
2833+
2834+
Returns
2835+
-------
2836+
str : The CSS applicable to the cell.
2837+
2838+
Notes
2839+
-----
2840+
Uses ``base_css`` from outer scope.
2841+
"""
2842+
cell_css = base_css
2843+
if end > start:
2844+
cell_css += "background: linear-gradient(90deg,"
2845+
if start > 0:
2846+
cell_css += f" transparent {start*100:.1f}%, {color} {start*100:.1f}%,"
2847+
cell_css += f" {color} {end*100:.1f}%, transparent {end*100:.1f}%)"
2848+
return cell_css
2849+
2850+
def css_calc(x, left: float, right: float, align: str):
2851+
"""
2852+
Return the correct CSS for bar placement based on calculated values.
2853+
2854+
Parameters
2855+
----------
2856+
x : float
2857+
Value which determines the bar placement.
2858+
left : float
2859+
Value marking the left side of calculation, usually minimum of data.
2860+
right : float
2861+
Value marking the right side of the calculation, usually maximum of data
2862+
(left < right).
2863+
align : {"left", "right", "zero", "mid"}
2864+
How the bars will be positioned.
2865+
"left", "right", "zero" can be used with any values for ``left``, ``right``.
2866+
"mid" can only be used where ``left <= 0`` and ``right >= 0``.
2867+
"zero" is used to specify a center when all values ``x``, ``left``,
2868+
``right`` are translated, e.g. by say a mean or median.
2869+
2870+
Returns
2871+
-------
2872+
str : Resultant CSS with linear gradient.
2873+
2874+
Notes
2875+
-----
2876+
Uses ``colors`` and ``width`` from outer scope.
2877+
"""
2878+
if pd.isna(x):
2879+
return base_css
2880+
2881+
color = colors[0] if x < 0 else colors[1]
2882+
x = left if x < left else x
2883+
x = right if x > right else x # trim data if outside of the window
2884+
2885+
start: float = 0
2886+
end: float = 1
2887+
2888+
if align == "left":
2889+
# all proportions are measured from the left side between left and right
2890+
end = (x - left) / (right - left)
2891+
2892+
elif align == "right":
2893+
# all proportions are measured from the right side between left and right
2894+
start = (x - left) / (right - left)
2895+
2896+
else:
2897+
z_frac: float = 0.5 # location of zero based on the left-right range
2898+
if align == "zero":
2899+
# all proportions are measured from the center at zero
2900+
limit: float = max(abs(left), abs(right))
2901+
left, right = -limit, limit
2902+
elif align == "mid":
2903+
# bars drawn from zero either leftwards or rightwards with center at mid
2904+
mid: float = (left + right) / 2
2905+
z_frac = (
2906+
-mid / (right - left) + 0.5 if mid < 0 else -left / (right - left)
2907+
)
2908+
2909+
if x < 0:
2910+
start, end = (x - left) / (right - left), z_frac
2911+
else:
2912+
start, end = z_frac, (x - left) / (right - left)
2913+
2914+
return css_bar(start * width, end * width, color)
2915+
2916+
values = data.to_numpy()
2917+
left = np.nanmin(values) if vmin is None else vmin
2918+
right = np.nanmax(values) if vmax is None else vmax
2919+
z: float = 0 # adjustment to translate data
2920+
2921+
if align == "mid":
2922+
if left >= 0: # "mid" is documented to act as "left" if all values positive
2923+
align, left = "left", 0 if vmin is None else vmin
2924+
elif right <= 0: # "mid" is documented to act as "right" if all values negative
2925+
align, right = "right", 0 if vmax is None else vmax
2926+
elif align == "mean":
2927+
z, align = np.nanmean(values), "zero"
2928+
elif callable(align):
2929+
z, align = align(values), "zero"
2930+
elif isinstance(align, (float, int)):
2931+
z, align = float(align), "zero"
2932+
elif not (align == "left" or align == "right" or align == "zero"):
2933+
raise ValueError(
2934+
"`align` should be in {'left', 'right', 'mid', 'mean', 'zero'} or be a "
2935+
"value defining the center line or a callable that returns a float"
2936+
)
2937+
2938+
assert isinstance(align, str) # mypy: should now be in [left, right, mid, zero]
2939+
if data.ndim == 1:
2940+
return [css_calc(x - z, left - z, right - z, align) for x in values]
2941+
else:
2942+
return DataFrame(
2943+
[
2944+
[css_calc(x - z, left - z, right - z, align) for x in row]
2945+
for row in values
2946+
],
2947+
index=data.index,
2948+
columns=data.columns,
2949+
)

0 commit comments

Comments
 (0)