Update charts to include titles and legends

This commit is contained in:
2026-07-16 16:26:06 -04:00
parent 81f0c0624e
commit 530fdf6f75
4 changed files with 203 additions and 83 deletions

View File

@@ -1217,19 +1217,20 @@ func chartsSection() func() *VNode {
prose("webui.Chart draws its own SVG — nice-scale axes, rounded columns, arc slices, a "+ prose("webui.Chart draws its own SVG — nice-scale axes, rounded columns, arc slices, a "+
"pointer crosshair — with no charting library. The geometry is pure Go, so the same shapes "+ "pointer crosshair — with no charting library. The geometry is pure Go, so the same shapes "+
"the Solid kit draws on /js run here in the WebAssembly, against the same theme tokens. Change "+ "the Solid kit draws on /js run here in the WebAssembly, against the same theme tokens. Change "+
"the data and only the marks that moved re-render."), "the data and only the marks that moved re-render. A Title captions the plot, and the legend a "+
"multi-series or pie/donut chart draws is interactive — clicking a key toggles that series or slice."),
demo("Bar, donut, smooth area, two lines — one data set, and a 3D toggle", demo("Bar, donut, smooth area, two lines — one data set, and a 3D toggle",
row("grid gap-6 lg:grid-cols-12", row("grid gap-6 lg:grid-cols-12",
Div(Attr("class", "lg:col-span-7"), barC.Render(ui.ChartProps{Kind: ui.ChartBar, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 260, ThreeD: threeD.Get()})), Div(Attr("class", "lg:col-span-7"), barC.Render(ui.ChartProps{Kind: ui.ChartBar, Title: "Requests & errors this week", Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 260, ThreeD: threeD.Get()})),
Div(Attr("class", "lg:col-span-5"), donutC.Render(ui.ChartProps{Kind: ui.ChartDonut, Labels: pieLabelsWasm, Series: []ui.ChartSeries{pie}, Height: 260, ThreeD: threeD.Get()})), Div(Attr("class", "lg:col-span-5"), donutC.Render(ui.ChartProps{Kind: ui.ChartDonut, Title: "Traffic by source", Labels: pieLabelsWasm, Series: []ui.ChartSeries{pie}, Height: 260, ThreeD: threeD.Get()})),
Div(Attr("class", "lg:col-span-7"), areaC.Render(ui.ChartProps{Kind: ui.ChartArea, Smooth: true, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests}, Height: 220})), Div(Attr("class", "lg:col-span-7"), areaC.Render(ui.ChartProps{Kind: ui.ChartArea, Title: "Requests, smoothed", Smooth: true, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests}, Height: 220})),
Div(Attr("class", "lg:col-span-5"), lineC.Render(ui.ChartProps{Kind: ui.ChartLine, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 220})), Div(Attr("class", "lg:col-span-5"), lineC.Render(ui.ChartProps{Kind: ui.ChartLine, Title: "Requests & errors", Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 220})),
), ),
row("mt-4 flex items-center gap-3", row("mt-4 flex items-center gap-3",
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "New data", OnClick: func() { seed.Set(seed.Get() + 1) }}), ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "New data", OnClick: func() { seed.Set(seed.Get() + 1) }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Small: true, Text: threeDLabel, OnClick: func() { threeD.Set(!threeD.Get()) }}), ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Small: true, Text: threeDLabel, OnClick: func() { threeD.Set(!threeD.Get()) }}),
Span(Attr("class", "text-xs text-ink-muted"), Text("Hover any chart. 3D extrudes the bars and tilts the donut.")), Span(Attr("class", "text-xs text-ink-muted"), Text("Each chart takes a Title; a multi-series chart also draws a legend. Click a legend key (Errors, or a donut slice) to hide it — the scale and marks recompute. 3D extrudes the bars and tilts the donut.")),
), ),
), ),

View File

@@ -1419,16 +1419,16 @@ function Charts() {
<Panel title="Grouped bars, a smooth area, a two-line series, and a donut — from one data set"> <Panel title="Grouped bars, a smooth area, a two-line series, and a donut — from one data set">
<div class="grid gap-6 lg:grid-cols-12"> <div class="grid gap-6 lg:grid-cols-12">
<div class="lg:col-span-7"> <div class="lg:col-span-7">
<Chart kind="bar" labels={CHART_DAYS} series={[requests(), errors()]} height={260} threeD={threeD()} /> <Chart kind="bar" title="Requests & errors this week" labels={CHART_DAYS} series={[requests(), errors()]} height={260} threeD={threeD()} />
</div> </div>
<div class="lg:col-span-5"> <div class="lg:col-span-5">
<Chart kind="donut" labels={pieLabels} series={[pieData()]} height={260} threeD={threeD()} /> <Chart kind="donut" title="Traffic by source" labels={pieLabels} series={[pieData()]} height={260} threeD={threeD()} />
</div> </div>
<div class="lg:col-span-7"> <div class="lg:col-span-7">
<Chart kind="area" curve="smooth" labels={CHART_DAYS} series={[requests()]} height={220} /> <Chart kind="area" curve="smooth" title="Requests, smoothed" labels={CHART_DAYS} series={[requests()]} height={220} />
</div> </div>
<div class="lg:col-span-5"> <div class="lg:col-span-5">
<Chart kind="line" labels={CHART_DAYS} series={[requests(), errors()]} height={220} /> <Chart kind="line" title="Requests & errors" labels={CHART_DAYS} series={[requests(), errors()]} height={220} />
</div> </div>
</div> </div>
<div class="mt-4 flex items-center gap-3"> <div class="mt-4 flex items-center gap-3">
@@ -1439,8 +1439,10 @@ function Charts() {
{threeD() ? "Flat" : "3D"} {threeD() ? "Flat" : "3D"}
</ButtonUI> </ButtonUI>
<span class="text-xs text-ink-muted"> <span class="text-xs text-ink-muted">
The one <code class="font-mono">threeD</code> prop extrudes the bars and tilts the donut Each chart carries a <code class="font-mono">title</code>; a multi-series chart also draws a
(line and area stay flat depth just reads as noise on a curve). legend. Click a legend key say <em>Errors</em> or a donut slice to hide it, and the scale
and marks recompute from what's left. The <code class="font-mono">threeD</code> prop extrudes
the bars and tilts the donut (line and area stay flat — depth reads as noise on a curve).
</span> </span>
</div> </div>
</Panel> </Panel>
@@ -1450,8 +1452,10 @@ function Charts() {
<code class="font-mono">"line" | "area" | "bar" | "pie" | "donut"</code> — and{" "} <code class="font-mono">"line" | "area" | "bar" | "pie" | "donut"</code> — and{" "}
<code class="font-mono">stacked</code>, <code class="font-mono">horizontal</code>,{" "} <code class="font-mono">stacked</code>, <code class="font-mono">horizontal</code>,{" "}
<code class="font-mono">curve</code>, <code class="font-mono">threeD</code>,{" "} <code class="font-mono">curve</code>, <code class="font-mono">threeD</code>,{" "}
<code class="font-mono">palette</code> and <code class="font-mono">valueFormat</code> refine it. <code class="font-mono">title</code>, <code class="font-mono">palette</code> and{" "}
The width is measured from the container, so a chart fills whatever column you give it. <code class="font-mono">valueFormat</code> refine it. The legend it draws for a multi-series or
pie/donut chart is interactive — clicking a key toggles that series or slice. The width is measured
from the container, so a chart fills whatever column you give it.
</Prose> </Prose>
<Panel title="Horizontal bars, and stacked — the same kind, transposed and layered"> <Panel title="Horizontal bars, and stacked — the same kind, transposed and layered">

View File

@@ -56,6 +56,7 @@ export interface ChartProps {
height?: number; // px of the plot area (default 300). The legend adds its own height. height?: number; // px of the plot area (default 300). The legend adds its own height.
width?: number; // fix the width instead of measuring the container. width?: number; // fix the width instead of measuring the container.
class?: string; class?: string;
title?: string; // a caption centred above the plot.
// Override the whole categorical palette (else the --color-chart-1..8 tokens). // Override the whole categorical palette (else the --color-chart-1..8 tokens).
palette?: string[]; palette?: string[];
@@ -251,15 +252,32 @@ export function Chart(props: ChartProps): JSXElement {
const fmt = (v: number) => (props.valueFormat ?? defaultFormat)(v); const fmt = (v: number) => (props.valueFormat ?? defaultFormat)(v);
const showLegend = () => props.legend ?? (isRadial() || props.series.length > 1); const showLegend = () => props.legend ?? (isRadial() || props.series.length > 1);
// Clicking a legend key hides its series (cartesian) or slice (radial); the domain,
// layout and marks recompute from what's left. A Set of the hidden indices — the index
// is the series index for a cartesian chart, the slice index for a pie/donut.
const [hidden, setHidden] = createSignal<Set<number>>(new Set());
const toggle = (i: number) => setHidden((prev) => {
const next = new Set(prev);
next.has(i) ? next.delete(i) : next.add(i);
return next;
});
return ( return (
<div ref={wrap} class={"relative w-full" + (props.class ? " " + props.class : "")}> <div ref={wrap} class={"w-full" + (props.class ? " " + props.class : "")}>
<Show when={isRadial()} fallback={ <Show when={props.title}>
<CartesianChart props={props} width={width()} height={height()} colorOf={colorOf} fmt={fmt} /> <div class="mb-2 text-center text-sm font-medium text-ink">{props.title}</div>
}>
<RadialChart props={props} width={width()} height={height()} colorOf={colorOf} fmt={fmt} />
</Show> </Show>
{/* the plot + its absolutely-positioned tooltip share this relative box, so the
tooltip's pointer coordinates aren't thrown off by a title or legend outside it. */}
<div class="relative w-full">
<Show when={isRadial()} fallback={
<CartesianChart props={props} width={width()} height={height()} colorOf={colorOf} fmt={fmt} hidden={hidden} />
}>
<RadialChart props={props} width={width()} height={height()} colorOf={colorOf} fmt={fmt} hidden={hidden} />
</Show>
</div>
<Show when={showLegend()}> <Show when={showLegend()}>
<Legend props={props} colorOf={colorOf} /> <Legend props={props} colorOf={colorOf} hidden={hidden()} onToggle={toggle} />
</Show> </Show>
</div> </div>
); );
@@ -271,6 +289,7 @@ interface SubProps {
height: number; height: number;
colorOf: (i: number) => string; colorOf: (i: number) => string;
fmt: (v: number) => string; fmt: (v: number) => string;
hidden: () => Set<number>; // series/slice indices the legend has toggled off
} }
type BarSide = "top" | "bottom" | "left" | "right"; type BarSide = "top" | "bottom" | "left" | "right";
@@ -295,6 +314,7 @@ function CartesianChart(p: SubProps): JSXElement {
// The value domain. Stacked bars/areas reach the tallest STACK, not the tallest single // The value domain. Stacked bars/areas reach the tallest STACK, not the tallest single
// value; bars and areas always include zero so the baseline is honest. // value; bars and areas always include zero so the baseline is honest.
const shown = (i: number) => !p.hidden().has(i);
const domain = createMemo(() => { const domain = createMemo(() => {
const series = p.props.series; const series = p.props.series;
const count = n(); const count = n();
@@ -303,14 +323,18 @@ function CartesianChart(p: SubProps): JSXElement {
if (p.props.stacked) { if (p.props.stacked) {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
let pos = 0, neg = 0; let pos = 0, neg = 0;
for (const s of series) { for (let s = 0; s < series.length; s++) {
const v = s.data[i] ?? 0; if (!shown(s)) continue;
const v = series[s].data[i] ?? 0;
if (v >= 0) pos += v; else neg += v; if (v >= 0) pos += v; else neg += v;
} }
hi = Math.max(hi, pos); lo = Math.min(lo, neg); hi = Math.max(hi, pos); lo = Math.min(lo, neg);
} }
} else { } else {
for (const s of series) for (const v of s.data) { hi = Math.max(hi, v); lo = Math.min(lo, v); } for (let s = 0; s < series.length; s++) {
if (!shown(s)) continue;
for (const v of series[s].data) { hi = Math.max(hi, v); lo = Math.min(lo, v); }
}
} }
if (includeZero) { lo = Math.min(lo, 0); hi = Math.max(hi, 0); } if (includeZero) { lo = Math.min(lo, 0); hi = Math.max(hi, 0); }
const scale = niceScale(p.props.yMin ?? lo, p.props.yMax ?? hi); const scale = niceScale(p.props.yMin ?? lo, p.props.yMax ?? hi);
@@ -373,11 +397,13 @@ function CartesianChart(p: SubProps): JSXElement {
const off = catStart() + bf * i + (bf - thick) / 2; const off = catStart() + bf * i + (bf - thick) / 2;
let lastPos = -1, lastNeg = -1; let lastPos = -1, lastNeg = -1;
for (let s = 0; s < series.length; s++) { for (let s = 0; s < series.length; s++) {
if (!shown(s)) continue;
const v = series[s].data[i] ?? 0; const v = series[s].data[i] ?? 0;
if (v > 0) lastPos = s; else if (v < 0) lastNeg = s; if (v > 0) lastPos = s; else if (v < 0) lastNeg = s;
} }
let accPos = 0, accNeg = 0; let accPos = 0, accNeg = 0;
for (let s = 0; s < series.length; s++) { for (let s = 0; s < series.length; s++) {
if (!shown(s)) continue;
const v = series[s].data[i] ?? 0; const v = series[s].data[i] ?? 0;
if (v === 0) continue; if (v === 0) continue;
const from = v >= 0 ? accPos : accNeg; const from = v >= 0 ? accPos : accNeg;
@@ -393,17 +419,21 @@ function CartesianChart(p: SubProps): JSXElement {
} }
} }
} else { } else {
const nS = Math.max(1, series.length); // Grouped bars re-flow around hidden series: only the shown ones take a slot, so
// the group re-centres instead of leaving a gap. Colour still keys off the
// original series index.
const vis = series.map((_, s) => s).filter(shown);
const nS = Math.max(1, vis.length);
const groupSize = Math.min(bf * 0.72, (BAR_MAX_W + SEG_GAP) * nS); const groupSize = Math.min(bf * 0.72, (BAR_MAX_W + SEG_GAP) * nS);
const each = Math.max(1, Math.min(BAR_MAX_W, groupSize / nS - SEG_GAP)); const each = Math.max(1, Math.min(BAR_MAX_W, groupSize / nS - SEG_GAP));
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const g = catStart() + bf * i + (bf - groupSize) / 2; const g = catStart() + bf * i + (bf - groupSize) / 2;
for (let s = 0; s < nS; s++) { vis.forEach((s, j) => {
const v = series[s].data[i] ?? 0; const v = series[s].data[i] ?? 0;
const off = g + s * (groupSize / nS) + (groupSize / nS - each) / 2; const off = g + j * (groupSize / nS) + (groupSize / nS - each) / 2;
const r = rect(off, each, base, valuePos(v)); const r = rect(off, each, base, valuePos(v));
out.push({ ...r, side: barSide(h, v), seriesIdx: s, catIdx: i, value: v, round: true }); out.push({ ...r, side: barSide(h, v), seriesIdx: s, catIdx: i, value: v, round: true });
} });
} }
} }
return out; return out;
@@ -415,7 +445,9 @@ function CartesianChart(p: SubProps): JSXElement {
const count = n(), base = baseValue(); const count = n(), base = baseValue();
const smooth = p.props.curve === "smooth"; const smooth = p.props.curve === "smooth";
const stackAcc = new Array(count).fill(0); const stackAcc = new Array(count).fill(0);
return p.props.series.map((s, si) => { const out: LineMark[] = [];
p.props.series.forEach((s, si) => {
if (!shown(si)) return; // a hidden series draws nothing and doesn't lift the stack
const pts: [number, number][] = []; const pts: [number, number][] = [];
const lowerPts: [number, number][] = []; const lowerPts: [number, number][] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@@ -429,8 +461,9 @@ function CartesianChart(p: SubProps): JSXElement {
const line = smooth ? smoothPathD(pts) : linePathD(pts); const line = smooth ? smoothPathD(pts) : linePathD(pts);
const rev = [...lowerPts].reverse(); const rev = [...lowerPts].reverse();
const area = line + " L" + linePathD(rev).slice(1) + " Z"; const area = line + " L" + linePathD(rev).slice(1) + " Z";
return { seriesIdx: si, line, area, pts }; out.push({ seriesIdx: si, line, area, pts });
}); });
return out;
}); });
const showAxes = () => p.props.axes ?? true; const showAxes = () => p.props.axes ?? true;
@@ -529,7 +562,7 @@ function CartesianChart(p: SubProps): JSXElement {
role="img" innerHTML={body()} onpointermove={onMove} onpointerleave={() => setHover(null)} /> role="img" innerHTML={body()} onpointermove={onMove} onpointerleave={() => setHover(null)} />
<Show when={p.props.tooltip !== false && hover() !== null}> <Show when={p.props.tooltip !== false && hover() !== null}>
<CartesianTooltip <CartesianTooltip
props={p.props} colorOf={p.colorOf} fmt={p.fmt} props={p.props} colorOf={p.colorOf} fmt={p.fmt} hidden={p.hidden()}
index={hover()!} label={labels()[hover()!] ?? ""} index={hover()!} label={labels()[hover()!] ?? ""}
anchorX={anchorX()} anchorY={anchorY()} width={p.width} height={p.height} /> anchorX={anchorX()} anchorY={anchorY()} width={p.width} height={p.height} />
</Show> </Show>
@@ -542,7 +575,7 @@ function barSide(horiz: boolean, v: number): BarSide {
} }
function CartesianTooltip(p: { function CartesianTooltip(p: {
props: ChartProps; colorOf: (i: number) => string; fmt: (v: number) => string; props: ChartProps; colorOf: (i: number) => string; fmt: (v: number) => string; hidden: Set<number>;
index: number; label: string; anchorX: number; anchorY: number; width: number; height: number; index: number; label: string; anchorX: number; anchorY: number; width: number; height: number;
}): JSXElement { }): JSXElement {
const flipLeft = () => p.anchorX > p.width / 2; const flipLeft = () => p.anchorX > p.width / 2;
@@ -551,16 +584,17 @@ function CartesianTooltip(p: {
top: `${clamp(p.anchorY, 8, p.height - 8)}px`, top: `${clamp(p.anchorY, 8, p.height - 8)}px`,
transform: `translate(${flipLeft() ? "calc(-100% - 12px)" : "12px"}, -50%)`, transform: `translate(${flipLeft() ? "calc(-100% - 12px)" : "12px"}, -50%)`,
}); });
const rows = () => p.props.series.map((s, i) => ({ s, i })).filter(({ i }) => !p.hidden.has(i));
return ( return (
<div class="pointer-events-none absolute z-10 min-w-32 max-w-64 rounded-default border border-line bg-surface px-3 py-2 text-xs shadow-lg" <div class="pointer-events-none absolute z-10 min-w-32 max-w-64 rounded-default border border-line bg-surface px-3 py-2 text-xs shadow-lg"
style={style()}> style={style()}>
<div class="mb-1 font-medium text-ink">{p.label}</div> <div class="mb-1 font-medium text-ink">{p.label}</div>
<For each={p.props.series}>{(s, i) => ( <For each={rows()}>{(row) => (
<div class="flex items-center gap-2 leading-relaxed"> <div class="flex items-center gap-2 leading-relaxed">
<span class="inline-block h-2.5 w-2.5 shrink-0 rounded-xs" style={{ "background-color": p.colorOf(i()) }} /> <span class="inline-block h-2.5 w-2.5 shrink-0 rounded-xs" style={{ "background-color": p.colorOf(row.i) }} />
<span class="text-ink-muted">{s.name}</span> <span class="text-ink-muted">{row.s.name}</span>
<span class="ml-auto font-semibold text-ink" style={{ "font-variant-numeric": "tabular-nums" }}> <span class="ml-auto font-semibold text-ink" style={{ "font-variant-numeric": "tabular-nums" }}>
{p.fmt(s.data[p.index] ?? 0)} {p.fmt(row.s.data[p.index] ?? 0)}
</span> </span>
</div> </div>
)}</For> )}</For>
@@ -580,7 +614,8 @@ function RadialChart(p: SubProps): JSXElement {
const values = () => p.props.series[0]?.data ?? []; const values = () => p.props.series[0]?.data ?? [];
const labels = () => p.props.labels ?? values().map((_, i) => String(i + 1)); const labels = () => p.props.labels ?? values().map((_, i) => String(i + 1));
const total = () => values().reduce((a, v) => a + Math.max(0, v), 0); const shown = (i: number) => !p.hidden().has(i);
const total = () => values().reduce((a, v, i) => a + (shown(i) ? Math.max(0, v) : 0), 0);
const geo = () => { const geo = () => {
const k = tilt(); const k = tilt();
@@ -599,8 +634,9 @@ function RadialChart(p: SubProps): JSXElement {
const out: { idx: number; a0: number; a1: number; value: number }[] = []; const out: { idx: number; a0: number; a1: number; value: number }[] = [];
let a = 0; let a = 0;
values().forEach((v, i) => { values().forEach((v, i) => {
const sweep = t > 0 ? (Math.max(0, v) / t) * 360 : 0; const val = shown(i) ? Math.max(0, v) : 0; // a hidden slice takes no arc
out.push({ idx: i, a0: a, a1: a + sweep, value: Math.max(0, v) }); const sweep = t > 0 ? (val / t) * 360 : 0;
out.push({ idx: i, a0: a, a1: a + sweep, value: val });
a += sweep; a += sweep;
}); });
return out; return out;
@@ -694,9 +730,11 @@ function RadialChart(p: SubProps): JSXElement {
// ── legend ───────────────────────────────────────────────────────────────────── // ── legend ─────────────────────────────────────────────────────────────────────
function Legend(p: { props: ChartProps; colorOf: (i: number) => string }): JSXElement { function Legend(p: { props: ChartProps; colorOf: (i: number) => string; hidden: Set<number>; onToggle: (i: number) => void }): JSXElement {
// Pie/donut identity is the SLICE; cartesian identity is the SERIES. A line keys with // Pie/donut identity is the SLICE; cartesian identity is the SERIES. A line keys with
// a short stroke, a fill (bar/area/slice) with a swatch — the legend mirrors the mark. // a short stroke, a fill (bar/area/slice) with a swatch — the legend mirrors the mark.
// Each key is a button: click it to toggle that series/slice, which greys the key and
// strikes its label while the chart recomputes without it.
const isRadial = p.props.kind === "pie" || p.props.kind === "donut"; const isRadial = p.props.kind === "pie" || p.props.kind === "donut";
const isLine = p.props.kind === "line"; const isLine = p.props.kind === "line";
const items = () => isRadial const items = () => isRadial
@@ -704,16 +742,20 @@ function Legend(p: { props: ChartProps; colorOf: (i: number) => string }): JSXEl
: p.props.series.map((s, i) => ({ name: s.name, i })); : p.props.series.map((s, i) => ({ name: s.name, i }));
return ( return (
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5"> <div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5">
<For each={items()}>{(it) => ( <For each={items()}>{(it) => {
<div class="flex items-center gap-1.5"> const off = () => p.hidden.has(it.i);
return (
<button type="button" onclick={() => p.onToggle(it.i)} aria-pressed={!off()}
class="flex cursor-pointer select-none items-center gap-1.5">
<Show when={isLine} fallback={ <Show when={isLine} fallback={
<span class="inline-block h-2.5 w-2.5 rounded-xs" style={{ "background-color": p.colorOf(it.i) }} /> <span class="inline-block h-2.5 w-2.5 rounded-xs" style={{ "background-color": p.colorOf(it.i), opacity: off() ? 0.35 : 1 }} />
}> }>
<span class="inline-block h-0.5 w-4 rounded-full" style={{ "background-color": p.colorOf(it.i) }} /> <span class="inline-block h-0.5 w-4 rounded-full" style={{ "background-color": p.colorOf(it.i), opacity: off() ? 0.35 : 1 }} />
</Show> </Show>
<span class="text-xs text-ink-soft">{it.name}</span> <span class={"text-xs " + (off() ? "text-ink-faint line-through" : "text-ink-soft")}>{it.name}</span>
</div> </button>
)}</For> );
}}</For>
</div> </div>
); );
} }

View File

@@ -62,6 +62,7 @@ type ChartProps struct {
Width float64 // internal viewBox width (default 640); the SVG scales to its container Width float64 // internal viewBox width (default 640); the SVG scales to its container
Height float64 // default 300 Height float64 // default 300
Class string Class string
Title string // a caption centred above the plot
Palette []string Palette []string
ValueFormat func(float64) string ValueFormat func(float64) string
@@ -71,8 +72,15 @@ type ChartProps struct {
NoTooltip bool NoTooltip bool
YMin *float64 YMin *float64
YMax *float64 YMax *float64
// hidden is the set of legend indices toggled off (series index for a cartesian chart,
// slice index for a pie/donut). The controller injects its live set before each render;
// the geometry functions skip whatever it names. Not a caller-facing prop.
hidden map[int]bool
} }
func chartShown(p ChartProps, i int) bool { return p.hidden == nil || !p.hidden[i] }
const ( const (
chartDefaultWidth = 640.0 chartDefaultWidth = 640.0
chartDefaultHeight = 300.0 chartDefaultHeight = 300.0
@@ -100,6 +108,7 @@ var chartTokens = []string{
// } // }
type Chart struct { type Chart struct {
hover *vdom.Signal[int] hover *vdom.Signal[int]
hidden *vdom.Signal[map[int]bool] // legend toggles; a new map each Set so it re-renders
width *vdom.Signal[float64] // measured container width; 0 until the first measure width *vdom.Signal[float64] // measured container width; 0 until the first measure
svgRef *vdom.Ref svgRef *vdom.Ref
wrapRef *vdom.Ref wrapRef *vdom.Ref
@@ -111,7 +120,24 @@ type Chart struct {
// NewChart creates a chart controller. Call it once, OUTSIDE the render function. // NewChart creates a chart controller. Call it once, OUTSIDE the render function.
func NewChart() *Chart { func NewChart() *Chart {
return &Chart{hover: vdom.NewSignal(-1), width: vdom.NewSignal(0.0), svgRef: vdom.NewRef(), wrapRef: vdom.NewRef(), tipRef: vdom.NewRef()} return &Chart{hover: vdom.NewSignal(-1), hidden: vdom.NewSignal(map[int]bool{}), width: vdom.NewSignal(0.0), svgRef: vdom.NewRef(), wrapRef: vdom.NewRef(), tipRef: vdom.NewRef()}
}
// toggle flips a legend item's visibility. It Sets a fresh map (never mutates the current
// one) so the signal fires and the whole chart re-renders from the new set.
func (c *Chart) toggle(i int) {
next := map[int]bool{}
for k, v := range c.hidden.Get() {
if v {
next[k] = true
}
}
if next[i] {
delete(next, i)
} else {
next[i] = true
}
c.hidden.Set(next)
} }
// ChartSVG renders a static, non-interactive chart as one complete <svg> element string — // ChartSVG renders a static, non-interactive chart as one complete <svg> element string —
@@ -165,6 +191,7 @@ func (c *Chart) onMounted() {
} }
func (c *Chart) Render(p ChartProps) *vdom.VNode { func (c *Chart) Render(p ChartProps) *vdom.VNode {
p.hidden = c.hidden.Get() // so the geometry (and onMove, via c.props) skips hidden items
c.props = p c.props = p
if !c.mounted { if !c.mounted {
c.mounted = true c.mounted = true
@@ -196,11 +223,18 @@ func (c *Chart) Render(p ChartProps) *vdom.VNode {
) )
} }
children := []*vdom.VNode{vdom.Svg(mods...), c.tooltipNode(p, hv)} // The plot + its imperatively-positioned tooltip share one relative box; a title or
if chartShowLegend(p) { // legend sits OUTSIDE it, so neither shifts the coordinate frame onMove writes into.
children = append(children, chartLegend(p)) plot := vdom.Div(vdom.Attr("class", "relative w-full"), vdom.Svg(mods...), c.tooltipNode(p, hv))
children := []*vdom.VNode{}
if p.Title != "" {
children = append(children, vdom.Div(vdom.Attr("class", "mb-2 text-center text-sm font-medium text-ink"), vdom.Text(p.Title)))
} }
return vdom.Div(kids([]vdom.Mod{vdom.WithRef(c.wrapRef), vdom.Attr("class", cx("relative w-full", p.Class))}, children)...) children = append(children, plot)
if chartShowLegend(p) {
children = append(children, c.legend(p))
}
return vdom.Div(kids([]vdom.Mod{vdom.WithRef(c.wrapRef), vdom.Attr("class", cx("w-full", p.Class))}, children)...)
} }
func (c *Chart) onLeave() { func (c *Chart) onLeave() {
@@ -297,6 +331,9 @@ func swatchSpan(color string) *vdom.VNode {
func cartesianTipContent(p ChartProps, hv int) []*vdom.VNode { func cartesianTipContent(p ChartProps, hv int) []*vdom.VNode {
out := []*vdom.VNode{vdom.Div(vdom.Attr("class", "mb-1 font-medium text-ink"), vdom.Text(labelAt(p, hv)))} out := []*vdom.VNode{vdom.Div(vdom.Attr("class", "mb-1 font-medium text-ink"), vdom.Text(labelAt(p, hv)))}
for i, s := range p.Series { for i, s := range p.Series {
if !chartShown(p, i) {
continue
}
out = append(out, vdom.Div(vdom.Attr("class", "flex items-center gap-2 leading-relaxed"), out = append(out, vdom.Div(vdom.Attr("class", "flex items-center gap-2 leading-relaxed"),
swatchSpan(chartColor(p, i)), swatchSpan(chartColor(p, i)),
vdom.Span(vdom.Attr("class", "text-ink-muted"), vdom.Text(s.Name)), vdom.Span(vdom.Attr("class", "text-ink-muted"), vdom.Text(s.Name)),
@@ -427,8 +464,11 @@ func chartDomain(p ChartProps) chartScale {
if p.Stacked { if p.Stacked {
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
pos, neg := 0.0, 0.0 pos, neg := 0.0, 0.0
for _, s := range p.Series { for s := range p.Series {
v := datum(s, i) if !chartShown(p, s) {
continue
}
v := datum(p.Series[s], i)
if v >= 0 { if v >= 0 {
pos += v pos += v
} else { } else {
@@ -438,7 +478,10 @@ func chartDomain(p ChartProps) chartScale {
hi, lo = math.Max(hi, pos), math.Min(lo, neg) hi, lo = math.Max(hi, pos), math.Min(lo, neg)
} }
} else { } else {
for _, s := range p.Series { for si, s := range p.Series {
if !chartShown(p, si) {
continue
}
for _, v := range s.Data { for _, v := range s.Data {
hi, lo = math.Max(hi, v), math.Min(lo, v) hi, lo = math.Max(hi, v), math.Min(lo, v)
} }
@@ -564,6 +607,9 @@ func chartBars(p ChartProps, w, h float64) []barMark {
off := catStart(p, w, h) + bf*float64(i) + (bf-thick)/2 off := catStart(p, w, h) + bf*float64(i) + (bf-thick)/2
lastPos, lastNeg := -1, -1 lastPos, lastNeg := -1, -1
for s := range p.Series { for s := range p.Series {
if !chartShown(p, s) {
continue
}
v := datum(p.Series[s], i) v := datum(p.Series[s], i)
if v > 0 { if v > 0 {
lastPos = s lastPos = s
@@ -573,6 +619,9 @@ func chartBars(p ChartProps, w, h float64) []barMark {
} }
accPos, accNeg := 0.0, 0.0 accPos, accNeg := 0.0, 0.0
for s := range p.Series { for s := range p.Series {
if !chartShown(p, s) {
continue
}
v := datum(p.Series[s], i) v := datum(p.Series[s], i)
if v == 0 { if v == 0 {
continue continue
@@ -614,17 +663,22 @@ func chartBars(p ChartProps, w, h float64) []barMark {
} }
} }
} else { } else {
nS := maxi(1, len(p.Series)) // Grouped bars re-flow around hidden series: only shown ones take a slot, so the
// group re-centres rather than leaving a gap. Colour still keys off the real index.
var vis []int
for s := range p.Series {
if chartShown(p, s) {
vis = append(vis, s)
}
}
nS := maxi(1, len(vis))
groupSize := math.Min(bf*0.72, (chartBarMaxW+chartSegGap)*float64(nS)) groupSize := math.Min(bf*0.72, (chartBarMaxW+chartSegGap)*float64(nS))
each := math.Max(1, math.Min(chartBarMaxW, groupSize/float64(nS)-chartSegGap)) each := math.Max(1, math.Min(chartBarMaxW, groupSize/float64(nS)-chartSegGap))
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
g := catStart(p, w, h) + bf*float64(i) + (bf-groupSize)/2 g := catStart(p, w, h) + bf*float64(i) + (bf-groupSize)/2
for s := 0; s < nS; s++ { for j, s := range vis {
v := 0.0 v := datum(p.Series[s], i)
if s < len(p.Series) { off := g + float64(j)*(groupSize/float64(nS)) + (groupSize/float64(nS)-each)/2
v = datum(p.Series[s], i)
}
off := g + float64(s)*(groupSize/float64(nS)) + (groupSize/float64(nS)-each)/2
x, y, ww, hh := rect(off, each, base, valuePos(p, w, h, v)) x, y, ww, hh := rect(off, each, base, valuePos(p, w, h, v))
out = append(out, barMark{x, y, ww, hh, barSide(hz, v), s, i, v, true}) out = append(out, barMark{x, y, ww, hh, barSide(hz, v), s, i, v, true})
} }
@@ -662,6 +716,9 @@ func chartPaths(p ChartProps, w, h float64) []lineMark {
stackAcc := make([]float64, count) stackAcc := make([]float64, count)
out := make([]lineMark, 0, len(p.Series)) out := make([]lineMark, 0, len(p.Series))
for si, s := range p.Series { for si, s := range p.Series {
if !chartShown(p, si) { // a hidden series draws nothing and doesn't lift the stack
continue
}
pts := make([][2]float64, 0, count) pts := make([][2]float64, 0, count)
lower := make([][2]float64, 0, count) lower := make([][2]float64, 0, count)
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
@@ -842,19 +899,23 @@ func radialSlices(p ChartProps) []slice {
vals = p.Series[0].Data vals = p.Series[0].Data
} }
total := 0.0 total := 0.0
for _, v := range vals { for i, v := range vals {
if v > 0 { if v > 0 && chartShown(p, i) {
total += v total += v
} }
} }
out := make([]slice, 0, len(vals)) out := make([]slice, 0, len(vals))
a := 0.0 a := 0.0
for i, v := range vals { for i, v := range vals {
sweep := 0.0 val := 0.0 // a hidden slice takes no arc
if total > 0 && v > 0 { if chartShown(p, i) {
sweep = v / total * 360 val = math.Max(0, v)
} }
out = append(out, slice{i, a, a + sweep, math.Max(0, v)}) sweep := 0.0
if total > 0 && val > 0 {
sweep = val / total * 360
}
out = append(out, slice{i, a, a + sweep, val})
a += sweep a += sweep
} }
return out return out
@@ -863,8 +924,8 @@ func radialSlices(p ChartProps) []slice {
func radialTotal(p ChartProps) float64 { func radialTotal(p ChartProps) float64 {
t := 0.0 t := 0.0
if len(p.Series) > 0 { if len(p.Series) > 0 {
for _, v := range p.Series[0].Data { for i, v := range p.Series[0].Data {
if v > 0 { if v > 0 && chartShown(p, i) {
t += v t += v
} }
} }
@@ -986,7 +1047,10 @@ func pieWall(g radialGeo, a0, a1, depth float64) string {
// ── legend (HTML, below the chart) ────────────────────────────────────────────── // ── legend (HTML, below the chart) ──────────────────────────────────────────────
func chartLegend(p ChartProps) *vdom.VNode { // legend is a method (not a free function) because each key is a button that calls back
// into the controller to toggle its series/slice. A toggled-off key greys its swatch and
// strikes its label; the chart recomputes without it.
func (c *Chart) legend(p ChartProps) *vdom.VNode {
isRadial := radial(p.Kind) isRadial := radial(p.Kind)
isLine := p.Kind == ChartLine isLine := p.Kind == ChartLine
type item struct { type item struct {
@@ -1013,18 +1077,27 @@ func chartLegend(p ChartProps) *vdom.VNode {
} }
} }
swatchClass := "inline-block h-2.5 w-2.5 rounded-xs"
if isLine {
swatchClass = "inline-block h-0.5 w-4 rounded-full"
}
nodes := []*vdom.VNode{} nodes := []*vdom.VNode{}
for _, it := range items { for _, it := range items {
var key *vdom.VNode it := it // capture per iteration for the click closure
if isLine { off := !chartShown(p, it.i)
key = vdom.Span(vdom.Attr("class", "inline-block h-0.5 w-4 rounded-full"), swatchStyle := "background-color:" + chartColor(p, it.i)
vdom.Attr("style", "background-color:"+chartColor(p, it.i))) labelClass := "text-xs text-ink-soft"
} else { if off {
key = vdom.Span(vdom.Attr("class", "inline-block h-2.5 w-2.5 rounded-xs"), swatchStyle += ";opacity:0.35"
vdom.Attr("style", "background-color:"+chartColor(p, it.i))) labelClass = "text-xs text-ink-faint line-through"
} }
nodes = append(nodes, vdom.Div(vdom.Attr("class", "flex items-center gap-1.5"), nodes = append(nodes, vdom.El("button",
key, vdom.Span(vdom.Attr("class", "text-xs text-ink-soft"), vdom.Text(it.name)))) vdom.Attr("type", "button"),
vdom.Attr("class", "flex cursor-pointer select-none items-center gap-1.5"),
vdom.On(vdom.EVENT_CLICK, func() { c.toggle(it.i) }),
vdom.Span(vdom.Attr("class", swatchClass), vdom.Attr("style", swatchStyle)),
vdom.Span(vdom.Attr("class", labelClass), vdom.Text(it.name)),
))
} }
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5")}, nodes)...) return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5")}, nodes)...)
} }