From 530fdf6f75a5cc7d85cefe0fe828b4cfebfa451c Mon Sep 17 00:00:00 2001 From: Max Amundsen Date: Thu, 16 Jul 2026 16:26:06 -0400 Subject: [PATCH] Update charts to include titles and legends --- go/cmd/kjol-website/app/components.go | 13 +- .../frontend/src/pages/Components.tsx | 20 ++- go/jsruntime/uikit/Chart.tsx | 112 +++++++++----- go/webui/chart.go | 141 +++++++++++++----- 4 files changed, 203 insertions(+), 83 deletions(-) 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 (
{p.label}
- {(s, i) => ( + {(row) => (
- - {s.name} + + {row.s.name} - {p.fmt(s.data[p.index] ?? 0)} + {p.fmt(row.s.data[p.index] ?? 0)}
)}
@@ -580,7 +614,8 @@ function RadialChart(p: SubProps): JSXElement { const values = () => p.props.series[0]?.data ?? []; 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 k = tilt(); @@ -599,8 +634,9 @@ function RadialChart(p: SubProps): JSXElement { const out: { idx: number; a0: number; a1: number; value: number }[] = []; let a = 0; values().forEach((v, i) => { - const sweep = t > 0 ? (Math.max(0, v) / t) * 360 : 0; - out.push({ idx: i, a0: a, a1: a + sweep, value: Math.max(0, v) }); + const val = shown(i) ? Math.max(0, v) : 0; // a hidden slice takes no arc + const sweep = t > 0 ? (val / t) * 360 : 0; + out.push({ idx: i, a0: a, a1: a + sweep, value: val }); a += sweep; }); return out; @@ -694,9 +730,11 @@ function RadialChart(p: SubProps): JSXElement { // ── legend ───────────────────────────────────────────────────────────────────── -function Legend(p: { props: ChartProps; colorOf: (i: number) => string }): JSXElement { +function Legend(p: { props: ChartProps; colorOf: (i: number) => string; hidden: Set; onToggle: (i: number) => void }): JSXElement { // 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. + // 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 isLine = p.props.kind === "line"; 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 })); return (
- {(it) => ( -
- - }> - - - {it.name} -
- )}
+ {(it) => { + const off = () => p.hidden.has(it.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 element string — @@ -165,6 +191,7 @@ func (c *Chart) onMounted() { } 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 if !c.mounted { 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)} - if chartShowLegend(p) { - children = append(children, chartLegend(p)) + // The plot + its imperatively-positioned tooltip share one relative box; a title or + // legend sits OUTSIDE it, so neither shifts the coordinate frame onMove writes into. + 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() { @@ -297,6 +331,9 @@ func swatchSpan(color string) *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)))} 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"), swatchSpan(chartColor(p, i)), vdom.Span(vdom.Attr("class", "text-ink-muted"), vdom.Text(s.Name)), @@ -427,8 +464,11 @@ func chartDomain(p ChartProps) chartScale { if p.Stacked { for i := 0; i < count; i++ { pos, neg := 0.0, 0.0 - for _, s := range p.Series { - v := datum(s, i) + for s := range p.Series { + if !chartShown(p, s) { + continue + } + v := datum(p.Series[s], i) if v >= 0 { pos += v } else { @@ -438,7 +478,10 @@ func chartDomain(p ChartProps) chartScale { hi, lo = math.Max(hi, pos), math.Min(lo, neg) } } else { - for _, s := range p.Series { + for si, s := range p.Series { + if !chartShown(p, si) { + continue + } for _, v := range s.Data { 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 lastPos, lastNeg := -1, -1 for s := range p.Series { + if !chartShown(p, s) { + continue + } v := datum(p.Series[s], i) if v > 0 { lastPos = s @@ -573,6 +619,9 @@ func chartBars(p ChartProps, w, h float64) []barMark { } accPos, accNeg := 0.0, 0.0 for s := range p.Series { + if !chartShown(p, s) { + continue + } v := datum(p.Series[s], i) if v == 0 { continue @@ -614,17 +663,22 @@ func chartBars(p ChartProps, w, h float64) []barMark { } } } 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)) each := math.Max(1, math.Min(chartBarMaxW, groupSize/float64(nS)-chartSegGap)) for i := 0; i < count; i++ { g := catStart(p, w, h) + bf*float64(i) + (bf-groupSize)/2 - for s := 0; s < nS; s++ { - v := 0.0 - if s < len(p.Series) { - v = datum(p.Series[s], i) - } - off := g + float64(s)*(groupSize/float64(nS)) + (groupSize/float64(nS)-each)/2 + for j, s := range vis { + v := datum(p.Series[s], i) + off := g + float64(j)*(groupSize/float64(nS)) + (groupSize/float64(nS)-each)/2 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}) } @@ -662,6 +716,9 @@ func chartPaths(p ChartProps, w, h float64) []lineMark { stackAcc := make([]float64, count) out := make([]lineMark, 0, len(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) lower := make([][2]float64, 0, count) for i := 0; i < count; i++ { @@ -842,19 +899,23 @@ func radialSlices(p ChartProps) []slice { vals = p.Series[0].Data } total := 0.0 - for _, v := range vals { - if v > 0 { + for i, v := range vals { + if v > 0 && chartShown(p, i) { total += v } } out := make([]slice, 0, len(vals)) a := 0.0 for i, v := range vals { - sweep := 0.0 - if total > 0 && v > 0 { - sweep = v / total * 360 + val := 0.0 // a hidden slice takes no arc + if chartShown(p, i) { + 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 } return out @@ -863,8 +924,8 @@ func radialSlices(p ChartProps) []slice { func radialTotal(p ChartProps) float64 { t := 0.0 if len(p.Series) > 0 { - for _, v := range p.Series[0].Data { - if v > 0 { + for i, v := range p.Series[0].Data { + if v > 0 && chartShown(p, i) { t += v } } @@ -986,7 +1047,10 @@ func pieWall(g radialGeo, a0, a1, depth float64) string { // ── 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) isLine := p.Kind == ChartLine 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{} for _, it := range items { - var key *vdom.VNode - if isLine { - key = vdom.Span(vdom.Attr("class", "inline-block h-0.5 w-4 rounded-full"), - vdom.Attr("style", "background-color:"+chartColor(p, it.i))) - } else { - key = vdom.Span(vdom.Attr("class", "inline-block h-2.5 w-2.5 rounded-xs"), - vdom.Attr("style", "background-color:"+chartColor(p, it.i))) + it := it // capture per iteration for the click closure + off := !chartShown(p, it.i) + swatchStyle := "background-color:" + chartColor(p, it.i) + labelClass := "text-xs text-ink-soft" + if off { + swatchStyle += ";opacity:0.35" + labelClass = "text-xs text-ink-faint line-through" } - nodes = append(nodes, vdom.Div(vdom.Attr("class", "flex items-center gap-1.5"), - key, vdom.Span(vdom.Attr("class", "text-xs text-ink-soft"), vdom.Text(it.name)))) + nodes = append(nodes, vdom.El("button", + 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)...) }