diff --git a/go/cmd/kjol-website/app/components.go b/go/cmd/kjol-website/app/components.go
index e9fa58bf..9ad4fbc2 100644
--- a/go/cmd/kjol-website/app/components.go
+++ b/go/cmd/kjol-website/app/components.go
@@ -1217,19 +1217,20 @@ func chartsSection() func() *VNode {
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 "+
"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",
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-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-7"), areaC.Render(ui.ChartProps{Kind: ui.ChartArea, 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-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, 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, 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, Title: "Requests & errors", Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 220})),
),
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.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.")),
),
),
diff --git a/go/cmd/kjol-website/frontend/src/pages/Components.tsx b/go/cmd/kjol-website/frontend/src/pages/Components.tsx
index 4c5db406..7b2c97f1 100644
--- a/go/cmd/kjol-website/frontend/src/pages/Components.tsx
+++ b/go/cmd/kjol-website/frontend/src/pages/Components.tsx
@@ -1419,16 +1419,16 @@ function Charts() {
-
+
-
+
-
+
-
+
@@ -1439,8 +1439,10 @@ function Charts() {
{threeD() ? "Flat" : "3D"}
- The one threeD prop extrudes the bars and tilts the donut
- (line and area stay flat — depth just reads as noise on a curve).
+ Each chart carries a title; a multi-series chart also draws a
+ legend. Click a legend key — say Errors or a donut slice — to hide it, and the scale
+ and marks recompute from what's left. The threeD prop extrudes
+ the bars and tilts the donut (line and area stay flat — depth reads as noise on a curve).
@@ -1450,8 +1452,10 @@ function Charts() {
"line" | "area" | "bar" | "pie" | "donut" — and{" "}
stacked, horizontal,{" "}
curve, threeD,{" "}
- palette and valueFormat refine it.
- The width is measured from the container, so a chart fills whatever column you give it.
+ title, palette and{" "}
+ valueFormat 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.
diff --git a/go/jsruntime/uikit/Chart.tsx b/go/jsruntime/uikit/Chart.tsx
index cb4e55cf..131950cf 100644
--- a/go/jsruntime/uikit/Chart.tsx
+++ b/go/jsruntime/uikit/Chart.tsx
@@ -56,6 +56,7 @@ export interface ChartProps {
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.
class?: string;
+ title?: string; // a caption centred above the plot.
// Override the whole categorical palette (else the --color-chart-1..8 tokens).
palette?: string[];
@@ -251,15 +252,32 @@ export function Chart(props: ChartProps): JSXElement {
const fmt = (v: number) => (props.valueFormat ?? defaultFormat)(v);
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>(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 (
-
-
- }>
-
+
+
+
{props.title}
+ {/* 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. */}
+
+
+ }>
+
+
+
-
+
);
@@ -271,6 +289,7 @@ interface SubProps {
height: number;
colorOf: (i: number) => string;
fmt: (v: number) => string;
+ hidden: () => Set; // series/slice indices the legend has toggled off
}
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
// value; bars and areas always include zero so the baseline is honest.
+ const shown = (i: number) => !p.hidden().has(i);
const domain = createMemo(() => {
const series = p.props.series;
const count = n();
@@ -303,14 +323,18 @@ function CartesianChart(p: SubProps): JSXElement {
if (p.props.stacked) {
for (let i = 0; i < count; i++) {
let pos = 0, neg = 0;
- for (const s of series) {
- const v = s.data[i] ?? 0;
+ for (let s = 0; s < series.length; s++) {
+ if (!shown(s)) continue;
+ const v = series[s].data[i] ?? 0;
if (v >= 0) pos += v; else neg += v;
}
hi = Math.max(hi, pos); lo = Math.min(lo, neg);
}
} 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); }
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;
let lastPos = -1, lastNeg = -1;
for (let s = 0; s < series.length; s++) {
+ if (!shown(s)) continue;
const v = series[s].data[i] ?? 0;
if (v > 0) lastPos = s; else if (v < 0) lastNeg = s;
}
let accPos = 0, accNeg = 0;
for (let s = 0; s < series.length; s++) {
+ if (!shown(s)) continue;
const v = series[s].data[i] ?? 0;
if (v === 0) continue;
const from = v >= 0 ? accPos : accNeg;
@@ -393,17 +419,21 @@ function CartesianChart(p: SubProps): JSXElement {
}
}
} 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 each = Math.max(1, Math.min(BAR_MAX_W, groupSize / nS - SEG_GAP));
for (let i = 0; i < count; i++) {
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 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));
out.push({ ...r, side: barSide(h, v), seriesIdx: s, catIdx: i, value: v, round: true });
- }
+ });
}
}
return out;
@@ -415,7 +445,9 @@ function CartesianChart(p: SubProps): JSXElement {
const count = n(), base = baseValue();
const smooth = p.props.curve === "smooth";
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 lowerPts: [number, number][] = [];
for (let i = 0; i < count; i++) {
@@ -429,8 +461,9 @@ function CartesianChart(p: SubProps): JSXElement {
const line = smooth ? smoothPathD(pts) : linePathD(pts);
const rev = [...lowerPts].reverse();
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;
@@ -529,7 +562,7 @@ function CartesianChart(p: SubProps): JSXElement {
role="img" innerHTML={body()} onpointermove={onMove} onpointerleave={() => setHover(null)} />
@@ -542,7 +575,7 @@ function barSide(horiz: boolean, v: number): BarSide {
}
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;
index: number; label: string; anchorX: number; anchorY: number; width: number; height: number;
}): JSXElement {
const flipLeft = () => p.anchorX > p.width / 2;
@@ -551,16 +584,17 @@ function CartesianTooltip(p: {
top: `${clamp(p.anchorY, 8, p.height - 8)}px`,
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 (
);
}
diff --git a/go/webui/chart.go b/go/webui/chart.go
index dd5f1b42..3555f459 100644
--- a/go/webui/chart.go
+++ b/go/webui/chart.go
@@ -62,6 +62,7 @@ type ChartProps struct {
Width float64 // internal viewBox width (default 640); the SVG scales to its container
Height float64 // default 300
Class string
+ Title string // a caption centred above the plot
Palette []string
ValueFormat func(float64) string
@@ -71,8 +72,15 @@ type ChartProps struct {
NoTooltip bool
YMin *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 (
chartDefaultWidth = 640.0
chartDefaultHeight = 300.0
@@ -100,7 +108,8 @@ var chartTokens = []string{
// }
type Chart struct {
hover *vdom.Signal[int]
- width *vdom.Signal[float64] // measured container width; 0 until the first measure
+ 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
svgRef *vdom.Ref
wrapRef *vdom.Ref
tipRef *vdom.Ref // the HTML tooltip, positioned imperatively so it follows the cursor
@@ -111,7 +120,24 @@ type Chart struct {
// NewChart creates a chart controller. Call it once, OUTSIDE the render function.
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