Update charts to include titles and legends
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user