Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/create-dashboard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: ... }`.

Expand Down
2 changes: 1 addition & 1 deletion cmd/skill_templates/create-dashboard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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%"}`.

Expand Down
3 changes: 3 additions & 0 deletions docs/dashboards/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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 |
Expand Down
51 changes: 34 additions & 17 deletions frontend/src/components/widgets/ChartWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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 (
Expand All @@ -160,7 +162,7 @@ function CustomTooltip({ active, payload, label, labelFormatter = formatAxisTick
{payload.map((p, i) => (
<div key={i} className="dac-tooltip-row">
<span className="dac-tooltip-dot" style={{ background: p.color ?? p.fill }} />
<span className="dac-tooltip-name">{p.name ?? p.dataKey}</span>
<span className="dac-tooltip-name">{nameFormatter ? nameFormatter(p.name) : (p.name ?? p.dataKey)}</span>
<span className="dac-tooltip-value">{(valueFormatterFor?.(p.dataKey) ?? valueFormatter)(p.value)}</span>
</div>
))}
Expand Down Expand Up @@ -1031,6 +1033,7 @@ function FunnelChart({
labelKey,
valueKey,
colors,
slices,
tokens,
height,
horizontal = false,
Expand All @@ -1040,6 +1043,7 @@ function FunnelChart({
labelKey: string;
valueKey: string;
colors: string[];
slices?: Record<string, SliceStyle>;
tokens: Record<string, string>;
height: number;
horizontal?: boolean;
Expand Down Expand Up @@ -1069,6 +1073,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) {
Expand All @@ -1090,14 +1098,14 @@ function FunnelChart({
<span
className="w-full truncate text-center text-[11px] font-medium leading-tight"
style={{ color: textPrimary }}
title={s.label}
title={s.name}
>
{s.label}
{s.name}
</span>
<div className="flex w-full flex-1 items-center justify-center py-1">
<div
className="flex w-[72%] items-center justify-center rounded-[3px]"
style={{ height: `${s.extent}%`, background: barColor, opacity: s.opacity }}
style={{ height: `${s.extent}%`, background: s.color, opacity: s.opacity }}
>
<span
className="px-1 text-[11px] font-semibold tabular-nums text-white"
Expand Down Expand Up @@ -1138,9 +1146,9 @@ function FunnelChart({
<span
className="truncate text-[11px] font-medium"
style={{ color: textPrimary }}
title={s.label}
title={s.name}
>
{s.label}
{s.name}
</span>
<span className="shrink-0 text-[10px] tabular-nums" style={{ color: textMuted }}>
{Math.round(s.pctOfTop)}%
Expand All @@ -1149,7 +1157,7 @@ function FunnelChart({
<div className="mt-1 flex w-full justify-center">
<div
className="flex h-7 items-center justify-center rounded-[3px]"
style={{ width: `${s.extent}%`, background: barColor, opacity: s.opacity }}
style={{ width: `${s.extent}%`, background: s.color, opacity: s.opacity }}
>
<span
className="px-2 text-[11px] font-semibold tabular-nums text-white"
Expand Down Expand Up @@ -1229,6 +1237,11 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu
const seriesCurve = (field: string, own: boolean) => 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).
Expand Down Expand Up @@ -1466,31 +1479,34 @@ function ChartBody({ widget, data, titleOffset = 0 }: Props & { titleOffset?: nu
}

case "pie": {
const nameKey = widget.label || "label";
// Rename only at the display layer (label/legend/tooltip) so the underlying
// data is never mutated; Cells key colours off the raw label.
return (
<ResponsiveContainer width="100%" height={chartHeight}>
<PieChart>
<Pie
data={chartData}
dataKey={valueField(widget.value) || "value"}
nameKey={widget.label || "label"}
nameKey={nameKey}
cx="50%"
cy="45%"
outerRadius={75}
innerRadius={40}
strokeWidth={0}
isAnimationActive={false}
label={({ name, percent }: { name?: string; percent?: number }) =>
`${name ?? ""} ${((percent ?? 0) * 100).toFixed(0)}%`
`${sliceLabel(name)} ${((percent ?? 0) * 100).toFixed(0)}%`
}
labelLine={false}
style={AXIS_STYLE}
>
{chartData.map((_, i) => (
<Cell key={i} fill={colors[i % colors.length]} />
{chartData.map((row, i) => (
<Cell key={i} fill={sliceColor(row[nameKey], i)} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={AXIS_STYLE} iconSize={7} />
<Tooltip content={<CustomTooltip nameFormatter={sliceLabel} />} />
<Legend wrapperStyle={AXIS_STYLE} iconSize={7} formatter={(value) => sliceLabel(value)} />
</PieChart>
</ResponsiveContainer>
);
Expand Down Expand Up @@ -1629,6 +1645,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}
Expand Down Expand Up @@ -1784,9 +1801,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 (
<ResponsiveContainer width="100%" height={240}>
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/types/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SeriesStyle>; // per-series line style overrides, keyed by y-column
slices?: Record<string, SliceStyle>; // 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<string, string>;
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions pkg/dashboard/jsloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading