,
+ isTotalsRow: boolean,
+ ): { background: string; color: string } | null {
+ if (isTotalsRow) return null;
+ const meta = cell.column.columnDef.meta;
+ if (!meta?.conditionalFormat || meta.isRowTotal || !meta.measureName) {
+ return null;
+ }
+ const formatter = cellFormatters.get(meta.measureName);
+ if (!formatter) return null;
+ const value = cell.getValue();
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
+ return formatter(value);
+ }
+
function isCellActive(rowId: string, columnId: string) {
return rowId === activeCell?.rowId && columnId === activeCell?.columnId;
}
@@ -254,8 +280,12 @@
{@const tooltipValue = cell.column.columnDef.meta?.tooltipFormatter
? cell.column.columnDef.meta.tooltipFormatter(cell.getValue())
: cell.getValue()}
+ {@const cellFmt = getCellFormatting(cell, isTotalsRow)}
td {
@apply font-normal;
}
diff --git a/web-common/src/features/dashboards/pivot/FormattingRulesEditor.svelte b/web-common/src/features/dashboards/pivot/FormattingRulesEditor.svelte
new file mode 100644
index 000000000000..a512c8de7bf0
--- /dev/null
+++ b/web-common/src/features/dashboards/pivot/FormattingRulesEditor.svelte
@@ -0,0 +1,217 @@
+
+
+
+ {#each rules as rule, index (index)}
+
+ {/each}
+
+
+
+
+
diff --git a/web-common/src/features/dashboards/pivot/MeasureFormatChip.svelte b/web-common/src/features/dashboards/pivot/MeasureFormatChip.svelte
new file mode 100644
index 000000000000..1e7d5ba84d25
--- /dev/null
+++ b/web-common/src/features/dashboards/pivot/MeasureFormatChip.svelte
@@ -0,0 +1,65 @@
+
+
+
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
+
+ {item.title}
+
+
+
+
+
diff --git a/web-common/src/features/dashboards/pivot/MeasureFormattingControls.svelte b/web-common/src/features/dashboards/pivot/MeasureFormattingControls.svelte
new file mode 100644
index 000000000000..ad92f278866a
--- /dev/null
+++ b/web-common/src/features/dashboards/pivot/MeasureFormattingControls.svelte
@@ -0,0 +1,123 @@
+
+
+
+
+ Style
+
+
+
+ {#if mode === "heatmap" || mode === "data_bar"}
+
+
+
+ {#if mode === "heatmap"}
+
+ Reverse colors
+
+ onChange({ mode, scheme, reverse: Boolean(checked) })}
+ />
+
+ {/if}
+ {:else if mode === "rules"}
+
+ {/if}
+
+
+
diff --git a/web-common/src/features/dashboards/pivot/NestedTable.svelte b/web-common/src/features/dashboards/pivot/NestedTable.svelte
index 988d19f61319..326623f7d748 100644
--- a/web-common/src/features/dashboards/pivot/NestedTable.svelte
+++ b/web-common/src/features/dashboards/pivot/NestedTable.svelte
@@ -3,7 +3,7 @@
import Resizer from "@rilldata/web-common/layout/Resizer.svelte";
import { modified } from "@rilldata/web-common/lib/actions/modified-click";
import { writable } from "svelte/store";
- import type { HeaderGroup, Row } from "tanstack-table-8-svelte-5";
+ import type { Cell, HeaderGroup, Row } from "tanstack-table-8-svelte-5";
import { flexRender } from "tanstack-table-8-svelte-5";
import { cellInspectorStore } from "../stores/cell-inspector-store";
import {
@@ -40,6 +40,7 @@
isInSelectedColRange,
type HoveredColRange,
} from "./pivot-selection-indices";
+ import type { CellFormatter } from "./pivot-conditional-formatting";
import { isShowMoreRow } from "./pivot-utils";
import PivotHeaderLabel from "./PivotHeaderLabel.svelte";
import type { PivotDataRow } from "./types";
@@ -52,6 +53,7 @@
export let rowDimensions: DimensionColumnProps;
export let dataRows: PivotDataRow[];
export let measures: MeasureColumnProps;
+ export let cellFormatters: Map = new Map();
export let totalsRow: PivotDataRow | undefined;
export let canShowDataViewer = false;
export let enableClickToFilter = false;
@@ -179,6 +181,25 @@
$: measureCount = measures.length;
+ // Resolve conditional-formatting styling for a measure cell. Returns null for
+ // cells that should not be formatted (no config, the row-totals column, the
+ // grand-totals row, non-numeric values, or no matching threshold rule).
+ function getCellFormatting(
+ cell: Cell,
+ isTotalsRow: boolean,
+ ): { background: string; color: string } | null {
+ if (isTotalsRow) return null;
+ const meta = cell.column.columnDef.meta;
+ if (!meta?.conditionalFormat || meta.isRowTotal || !meta.measureName) {
+ return null;
+ }
+ const formatter = cellFormatters.get(meta.measureName);
+ if (!formatter) return null;
+ const value = cell.getValue();
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
+ return formatter(value);
+ }
+
$: subHeaders = [
{
subHeaders: measures.map((m) => ({
@@ -602,8 +623,12 @@
{@const tooltipValue = cell.column.columnDef.meta?.tooltipFormatter
? cell.column.columnDef.meta.tooltipFormatter(cell.getValue())
: cell.getValue()}
+ {@const cellFmt = getCellFormatting(cell, isTotalsRow)}
tr:not(:last-of-type) > th:first-of-type {
@apply border-b-0;
diff --git a/web-common/src/features/dashboards/pivot/PivotDisplay.svelte b/web-common/src/features/dashboards/pivot/PivotDisplay.svelte
index 6d38cc948cb8..ce8dc10e78b4 100644
--- a/web-common/src/features/dashboards/pivot/PivotDisplay.svelte
+++ b/web-common/src/features/dashboards/pivot/PivotDisplay.svelte
@@ -156,6 +156,12 @@
metricsExplorerStore.setPivotRows($exploreName, rows)}
setColumns={(columns) =>
metricsExplorerStore.setPivotColumns($exploreName, columns)}
+ setMeasureFormatting={(measureName, fmt) =>
+ metricsExplorerStore.setPivotMeasureFormatting(
+ $exploreName,
+ measureName,
+ fmt,
+ )}
/>
{/if}
void;
export let setColumns: (items: PivotChipData[]) => void;
+ export let setMeasureFormatting:
+ | ((measureName: string, fmt: PivotMeasureFormatting | null) => void)
+ | undefined = undefined;
- $: ({ rows, columns, tableMode } = pivotState);
+ $: ({ rows, columns, tableMode, measureFormatting } = pivotState);
$: splitColumns = splitPivotChips(columns);
$: fullColumns = splitColumns.dimension.concat(splitColumns.measure);
$: isFlat = tableMode === "flat";
@@ -63,6 +71,8 @@
items={columnsForList}
placeholder="Drag dimensions or measures here"
onUpdate={updateColumn}
+ {measureFormatting}
+ {setMeasureFormatting}
/>
diff --git a/web-common/src/features/dashboards/pivot/PivotTable.svelte b/web-common/src/features/dashboards/pivot/PivotTable.svelte
index 2fd71d8cc566..439c4d0867bf 100644
--- a/web-common/src/features/dashboards/pivot/PivotTable.svelte
+++ b/web-common/src/features/dashboards/pivot/PivotTable.svelte
@@ -26,6 +26,7 @@
import { derived } from "svelte/store";
import {
type ExpandedState,
+ type Row,
type SortingState,
type TableOptions,
createSvelteTable,
@@ -33,6 +34,11 @@
getExpandedRowModel,
} from "tanstack-table-8-svelte-5";
import NestedTable from "./NestedTable.svelte";
+ import {
+ type CellFormatter,
+ computeMeasureDomains,
+ makeCellFormatter,
+ } from "./pivot-conditional-formatting";
import type {
PivotDataRow,
PivotDataStore,
@@ -141,6 +147,16 @@
$config,
);
+ // Per-measure conditional formatting. Domains are computed over leaf data
+ // cells only (excluding the totals row and the row-totals column), so
+ // aggregate magnitudes don't dominate the gradient. Recomputes when the row
+ // model or the formatting config changes.
+ $: cellFormatters = buildCellFormatters(
+ $table.getRowModel().flatRows,
+ $config.pivot.measureFormatting,
+ !!totalsRow,
+ );
+
$: headerGroups = $table.getHeaderGroups();
$: totalHeaderHeight = headerGroups.length * HEADER_HEIGHT;
@@ -185,6 +201,58 @@
return [];
}
+ function buildCellFormatters(
+ flatRows: Row[],
+ measureFormatting: PivotState["measureFormatting"],
+ hasTotalsRow: boolean,
+ ): Map {
+ const formatters = new Map();
+ if (!measureFormatting || Object.keys(measureFormatting).length === 0) {
+ return formatters;
+ }
+
+ // Rules formatters don't depend on the value domain, so only scan the row
+ // model when a scale-mode (heatmap/data bar) measure needs one.
+ const needsDomains = Object.values(measureFormatting).some(
+ (fmt) => fmt.mode !== "rules",
+ );
+ const values: { measureName: string; value: number }[] = [];
+ if (needsDomains) {
+ for (const row of flatRows) {
+ // Skip aggregate rows: the prepended totals row and nested parent rows.
+ if (hasTotalsRow && row.id === "0") continue;
+ if (row.subRows.length > 0) continue;
+ for (const cell of row.getAllCells()) {
+ const meta = cell.column.columnDef.meta;
+ if (
+ !meta?.conditionalFormat ||
+ meta.isRowTotal ||
+ !meta.measureName
+ ) {
+ continue;
+ }
+ const value = cell.getValue();
+ if (typeof value === "number") {
+ values.push({ measureName: meta.measureName, value });
+ }
+ }
+ }
+ }
+
+ const domains = computeMeasureDomains(values);
+ for (const [measureName, fmt] of Object.entries(measureFormatting)) {
+ if (fmt.mode === "rules") {
+ formatters.set(measureName, makeCellFormatter(fmt));
+ continue;
+ }
+ const domain = domains.get(measureName);
+ if (domain) {
+ formatters.set(measureName, makeCellFormatter(fmt, domain));
+ }
+ }
+ return formatters;
+ }
+
const handleScroll = (containerRefElement?: HTMLDivElement | null) => {
if (containerRefElement) {
if (hovering) hovering = null;
@@ -368,6 +436,7 @@
{rows}
{virtualRows}
{measures}
+ {cellFormatters}
{totalsRow}
{dataRows}
{before}
@@ -403,6 +472,7 @@
{hasColumnDimension}
{dataRows}
{measures}
+ {cellFormatters}
{canShowDataViewer}
{enableClickToFilter}
{rowSelectionState}
diff --git a/web-common/src/features/dashboards/pivot/PivotToolbar.svelte b/web-common/src/features/dashboards/pivot/PivotToolbar.svelte
index 2dcb0e6a2d2f..2970c6724260 100644
--- a/web-common/src/features/dashboards/pivot/PivotToolbar.svelte
+++ b/web-common/src/features/dashboards/pivot/PivotToolbar.svelte
@@ -233,6 +233,7 @@
{/if}
{/if}
+
{#if isFetching}
diff --git a/web-common/src/features/dashboards/pivot/pivot-column-definition.ts b/web-common/src/features/dashboards/pivot/pivot-column-definition.ts
index 22f207b9ca43..efc6a90911fc 100644
--- a/web-common/src/features/dashboards/pivot/pivot-column-definition.ts
+++ b/web-common/src/features/dashboards/pivot/pivot-column-definition.ts
@@ -34,6 +34,7 @@ import {
type MeasureType,
type PivotDataRow,
type PivotDataStoreConfig,
+ type PivotMeasureFormatting,
type PivotTimeConfig,
} from "./types";
@@ -141,6 +142,13 @@ function createColumnDefinitionForDimensions(
config.rowDimensionNames.length &&
config.colDimensionNames.length
) {
+ // The row-totals column reuses the leaf measure defs but holds aggregate
+ // values, so mark them to exclude from conditional formatting (their
+ // magnitudes would dominate the per-measure color domain).
+ const totalsLeafData: ColumnDef[] = leafData.map((leaf) => ({
+ ...leaf,
+ meta: { ...leaf.meta, isRowTotal: true },
+ }));
rowTotalsColumns = colDimensions.reverse().reduce((acc, dimension) => {
const { name } = dimension;
@@ -151,7 +159,7 @@ function createColumnDefinitionForDimensions(
};
return [headColumn];
- }, leafData);
+ }, totalsLeafData);
}
// Start the recursion
@@ -206,6 +214,10 @@ export type MeasureColumnProps = Array<{
type: MeasureType;
lowerIsBetter: boolean;
description?: string;
+ // Base measure name (without comparison suffix), used to key the color domain.
+ measureName: string;
+ // Conditional formatting for the main measure column (heatmap/data bar).
+ conditionalFormat?: PivotMeasureFormatting;
}>;
export function getMeasureColumnProps(
config: PivotDataStoreConfig,
@@ -258,6 +270,13 @@ export function getMeasureColumnProps(
icon,
lowerIsBetter: measure?.lowerIsBetter ?? false,
description: measure?.description,
+ measureName,
+ // Conditional formatting only applies to the main measure column, not its
+ // comparison delta/percent sub-columns.
+ conditionalFormat:
+ type === "measure"
+ ? config.pivot.measureFormatting?.[measureName]
+ : undefined,
};
});
}
@@ -368,6 +387,8 @@ function getFlatColumnDef(
icon: m.icon,
tooltipFormatter: m.tooltipFormatter,
description: m.description,
+ conditionalFormat: m.conditionalFormat,
+ measureName: m.measureName,
},
cell: (info) => {
const measureValue = info.getValue() as number | null | undefined;
@@ -564,6 +585,8 @@ function getNestedColumnDef(
icon: m.icon,
tooltipFormatter: m.tooltipFormatter,
description: m.description,
+ conditionalFormat: m.conditionalFormat,
+ measureName: m.measureName,
},
cell: (info) => {
const measureValue = info.getValue() as number | null | undefined;
diff --git a/web-common/src/features/dashboards/pivot/pivot-conditional-formatting.spec.ts b/web-common/src/features/dashboards/pivot/pivot-conditional-formatting.spec.ts
new file mode 100644
index 000000000000..ced48ceca8ee
--- /dev/null
+++ b/web-common/src/features/dashboards/pivot/pivot-conditional-formatting.spec.ts
@@ -0,0 +1,190 @@
+import { describe, expect, it } from "vitest";
+import {
+ computeMeasureDomains,
+ getSchemeStops,
+ makeCellFormatter,
+ PIVOT_RULE_COLORS,
+ resolveRuleColor,
+ ruleMatches,
+} from "./pivot-conditional-formatting";
+import type { PivotFormatRule } from "./types";
+
+describe("computeMeasureDomains", () => {
+ it("computes per-measure min/max independently", () => {
+ const domains = computeMeasureDomains([
+ { measureName: "revenue", value: 10 },
+ { measureName: "revenue", value: 30 },
+ { measureName: "revenue", value: 20 },
+ { measureName: "orders", value: 1 },
+ { measureName: "orders", value: 5 },
+ ]);
+ expect(domains.get("revenue")).toEqual([10, 30]);
+ expect(domains.get("orders")).toEqual([1, 5]);
+ });
+
+ it("skips non-finite values and omits measures with no finite values", () => {
+ const domains = computeMeasureDomains([
+ { measureName: "a", value: NaN },
+ { measureName: "a", value: Infinity },
+ { measureName: "b", value: -2 },
+ { measureName: "b", value: 4 },
+ ]);
+ expect(domains.has("a")).toBe(false);
+ expect(domains.get("b")).toEqual([-2, 4]);
+ });
+});
+
+describe("getSchemeStops", () => {
+ it("returns the scheme stops and reverses when requested", () => {
+ const stops = getSchemeStops("greens");
+ expect(stops.length).toBeGreaterThan(1);
+ expect(getSchemeStops("greens", true)).toEqual([...stops].reverse());
+ });
+
+ it("falls back to the default scheme for unknown keys", () => {
+ expect(getSchemeStops("does-not-exist")).toBeInstanceOf(Array);
+ });
+});
+
+describe("makeCellFormatter — heatmap", () => {
+ it("maps domain endpoints to the scheme's end colors", () => {
+ const fmt = makeCellFormatter(
+ { mode: "heatmap", scheme: "greens" },
+ [0, 100],
+ );
+ const stops = getSchemeStops("greens");
+ expect(fmt(0)?.background.toLowerCase()).toBe(stops[0].toLowerCase());
+ expect(fmt(100)?.background.toLowerCase()).toBe(
+ stops[stops.length - 1].toLowerCase(),
+ );
+ });
+
+ it("uses light text on dark backgrounds and inherits on light", () => {
+ const fmt = makeCellFormatter(
+ { mode: "heatmap", scheme: "greens" },
+ [0, 100],
+ );
+ expect(fmt(0)?.color).toBe("inherit"); // light end
+ expect(fmt(100)?.color).toBe("#ffffff"); // dark end
+ });
+
+ it("does not throw on a degenerate domain", () => {
+ const fmt = makeCellFormatter({ mode: "heatmap", scheme: "blues" }, [5, 5]);
+ expect(() => fmt(5)).not.toThrow();
+ });
+});
+
+describe("makeCellFormatter — data bar", () => {
+ it("produces a gradient whose length is proportional to magnitude", () => {
+ const fmt = makeCellFormatter(
+ { mode: "data_bar", scheme: "blues" },
+ [0, 200],
+ );
+ expect(fmt(0)?.background).toContain("0%");
+ expect(fmt(100)?.background).toContain("50%");
+ expect(fmt(200)?.background).toContain("100%");
+ expect(fmt(100)?.color).toBe("inherit");
+ });
+
+ it("clamps to 100% and uses magnitude for negative values", () => {
+ const fmt = makeCellFormatter(
+ { mode: "data_bar", scheme: "blues" },
+ [-100, 50],
+ );
+ // absMax is 100, so -100 fills the full bar.
+ expect(fmt(-100)?.background).toContain("100%");
+ });
+});
+
+describe("ruleMatches", () => {
+ it("evaluates each operator", () => {
+ expect(ruleMatches({ operator: "gt", value: 5, color: "x" }, 6)).toBe(true);
+ expect(ruleMatches({ operator: "gt", value: 5, color: "x" }, 5)).toBe(
+ false,
+ );
+ expect(ruleMatches({ operator: "gte", value: 5, color: "x" }, 5)).toBe(
+ true,
+ );
+ expect(ruleMatches({ operator: "lt", value: 5, color: "x" }, 4)).toBe(true);
+ expect(ruleMatches({ operator: "lte", value: 5, color: "x" }, 5)).toBe(
+ true,
+ );
+ expect(ruleMatches({ operator: "eq", value: 5, color: "x" }, 5)).toBe(true);
+ expect(ruleMatches({ operator: "eq", value: 5, color: "x" }, 5.1)).toBe(
+ false,
+ );
+ });
+
+ it("treats between as inclusive of both bounds", () => {
+ const rule: PivotFormatRule = {
+ operator: "between",
+ value: 0,
+ value2: 10,
+ color: "x",
+ };
+ expect(ruleMatches(rule, 0)).toBe(true);
+ expect(ruleMatches(rule, 10)).toBe(true);
+ expect(ruleMatches(rule, 10.1)).toBe(false);
+ expect(ruleMatches(rule, -0.1)).toBe(false);
+ });
+});
+
+describe("resolveRuleColor", () => {
+ it("resolves semantic keys to their paired fill and text colors", () => {
+ const positive = PIVOT_RULE_COLORS.find((c) => c.key === "positive")!;
+ expect(resolveRuleColor("positive")).toEqual({
+ fill: positive.fill,
+ text: positive.text,
+ });
+ });
+
+ it("accepts raw hex with or without the leading #", () => {
+ expect(resolveRuleColor("#00441b").fill).toBe("#00441b");
+ expect(resolveRuleColor("00441b").fill).toBe("#00441b");
+ // Dark fill gets light text.
+ expect(resolveRuleColor("00441b").text).toBe("#ffffff");
+ });
+
+ it("falls back to neutral for invalid colors", () => {
+ const neutral = PIVOT_RULE_COLORS.find((c) => c.key === "neutral")!;
+ expect(resolveRuleColor("not-a-color")).toEqual({
+ fill: neutral.fill,
+ text: neutral.text,
+ });
+ });
+});
+
+describe("makeCellFormatter — rules", () => {
+ const rules: PivotFormatRule[] = [
+ { operator: "lt", value: 0, color: "negative" },
+ { operator: "gte", value: 0.4, color: "positive" },
+ ];
+
+ it("applies the first matching rule and leaves other values unformatted", () => {
+ const fmt = makeCellFormatter({ mode: "rules", rules });
+ const negative = PIVOT_RULE_COLORS.find((c) => c.key === "negative")!;
+ const positive = PIVOT_RULE_COLORS.find((c) => c.key === "positive")!;
+ expect(fmt(-1)).toEqual({
+ background: negative.fill,
+ color: negative.text,
+ });
+ expect(fmt(0.5)).toEqual({
+ background: positive.fill,
+ color: positive.text,
+ });
+ expect(fmt(0.2)).toBeNull();
+ });
+
+ it("respects rule order: first match wins", () => {
+ const fmt = makeCellFormatter({
+ mode: "rules",
+ rules: [
+ { operator: "gt", value: 0, color: "warning" },
+ { operator: "gt", value: 10, color: "positive" },
+ ],
+ });
+ const warning = PIVOT_RULE_COLORS.find((c) => c.key === "warning")!;
+ // 20 matches both rules; the earlier one wins.
+ expect(fmt(20)?.background).toBe(warning.fill);
+ });
+});
diff --git a/web-common/src/features/dashboards/pivot/pivot-conditional-formatting.ts b/web-common/src/features/dashboards/pivot/pivot-conditional-formatting.ts
new file mode 100644
index 000000000000..3e0fb47b6602
--- /dev/null
+++ b/web-common/src/features/dashboards/pivot/pivot-conditional-formatting.ts
@@ -0,0 +1,205 @@
+import {
+ getDivergingColorsAsHex,
+ getSequentialColorsAsHex,
+} from "@rilldata/web-common/features/themes/palette-store";
+import chroma from "chroma-js";
+import type { PivotFormatRule, PivotMeasureFormatting } from "./types";
+
+/**
+ * Conditional formatting color schemes available per measure. Theme-aware
+ * schemes resolve their stops from the active theme's palettes at call time, so
+ * `getStops` is a function rather than a static array.
+ */
+export interface PivotFormattingScheme {
+ key: string;
+ label: string;
+ getStops: () => string[];
+}
+
+export const PIVOT_FORMATTING_SCHEMES: PivotFormattingScheme[] = [
+ {
+ key: "theme-sequential",
+ label: "Theme",
+ getStops: () => getSequentialColorsAsHex(),
+ },
+ {
+ key: "theme-diverging",
+ label: "Theme (diverging)",
+ getStops: () => getDivergingColorsAsHex(),
+ },
+ {
+ key: "greens",
+ label: "Greens",
+ getStops: () => ["#f7fcf5", "#74c476", "#00441b"],
+ },
+ {
+ key: "blues",
+ label: "Blues",
+ getStops: () => ["#f7fbff", "#6baed6", "#08306b"],
+ },
+ {
+ key: "redYellowGreen",
+ label: "Red–Yellow–Green",
+ getStops: () => ["#d73027", "#ffffbf", "#1a9850"],
+ },
+];
+
+const DEFAULT_SCHEME_KEY = "theme-sequential";
+
+function getScheme(schemeKey: string): PivotFormattingScheme {
+ return (
+ PIVOT_FORMATTING_SCHEMES.find((s) => s.key === schemeKey) ??
+ PIVOT_FORMATTING_SCHEMES.find((s) => s.key === DEFAULT_SCHEME_KEY)!
+ );
+}
+
+/**
+ * Resolve a scheme's ordered color stops, reversing them when requested.
+ */
+export function getSchemeStops(schemeKey: string, reverse = false): string[] {
+ const stops = getScheme(schemeKey).getStops();
+ return reverse ? [...stops].reverse() : stops;
+}
+
+/**
+ * Accumulate per-measure [min, max] domains from a flat list of measure values.
+ * Measures with no finite values are omitted.
+ */
+export function computeMeasureDomains(
+ entries: Iterable<{ measureName: string; value: number }>,
+): Map {
+ const domains = new Map();
+ for (const { measureName, value } of entries) {
+ if (!Number.isFinite(value)) continue;
+ const existing = domains.get(measureName);
+ if (!existing) {
+ domains.set(measureName, [value, value]);
+ } else {
+ if (value < existing[0]) existing[0] = value;
+ if (value > existing[1]) existing[1] = value;
+ }
+ }
+ return domains;
+}
+
+// Semantic rule colors, modeled on the classic Excel conditional-formatting
+// fills so thresholds read instantly as good/bad/caution. Each pairs a light
+// fill with a legible dark text color.
+export interface PivotRuleColor {
+ key: string;
+ label: string;
+ fill: string;
+ text: string;
+}
+
+export const PIVOT_RULE_COLORS: PivotRuleColor[] = [
+ { key: "positive", label: "Positive", fill: "#c6efce", text: "#006100" },
+ { key: "negative", label: "Negative", fill: "#ffc7ce", text: "#9c0d07" },
+ { key: "warning", label: "Warning", fill: "#ffeb9c", text: "#9c6500" },
+ { key: "info", label: "Info", fill: "#cfe2f3", text: "#0b5394" },
+ { key: "neutral", label: "Neutral", fill: "#e5e7eb", text: "#374151" },
+];
+
+/**
+ * Resolve a rule's stored color (semantic key or raw hex, with or without "#")
+ * to fill and text hex colors. Unknown/invalid values fall back to the neutral
+ * semantic color so a malformed URL never breaks rendering.
+ */
+export function resolveRuleColor(color: string): {
+ fill: string;
+ text: string;
+} {
+ const semantic = PIVOT_RULE_COLORS.find((c) => c.key === color);
+ if (semantic) return { fill: semantic.fill, text: semantic.text };
+ const hex = color.startsWith("#") ? color : `#${color}`;
+ if (!chroma.valid(hex)) {
+ const neutral = PIVOT_RULE_COLORS.find((c) => c.key === "neutral")!;
+ return { fill: neutral.fill, text: neutral.text };
+ }
+ return {
+ fill: hex,
+ text:
+ chroma(hex).luminance() < LIGHT_TEXT_LUMINANCE ? "#ffffff" : "inherit",
+ };
+}
+
+export function ruleMatches(rule: PivotFormatRule, value: number): boolean {
+ switch (rule.operator) {
+ case "gt":
+ return value > rule.value;
+ case "gte":
+ return value >= rule.value;
+ case "lt":
+ return value < rule.value;
+ case "lte":
+ return value <= rule.value;
+ case "eq":
+ return value === rule.value;
+ case "between":
+ return value >= rule.value && value <= (rule.value2 ?? rule.value);
+ }
+}
+
+export interface CellFormat {
+ background: string;
+ color: string;
+}
+
+// Returns the style for a cell value, or null when the value should render
+// unformatted (e.g. no threshold rule matched).
+export type CellFormatter = (value: number) => CellFormat | null;
+
+// Below this background luminance, switch to light text for legibility. Mirrors
+// the threshold used by the canvas heatmap (charts/heatmap/spec.ts).
+const LIGHT_TEXT_LUMINANCE = 0.4;
+
+/**
+ * Build a cell formatter for a single measure. Scale modes (heatmap, data_bar)
+ * color across the measure's value domain; rules mode applies the first
+ * matching threshold rule and needs no domain.
+ */
+export function makeCellFormatter(
+ fmt: PivotMeasureFormatting,
+ domain?: [number, number],
+): CellFormatter {
+ if (fmt.mode === "rules") {
+ return (value: number) => {
+ const match = fmt.rules.find((rule) => ruleMatches(rule, value));
+ if (!match) return null;
+ const { fill, text } = resolveRuleColor(match.color);
+ return { background: fill, color: text };
+ };
+ }
+
+ const [min, max] = domain ?? [0, 1];
+ const stops = getSchemeStops(fmt.scheme, fmt.reverse);
+
+ if (fmt.mode === "data_bar") {
+ // Bar length is proportional to magnitude relative to the largest absolute
+ // value, mirroring the leaderboard bar (LeaderboardRow.svelte).
+ const absMax = Math.max(Math.abs(min), Math.abs(max)) || 1;
+ // Use the scheme's most saturated stop, lightened so right-aligned numbers
+ // stay legible over the bar.
+ const barColor = chroma(stops[stops.length - 1])
+ .luminance(0.72)
+ .hex();
+ return (value: number) => {
+ const pct = Math.min(100, (Math.abs(value) / absMax) * 100);
+ return {
+ background: `linear-gradient(to right, ${barColor} ${pct}%, transparent ${pct}%)`,
+ color: "inherit",
+ };
+ };
+ }
+
+ // Heatmap: interpolate a solid background color across the domain. Guard
+ // against a degenerate domain (all values equal) where chroma cannot scale.
+ const scale = chroma
+ .scale(stops)
+ .domain(min === max ? [min, min + 1] : [min, max]);
+ return (value: number) => ({
+ background: scale(value).hex(),
+ color:
+ scale(value).luminance() < LIGHT_TEXT_LUMINANCE ? "#ffffff" : "inherit",
+ });
+}
diff --git a/web-common/src/features/dashboards/pivot/pivot-formatting-param.spec.ts b/web-common/src/features/dashboards/pivot/pivot-formatting-param.spec.ts
new file mode 100644
index 000000000000..8e8e183c0d69
--- /dev/null
+++ b/web-common/src/features/dashboards/pivot/pivot-formatting-param.spec.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from "vitest";
+import {
+ fromPivotFormattingParam,
+ toPivotFormattingParam,
+} from "./pivot-formatting-param";
+import type { PivotMeasureFormatting } from "./types";
+
+describe("pivot formatting url param codec", () => {
+ it("serializes scale modes with optional reverse", () => {
+ expect(
+ toPivotFormattingParam({
+ revenue: { mode: "heatmap", scheme: "greens" },
+ orders: { mode: "heatmap", scheme: "blues", reverse: true },
+ margin: { mode: "data_bar", scheme: "theme-sequential" },
+ }),
+ ).toBe(
+ "revenue:heatmap:greens;orders:heatmap:blues:reverse;margin:data_bar:theme-sequential",
+ );
+ });
+
+ it("serializes threshold rules, stripping # from hex colors", () => {
+ expect(
+ toPivotFormattingParam({
+ margin: {
+ mode: "rules",
+ rules: [
+ { operator: "lt", value: 0, color: "negative" },
+ { operator: "between", value: 0, value2: 0.4, color: "#ffcc00" },
+ { operator: "gte", value: 0.4, color: "positive" },
+ ],
+ },
+ }),
+ ).toBe("margin:rules:lt,0,negative|between,0,0.4,ffcc00|gte,0.4,positive");
+ });
+
+ it("returns an empty string for empty or undefined config", () => {
+ expect(toPivotFormattingParam(undefined)).toBe("");
+ expect(toPivotFormattingParam({})).toBe("");
+ });
+
+ it("round-trips every mode", () => {
+ const config: Record = {
+ revenue: { mode: "heatmap", scheme: "greens", reverse: true },
+ orders: { mode: "data_bar", scheme: "blues" },
+ margin: {
+ mode: "rules",
+ rules: [
+ { operator: "lt", value: 0, color: "negative" },
+ { operator: "between", value: 0, value2: 0.4, color: "ffcc00" },
+ ],
+ },
+ };
+ const param = toPivotFormattingParam(config);
+ const { measureFormatting, invalidEntries } =
+ fromPivotFormattingParam(param);
+ expect(invalidEntries).toEqual([]);
+ expect(measureFormatting).toEqual(config);
+ // Stability: re-serializing the parsed value yields the same param.
+ expect(toPivotFormattingParam(measureFormatting)).toBe(param);
+ });
+
+ it("parses an empty param to undefined", () => {
+ expect(fromPivotFormattingParam("").measureFormatting).toBeUndefined();
+ });
+
+ it("rejects malformed entries but keeps valid ones", () => {
+ const { measureFormatting, invalidEntries } = fromPivotFormattingParam(
+ [
+ "revenue:heatmap:greens",
+ "orders:heatmap", // missing scheme
+ "margin:rules:noop,1,red", // unknown operator
+ "profit:rules:lt,abc,red", // non-numeric value
+ "cost:sparkles:greens", // unknown mode
+ "sales:rules:between,1,2,blue", // valid
+ ].join(";"),
+ );
+ expect(Object.keys(measureFormatting ?? {})).toEqual(["revenue", "sales"]);
+ expect(invalidEntries).toHaveLength(4);
+ });
+
+ it("rejects rules with a wrong number of parts", () => {
+ expect(
+ fromPivotFormattingParam("m:rules:lt,0").measureFormatting,
+ ).toBeUndefined();
+ expect(
+ fromPivotFormattingParam("m:rules:lt,0,red,extra").measureFormatting,
+ ).toBeUndefined();
+ expect(
+ fromPivotFormattingParam("m:rules:between,0,1").measureFormatting,
+ ).toBeUndefined();
+ });
+});
diff --git a/web-common/src/features/dashboards/pivot/pivot-formatting-param.ts b/web-common/src/features/dashboards/pivot/pivot-formatting-param.ts
new file mode 100644
index 000000000000..bc532d08253c
--- /dev/null
+++ b/web-common/src/features/dashboards/pivot/pivot-formatting-param.ts
@@ -0,0 +1,138 @@
+import type {
+ PivotFormatRule,
+ PivotFormatRuleOperator,
+ PivotMeasureFormatting,
+} from "./types";
+
+/**
+ * Compact serialization of per-measure conditional formatting for the stateful
+ * URL (and the explore preset, which stores the same string).
+ *
+ * Grammar:
+ * param := entry (";" entry)*
+ * entry := measure ":" "heatmap" ":" scheme [":reverse"]
+ * | measure ":" "data_bar" ":" scheme
+ * | measure ":" "rules" ":" rule ("|" rule)*
+ * rule := op "," value ["," value2] "," color
+ *
+ * Example: `margin:rules:lt,0,negative|gte,0.4,positive;revenue:heatmap:greens`
+ * Hex rule colors are stored without the leading "#" to keep URLs clean.
+ */
+
+const OPERATORS = new Set([
+ "gt",
+ "gte",
+ "lt",
+ "lte",
+ "eq",
+ "between",
+]);
+
+export function toPivotFormattingParam(
+ measureFormatting: Record | undefined,
+): string {
+ if (!measureFormatting) return "";
+ return Object.entries(measureFormatting)
+ .map(([measure, fmt]) => {
+ if (fmt.mode === "rules") {
+ const rules = fmt.rules.map(ruleToString).join("|");
+ return `${measure}:rules:${rules}`;
+ }
+ const parts = [measure, fmt.mode, fmt.scheme];
+ if (fmt.reverse) parts.push("reverse");
+ return parts.join(":");
+ })
+ .join(";");
+}
+
+function ruleToString(rule: PivotFormatRule): string {
+ const parts: string[] = [rule.operator, String(rule.value)];
+ if (rule.operator === "between") {
+ parts.push(String(rule.value2 ?? rule.value));
+ }
+ parts.push(rule.color.replace(/^#/, ""));
+ return parts.join(",");
+}
+
+export function fromPivotFormattingParam(param: string): {
+ measureFormatting: Record | undefined;
+ invalidEntries: string[];
+} {
+ const measureFormatting: Record = {};
+ const invalidEntries: string[] = [];
+
+ for (const entry of param.split(";")) {
+ if (!entry) continue;
+ const parsed = parseEntry(entry);
+ if (parsed) {
+ measureFormatting[parsed.measure] = parsed.fmt;
+ } else {
+ invalidEntries.push(entry);
+ }
+ }
+
+ return {
+ measureFormatting: Object.keys(measureFormatting).length
+ ? measureFormatting
+ : undefined,
+ invalidEntries,
+ };
+}
+
+function parseEntry(
+ entry: string,
+): { measure: string; fmt: PivotMeasureFormatting } | undefined {
+ const [measure, mode, ...rest] = entry.split(":");
+ if (!measure || rest.length === 0) return undefined;
+
+ if (mode === "heatmap" || mode === "data_bar") {
+ const [scheme, reverse] = rest;
+ if (!scheme || (reverse !== undefined && reverse !== "reverse")) {
+ return undefined;
+ }
+ return {
+ measure,
+ fmt: {
+ mode,
+ scheme,
+ reverse: reverse === "reverse" || undefined,
+ },
+ };
+ }
+
+ if (mode === "rules") {
+ const rules: PivotFormatRule[] = [];
+ for (const ruleStr of rest.join(":").split("|")) {
+ const rule = parseRule(ruleStr);
+ if (!rule) return undefined;
+ rules.push(rule);
+ }
+ if (rules.length === 0) return undefined;
+ return { measure, fmt: { mode: "rules", rules } };
+ }
+
+ return undefined;
+}
+
+function parseRule(ruleStr: string): PivotFormatRule | undefined {
+ const parts = ruleStr.split(",");
+ const operator = parts[0] as PivotFormatRuleOperator;
+ if (!OPERATORS.has(operator)) return undefined;
+
+ const expectedParts = operator === "between" ? 4 : 3;
+ if (parts.length !== expectedParts) return undefined;
+
+ const value = Number(parts[1]);
+ if (!Number.isFinite(value)) return undefined;
+
+ const color = parts[expectedParts - 1];
+ if (!color) return undefined;
+
+ if (operator === "between") {
+ const value2 = Number(parts[2]);
+ if (!Number.isFinite(value2)) return undefined;
+ return { operator, value, value2, color };
+ }
+
+ return { operator, value, color };
+}
diff --git a/web-common/src/features/dashboards/pivot/types.ts b/web-common/src/features/dashboards/pivot/types.ts
index 38c2a0654b20..3f626065a6d6 100644
--- a/web-common/src/features/dashboards/pivot/types.ts
+++ b/web-common/src/features/dashboards/pivot/types.ts
@@ -63,10 +63,57 @@ export interface PivotState {
rowLimit?: number;
outermostRowLimit?: number; // Local limit for outermost dimension only
nestedRowLimits?: Record; // Local per-row limits keyed by expand index (e.g., "0.1.2")
+ // Per-measure conditional formatting, keyed by measure name. Measures not
+ // present here render without any cell formatting.
+ measureFormatting?: Record;
}
export type PivotTableMode = "flat" | "nest";
+// Conditional formatting applied to a measure's cells in the pivot table,
+// discriminated on `mode`.
+export type PivotMeasureFormatting =
+ | PivotScaleFormatting
+ | PivotRulesFormatting;
+
+// Value-scaled formatting: a color gradient (heatmap) or proportional bar
+// (data_bar) over the measure's min-max domain in the current result.
+export type PivotScaleFormatting = {
+ mode: "heatmap" | "data_bar";
+ // For heatmap: the gradient color scheme. For data_bar: the source of the bar
+ // color. Keyed into PIVOT_FORMATTING_SCHEMES (e.g. "greens", "redYellowGreen",
+ // "theme-sequential").
+ scheme: string;
+ // For heatmap: flip the gradient direction.
+ reverse?: boolean;
+};
+
+// Threshold-based formatting: an ordered rule list where the first matching
+// rule colors the cell (Excel convention). Cells matching no rule are left
+// unformatted.
+export type PivotRulesFormatting = {
+ mode: "rules";
+ rules: PivotFormatRule[];
+};
+
+export type PivotFormatRuleOperator =
+ | "gt"
+ | "gte"
+ | "lt"
+ | "lte"
+ | "eq"
+ | "between";
+
+export type PivotFormatRule = {
+ operator: PivotFormatRuleOperator;
+ value: number;
+ // Upper bound (inclusive), only for "between".
+ value2?: number;
+ // A semantic color key from PIVOT_RULE_COLORS (e.g. "positive") or a raw hex
+ // string (with or without the leading "#").
+ color: string;
+};
+
export interface PivotDataRow {
subRows?: PivotDataRow[];
diff --git a/web-common/src/features/dashboards/proto-state/fromProto.ts b/web-common/src/features/dashboards/proto-state/fromProto.ts
index c5c9553b685a..80fdee93e7f4 100644
--- a/web-common/src/features/dashboards/proto-state/fromProto.ts
+++ b/web-common/src/features/dashboards/proto-state/fromProto.ts
@@ -7,6 +7,8 @@ import { LeaderboardContextColumn } from "@rilldata/web-common/features/dashboar
import {
type PivotChipData,
PivotChipType,
+ type PivotFormatRule,
+ type PivotMeasureFormatting,
type PivotState,
type PivotTableMode,
} from "@rilldata/web-common/features/dashboards/pivot/types";
@@ -437,9 +439,50 @@ function fromPivotProto(
dashboard.pivotTableMode || DashboardState_PivotTableMode.NEST
],
rowLimit: dashboard.pivotRowLimit,
+ measureFormatting: fromPivotConditionalFormattingProto(
+ dashboard.pivotConditionalFormatting,
+ ),
};
}
+export function fromPivotConditionalFormattingProto(
+ formats: {
+ measure: string;
+ mode: string;
+ scheme: string;
+ reverse: boolean;
+ rules: {
+ operator: string;
+ value: number;
+ value2?: number;
+ color: string;
+ }[];
+ }[],
+): Record | undefined {
+ if (!formats?.length) return undefined;
+ const measureFormatting: Record = {};
+ for (const f of formats) {
+ if (f.mode === "heatmap" || f.mode === "data_bar") {
+ measureFormatting[f.measure] = {
+ mode: f.mode,
+ scheme: f.scheme,
+ reverse: f.reverse || undefined,
+ };
+ } else if (f.mode === "rules" && f.rules.length) {
+ measureFormatting[f.measure] = {
+ mode: "rules",
+ rules: f.rules.map((r) => ({
+ operator: r.operator as PivotFormatRule["operator"],
+ value: r.value,
+ value2: r.value2,
+ color: r.color,
+ })),
+ };
+ }
+ }
+ return Object.keys(measureFormatting).length ? measureFormatting : undefined;
+}
+
function blankPivotState(): PivotState {
return {
rows: [],
diff --git a/web-common/src/features/dashboards/proto-state/toProto.ts b/web-common/src/features/dashboards/proto-state/toProto.ts
index 41cfa3a09e08..7b1d48788abc 100644
--- a/web-common/src/features/dashboards/proto-state/toProto.ts
+++ b/web-common/src/features/dashboards/proto-state/toProto.ts
@@ -334,6 +334,27 @@ function toPivotProto(pivotState: PivotState): PartialMessage {
pivotRowLimit: pivotState.rowLimit,
pivotShowTotalsColumn: pivotState.showTotalsColumn,
pivotShowTotalsRow: pivotState.showTotalsRow,
+ pivotConditionalFormatting: Object.entries(
+ pivotState.measureFormatting ?? {},
+ ).map(([measure, fmt]) =>
+ fmt.mode === "rules"
+ ? {
+ measure,
+ mode: fmt.mode,
+ rules: fmt.rules.map((r) => ({
+ operator: r.operator,
+ value: r.value,
+ value2: r.value2,
+ color: r.color,
+ })),
+ }
+ : {
+ measure,
+ mode: fmt.mode,
+ scheme: fmt.scheme,
+ reverse: fmt.reverse ?? false,
+ },
+ ),
};
}
diff --git a/web-common/src/features/dashboards/stores/dashboard-stores.ts b/web-common/src/features/dashboards/stores/dashboard-stores.ts
index 690d183e5de0..6beecd0c54b3 100644
--- a/web-common/src/features/dashboards/stores/dashboard-stores.ts
+++ b/web-common/src/features/dashboards/stores/dashboard-stores.ts
@@ -30,6 +30,7 @@ import { SortType } from "web-common/src/features/dashboards/proto-state/derived
import {
PivotChipType,
type PivotChipData,
+ type PivotMeasureFormatting,
type PivotTableMode,
} from "../pivot/types";
@@ -596,6 +597,25 @@ const metricsViewReducers = {
});
},
+ setPivotMeasureFormatting(
+ name: string,
+ measureName: string,
+ fmt: PivotMeasureFormatting | null,
+ ) {
+ updateMetricsExplorerByName(name, (exploreState) => {
+ const measureFormatting = { ...exploreState.pivot.measureFormatting };
+ if (fmt) {
+ measureFormatting[measureName] = fmt;
+ } else {
+ delete measureFormatting[measureName];
+ }
+ exploreState.pivot = {
+ ...exploreState.pivot,
+ measureFormatting,
+ };
+ });
+ },
+
setPivotRowLimitForExpandedRow(
name: string,
expandIndex: string,
diff --git a/web-common/src/features/dashboards/url-state/convert-partial-explore-state-to-url-params.ts b/web-common/src/features/dashboards/url-state/convert-partial-explore-state-to-url-params.ts
index c20a4b1e9749..30e9b9a8e48e 100644
--- a/web-common/src/features/dashboards/url-state/convert-partial-explore-state-to-url-params.ts
+++ b/web-common/src/features/dashboards/url-state/convert-partial-explore-state-to-url-params.ts
@@ -1,4 +1,5 @@
import { mergeDimensionAndMeasureFilters } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils";
+import { toPivotFormattingParam } from "@rilldata/web-common/features/dashboards/pivot/pivot-formatting-param";
import {
type PivotChipData,
PivotChipType,
@@ -449,6 +450,13 @@ function toPivotUrlParams(partialExploreState: Partial) {
searchParams.set(ExploreStateURLParams.PivotShowTotalsRow, "false");
}
+ // Always set so clearing formatting removes it from the URL; cleanUrlParams
+ // strips the empty value.
+ searchParams.set(
+ ExploreStateURLParams.PivotFormatting,
+ toPivotFormattingParam(partialExploreState.pivot.measureFormatting),
+ );
+
// TODO: other fields like expanded state and pin are not supported right now
return searchParams;
}
diff --git a/web-common/src/features/dashboards/url-state/convertLegacyStateToExplorePreset.ts b/web-common/src/features/dashboards/url-state/convertLegacyStateToExplorePreset.ts
index 892058c74cd9..908e10ff19ef 100644
--- a/web-common/src/features/dashboards/url-state/convertLegacyStateToExplorePreset.ts
+++ b/web-common/src/features/dashboards/url-state/convertLegacyStateToExplorePreset.ts
@@ -1,8 +1,10 @@
+import { toPivotFormattingParam } from "@rilldata/web-common/features/dashboards/pivot/pivot-formatting-param";
import { FromProtoTimeGrainMap } from "@rilldata/web-common/features/dashboards/proto-state/enum-maps";
import { convertFilterToExpression } from "@rilldata/web-common/features/dashboards/proto-state/filter-converter";
import {
correctComparisonTimeRange,
fromExpressionProto,
+ fromPivotConditionalFormattingProto,
} from "@rilldata/web-common/features/dashboards/proto-state/fromProto";
import {
createAndExpression,
@@ -410,6 +412,14 @@ function fromLegacyPivotFields(
preset.pivotSortAsc = !sortBy.desc;
}
+ if (legacyState.pivotConditionalFormatting?.length) {
+ preset.pivotFormatting = toPivotFormattingParam(
+ fromPivotConditionalFormattingProto(
+ legacyState.pivotConditionalFormatting,
+ ),
+ );
+ }
+
// TODO: other fields like expanded state and pin are not supported right now
return { preset, errors };
}
diff --git a/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts b/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts
index 717b4f53cfef..6df0c4e0dfd6 100644
--- a/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts
+++ b/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts
@@ -1,4 +1,5 @@
import { splitWhereFilter } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-utils";
+import { fromPivotFormattingParam } from "@rilldata/web-common/features/dashboards/pivot/pivot-formatting-param";
import {
type PivotChipData,
PivotChipType,
@@ -455,6 +456,9 @@ function fromPivotUrlParams(
const showPivot = preset.view === V1ExploreWebView.EXPLORE_WEB_VIEW_PIVOT;
const showTotalsColumn = preset.pivotShowTotalsColumn ?? true;
const showTotalsRow = preset.pivotShowTotalsRow ?? true;
+ const measureFormatting = preset.pivotFormatting
+ ? fromPivotFormattingParam(preset.pivotFormatting).measureFormatting
+ : undefined;
if (!hasSomePivotFields && !showPivot) {
return {
@@ -471,6 +475,7 @@ function fromPivotUrlParams(
showTotalsColumn,
showTotalsRow,
tableMode: "nest",
+ measureFormatting,
},
},
errors,
@@ -530,6 +535,7 @@ function fromPivotUrlParams(
showTotalsRow,
tableMode,
rowLimit,
+ measureFormatting,
},
},
errors,
diff --git a/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts b/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts
index 2ece1cd57790..35ea0df51d5d 100644
--- a/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts
+++ b/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts
@@ -1,5 +1,9 @@
import { stripMeasureSuffix } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry";
import { PIVOT_ROW_LIMIT_OPTIONS } from "@rilldata/web-common/features/dashboards/pivot/pivot-constants";
+import {
+ fromPivotFormattingParam,
+ toPivotFormattingParam,
+} from "@rilldata/web-common/features/dashboards/pivot/pivot-formatting-param";
import { base64ToProto } from "@rilldata/web-common/features/dashboards/proto-state/fromProto";
import {
createAndExpression,
@@ -669,6 +673,28 @@ function fromPivotUrlParams(
searchParams.get(ExploreStateURLParams.PivotShowTotalsRow) !== "false";
}
+ if (searchParams.has(ExploreStateURLParams.PivotFormatting)) {
+ const formattingParam = searchParams.get(
+ ExploreStateURLParams.PivotFormatting,
+ ) as string;
+ const { measureFormatting, invalidEntries } =
+ fromPivotFormattingParam(formattingParam);
+ // Drop entries for unknown measures; keep the rest.
+ const validFormatting = Object.fromEntries(
+ Object.entries(measureFormatting ?? {}).filter(([measureName]) => {
+ if (measures.has(measureName)) return true;
+ invalidEntries.push(measureName);
+ return false;
+ }),
+ );
+ preset.pivotFormatting = toPivotFormattingParam(
+ Object.keys(validFormatting).length ? validFormatting : undefined,
+ );
+ if (invalidEntries.length) {
+ errors.push(getMultiFieldError("pivot formatting", invalidEntries));
+ }
+ }
+
// TODO: other fields like expanded state and pin are not supported right now
return { preset, errors };
}
diff --git a/web-common/src/features/dashboards/url-state/explore-web-view-specific-url-params.ts b/web-common/src/features/dashboards/url-state/explore-web-view-specific-url-params.ts
index 8f2a8d8f5f24..fe486290ccd3 100644
--- a/web-common/src/features/dashboards/url-state/explore-web-view-specific-url-params.ts
+++ b/web-common/src/features/dashboards/url-state/explore-web-view-specific-url-params.ts
@@ -38,6 +38,7 @@ export const ExploreWebViewSpecificURLParams: Record<
ExploreStateURLParams.PivotRowLimit,
ExploreStateURLParams.PivotShowTotalsColumn,
ExploreStateURLParams.PivotShowTotalsRow,
+ ExploreStateURLParams.PivotFormatting,
ExploreStateURLParams.SortBy,
ExploreStateURLParams.SortDirection,
],
diff --git a/web-common/src/features/dashboards/url-state/url-params.ts b/web-common/src/features/dashboards/url-state/url-params.ts
index f617b3a4e256..4971206d9e88 100644
--- a/web-common/src/features/dashboards/url-state/url-params.ts
+++ b/web-common/src/features/dashboards/url-state/url-params.ts
@@ -33,6 +33,7 @@ export enum ExploreStateURLParams {
PivotRowLimit = "row_limit",
PivotShowTotalsColumn = "show_totals_column",
PivotShowTotalsRow = "show_totals_row",
+ PivotFormatting = "format",
DynamicYAxisScale = "dyn_y",
diff --git a/web-common/src/proto/gen/rill/runtime/v1/resources_pb.ts b/web-common/src/proto/gen/rill/runtime/v1/resources_pb.ts
index 265ff2034991..054be1e9cfde 100644
--- a/web-common/src/proto/gen/rill/runtime/v1/resources_pb.ts
+++ b/web-common/src/proto/gen/rill/runtime/v1/resources_pb.ts
@@ -3239,6 +3239,14 @@ export class ExplorePreset extends Message {
*/
pivotShowTotalsRow?: boolean;
+ /**
+ * Per-measure pivot conditional formatting, serialized in the URL param
+ * format (frontend-only; persisted in URL state).
+ *
+ * @generated from field: optional string pivot_formatting = 39;
+ */
+ pivotFormatting?: string;
+
/**
* Chart display settings (frontend-only; persisted in URL state)
*
@@ -3287,6 +3295,7 @@ export class ExplorePreset extends Message {
{ no: 33, name: "pivot_row_limit", kind: "scalar", T: 5 /* ScalarType.INT32 */, opt: true },
{ no: 37, name: "pivot_show_totals_column", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true },
{ no: 38, name: "pivot_show_totals_row", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true },
+ { no: 39, name: "pivot_formatting", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true },
{ no: 35, name: "chart_dynamic_y_axis", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true },
]);
diff --git a/web-common/src/proto/gen/rill/ui/v1/dashboard_pb.ts b/web-common/src/proto/gen/rill/ui/v1/dashboard_pb.ts
index d5e9d5112e3b..8deb439724bd 100644
--- a/web-common/src/proto/gen/rill/ui/v1/dashboard_pb.ts
+++ b/web-common/src/proto/gen/rill/ui/v1/dashboard_pb.ts
@@ -281,6 +281,13 @@ export class DashboardState extends Message {
*/
pivotColumnAllDimensions: PivotElement[] = [];
+ /**
+ * Per-measure conditional formatting (heatmap / data bar) for pivot cells.
+ *
+ * @generated from field: repeated rill.ui.v1.PivotConditionalFormat pivot_conditional_formatting = 44;
+ */
+ pivotConditionalFormatting: PivotConditionalFormat[] = [];
+
constructor(data?: PartialMessage) {
super();
proto3.util.initPartial(data, this);
@@ -331,6 +338,7 @@ export class DashboardState extends Message {
{ no: 43, name: "pivot_show_totals_row", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true },
{ no: 35, name: "pivot_row_all_dimensions", kind: "message", T: PivotElement, repeated: true },
{ no: 36, name: "pivot_column_all_dimensions", kind: "message", T: PivotElement, repeated: true },
+ { no: 44, name: "pivot_conditional_formatting", kind: "message", T: PivotConditionalFormat, repeated: true },
]);
static fromBinary(bytes: Uint8Array, options?: Partial): DashboardState {
@@ -729,3 +737,138 @@ export class PivotElement extends Message {
}
}
+/**
+ * Conditional formatting applied to a measure's cells in a pivot table.
+ *
+ * @generated from message rill.ui.v1.PivotConditionalFormat
+ */
+export class PivotConditionalFormat extends Message {
+ /**
+ * @generated from field: string measure = 1;
+ */
+ measure = "";
+
+ /**
+ * Formatting style: "heatmap", "data_bar" or "rules".
+ *
+ * @generated from field: string mode = 2;
+ */
+ mode = "";
+
+ /**
+ * Color scheme key (e.g. "theme-sequential", "greens", "redYellowGreen").
+ * Only for "heatmap" and "data_bar" modes.
+ *
+ * @generated from field: string scheme = 3;
+ */
+ scheme = "";
+
+ /**
+ * Flip the gradient direction (heatmap only).
+ *
+ * @generated from field: bool reverse = 4;
+ */
+ reverse = false;
+
+ /**
+ * Ordered threshold rules; first match wins. Only for "rules" mode.
+ *
+ * @generated from field: repeated rill.ui.v1.PivotFormatRule rules = 5;
+ */
+ rules: PivotFormatRule[] = [];
+
+ constructor(data?: PartialMessage) {
+ super();
+ proto3.util.initPartial(data, this);
+ }
+
+ static readonly runtime: typeof proto3 = proto3;
+ static readonly typeName = "rill.ui.v1.PivotConditionalFormat";
+ static readonly fields: FieldList = proto3.util.newFieldList(() => [
+ { no: 1, name: "measure", kind: "scalar", T: 9 /* ScalarType.STRING */ },
+ { no: 2, name: "mode", kind: "scalar", T: 9 /* ScalarType.STRING */ },
+ { no: 3, name: "scheme", kind: "scalar", T: 9 /* ScalarType.STRING */ },
+ { no: 4, name: "reverse", kind: "scalar", T: 8 /* ScalarType.BOOL */ },
+ { no: 5, name: "rules", kind: "message", T: PivotFormatRule, repeated: true },
+ ]);
+
+ static fromBinary(bytes: Uint8Array, options?: Partial): PivotConditionalFormat {
+ return new PivotConditionalFormat().fromBinary(bytes, options);
+ }
+
+ static fromJson(jsonValue: JsonValue, options?: Partial): PivotConditionalFormat {
+ return new PivotConditionalFormat().fromJson(jsonValue, options);
+ }
+
+ static fromJsonString(jsonString: string, options?: Partial): PivotConditionalFormat {
+ return new PivotConditionalFormat().fromJsonString(jsonString, options);
+ }
+
+ static equals(a: PivotConditionalFormat | PlainMessage | undefined, b: PivotConditionalFormat | PlainMessage | undefined): boolean {
+ return proto3.util.equals(PivotConditionalFormat, a, b);
+ }
+}
+
+/**
+ * A single threshold rule for "rules" mode conditional formatting.
+ *
+ * @generated from message rill.ui.v1.PivotFormatRule
+ */
+export class PivotFormatRule extends Message {
+ /**
+ * Comparison operator: "gt", "gte", "lt", "lte", "eq" or "between".
+ *
+ * @generated from field: string operator = 1;
+ */
+ operator = "";
+
+ /**
+ * @generated from field: double value = 2;
+ */
+ value = 0;
+
+ /**
+ * Upper bound (inclusive), only for "between".
+ *
+ * @generated from field: optional double value2 = 3;
+ */
+ value2?: number;
+
+ /**
+ * Semantic color key (e.g. "positive") or hex string.
+ *
+ * @generated from field: string color = 4;
+ */
+ color = "";
+
+ constructor(data?: PartialMessage) {
+ super();
+ proto3.util.initPartial(data, this);
+ }
+
+ static readonly runtime: typeof proto3 = proto3;
+ static readonly typeName = "rill.ui.v1.PivotFormatRule";
+ static readonly fields: FieldList = proto3.util.newFieldList(() => [
+ { no: 1, name: "operator", kind: "scalar", T: 9 /* ScalarType.STRING */ },
+ { no: 2, name: "value", kind: "scalar", T: 1 /* ScalarType.DOUBLE */ },
+ { no: 3, name: "value2", kind: "scalar", T: 1 /* ScalarType.DOUBLE */, opt: true },
+ { no: 4, name: "color", kind: "scalar", T: 9 /* ScalarType.STRING */ },
+ ]);
+
+ static fromBinary(bytes: Uint8Array, options?: Partial): PivotFormatRule {
+ return new PivotFormatRule().fromBinary(bytes, options);
+ }
+
+ static fromJson(jsonValue: JsonValue, options?: Partial): PivotFormatRule {
+ return new PivotFormatRule().fromJson(jsonValue, options);
+ }
+
+ static fromJsonString(jsonString: string, options?: Partial): PivotFormatRule {
+ return new PivotFormatRule().fromJsonString(jsonString, options);
+ }
+
+ static equals(a: PivotFormatRule | PlainMessage | undefined, b: PivotFormatRule | PlainMessage | undefined): boolean {
+ return proto3.util.equals(PivotFormatRule, a, b);
+ }
+}
+
diff --git a/web-common/src/runtime-client/gen/index.schemas.ts b/web-common/src/runtime-client/gen/index.schemas.ts
index 4058c76cc057..b39332d570d2 100644
--- a/web-common/src/runtime-client/gen/index.schemas.ts
+++ b/web-common/src/runtime-client/gen/index.schemas.ts
@@ -947,6 +947,8 @@ If not found in `time_ranges`, it should be added to the list. */
pivotRowLimit?: number;
pivotShowTotalsColumn?: boolean;
pivotShowTotalsRow?: boolean;
+ /** Per-measure pivot conditional formatting, serialized in the URL param format. */
+ pivotFormatting?: string;
/** When true, time-series charts use a dynamic Y-axis scale that fits the visible data range. */
chartDynamicYAxis?: boolean;
}
| |