828 lines
44 KiB
TypeScript
828 lines
44 KiB
TypeScript
import { createMemo, createSignal, For, Show, onMount, onCleanup, JSXElement } from "solid-js";
|
||
|
||
// A dependency-free, reactive chart. It draws SVG — nothing is vendored, nothing is
|
||
// registered on a global, and there is no <canvas>.
|
||
//
|
||
// Being SVG is what makes it small. The picture IS the reactive tree: change
|
||
// props.series and Solid re-renders it, so there is no imperative update() to call and
|
||
// no mount-time construction pushing data into a canvas the reactive system cannot see.
|
||
// Marks name CSS variables (`fill: var(--color-chart-1)`), so the palette themes and
|
||
// inverts for dark mode for free — a canvas paints pixels and cannot read a variable.
|
||
//
|
||
// The marks are assembled as an SVG STRING and set with innerHTML, not written as JSX
|
||
// <path>/<line> elements. The Go-native Solid compiler namespaces an element as SVG only
|
||
// when it is written literally inside an <svg> in the same template; a <path> produced by
|
||
// control flow (<For>, a callback) is created in the HTML namespace and never paints.
|
||
// Setting innerHTML on an <svg> parses the string in the SVG namespace — the same trick
|
||
// uikit/Icons.tsx uses. Pointer handlers ride the <svg> element (a real Solid node), and
|
||
// which bar/slice the pointer is over is computed from geometry, not per-element listeners.
|
||
//
|
||
// Layout is measured, not scaled: a ResizeObserver reports the container's pixel width
|
||
// and the SVG is drawn at that width (viewBox === pixel box, 1:1), so text stays crisp
|
||
// at any size and pointer coordinates map straight onto the drawing. On the server (no
|
||
// ResizeObserver) it renders once at a sane default width and re-measures on mount.
|
||
|
||
export type ChartKind = "line" | "area" | "bar" | "pie" | "donut";
|
||
|
||
export interface ChartSeries {
|
||
name: string;
|
||
data: number[];
|
||
// Override this series' palette slot. Any CSS colour; a var(--color-chart-n) keeps
|
||
// it theme-aware. Leave unset to take the Nth slot of the categorical palette.
|
||
color?: string;
|
||
}
|
||
|
||
export interface ChartProps {
|
||
kind: ChartKind;
|
||
// The x categories (line/area/bar) or the slice names (pie/donut). Optional only
|
||
// because a bare numeric series can fall back to 1..n; supply them for real charts.
|
||
labels?: string[];
|
||
series: ChartSeries[];
|
||
|
||
// bar / area: stack the series instead of grouping them side by side.
|
||
stacked?: boolean;
|
||
// bar only: lay the bars horizontally — categories run down the y-axis, values along x.
|
||
horizontal?: boolean;
|
||
// line / area: "smooth" draws a Catmull-Rom spline through the points.
|
||
curve?: "linear" | "smooth";
|
||
// donut only: inner-radius fraction of the outer radius (0.6 by default).
|
||
donutRatio?: number;
|
||
|
||
// Controls border/gap between pie/doughnut segments (acts as a per-slice & full pie "stroke")
|
||
segmentGap?: number; // value in px (default 2)
|
||
|
||
// Give the chart depth: bars extrude and stand in an extruded grid, pie/donut tilt and
|
||
// gain a rim (line/area ignore it). An embellishment — flat reads more precisely — but
|
||
// sometimes wanted.
|
||
threeD?: boolean;
|
||
depth?: number; // 3D depth in px: bar extrusion / grid sweep length, pie/donut rim (default 16).
|
||
// 3D perspective scalar, read per kind but always "1 = flattest, 0 = most tilted".
|
||
// bar: the depth-axis angle — 1 extrudes head-on (grid reads almost flat), 0 rotates to
|
||
// a bird's-eye view (default 0.6). pie/donut: the disc squash — 0 edge-on, 1 flat
|
||
// (default 0.85).
|
||
tilt?: number;
|
||
|
||
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[];
|
||
// Format a value for the value-axis ticks and the tooltip. Defaults to en-US grouping.
|
||
valueFormat?: (v: number) => string;
|
||
|
||
legend?: boolean; // default: true when there is more than one series (or a pie).
|
||
grid?: boolean; // cartesian only; default true.
|
||
axes?: boolean; // cartesian only; default true.
|
||
tooltip?: boolean; // default true.
|
||
yMin?: number; // pin the value domain instead of deriving it from the data.
|
||
yMax?: number;
|
||
}
|
||
|
||
// The palette a component IS allowed to name — the tokens carry the theming. Referenced
|
||
// by index; a ninth series is the caller's problem (fold it into "Other"), never a
|
||
// synthesised ninth hue.
|
||
const CHART_TOKENS = [
|
||
"var(--color-chart-1)", "var(--color-chart-2)", "var(--color-chart-3)", "var(--color-chart-4)",
|
||
"var(--color-chart-5)", "var(--color-chart-6)", "var(--color-chart-7)", "var(--color-chart-8)",
|
||
];
|
||
|
||
const DEFAULT_HEIGHT = 300;
|
||
const DEFAULT_WIDTH = 640; // used only until the container is measured (and on the server)
|
||
const BAR_MAX_W = 24; // cap a bar's thickness; the band's leftover is deliberate air
|
||
const BAR_RADIUS = 4; // rounded data-end
|
||
const MARK_R = 4; // hover marker radius (8px mark)
|
||
const DEFAULT_DEPTH = 16; // 3D extrusion depth
|
||
const DEFAULT_TILT_CART = 0.6; // 3D bar depth-axis (1 → head-on/flat, 0 → bird's-eye)
|
||
|
||
// ── number + geometry helpers ───────────────────────────────────────────────────
|
||
|
||
// A value or label can carry markup-breaking characters (a series name from a CSV
|
||
// header, a "<"), and it goes into an innerHTML string — escape everything untrusted.
|
||
const esc = (s: unknown) =>
|
||
String(s).replace(/[&<>"]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """));
|
||
|
||
// Constructed lazily, not at module load: the SSR runtime (goja) may import this file
|
||
// while baking a page, and building the formatter at import time would run there too.
|
||
let _intl: Intl.NumberFormat | null = null;
|
||
const defaultFormat = (v: number) => {
|
||
if (!Number.isFinite(v)) return String(v);
|
||
_intl ??= new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
|
||
return _intl.format(v);
|
||
};
|
||
|
||
// "Nice" axis bounds: rounded min/max plus tick values a reader recognises (0, 20, 40 …)
|
||
// rather than the raw data extent.
|
||
function niceScale(min: number, max: number, maxTicks = 5): { min: number; max: number; ticks: number[] } {
|
||
if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) {
|
||
// A flat or empty series still needs a drawable axis.
|
||
const v = Number.isFinite(max) ? max : 0;
|
||
min = Math.min(0, v);
|
||
max = v === min ? min + 1 : Math.max(0, v);
|
||
}
|
||
const niceNum = (range: number, round: boolean) => {
|
||
const exp = Math.floor(Math.log10(range));
|
||
const frac = range / Math.pow(10, exp);
|
||
const nf = round
|
||
? frac < 1.5 ? 1 : frac < 3 ? 2 : frac < 7 ? 5 : 10
|
||
: frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 5 ? 5 : 10;
|
||
return nf * Math.pow(10, exp);
|
||
};
|
||
const step = niceNum((max - min) / Math.max(1, maxTicks - 1), true);
|
||
const niceMin = Math.floor(min / step) * step;
|
||
const niceMax = Math.ceil(max / step) * step;
|
||
const ticks: number[] = [];
|
||
const decimals = Math.max(0, -Math.floor(Math.log10(step)));
|
||
for (let v = niceMin; v <= niceMax + step * 0.5; v += step) {
|
||
ticks.push(Number(v.toFixed(decimals + 2)));
|
||
}
|
||
return { min: niceMin, max: niceMax, ticks };
|
||
}
|
||
|
||
// A rectangle with a chosen subset of corners rounded — the data-end of a bar rounds,
|
||
// the baseline end stays square, and which end that is depends on orientation and sign.
|
||
function roundRectPath(x: number, y: number, w: number, h: number, r: number, side: BarSide): string {
|
||
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
|
||
const tl = side === "top" || side === "left" ? rr : 0;
|
||
const tr = side === "top" || side === "right" ? rr : 0;
|
||
const br = side === "bottom" || side === "right" ? rr : 0;
|
||
const bl = side === "bottom" || side === "left" ? rr : 0;
|
||
return `M${x + tl},${y} L${x + w - tr},${y} Q${x + w},${y} ${x + w},${y + tr}` +
|
||
` L${x + w},${y + h - br} Q${x + w},${y + h} ${x + w - br},${y + h}` +
|
||
` L${x + bl},${y + h} Q${x},${y + h} ${x},${y + h - bl}` +
|
||
` L${x},${y + tl} Q${x},${y} ${x + tl},${y} Z`;
|
||
}
|
||
|
||
// A bar extruded up-and-right by (dx, dy): a right side face (darkened), a top face
|
||
// (lightened) and the front face. The overlays are flat black/white washes so the shading
|
||
// needs no colour maths on a CSS variable it cannot read at build time.
|
||
function bar3D(x: number, y: number, w: number, h: number, color: string, op: number, dx: number, dy: number): string {
|
||
const top = `M${x},${y} L${x + dx},${y - dy} L${x + w + dx},${y - dy} L${x + w},${y} Z`;
|
||
const right = `M${x + w},${y} L${x + w + dx},${y - dy} L${x + w + dx},${y + h - dy} L${x + w},${y + h} Z`;
|
||
return `<path d="${right}" fill="${color}" fill-opacity="${op}"/><path d="${right}" fill="#000" fill-opacity="${0.24 * op}"/>` +
|
||
`<path d="${top}" fill="${color}" fill-opacity="${op}"/><path d="${top}" fill="#fff" fill-opacity="${0.2 * op}"/>` +
|
||
`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${color}" fill-opacity="${op}"/>`;
|
||
}
|
||
|
||
function linePathD(pts: [number, number][]): string {
|
||
if (!pts.length) return "";
|
||
return pts.map((p, i) => `${i ? "L" : "M"}${p[0]},${p[1]}`).join(" ");
|
||
}
|
||
|
||
const sign = (x: number) => (x < 0 ? -1 : 1);
|
||
|
||
// Interior tangent for monotone-cubic interpolation (d3's curveMonotoneX): the lesser of
|
||
// the two neighbouring secant slopes, and zero at a local extremum. This is the whole
|
||
// point of the monotone curve — the tangent is capped so a segment can never bulge past
|
||
// its endpoints, so an area fill can't dip below a value-0 point into negative space the
|
||
// way a Catmull-Rom overshoot does.
|
||
function monoTangent(x0: number, y0: number, x1: number, y1: number, x2: number, y2: number): number {
|
||
const h0 = x1 - x0, h1 = x2 - x1;
|
||
const s0 = h0 !== 0 ? (y1 - y0) / h0 : 0;
|
||
const s1 = h1 !== 0 ? (y2 - y1) / h1 : 0;
|
||
const p = (s0 * h1 + s1 * h0) / (h0 + h1);
|
||
return (sign(s0) + sign(s1)) * Math.min(Math.abs(s0), Math.abs(s1), 0.5 * Math.abs(p)) || 0;
|
||
}
|
||
|
||
// Endpoint tangent (d3 slope2): a parabola-end estimate constrained by the adjacent
|
||
// interior tangent t, so the boundary segments don't overshoot either.
|
||
function endTangent(x0: number, y0: number, x1: number, y1: number, t: number): number {
|
||
const h = x1 - x0;
|
||
return h !== 0 ? (3 * (y1 - y0) / h - t) / 2 : t;
|
||
}
|
||
|
||
// Monotone cubic through every point, emitted as cubic beziers. A monotone interpolant
|
||
// never overshoots its data, so the smoothed curve stays within the value range of each
|
||
// pair of adjacent points.
|
||
function smoothPathD(pts: [number, number][]): string {
|
||
const n = pts.length;
|
||
if (n < 3) return linePathD(pts);
|
||
const m: number[] = new Array(n);
|
||
for (let i = 1; i < n - 1; i++) {
|
||
m[i] = monoTangent(pts[i - 1][0], pts[i - 1][1], pts[i][0], pts[i][1], pts[i + 1][0], pts[i + 1][1]);
|
||
}
|
||
m[0] = endTangent(pts[0][0], pts[0][1], pts[1][0], pts[1][1], m[1]);
|
||
m[n - 1] = endTangent(pts[n - 2][0], pts[n - 2][1], pts[n - 1][0], pts[n - 1][1], m[n - 2]);
|
||
let d = `M${pts[0][0]},${pts[0][1]}`;
|
||
for (let i = 0; i < n - 1; i++) {
|
||
const dx = (pts[i + 1][0] - pts[i][0]) / 3;
|
||
const c1x = pts[i][0] + dx, c1y = pts[i][1] + dx * m[i];
|
||
const c2x = pts[i + 1][0] - dx, c2y = pts[i + 1][1] - dx * m[i + 1];
|
||
d += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1][0]},${pts[i + 1][1]}`;
|
||
}
|
||
return d;
|
||
}
|
||
|
||
// A point on a circle tilted about its horizontal axis by factor k (k=1 is upright): the
|
||
// vertical radius shrinks to k·r, so the circle reads as an ellipse seen at an angle.
|
||
function tiltPoint(cx: number, cy: number, r: number, deg: number, k: number): [number, number] {
|
||
const a = (deg - 90) * Math.PI / 180; // 0° at 12 o'clock, clockwise
|
||
return [cx + r * Math.cos(a), cy + k * r * Math.sin(a)];
|
||
}
|
||
|
||
// One pie/donut slice from a0 to a1 degrees, tilted by k (k=1 upright). rIn === 0 gives a
|
||
// pie wedge. Uses elliptical arcs so the tilt is exact, not a polygon approximation.
|
||
function slicePathD(cx: number, cy: number, rOut: number, rIn: number, a0: number, a1: number, k = 1): string {
|
||
const large = a1 - a0 > 180 ? 1 : 0;
|
||
const [ox0, oy0] = tiltPoint(cx, cy, rOut, a0, k);
|
||
const [ox1, oy1] = tiltPoint(cx, cy, rOut, a1, k);
|
||
if (rIn <= 0) {
|
||
return `M${cx},${cy} L${ox0},${oy0} A${rOut},${k * rOut} 0 ${large} 1 ${ox1},${oy1} Z`;
|
||
}
|
||
const [ix1, iy1] = tiltPoint(cx, cy, rIn, a1, k);
|
||
const [ix0, iy0] = tiltPoint(cx, cy, rIn, a0, k);
|
||
return `M${ox0},${oy0} A${rOut},${k * rOut} 0 ${large} 1 ${ox1},${oy1} L${ix1},${iy1} A${rIn},${k * rIn} 0 ${large} 0 ${ix0},${iy0} Z`;
|
||
}
|
||
|
||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||
|
||
// ── the component ────────────────────────────────────────────────────────────────
|
||
|
||
export function Chart(props: ChartProps): JSXElement {
|
||
let wrap: HTMLDivElement | undefined;
|
||
const [measured, setMeasured] = createSignal(props.width ?? DEFAULT_WIDTH);
|
||
const width = () => props.width ?? measured();
|
||
const height = () => props.height ?? DEFAULT_HEIGHT;
|
||
|
||
onMount(() => {
|
||
if (props.width != null || !wrap) return;
|
||
const read = () => { if (wrap && wrap.clientWidth > 0) setMeasured(wrap.clientWidth); };
|
||
read();
|
||
if (typeof ResizeObserver === "undefined") return;
|
||
const ro = new ResizeObserver(read);
|
||
ro.observe(wrap);
|
||
onCleanup(() => ro.disconnect());
|
||
});
|
||
|
||
const isRadial = () => props.kind === "pie" || props.kind === "donut";
|
||
const colorOf = (i: number) =>
|
||
props.series[i]?.color ?? props.palette?.[i % (props.palette.length || 1)] ?? CHART_TOKENS[i % CHART_TOKENS.length];
|
||
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={"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} hidden={hidden()} onToggle={toggle} />
|
||
</Show>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface SubProps {
|
||
props: ChartProps;
|
||
width: number;
|
||
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";
|
||
interface BarMark { x: number; y: number; w: number; h: number; side: BarSide; seriesIdx: number; catIdx: number; value: number; round: boolean; }
|
||
interface LineMark { seriesIdx: number; line: string; area: string; pts: [number, number][]; }
|
||
|
||
// ── cartesian (line / area / bar) ─────────────────────────────────────────────────
|
||
|
||
function CartesianChart(p: SubProps): JSXElement {
|
||
const [hover, setHover] = createSignal<number | null>(null);
|
||
const [pointer, setPointer] = createSignal<[number, number]>([0, 0]);
|
||
|
||
const horiz = () => p.props.kind === "bar" && !!p.props.horizontal;
|
||
// threeD draws the extruded grid (floor + back wall) for any cartesian kind. Bars also
|
||
// extrude into it; a line/area is only shifted onto the back wall so it tracks the wall's
|
||
// gridlines — the trace itself is never extruded (depth on a 1px stroke reads as noise).
|
||
const threeD = () => !!p.props.threeD;
|
||
const depth = () => p.props.depth ?? DEFAULT_DEPTH;
|
||
// The depth axis (right, up), length `depth`: tilt turns it from head-on (all run, no
|
||
// rise) toward bird's-eye (all rise). Bars extrude along it, the grid is swept along it,
|
||
// and a line/area is shifted onto the back wall by it, so all read as one 3D space.
|
||
const cartTilt = () => clamp(p.props.tilt ?? DEFAULT_TILT_CART, 0, 1);
|
||
const depthAngle = () => (1 - cartTilt()) * Math.PI / 2;
|
||
const dx = () => (threeD() ? depth() * Math.cos(depthAngle()) : 0);
|
||
const dy = () => (threeD() ? depth() * Math.sin(depthAngle()) : 0);
|
||
|
||
const labels = () => p.props.labels ?? p.props.series[0]?.data.map((_, i) => String(i + 1)) ?? [];
|
||
const n = () => Math.max(labels().length, ...p.props.series.map((s) => s.data.length), 0);
|
||
|
||
// 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();
|
||
const includeZero = p.props.kind === "bar" || p.props.kind === "area";
|
||
let lo = Infinity, hi = -Infinity;
|
||
if (p.props.stacked) {
|
||
for (let i = 0; i < count; i++) {
|
||
let pos = 0, neg = 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 (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);
|
||
if (p.props.yMin != null) scale.min = p.props.yMin;
|
||
if (p.props.yMax != null) scale.max = p.props.yMax;
|
||
return scale;
|
||
});
|
||
|
||
// Margins: the value axis wants room for its ticks, the category axis for its labels —
|
||
// which sides those are on flips with orientation. 3D adds depth to the top and right,
|
||
// where bars extrude, so nothing clips.
|
||
const layout = createMemo(() => {
|
||
const d = domain();
|
||
const showAxes = p.props.axes ?? true;
|
||
const valTickW = Math.max(...d.ticks.map((t) => p.fmt(t).length), 1) * 7 + 12;
|
||
const catLabelW = Math.max(...labels().map((s) => s.length), 1) * 7 + 12;
|
||
let left: number, bottom: number;
|
||
if (horiz()) {
|
||
left = showAxes ? Math.max(28, catLabelW) : 8; // category labels on the left
|
||
bottom = showAxes ? 28 : 8; // value ticks on the bottom
|
||
} else {
|
||
left = showAxes ? Math.max(28, valTickW) : 8; // value ticks on the left
|
||
bottom = showAxes ? 28 : 8; // category labels on the bottom
|
||
}
|
||
const top = 12 + dy();
|
||
const right = 12 + dx();
|
||
return {
|
||
left, top, right, bottom,
|
||
plotW: Math.max(0, p.width - left - right),
|
||
plotH: Math.max(0, p.height - top - bottom),
|
||
};
|
||
});
|
||
|
||
// valuePos: pixel along the VALUE axis (y for vertical, x for horizontal).
|
||
// catCenter: pixel of category i along the CATEGORY axis (x for vertical, y for horizontal).
|
||
const valuePos = (v: number) => {
|
||
const d = domain(), l = layout();
|
||
const t = (v - d.min) / (d.max - d.min || 1);
|
||
return horiz() ? l.left + l.plotW * t : l.top + l.plotH * (1 - t);
|
||
};
|
||
const bandFull = () => (horiz() ? layout().plotH : layout().plotW) / Math.max(1, n());
|
||
const catStart = () => (horiz() ? layout().top : layout().left);
|
||
const catCenter = (i: number) => catStart() + bandFull() * (i + 0.5);
|
||
const baseValue = () => valuePos(clamp(0, domain().min, domain().max));
|
||
|
||
// Bars resolved to plain rectangles + which side is the (rounded) data-end, so the
|
||
// renderer draws vertical and horizontal bars the same way.
|
||
const bars = createMemo(() => {
|
||
if (p.props.kind !== "bar") return [] as BarMark[];
|
||
const out: BarMark[] = [];
|
||
const count = n(), bf = bandFull(), base = baseValue(), h = horiz();
|
||
const series = p.props.series;
|
||
// rect(bandOffset, thickness, valueA, valueB) → a rectangle in the right orientation.
|
||
const rect = (off: number, thick: number, va: number, vb: number): { x: number; y: number; w: number; h: number } =>
|
||
h ? { x: Math.min(va, vb), y: off, w: Math.abs(vb - va), h: thick }
|
||
: { x: off, y: Math.min(va, vb), w: thick, h: Math.abs(vb - va) };
|
||
if (p.props.stacked) {
|
||
const thick = Math.min(BAR_MAX_W, bf * 0.72);
|
||
for (let i = 0; i < count; i++) {
|
||
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;
|
||
const to = from + v;
|
||
if (v >= 0) accPos = to; else accNeg = to;
|
||
const isEnd = (v > 0 && s === lastPos) || (v < 0 && s === lastNeg);
|
||
const inset = isEnd ? 0 : (p.props.segmentGap ?? 2);
|
||
const vFrom = valuePos(from);
|
||
// pull the value end toward the baseline by the gap (except the outer end)
|
||
const vTo = valuePos(to) + (h ? (v >= 0 ? -inset : inset) : (v >= 0 ? inset : -inset));
|
||
const r = rect(off, thick, vFrom, vTo);
|
||
out.push({ ...r, side: barSide(h, v), seriesIdx: s, catIdx: i, value: v, round: isEnd });
|
||
}
|
||
}
|
||
} else {
|
||
// 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 + (p.props.segmentGap ?? 2)) * nS);
|
||
const each = Math.max(1, Math.min(BAR_MAX_W, groupSize / nS - (p.props.segmentGap ?? 2)));
|
||
for (let i = 0; i < count; i++) {
|
||
const g = catStart() + bf * i + (bf - groupSize) / 2;
|
||
vis.forEach((s, j) => {
|
||
const v = series[s].data[i] ?? 0;
|
||
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;
|
||
});
|
||
|
||
// Line/area paths, one per series. Stacked areas ride on the running total below. In 3D
|
||
// the trace stays on the FRONT plane — it draws last, on top of the extruded grid, and
|
||
// reads against the front value axis and its front gridlines. Only the grid carries the
|
||
// depth; the stroke is never extruded.
|
||
const paths = createMemo(() => {
|
||
if (p.props.kind !== "line" && p.props.kind !== "area") return [] as LineMark[];
|
||
const count = n(), base = baseValue();
|
||
const smooth = p.props.curve === "smooth";
|
||
const stackAcc = new Array(count).fill(0);
|
||
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++) {
|
||
const v = s.data[i] ?? 0;
|
||
const yTop = p.props.stacked ? stackAcc[i] + v : v;
|
||
const yBot = p.props.stacked ? stackAcc[i] : 0;
|
||
pts.push([catCenter(i), valuePos(yTop)]);
|
||
lowerPts.push([catCenter(i), p.props.stacked ? valuePos(yBot) : base]);
|
||
if (p.props.stacked) stackAcc[i] = yTop;
|
||
}
|
||
const line = smooth ? smoothPathD(pts) : linePathD(pts);
|
||
const rev = [...lowerPts].reverse();
|
||
const area = line + " L" + linePathD(rev).slice(1) + " Z";
|
||
out.push({ seriesIdx: si, line, area, pts });
|
||
});
|
||
return out;
|
||
});
|
||
|
||
const showAxes = () => p.props.axes ?? true;
|
||
const showGrid = () => p.props.grid ?? true;
|
||
const isBar = () => p.props.kind === "bar";
|
||
|
||
// The extruded grid a 3D bar chart stands in: a floor swept back from the value-0
|
||
// baseline and a back wall carrying the value gridlines, connected by receding lines at
|
||
// each category boundary and each gridline's labelled end. Same (dx,-dy) depth axis the
|
||
// bars extrude along, so grid and columns read as one 3D space. Drawn before the bars,
|
||
// which paint over the parts they occlude.
|
||
const grid3D = (): string => {
|
||
const l = layout(), d = domain(), ddx = dx(), ddy = dy(), h = horiz(), count = n(), bv = baseValue();
|
||
const out: string[] = [];
|
||
const quad = (x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number, op: number) =>
|
||
out.push(`<path d="M${x1},${y1} L${x2},${y2} L${x3},${y3} L${x4},${y4} Z" fill="var(--color-line)" fill-opacity="${op}"/>`);
|
||
const seg = (x1: number, y1: number, x2: number, y2: number, sop: number) =>
|
||
out.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="var(--color-line)" stroke-width="1" stroke-opacity="${sop}"/>`);
|
||
if (h) {
|
||
const yA = l.top, yB = l.top + l.plotH, xA = l.left, xB = l.left + l.plotW;
|
||
quad(bv, yA, bv, yB, bv + ddx, yB - ddy, bv + ddx, yA - ddy, 0.09); // floor (baseline plane)
|
||
quad(xA + ddx, yA - ddy, xB + ddx, yA - ddy, xB + ddx, yB - ddy, xA + ddx, yB - ddy, 0.05); // back wall
|
||
for (const t of d.ticks) {
|
||
const vp = valuePos(t);
|
||
seg(vp + ddx, yA - ddy, vp + ddx, yB - ddy, 1); // back-wall value gridline
|
||
seg(vp, yB, vp + ddx, yB - ddy, 0.5); // connector at the labelled (bottom) edge
|
||
}
|
||
const band = l.plotH / Math.max(1, count);
|
||
for (let i = 0; i <= count; i++) { const yc = l.top + band * i; seg(bv, yc, bv + ddx, yc - ddy, 0.5); }
|
||
seg(bv + ddx, yA - ddy, bv + ddx, yB - ddy, 1); // far edge of the floor / foot of the back wall
|
||
} else {
|
||
const xA = l.left, xB = l.left + l.plotW, yT = l.top, yB = l.top + l.plotH;
|
||
quad(xA, bv, xB, bv, xB + ddx, bv - ddy, xA + ddx, bv - ddy, 0.09); // floor (baseline plane)
|
||
quad(xA + ddx, yT - ddy, xB + ddx, yT - ddy, xB + ddx, yB - ddy, xA + ddx, yB - ddy, 0.05); // back wall
|
||
for (const t of d.ticks) {
|
||
const vp = valuePos(t);
|
||
seg(xA + ddx, vp - ddy, xB + ddx, vp - ddy, 1); // back-wall value gridline
|
||
seg(xA, vp, xA + ddx, vp - ddy, 0.5); // connector at the labelled (left) edge
|
||
}
|
||
const band = l.plotW / Math.max(1, count);
|
||
for (let i = 0; i <= count; i++) { const xc = l.left + band * i; seg(xc, bv, xc + ddx, bv - ddy, 0.5); }
|
||
seg(xA + ddx, bv - ddy, xB + ddx, bv - ddy, 1); // far edge of the floor / foot of the back wall
|
||
}
|
||
return out.join("");
|
||
};
|
||
|
||
// The whole SVG interior, as a string (see the file header for why innerHTML and not
|
||
// JSX marks). Recomputed when the data, the size, or the hovered index changes.
|
||
const body = createMemo(() => {
|
||
const l = layout(), d = domain(), hv = hover(), h = horiz();
|
||
const out: string[] = [];
|
||
|
||
// The extruded grid (floor + back wall) is drawn under everything when 3D. Front
|
||
// gridlines are kept for a 3D line/area (the trace lives on the front plane and reads
|
||
// against them) but dropped for a 3D bar (bars occlude the front and meet the wall).
|
||
const flatGrid = showGrid() && (!threeD() || p.props.kind !== "bar");
|
||
if (showGrid() && threeD()) out.push(grid3D());
|
||
|
||
// gridlines + value ticks (perpendicular to the value axis)
|
||
for (const t of d.ticks) {
|
||
const vp = valuePos(t);
|
||
if (flatGrid) {
|
||
out.push(h
|
||
? `<line x1="${vp}" x2="${vp}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line)" stroke-width="1"/>`
|
||
: `<line x1="${l.left}" x2="${l.left + l.plotW}" y1="${vp}" y2="${vp}" stroke="var(--color-line)" stroke-width="1"/>`);
|
||
}
|
||
if (showAxes()) {
|
||
out.push(h
|
||
? `<text x="${vp}" y="${p.height - 8}" text-anchor="middle" fill="var(--color-ink-faint)" style="font-size:11px;font-variant-numeric:tabular-nums">${esc(p.fmt(t))}</text>`
|
||
: `<text x="${l.left - 8}" y="${vp}" text-anchor="end" dominant-baseline="middle" fill="var(--color-ink-faint)" style="font-size:11px;font-variant-numeric:tabular-nums">${esc(p.fmt(t))}</text>`);
|
||
}
|
||
}
|
||
|
||
// baseline (the value-0 line), a touch stronger than the grid
|
||
const bv = baseValue();
|
||
out.push(h
|
||
? `<line x1="${bv}" x2="${bv}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line-strong)" stroke-width="1"/>`
|
||
: `<line x1="${l.left}" x2="${l.left + l.plotW}" y1="${bv}" y2="${bv}" stroke="var(--color-line-strong)" stroke-width="1"/>`);
|
||
|
||
// category labels (along the category axis)
|
||
if (showAxes()) {
|
||
labels().forEach((lab, i) =>
|
||
out.push(h
|
||
? `<text x="${l.left - 8}" y="${catCenter(i)}" text-anchor="end" dominant-baseline="middle" fill="var(--color-ink-faint)" style="font-size:11px">${esc(lab)}</text>`
|
||
: `<text x="${catCenter(i)}" y="${p.height - 8}" text-anchor="middle" fill="var(--color-ink-faint)" style="font-size:11px">${esc(lab)}</text>`));
|
||
}
|
||
|
||
// crosshair (line/area only — a bar reader aims at a bar, not a hairline). On the
|
||
// front plane, where the trace is.
|
||
if (p.props.tooltip !== false && hv !== null && !isBar()) {
|
||
const c = catCenter(hv);
|
||
out.push(`<line x1="${c}" x2="${c}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line-strong)" stroke-width="1"/>`);
|
||
}
|
||
|
||
// bars — flat or extruded. 3D draws back-to-front so nearer bars overlap farther ones.
|
||
const bs = bars();
|
||
if (threeD()) {
|
||
for (const b of bs) {
|
||
const op = hv === null || hv === b.catIdx ? 1 : 0.5;
|
||
out.push(bar3D(b.x, b.y, b.w, b.h, esc(p.colorOf(b.seriesIdx)), op, dx(), dy()));
|
||
}
|
||
} else {
|
||
for (const b of bs) {
|
||
const op = hv === null || hv === b.catIdx ? 1 : 0.5;
|
||
out.push(`<path d="${roundRectPath(b.x, b.y, b.w, b.h, b.round ? BAR_RADIUS : 0, b.side)}" fill="${esc(p.colorOf(b.seriesIdx))}" fill-opacity="${op}"/>`);
|
||
}
|
||
}
|
||
|
||
// areas then lines (3D does not apply — depth reads as noise on a line).
|
||
for (const pth of paths()) {
|
||
if (p.props.kind === "area") out.push(`<path d="${pth.area}" fill="${esc(p.colorOf(pth.seriesIdx))}" fill-opacity="0.1"/>`);
|
||
}
|
||
for (const pth of paths()) {
|
||
out.push(`<path d="${pth.line}" fill="none" stroke="${esc(p.colorOf(pth.seriesIdx))}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>`);
|
||
}
|
||
|
||
// hover markers on line/area, at the snapped index
|
||
if (p.props.tooltip !== false && hv !== null && !isBar()) {
|
||
for (const pth of paths()) {
|
||
const pt = pth.pts[hv];
|
||
if (pt) out.push(`<circle cx="${pt[0]}" cy="${pt[1]}" r="${MARK_R}" fill="${esc(p.colorOf(pth.seriesIdx))}" stroke="var(--color-surface)" stroke-width="2"/>`);
|
||
}
|
||
}
|
||
|
||
return out.join("");
|
||
});
|
||
|
||
const onMove = (e: PointerEvent) => {
|
||
if (p.props.tooltip === false) return;
|
||
const rect = (e.currentTarget as SVGElement).getBoundingClientRect();
|
||
const px = e.clientX - rect.left, py = e.clientY - rect.top;
|
||
const along = horiz() ? py - layout().top : px - layout().left;
|
||
setHover(clamp(Math.floor(along / bandFull()), 0, Math.max(0, n() - 1)));
|
||
setPointer([px, py]);
|
||
};
|
||
|
||
const anchorX = () => (horiz() ? pointer()[0] : catCenter(hover()!));
|
||
const anchorY = () => (horiz() ? catCenter(hover()!) : pointer()[1]);
|
||
|
||
return (
|
||
<>
|
||
<svg width={p.width} height={p.height} viewBox={`0 0 ${p.width} ${p.height}`} class="block overflow-visible"
|
||
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} hidden={p.hidden()}
|
||
index={hover()!} label={labels()[hover()!] ?? ""}
|
||
anchorX={anchorX()} anchorY={anchorY()} width={p.width} height={p.height} />
|
||
</Show>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function barSide(horiz: boolean, v: number): BarSide {
|
||
return horiz ? (v >= 0 ? "right" : "left") : (v >= 0 ? "top" : "bottom");
|
||
}
|
||
|
||
function CartesianTooltip(p: {
|
||
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;
|
||
const style = () => ({
|
||
left: `${p.anchorX}px`,
|
||
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-ss shadow-lg"
|
||
style={style()}>
|
||
<div class="mb-1 font-medium text-ink">{p.label}</div>
|
||
<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(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(row.s.data[p.index] ?? 0)}
|
||
</span>
|
||
</div>
|
||
)}</For>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── radial (pie / donut) ──────────────────────────────────────────────────────────
|
||
|
||
function RadialChart(p: SubProps): JSXElement {
|
||
const [hover, setHover] = createSignal<number | null>(null);
|
||
const [pointer, setPointer] = createSignal<[number, number]>([0, 0]);
|
||
|
||
const threeD = () => !!p.props.threeD;
|
||
const depth = () => p.props.depth ?? DEFAULT_DEPTH;
|
||
const tilt = () => (threeD() ? clamp((p.props.tilt ?? 0.85), 0, 1) : 1); // vertical squash of the disc when tilted
|
||
|
||
const values = () => p.props.series[0]?.data ?? [];
|
||
const labels = () => p.props.labels ?? values().map((_, i) => String(i + 1));
|
||
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();
|
||
const cx = p.width / 2;
|
||
// tilting shrinks the disc's height to k·2r and adds `depth` below; keep it centred.
|
||
const rOut = Math.max(0, Math.min(p.width, p.height - (threeD() ? depth() : 0)) / 2 - 8);
|
||
const cy = p.height / 2 - (threeD() ? depth() / 2 : 0);
|
||
const rIn = p.props.kind === "donut" ? rOut * (p.props.donutRatio ?? 0.6) : 0;
|
||
return { cx, cy, rOut, rIn, k };
|
||
};
|
||
|
||
// Slices with their angular spans. A lone value becomes a full ring (drawn as a
|
||
// circle/ellipse, since an arc from 0° to 360° collapses).
|
||
const slices = createMemo(() => {
|
||
const t = total();
|
||
const out: { idx: number; a0: number; a1: number; value: number }[] = [];
|
||
let a = 0;
|
||
values().forEach((v, i) => {
|
||
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;
|
||
});
|
||
|
||
// The extruded rim under one slice: the front-facing part of its outer arc (angles
|
||
// 90°–270°, where the ellipse edge dips below centre) swept down by `depth`.
|
||
const wall = (g: ReturnType<typeof geo>, a0: number, a1: number): string => {
|
||
const w0 = Math.max(a0, 90), w1 = Math.min(a1, 270);
|
||
if (w1 <= w0) return "";
|
||
const [x0, y0] = tiltPoint(g.cx, g.cy, g.rOut, w0, g.k);
|
||
const [x1, y1] = tiltPoint(g.cx, g.cy, g.rOut, w1, g.k);
|
||
const large = w1 - w0 > 180 ? 1 : 0;
|
||
return `M${x0},${y0} A${g.rOut},${g.k * g.rOut} 0 ${large} 1 ${x1},${y1}` +
|
||
` L${x1},${y1 + depth()} A${g.rOut},${g.k * g.rOut} 0 ${large} 0 ${x0},${y0 + depth()} Z`;
|
||
};
|
||
|
||
const body = createMemo(() => {
|
||
const g = geo(), hv = hover();
|
||
const out: string[] = [];
|
||
const positive = slices().filter((s) => s.value > 0);
|
||
const single = positive.length === 1;
|
||
|
||
// 3D: draw every slice's rim first (the disc's thickness), then the top faces on top.
|
||
if (threeD()) {
|
||
for (const s of single ? positive : slices()) {
|
||
if (s.a1 <= s.a0) continue;
|
||
const d = single ? wall(g, 90, 270) : wall(g, s.a0, s.a1);
|
||
if (!d) continue;
|
||
const c = esc(p.colorOf(s.idx));
|
||
out.push(`<path d="${d}" fill="${c}"/><path d="${d}" fill="#000" fill-opacity="0.3"/>`);
|
||
}
|
||
}
|
||
|
||
if (single) {
|
||
const s = positive[0];
|
||
out.push(`<path d="${slicePathD(g.cx, g.cy, g.rOut, 0, 0, 359.999, g.k)}" fill="${esc(p.colorOf(s.idx))}"/>`);
|
||
if (g.rIn > 0) out.push(`<ellipse cx="${g.cx}" cy="${g.cy}" rx="${g.rIn}" ry="${g.k * g.rIn}" fill="var(--color-surface)"/>`);
|
||
} else {
|
||
for (const s of slices()) {
|
||
if (s.a1 <= s.a0) continue;
|
||
const op = hv === null || hv === s.idx ? 1 : 0.55;
|
||
out.push(`<path d="${slicePathD(g.cx, g.cy, g.rOut, g.rIn, s.a0, s.a1, g.k)}" fill="${esc(p.colorOf(s.idx))}" fill-opacity="${op}" stroke="var(--color-surface)" stroke-width="${(p.props.segmentGap ?? 2)}"/>`);
|
||
}
|
||
}
|
||
return out.join("");
|
||
});
|
||
|
||
const onMove = (e: PointerEvent) => {
|
||
if (p.props.tooltip === false) return;
|
||
const rect = (e.currentTarget as SVGElement).getBoundingClientRect();
|
||
const g = geo();
|
||
const px = e.clientX - rect.left, py = e.clientY - rect.top;
|
||
const dx = px - g.cx, dy = (py - g.cy) / g.k; // undo the tilt to test against a circle
|
||
const dist = Math.hypot(dx, dy);
|
||
if (dist > g.rOut || (g.rIn > 0 && dist < g.rIn)) { setHover(null); return; }
|
||
let deg = (Math.atan2(dy, dx) * 180 / Math.PI + 90 + 360) % 360;
|
||
const s = slices().find((s) => s.value > 0 && deg >= s.a0 && deg < s.a1);
|
||
setHover(s ? s.idx : null);
|
||
setPointer([px, py]);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<svg width={p.width} height={p.height} viewBox={`0 0 ${p.width} ${p.height}`} class="block" role="img"
|
||
innerHTML={body()} onpointermove={onMove} onpointerleave={() => setHover(null)} />
|
||
<Show when={p.props.tooltip !== false && hover() !== null}>
|
||
<div class="pointer-events-none absolute z-10 min-w-28 max-w-64 rounded-default border border-line bg-surface px-3 py-2 text-ss shadow-lg"
|
||
style={{
|
||
left: `${clamp(pointer()[0], 8, p.width - 8)}px`,
|
||
top: `${clamp(pointer()[1], 8, p.height - 8)}px`,
|
||
transform: "translate(-50%, calc(-100% - 12px))",
|
||
}}>
|
||
<div class="flex items-center gap-2">
|
||
<span class="inline-block h-2.5 w-2.5 shrink-0 rounded-xs" style={{ "background-color": p.colorOf(hover()!) }} />
|
||
<span class="text-ink-muted">{labels()[hover()!] ?? ""}</span>
|
||
</div>
|
||
<div class="mt-1 flex items-baseline gap-2">
|
||
<span class="font-semibold text-ink" style={{ "font-variant-numeric": "tabular-nums" }}>
|
||
{p.fmt(values()[hover()!] ?? 0)}
|
||
</span>
|
||
<span class="text-ink-faint">
|
||
{total() > 0 ? ((Math.max(0, values()[hover()!] ?? 0) / total()) * 100).toFixed(1) + "%" : ""}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ── legend ─────────────────────────────────────────────────────────────────────
|
||
|
||
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
|
||
? (p.props.labels ?? p.props.series[0]?.data.map((_, i) => String(i + 1)) ?? []).map((name, i) => ({ name, i }))
|
||
: 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) => {
|
||
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-ss " + (off() ? "text-ink-faint line-through" : "text-ink-soft")}>{it.name}</span>
|
||
</button>
|
||
);
|
||
}}</For>
|
||
</div>
|
||
);
|
||
}
|