Skip to content

Commit 1e2edfc

Browse files
committed
Feat: support mid_value in color_scale
1 parent af4bd98 commit 1e2edfc

13 files changed

Lines changed: 643 additions & 10 deletions

File tree

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,10 +517,39 @@ Gradient fill between 2 or 3 colors based on min/mid/max values in the column.
517517
"min_color": "#ffffff", # required
518518
"max_color": "#2e7d32", # required
519519
"mid_color": "#a5d6a7", # optional (3-color scale)
520+
"mid_value": 0, # optional numeric anchor for the midpoint
520521
"include_totals": False, # optional, default False
521522
}
522523
```
523524

525+
When `mid_color` is provided without `mid_value`, the gradient bends at the
526+
visual midpoint of the observed column range (current default behavior).
527+
528+
When `mid_value` is also provided, the gradient is anchored at that numeric
529+
value for a smooth Excel-like diverging scale — ideal for PnL or variance
530+
columns where `0` should always be the neutral color:
531+
532+
```python
533+
{
534+
"type": "color_scale",
535+
"apply_to": ["PnL"],
536+
"min_color": "#ff0000", # darker red for more negative
537+
"mid_color": "#ffffff", # white at 0
538+
"max_color": "#0000ff", # darker blue for more positive
539+
"mid_value": 0,
540+
}
541+
```
542+
543+
`mid_value` is interpreted in the same numeric space as the underlying
544+
**aggregated cell values** (i.e. the raw `agg.value()` used by all
545+
conditional formatting rules), which is the same space as `min_color` /
546+
`max_color`. This is the natural fit for typical use cases like PnL or
547+
variance anchored at `0`. Conditional formatting runs **before** any
548+
`show_values_as` transformation, so pairing `mid_value` with a mode such
549+
as `"pct_of_total"` will anchor on the raw aggregate, not on the displayed
550+
percentage. Values outside the observed column range (for example, grand
551+
totals) clamp to the endpoint colors rather than extrapolating past them.
552+
524553
#### Data Bars
525554

526555
Horizontal bar fill proportional to the cell value.

SKILL.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,10 +291,20 @@ Gradient fill between 2 or 3 colors based on min/mid/max values in the column.
291291
"min_color": "#ffffff", # required
292292
"max_color": "#2e7d32", # required
293293
"mid_color": "#a5d6a7", # optional (3-color scale)
294+
"mid_value": 0, # optional numeric anchor (requires mid_color)
294295
"include_totals": False, # optional, default False
295296
}
296297
```
297298

299+
When `mid_value` is set, the gradient is anchored at that numeric value for a
300+
smooth Excel-like diverging scale (e.g. `mid_value=0` for PnL). `mid_value` is
301+
interpreted in the same numeric space as the underlying aggregated cell values
302+
(the raw `agg.value()` used by all conditional formatting rules), i.e. the same
303+
space as `min_color` / `max_color`. Conditional formatting runs **before** any
304+
`show_values_as` transformation, so anchoring against a transformed display
305+
mode like `"pct_of_total"` is not supported. Values outside the observed column
306+
range clamp to the endpoint colors.
307+
298308
#### Data Bars
299309

300310
Horizontal bar fill proportional to the cell value.

e2e_playwright/e2e_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
DATA_PIVOT_KEYS = [
119119
"test_pivot",
120120
"test_pivot_cond_fmt",
121+
"test_pivot_cond_fmt_mid_value",
121122
"test_pivot_number_fmt",
122123
"test_pivot_empty",
123124
"test_pivot_single_row",

e2e_playwright/pivot_table_data_app.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,40 @@ def render_app(data):
8181
on_config_change=noop,
8282
)
8383

84+
st.subheader("Conditional Format Pivot - Mid Value")
85+
# Deterministic fixture for e2e mid_value regression: a 3x1 pivot where
86+
# the middle cell's value equals `mid_value`, so the browser must render
87+
# it with `mid_color` (white) and the other cells with the endpoint
88+
# colors (pure red and pure blue).
89+
cond_fmt_mid_df = pd.DataFrame(
90+
[
91+
{"Region": "AA_Low", "Year": 2024, "Value": -100.0},
92+
{"Region": "BB_Mid", "Year": 2024, "Value": 0.0},
93+
{"Region": "CC_High", "Year": 2024, "Value": 100.0},
94+
]
95+
)
96+
st_pivot_table(
97+
cond_fmt_mid_df,
98+
key="test_pivot_cond_fmt_mid_value",
99+
rows=["Region"],
100+
columns=["Year"],
101+
values=["Value"],
102+
aggregation="sum",
103+
show_totals=False,
104+
conditional_formatting=[
105+
{
106+
"type": "color_scale",
107+
"apply_to": ["Value"],
108+
"min_color": "#ff0000",
109+
"mid_color": "#ffffff",
110+
"max_color": "#0000ff",
111+
"mid_value": 0,
112+
},
113+
],
114+
interactive=True,
115+
on_config_change=noop,
116+
)
117+
84118
st.subheader("Number Format Pivot")
85119
st_pivot_table(
86120
df,

e2e_playwright/pivot_table_data_test.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,34 @@ def test_conditional_formatting_color_scale(page_at_app: Page):
4747
), "Expected at least one data cell to have background-color from color scale"
4848

4949

50+
def test_conditional_formatting_color_scale_mid_value(page_at_app: Page):
51+
"""mid_value anchors the gradient at the specified numeric value.
52+
53+
Uses the deterministic `test_pivot_cond_fmt_mid_value` fixture whose three
54+
row values are exactly min (-100), mid_value (0), and max (100), so the
55+
rendered background colors must be the exact endpoint/mid colors
56+
configured on the rule.
57+
"""
58+
page = page_at_app
59+
container = get_pivot(page, "test_pivot_cond_fmt_mid_value")
60+
expect(container.get_by_test_id("pivot-table")).to_be_visible(timeout=15000)
61+
62+
cells = container.get_by_test_id("pivot-data-cell")
63+
expect(cells.first).to_be_visible(timeout=5000)
64+
# Three Region rows, one Year column -> exactly three data cells.
65+
assert cells.count() == 3
66+
67+
def bg(cell) -> str:
68+
return cell.evaluate("el => window.getComputedStyle(el).backgroundColor")
69+
70+
# Row order matches dataframe order: AA_Low (min), BB_Mid (mid), CC_High (max).
71+
assert bg(cells.nth(0)) == "rgb(255, 0, 0)", "min cell should render min_color"
72+
assert (
73+
bg(cells.nth(1)) == "rgb(255, 255, 255)"
74+
), "cell at mid_value should render mid_color"
75+
assert bg(cells.nth(2)) == "rgb(0, 0, 255)", "max cell should render max_color"
76+
77+
5078
def test_conditional_formatting_data_bars(page_at_app: Page):
5179
"""Data bars formatting applies background-image (gradient) to cells."""
5280
page = page_at_app

streamlit_app.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,39 @@ def section_cond_fmt():
597597
conditional_formatting=cond_fmt_rules,
598598
)
599599

600+
st.markdown(
601+
"""
602+
#### Diverging color scale anchored at a numeric midpoint (`mid_value`)
603+
604+
Pass `mid_color` with a numeric `mid_value` to anchor a smooth diverging
605+
gradient at a meaningful midpoint (e.g. `0` for PnL, or a target value).
606+
Below, the average-profit column is rendered red-white-blue around the
607+
overall average so cells below average appear red and cells above appear
608+
blue, regardless of the column's min/max.
609+
"""
610+
)
611+
612+
avg_profit = float(df["Profit"].mean())
613+
st_pivot_table(
614+
df,
615+
key="cond_fmt_mid_value",
616+
rows=["Region"],
617+
columns=["Year"],
618+
values=["Profit"],
619+
aggregation="avg",
620+
number_format={"Profit": ",.0f"},
621+
conditional_formatting=[
622+
{
623+
"type": "color_scale",
624+
"apply_to": ["Profit"],
625+
"min_color": "#c62828",
626+
"mid_color": "#ffffff",
627+
"max_color": "#1565c0",
628+
"mid_value": avg_profit,
629+
},
630+
],
631+
)
632+
600633
with st.expander("View Code"):
601634
st.code(
602635
"""
@@ -629,6 +662,26 @@ def section_cond_fmt():
629662
},
630663
],
631664
)
665+
666+
# Diverging color scale anchored at a numeric midpoint:
667+
st_pivot_table(
668+
df,
669+
key="cond_fmt_mid_value",
670+
rows=["Region"],
671+
columns=["Year"],
672+
values=["Profit"],
673+
aggregation="avg",
674+
conditional_formatting=[
675+
{
676+
"type": "color_scale",
677+
"apply_to": ["Profit"],
678+
"min_color": "#c62828", # red for below-midpoint
679+
"mid_color": "#ffffff", # neutral at mid_value
680+
"max_color": "#1565c0", # blue for above-midpoint
681+
"mid_value": df["Profit"].mean(), # anchor at overall average
682+
},
683+
],
684+
)
632685
""",
633686
language="python",
634687
)

streamlit_pivot/__init__.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import re
2222
import warnings
2323
from datetime import date, datetime
24+
import math
2425
from math import prod
2526
from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
2627

@@ -2325,6 +2326,16 @@ def st_pivot_table(
23252326
Each rule is a dict with ``"type"`` (``"color_scale"``,
23262327
``"data_bars"``, or ``"threshold"``), ``"apply_to"`` (list of
23272328
field names, empty = all), and type-specific keys.
2329+
``color_scale`` rules accept optional ``"mid_color"`` and
2330+
``"mid_value"`` keys. When ``"mid_value"`` is provided (a finite
2331+
number, and only valid alongside ``"mid_color"``), the gradient
2332+
is anchored at that midpoint for a smooth Excel-like diverging
2333+
scale (for example, ``mid_value=0`` for PnL columns). ``mid_value``
2334+
is interpreted in the same numeric space as the underlying
2335+
aggregated cell values (the raw ``agg.value()`` that feeds all
2336+
conditional formatting rules), not as a post-``show_values_as``
2337+
display value. Values outside the observed column range clamp
2338+
to the endpoint colors.
23282339
number_format : str or dict[str, str] or None
23292340
Number format pattern(s). A single string applies to all
23302341
value fields; a dict maps field names to patterns. Use
@@ -2754,6 +2765,26 @@ def st_pivot_table(
27542765
raise ValueError(
27552766
f"conditional_formatting[{i}]: color_scale requires 'min_color' and 'max_color'"
27562767
)
2768+
if "mid_color" in rule and not isinstance(
2769+
rule.get("mid_color", ""), str
2770+
):
2771+
raise TypeError(
2772+
f"conditional_formatting[{i}]['mid_color'] must be a string"
2773+
)
2774+
if "mid_value" in rule and rule["mid_value"] is not None:
2775+
mv = rule["mid_value"]
2776+
if not rule.get("mid_color"):
2777+
raise ValueError(
2778+
f"conditional_formatting[{i}]: 'mid_value' requires 'mid_color'"
2779+
)
2780+
if (
2781+
isinstance(mv, bool)
2782+
or not isinstance(mv, (int, float))
2783+
or not math.isfinite(mv)
2784+
):
2785+
raise TypeError(
2786+
f"conditional_formatting[{i}]['mid_value'] must be a finite number"
2787+
)
27572788
elif rtype == "threshold":
27582789
conditions = rule.get("conditions")
27592790
if not isinstance(conditions, list) or len(conditions) == 0:

streamlit_pivot/frontend/src/engine/exportExcel.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,51 @@ describe("buildExcelWorkbook", () => {
526526
]);
527527
});
528528

529+
it("color_scale: mid_value emits a numeric middle cfvo", () => {
530+
const grid = makeCondGrid();
531+
grid.conditionalFormatting = [
532+
{
533+
type: "color_scale",
534+
apply_to: ["Revenue"],
535+
min_color: "#ff0000",
536+
mid_color: "#ffffff",
537+
max_color: "#0000ff",
538+
mid_value: 0,
539+
} as ColorScaleRule,
540+
];
541+
const wb = buildExcelWorkbook(ExcelJS, grid);
542+
const cfs = getCf(wb.worksheets[0]);
543+
const rule = cfs[0].rules[0];
544+
expect(rule.cfvo).toEqual([
545+
{ type: "min" },
546+
{ type: "num", value: 0 },
547+
{ type: "max" },
548+
]);
549+
expect(rule.color).toEqual([
550+
{ argb: "FFFF0000" },
551+
{ argb: "FFFFFFFF" },
552+
{ argb: "FF0000FF" },
553+
]);
554+
});
555+
556+
it("color_scale: mid_value without mid_color is ignored (2-color scale)", () => {
557+
const grid = makeCondGrid();
558+
grid.conditionalFormatting = [
559+
{
560+
type: "color_scale",
561+
apply_to: ["Revenue"],
562+
min_color: "#ff0000",
563+
max_color: "#0000ff",
564+
mid_value: 0,
565+
} as ColorScaleRule,
566+
];
567+
const wb = buildExcelWorkbook(ExcelJS, grid);
568+
const cfs = getCf(wb.worksheets[0]);
569+
const rule = cfs[0].rules[0];
570+
// No mid_color means a 2-stop scale regardless of mid_value.
571+
expect(rule.cfvo).toEqual([{ type: "min" }, { type: "max" }]);
572+
});
573+
529574
// ---- Data bars ----
530575

531576
it("data_bars: produces dataBar rule with gradient flag and correct color", () => {

streamlit_pivot/frontend/src/engine/exportExcel.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -290,12 +290,23 @@ function applyConditionalFormatting(
290290

291291
if (rule.type === "color_scale") {
292292
const csRule = rule as ColorScaleRule;
293+
const hasMidValue =
294+
csRule.mid_color !== undefined &&
295+
typeof csRule.mid_value === "number" &&
296+
Number.isFinite(csRule.mid_value);
297+
// Midpoint CFVO: numeric anchor when mid_value is provided,
298+
// otherwise fall back to the legacy 50th-percentile midpoint so
299+
// existing mid_color-only rules keep producing the same workbook.
300+
// ExcelJS's TS types don't enumerate { type: "num" } on color-scale
301+
// CFVOs, so cast via unknown like the data-bar branch below.
302+
const midCfvo = hasMidValue
303+
? ({ type: "num", value: csRule.mid_value } as unknown as {
304+
type: "percentile";
305+
value: number;
306+
})
307+
: { type: "percentile" as const, value: 50 };
293308
const cfvo = csRule.mid_color
294-
? [
295-
{ type: "min" as const },
296-
{ type: "percentile" as const, value: 50 },
297-
{ type: "max" as const },
298-
]
309+
? [{ type: "min" as const }, midCfvo, { type: "max" as const }]
299310
: [{ type: "min" as const }, { type: "max" as const }];
300311
const color = csRule.mid_color
301312
? [

streamlit_pivot/frontend/src/engine/types.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,21 @@ export interface ColorScaleRule extends ConditionalFormatRule {
413413
min_color: string;
414414
max_color: string;
415415
mid_color?: string;
416+
/**
417+
* Explicit numeric midpoint for the diverging gradient. Requires `mid_color`.
418+
*
419+
* Interpreted in the same numeric space as the underlying aggregated cell
420+
* values (the raw `agg.value()` shared by all conditional formatting rules),
421+
* which is the same space as `min_color` / `max_color`. Conditional
422+
* formatting runs before any `show_values_as` transformation, so
423+
* `mid_value` is not aligned to a transformed display mode like
424+
* `"pct_of_total"`.
425+
*
426+
* When provided, produces a smooth diverging gradient anchored at this
427+
* value; the gradient clamps at the endpoint colors when a cell value falls
428+
* outside the column's observed range.
429+
*/
430+
mid_value?: number;
416431
}
417432

418433
export interface DataBarsRule extends ConditionalFormatRule {
@@ -1066,6 +1081,22 @@ export function validatePivotConfigV1(obj: unknown): PivotConfigV1 {
10661081
`'conditional_formatting[${i}].apply_to' must be an array of strings`,
10671082
);
10681083
}
1084+
if (ruleType === "color_scale") {
1085+
const midValue = (rule as Record<string, unknown>).mid_value;
1086+
if (midValue !== undefined && midValue !== null) {
1087+
if (typeof midValue !== "number" || !Number.isFinite(midValue)) {
1088+
throw new Error(
1089+
`'conditional_formatting[${i}].mid_value' must be a finite number`,
1090+
);
1091+
}
1092+
const midColor = (rule as Record<string, unknown>).mid_color;
1093+
if (typeof midColor !== "string" || midColor.length === 0) {
1094+
throw new Error(
1095+
`'conditional_formatting[${i}].mid_value' requires 'mid_color'`,
1096+
);
1097+
}
1098+
}
1099+
}
10691100
}
10701101
result.conditional_formatting =
10711102
cfRules as unknown as AnyConditionalFormatRule[];

0 commit comments

Comments
 (0)