Update charts to include titles and legends
This commit is contained in:
@@ -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.")),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
<Show when={isLine} fallback={
|
return (
|
||||||
<span class="inline-block h-2.5 w-2.5 rounded-xs" style={{ "background-color": p.colorOf(it.i) }} />
|
<button type="button" onclick={() => p.onToggle(it.i)} aria-pressed={!off()}
|
||||||
}>
|
class="flex cursor-pointer select-none items-center gap-1.5">
|
||||||
<span class="inline-block h-0.5 w-4 rounded-full" style={{ "background-color": p.colorOf(it.i) }} />
|
<Show when={isLine} fallback={
|
||||||
</Show>
|
<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="text-xs text-ink-soft">{it.name}</span>
|
}>
|
||||||
</div>
|
<span class="inline-block h-0.5 w-4 rounded-full" style={{ "background-color": p.colorOf(it.i), opacity: off() ? 0.35 : 1 }} />
|
||||||
)}</For>
|
</Show>
|
||||||
|
<span class={"text-xs " + (off() ? "text-ink-faint line-through" : "text-ink-soft")}>{it.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}}</For>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +108,8 @@ var chartTokens = []string{
|
|||||||
// }
|
// }
|
||||||
type Chart struct {
|
type Chart struct {
|
||||||
hover *vdom.Signal[int]
|
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
|
svgRef *vdom.Ref
|
||||||
wrapRef *vdom.Ref
|
wrapRef *vdom.Ref
|
||||||
tipRef *vdom.Ref // the HTML tooltip, positioned imperatively so it follows the cursor
|
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.
|
// 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)...)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user