From 8443e753d13ddaa8a645609441af33f63ac863a4 Mon Sep 17 00:00:00 2001 From: Tanay Bensu Yurtturk Date: Tue, 1 Sep 2026 15:34:24 +0300 Subject: [PATCH 1/3] Add per-slice color and label overrides for pie/treemap/funnel Adds a widget-level slices map ({label: {color, label}}) so label/value charts can recolor and rename individual slices, mirroring the existing per-series map. Wired through the schema, Go model/loader/validator, TS types, and the chart renderer. --- .claude/skills/create-dashboard/SKILL.md | 2 +- cmd/skill_templates/create-dashboard/SKILL.md | 2 +- docs/dashboards/widgets.md | 3 ++ .../src/components/widgets/ChartWidget.tsx | 41 +++++++++++++------ frontend/src/types/dashboard.ts | 7 ++++ pkg/dashboard/jsloader.go | 20 +++++++++ pkg/dashboard/model.go | 27 ++++++++---- pkg/dashboard/validator.go | 7 ++++ schemas/dac/dashboard/v1/schema.json | 15 +++++++ 9 files changed, 100 insertions(+), 24 deletions(-) diff --git a/.claude/skills/create-dashboard/SKILL.md b/.claude/skills/create-dashboard/SKILL.md index 87cc321..1c95bd4 100644 --- a/.claude/skills/create-dashboard/SKILL.md +++ b/.claude/skills/create-dashboard/SKILL.md @@ -456,7 +456,7 @@ Line/area (and combo) charts also accept these on `y`: - `curve` — chart-wide line interpolation: `smooth` | `straight` | `stepline`. Use `straight` for period totals so the line doesn't imply movement between points. - `dash` — chart-wide dash pattern every series inherits: `dotted`, `dashed`, or `long-dash` (omitted = solid). -Per-series style overrides live in a **widget-level `series`** map (a sibling of `x`/`y`, not inside `y`), keyed by y-column: `series: { target: { color: "#EC4899", curve: straight, dash: dashed } }`. Each of `color`/`curve`/`dash` is optional and falls back to the chart-wide default (or the theme palette for colour). Store only genuine differences — a column with no entry inherits everything. +Per-series style overrides live in a **widget-level `series`** map (a sibling of `x`/`y`, not inside `y`), keyed by y-column: `series: { target: { color: "#EC4899", curve: straight, dash: dashed } }`. Each of `color`/`curve`/`dash` is optional and falls back to the chart-wide default (or the theme palette for colour). Store only genuine differences — a column with no entry inherits everything. Label/value charts (`pie`/`treemap`/`funnel`) have no y-column series; style their **slices** with a sibling **`slices`** map keyed by the slice's data label: `slices: { Enterprise: { color: "#8B5CF6", label: "Enterprise (2026)" } }`. `color` overrides the palette; `label` renames the displayed slice. Both optional. Bare column names (`x: month`, `y: [revenue]`) are invalid — always wrap in `{ field: ... }`. diff --git a/cmd/skill_templates/create-dashboard/SKILL.md b/cmd/skill_templates/create-dashboard/SKILL.md index f9a42ae..658fb17 100644 --- a/cmd/skill_templates/create-dashboard/SKILL.md +++ b/cmd/skill_templates/create-dashboard/SKILL.md @@ -220,7 +220,7 @@ rows: col: 6 ``` -A chart's `x` and `y` are axis encoding objects with a required `field` (bare column names like `x: region` are invalid). `field` may be a single column or a list. On line/area (and combo), `y` also takes `beginAtZero: true` (anchor the value axis at 0), `markers: false` (hide point dots), `curve` (`smooth`/`straight`/`stepline` — chart-wide interpolation; use `straight` for period totals), and `dash` (chart-wide dash pattern every series inherits: `dotted`/`dashed`/`long-dash`; omit for solid). Per-series style overrides go in a **widget-level `series`** map (a sibling of `x`/`y`, not inside `y`), keyed by y-column: `series: {column: {color: "#EC4899", curve: straight, dash: dashed}}` — each key falls back to the chart-wide default / palette; store only genuine differences. +A chart's `x` and `y` are axis encoding objects with a required `field` (bare column names like `x: region` are invalid). `field` may be a single column or a list. On line/area (and combo), `y` also takes `beginAtZero: true` (anchor the value axis at 0), `markers: false` (hide point dots), `curve` (`smooth`/`straight`/`stepline` — chart-wide interpolation; use `straight` for period totals), and `dash` (chart-wide dash pattern every series inherits: `dotted`/`dashed`/`long-dash`; omit for solid). Per-series style overrides go in a **widget-level `series`** map (a sibling of `x`/`y`, not inside `y`), keyed by y-column: `series: {column: {color: "#EC4899", curve: straight, dash: dashed}}` — each key falls back to the chart-wide default / palette; store only genuine differences. Label/value charts (`pie`/`treemap`/`funnel`) style per **slice** instead, via a sibling **`slices`** map keyed by the slice's data label: `slices: {Enterprise: {color: "#8B5CF6", label: "Enterprise (2026)"}}` — `color` overrides the palette, `label` renames the displayed slice; both optional. Add a second (right-hand) value axis with `y2` when two series live on different scales (e.g. revenue `$` and conversion `%`) and one would otherwise be squashed flat. A y-column plots against the right axis when it is listed in `y2.field`; all other series stay on the left `y` axis. `y2` is a full axis encoding (same `title`/`format`/`beginAtZero`/`curve`/`dash` keys as `y`) and each axis formats its ticks and tooltip values independently. Supported on `line`/`area`/`bar`/`combo`; a column belongs to exactly one axis, `y2.type` must be `number`, and `y2` cannot combine with `stacked`, `horizontal` bars, or `color`. Axis (`y` vs `y2`) and shape (bar vs line via `lines`) are independent — the classic combo is revenue bars on the left with a rate line on the right: `chart: combo`, `lines: [conversion_rate]`, `y: {field: [revenue], format: "$,.0f"}`, `y2: {field: [conversion_rate], format: ".1%"}`. diff --git a/docs/dashboards/widgets.md b/docs/dashboards/widgets.md index 40a3a97..53e18ce 100644 --- a/docs/dashboards/widgets.md +++ b/docs/dashboards/widgets.md @@ -133,6 +133,8 @@ SQL-backed example: Per-series style overrides live in a **widget-level `series`** map (a sibling of `x`/`y`, not inside `y`), keyed by y-column: `series: { revenue: { color: "#EC4899", curve: straight, dash: dashed } }`. Each of `color`/`curve`/`dash` falls back to the chart-wide default (or palette for colour). Store only genuine differences. +Label/value charts (`pie`, `treemap`, `funnel`) have no y-column series — style their **slices** with a widget-level **`slices`** map keyed by the slice's data label: `slices: { Enterprise: { color: "#8B5CF6", label: "Enterprise (2026)" } }`. `color` overrides the palette; `label` renames the displayed slice. Both optional. + Without `format`, ticks fall back to automatic compact formatting. `beginAtZero`, `markers`, `curve`, and `dash` (on `y`) plus the widget-level `series` apply to line/area (and combo) charts. Bare column names (`x: month`, `y: [revenue]`) are not valid — always wrap the column in `{ field: ... }`. ### Second value axis (`y2`) @@ -211,6 +213,7 @@ Common chart fields: | `y` | object | Y-axis encoding (`field` may list several series columns; supports `beginAtZero`, `markers`, `curve`, `dash`, and per-series `series` overrides for line/area — see [Axis encoding](#axis-encoding)) | | `label` | string | Label column for pie, funnel, and treemap charts | | `value` | object | Value encoding (`{ field: ... }`) for pie, funnel, sankey, heatmap, calendar, treemap, and gauge charts | +| `slices` | object | Per-slice style overrides for pie/treemap/funnel, keyed by slice label: `{ Enterprise: { color: "#8B5CF6", label: "Enterprise (2026)" } }` | | `source` | string | Source node column for sankey charts | | `target` | string | Target/max column for gauge charts (also sankey target node) | | `open` | string | Open column for candlestick charts | diff --git a/frontend/src/components/widgets/ChartWidget.tsx b/frontend/src/components/widgets/ChartWidget.tsx index 236bf16..dd9aea3 100644 --- a/frontend/src/components/widgets/ChartWidget.tsx +++ b/frontend/src/components/widgets/ChartWidget.tsx @@ -10,7 +10,7 @@ import { ReferenceLine, ReferenceArea, ErrorBar, } from "recharts"; import type { TreemapNode } from "recharts"; -import type { ColorScale, Widget, WidgetData } from "../../types/dashboard"; +import type { ColorScale, SliceStyle, Widget, WidgetData } from "../../types/dashboard"; import { axisField, axisFields, buildAxisFormatter, valueField } from "../../lib/format"; import { useTokens } from "../../themes/TemplateProvider"; import { cellColor, resolveScale } from "./conditionalFormat"; @@ -1031,6 +1031,7 @@ function FunnelChart({ labelKey, valueKey, colors, + slices, tokens, height, horizontal = false, @@ -1040,6 +1041,7 @@ function FunnelChart({ labelKey: string; valueKey: string; colors: string[]; + slices?: Record; tokens: Record; height: number; horizontal?: boolean; @@ -1069,6 +1071,10 @@ function FunnelChart({ stepConv: i === 0 ? null : raw[i - 1].value > 0 ? (s.value / raw[i - 1].value) * 100 : 0, // Fade successive stages so depth reads without extra chrome. opacity: 1 - (i / Math.max(raw.length, 1)) * 0.55, + // Per-stage slices[label] override, else the single funnel colour. + color: slices?.[s.label]?.color || barColor, + // Renamed display name, else the raw label. + name: slices?.[s.label]?.label?.trim() || s.label, })); if (horizontal) { @@ -1090,14 +1096,14 @@ function FunnelChart({ - {s.label} + {s.name}
- {s.label} + {s.name} {Math.round(s.pctOfTop)}% @@ -1149,7 +1155,7 @@ function FunnelChart({
curveType(own ? (styleOf(field).curve ?? axisOf(field)?.curve) : widget.y?.curve); const seriesDash = (field: string, own: boolean) => dashArrayFor(own ? (styleOf(field).dash ?? axisOf(field)?.dash) : widget.y?.dash); const seriesColor = (field: string, i: number, own: boolean) => (own && styleOf(field).color) || colors[i % colors.length]; + // Per-slice colour for label/value charts (pie/treemap/funnel), keyed by the + // slice's data label; falls back to the palette by position. + const sliceColor = (label: unknown, i: number) => widget.slices?.[String(label ?? "")]?.color || colors[i % colors.length]; + // Display name for a slice: the slices[label].label rename, else the raw label. + const sliceLabel = (label: unknown) => widget.slices?.[String(label ?? "")]?.label?.trim() || String(label ?? ""); const yDomain: [number, string] | undefined = widget.y?.beginAtZero ? [0, "auto"] : undefined; // Point markers: shown on sparse line/area series by default, hidden when // y.markers is false (dense series stay dotless to avoid clutter). @@ -1466,13 +1477,16 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu } case "pie": { + const nameKey = widget.label || "label"; + // Rename slices for display; Cells still key colours off the raw label. + const pieData = chartData.map((d) => ({ ...d, [nameKey]: sliceLabel(d[nameKey]) })); return ( - {chartData.map((_, i) => ( - + {chartData.map((row, i) => ( + ))} } /> @@ -1629,6 +1643,7 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu labelKey={widget.label || "label"} valueKey={valueField(widget.value) || "value"} colors={colors} + slices={widget.slices} tokens={tokens} height={chartHeight} horizontal={widget.horizontal} @@ -1784,9 +1799,9 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu const labelKey = widget.label || "label"; const valueKey = valueField(widget.value) || "value"; const tmData = chartData.map((d, i) => ({ - name: String(d[labelKey]), + name: sliceLabel(d[labelKey]), size: Number(d[valueKey]) || 0, - fill: colors[i % colors.length], + fill: sliceColor(d[labelKey], i), })); return ( diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index 2ad1732..5c650be 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -97,6 +97,7 @@ export interface Widget { colorScale?: ColorScale; // heatmap: color ramp, same keys as a table gradient lines?: string[]; // combo: which y series are lines series?: Record; // per-series line style overrides, keyed by y-column + slices?: Record; // per-slice style overrides (pie/treemap/funnel), keyed by slice label // xmr control limit column; line/bar/forest CI bound — a column name, or a // per-series map { series column: bound column } for multi-line CI bands. yMin?: string | Record; @@ -231,6 +232,12 @@ export interface SeriesStyle { dash?: "solid" | "dotted" | "dashed" | "long-dash"; // falls back to y.dash } +/** Per-slice style override, grouped under widget-level `slices` (keyed by slice label). */ +export interface SliceStyle { + color?: string; // #hex; unset uses the palette + label?: string; // display name; unset uses the raw data label +} + /** Encoding for the color channel: the category column that splits y into series. */ export interface ColorEncoding { field: string; diff --git a/pkg/dashboard/jsloader.go b/pkg/dashboard/jsloader.go index 0d053e4..1492909 100644 --- a/pkg/dashboard/jsloader.go +++ b/pkg/dashboard/jsloader.go @@ -542,6 +542,7 @@ func vnodeToWidget(n *vnode) Widget { ColorScale: asColorScale(n.Props["colorScale"]), Lines: asStringSlice(n.Props["lines"]), Series: asSeriesStyles(n.Props["series"]), + Slices: asSliceStyles(n.Props["slices"]), YMin: asBoundEncoding(n.Props["yMin"]), YMax: asBoundEncoding(n.Props["yMax"]), RefLines: asRefLines(n.Props["refLines"]), @@ -814,6 +815,25 @@ func asSeriesStyles(v interface{}) map[string]SeriesStyle { return out } +func asSliceStyles(v interface{}) map[string]SliceStyle { + m, ok := v.(map[string]interface{}) + if !ok { + return nil + } + out := make(map[string]SliceStyle, len(m)) + for k, item := range m { + sm, ok := item.(map[string]interface{}) + if !ok { + continue + } + out[k] = SliceStyle{Color: asString(sm["color"]), Label: asString(sm["label"])} + } + if len(out) == 0 { + return nil + } + return out +} + func asColorScale(v interface{}) *ColorScale { m, ok := v.(map[string]interface{}) if !ok { diff --git a/pkg/dashboard/model.go b/pkg/dashboard/model.go index 05310e9..8c893c0 100644 --- a/pkg/dashboard/model.go +++ b/pkg/dashboard/model.go @@ -135,15 +135,17 @@ type Widget struct { ColorScale *ColorScale `yaml:"colorScale,omitempty" json:"colorScale,omitempty"` // heatmap: custom color ramp, same keys as a table gradient Lines []string `yaml:"lines,omitempty" json:"lines,omitempty"` // combo: which y series render as lines // Series holds per-series line style overrides keyed by y-column: {column: {color, curve, dash}}. - Series map[string]SeriesStyle `yaml:"series,omitempty" json:"series,omitempty"` - YMin *BoundEncoding `yaml:"yMin,omitempty" json:"yMin,omitempty"` // xmr: min control limit column; line/bar/forest: CI lower bound (column or per-series map) - YMax *BoundEncoding `yaml:"yMax,omitempty" json:"yMax,omitempty"` // xmr: max control limit column; line/bar/forest: CI upper bound (column or per-series map) - Open string `yaml:"open,omitempty" json:"open,omitempty"` // candlestick: open price column - High string `yaml:"high,omitempty" json:"high,omitempty"` // candlestick: high price column - Low string `yaml:"low,omitempty" json:"low,omitempty"` // candlestick: low price column - Close string `yaml:"close,omitempty" json:"close,omitempty"` // candlestick: close price column - RefLines []RefLine `yaml:"refLines,omitempty" json:"refLines,omitempty"` // reference guide lines (axis + value + optional label) - RefBands []RefBand `yaml:"refBands,omitempty" json:"refBands,omitempty"` // shaded reference bands (axis + from/to + optional label) + Series map[string]SeriesStyle `yaml:"series,omitempty" json:"series,omitempty"` + // Slices holds per-slice style overrides for label/value charts (pie/treemap/funnel), keyed by slice label: {label: {color}}. + Slices map[string]SliceStyle `yaml:"slices,omitempty" json:"slices,omitempty"` + YMin *BoundEncoding `yaml:"yMin,omitempty" json:"yMin,omitempty"` // xmr: min control limit column; line/bar/forest: CI lower bound (column or per-series map) + YMax *BoundEncoding `yaml:"yMax,omitempty" json:"yMax,omitempty"` // xmr: max control limit column; line/bar/forest: CI upper bound (column or per-series map) + Open string `yaml:"open,omitempty" json:"open,omitempty"` // candlestick: open price column + High string `yaml:"high,omitempty" json:"high,omitempty"` // candlestick: high price column + Low string `yaml:"low,omitempty" json:"low,omitempty"` // candlestick: low price column + Close string `yaml:"close,omitempty" json:"close,omitempty"` // candlestick: close price column + RefLines []RefLine `yaml:"refLines,omitempty" json:"refLines,omitempty"` // reference guide lines (axis + value + optional label) + RefBands []RefBand `yaml:"refBands,omitempty" json:"refBands,omitempty"` // shaded reference bands (axis + from/to + optional label) // Table fields Columns []TableColumn `yaml:"columns,omitempty" json:"columns,omitempty"` @@ -554,6 +556,13 @@ type SeriesStyle struct { Dash string `yaml:"dash,omitempty" json:"dash,omitempty"` // solid | dotted | dashed | long-dash (falls back to y.dash) } +// SliceStyle is a per-slice style override for label/value charts, grouped under +// Widget.Slices and keyed by the slice's displayed label. +type SliceStyle struct { + Color string `yaml:"color,omitempty" json:"color,omitempty"` // #hex; unset uses the palette + Label string `yaml:"label,omitempty" json:"label,omitempty"` // display name; unset uses the raw data label +} + type AxisEncoding struct { Field any `yaml:"field" json:"field"` Type string `yaml:"type,omitempty" json:"type,omitempty"` diff --git a/pkg/dashboard/validator.go b/pkg/dashboard/validator.go index b5b56d5..15955fc 100644 --- a/pkg/dashboard/validator.go +++ b/pkg/dashboard/validator.go @@ -438,6 +438,13 @@ func validateChartWidget(prefix string, w *Widget, d *Dashboard) []string { } } + // Per-slice overrides (widget.slices) for label/value charts, keyed by label. + for label, st := range w.Slices { + if st.Color != "" && !isHexColor(st.Color) { + errs = append(errs, fmt.Sprintf("%s: slices[%q].color must be a hex colour like #EC4899", prefix, label)) + } + } + // Second value axis (right side). Only cartesian composed charts render two // y axes; a right-axis column must be numeric, non-empty, and disjoint from // the left axis. Stacking across two axes is undefined, so reject the combo. diff --git a/schemas/dac/dashboard/v1/schema.json b/schemas/dac/dashboard/v1/schema.json index 98fa6f8..78bf31c 100644 --- a/schemas/dac/dashboard/v1/schema.json +++ b/schemas/dac/dashboard/v1/schema.json @@ -355,6 +355,9 @@ "series": { "$ref": "#/$defs/seriesStyles" }, + "slices": { + "$ref": "#/$defs/sliceStyles" + }, "yMin": { "$ref": "#/$defs/boundEncoding" }, @@ -661,6 +664,18 @@ "additionalProperties": false } }, + "sliceStyles": { + "description": "Per-slice style overrides for label/value charts (pie/treemap/funnel), keyed by the slice's data label: {label: {color, label}}. The nested `label` renames the slice's displayed name.", + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "color": { "type": "string", "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$" }, + "label": { "type": "string" } + }, + "additionalProperties": false + } + }, "valueEncoding": { "type": "object", "required": ["field"], From d6cfe1f2b00f499c0aaac5962ba3ca266bcb6b84 Mon Sep 17 00:00:00 2001 From: Tanay Bensu Yurtturk Date: Tue, 1 Sep 2026 15:45:25 +0300 Subject: [PATCH 2/3] Keep pie slice rename off the value column Write the display name to a dedicated key so a widget whose label and value point at the same column no longer overwrites its numeric value. --- frontend/src/components/widgets/ChartWidget.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/widgets/ChartWidget.tsx b/frontend/src/components/widgets/ChartWidget.tsx index dd9aea3..769f3b5 100644 --- a/frontend/src/components/widgets/ChartWidget.tsx +++ b/frontend/src/components/widgets/ChartWidget.tsx @@ -1478,15 +1478,16 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu case "pie": { const nameKey = widget.label || "label"; - // Rename slices for display; Cells still key colours off the raw label. - const pieData = chartData.map((d) => ({ ...d, [nameKey]: sliceLabel(d[nameKey]) })); + // Display name in a dedicated key so it never clobbers the value column + // (label and value can be the same column); Cells key colours off the raw label. + const pieData = chartData.map((d) => ({ ...d, __sliceName: sliceLabel(d[nameKey]) })); return ( Date: Tue, 1 Sep 2026 15:50:45 +0300 Subject: [PATCH 3/3] Rename pie slices at the display layer only Apply the slice rename in the label/legend/tooltip formatters instead of injecting it into the row data, so no synthetic key can collide with a result column. --- .../src/components/widgets/ChartWidget.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/widgets/ChartWidget.tsx b/frontend/src/components/widgets/ChartWidget.tsx index 769f3b5..e807bfc 100644 --- a/frontend/src/components/widgets/ChartWidget.tsx +++ b/frontend/src/components/widgets/ChartWidget.tsx @@ -143,7 +143,7 @@ interface TooltipPayloadEntry { value?: unknown; } -function CustomTooltip({ active, payload, label, labelFormatter = formatAxisTick, valueFormatter = formatTooltipValue, valueFormatterFor }: { +function CustomTooltip({ active, payload, label, labelFormatter = formatAxisTick, valueFormatter = formatTooltipValue, valueFormatterFor, nameFormatter }: { active?: boolean; payload?: TooltipPayloadEntry[]; label?: unknown; @@ -152,6 +152,8 @@ function CustomTooltip({ active, payload, label, labelFormatter = formatAxisTick // Per-series formatter keyed by dataKey; used for dual-axis charts so each // row formats against its own axis. Falls back to valueFormatter. valueFormatterFor?: (dataKey: unknown) => (val: unknown) => string; + // Maps a payload entry's name for display (e.g. pie slice renames). + nameFormatter?: (name: unknown) => string; }) { if (!active || !payload?.length) return null; return ( @@ -160,7 +162,7 @@ function CustomTooltip({ active, payload, label, labelFormatter = formatAxisTick {payload.map((p, i) => (
- {p.name ?? p.dataKey} + {nameFormatter ? nameFormatter(p.name) : (p.name ?? p.dataKey)} {(valueFormatterFor?.(p.dataKey) ?? valueFormatter)(p.value)}
))} @@ -1478,16 +1480,15 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu case "pie": { const nameKey = widget.label || "label"; - // Display name in a dedicated key so it never clobbers the value column - // (label and value can be the same column); Cells key colours off the raw label. - const pieData = chartData.map((d) => ({ ...d, __sliceName: sliceLabel(d[nameKey]) })); + // Rename only at the display layer (label/legend/tooltip) so the underlying + // data is never mutated; Cells key colours off the raw label. return ( - `${name ?? ""} ${((percent ?? 0) * 100).toFixed(0)}%` + `${sliceLabel(name)} ${((percent ?? 0) * 100).toFixed(0)}%` } labelLine={false} style={AXIS_STYLE} @@ -1504,8 +1505,8 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu ))} - } /> - + } /> + sliceLabel(value)} /> );