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

@@ -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<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 (
<div ref={wrap} class={"relative w-full" + (props.class ? " " + props.class : "")}>
<Show when={isRadial()} fallback={
<CartesianChart props={props} width={width()} height={height()} colorOf={colorOf} fmt={fmt} />
}>
<RadialChart props={props} width={width()} height={height()} colorOf={colorOf} fmt={fmt} />
<div ref={wrap} class={"w-full" + (props.class ? " " + props.class : "")}>
<Show when={props.title}>
<div class="mb-2 text-center text-sm font-medium text-ink">{props.title}</div>
</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()}>
<Legend props={props} colorOf={colorOf} />
<Legend props={props} colorOf={colorOf} hidden={hidden()} onToggle={toggle} />
</Show>
</div>
);
@@ -271,6 +289,7 @@ interface SubProps {
height: number;
colorOf: (i: number) => string;
fmt: (v: number) => string;
hidden: () => Set<number>; // 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)} />
<Show when={p.props.tooltip !== false && hover() !== null}>
<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()!] ?? ""}
anchorX={anchorX()} anchorY={anchorY()} width={p.width} height={p.height} />
</Show>
@@ -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<number>;
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 (
<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()}>
<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">
<span class="inline-block h-2.5 w-2.5 shrink-0 rounded-xs" style={{ "background-color": p.colorOf(i()) }} />
<span class="text-ink-muted">{s.name}</span>
<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">{row.s.name}</span>
<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>
</div>
)}</For>
@@ -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<number>; 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 (
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5">
<For each={items()}>{(it) => (
<div class="flex items-center gap-1.5">
<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-0.5 w-4 rounded-full" style={{ "background-color": p.colorOf(it.i) }} />
</Show>
<span class="text-xs text-ink-soft">{it.name}</span>
</div>
)}</For>
<For each={items()}>{(it) => {
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={
<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), opacity: off() ? 0.35 : 1 }} />
</Show>
<span class={"text-xs " + (off() ? "text-ink-faint line-through" : "text-ink-soft")}>{it.name}</span>
</button>
);
}}</For>
</div>
);
}