Files
kjol/go/jsruntime/uikit/Chart.tsx

789 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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 extrusion depth in px (default 16).
tilt?: number; // 3D tilt scalar (0 -> full tilt, 1 -> fully flattened)
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)",
];
export const CDRL_OLD_PALETTE = [
"#FF0F00",
"#FF6600",
"#FF9E01",
"#FCD202",
"#F8FF01",
"#B0DE09",
"#04D215",
"#0D8ECF",
"#0D52D1",
"#2A0CD0",
"#8A0CCF",
"#CD0D74",
"#754DEB",
"#DDDDDD",
"#999999",
"#333333",
"#000000",
"#57032A",
"#CA9726",
"#990000",
"#4B0C25"
]
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
// ── 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 === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;"));
// 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;
// 3D extrudes bars only; on a line/area it reads as noise, so it is a no-op there.
const threeD = () => !!p.props.threeD && p.props.kind === "bar";
const depth = () => p.props.depth ?? DEFAULT_DEPTH;
const dx = () => (threeD() ? depth() * 0.7 : 0);
const dy = () => (threeD() ? depth() * 0.55 : 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.
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 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[] = [];
// gridlines + value ticks (perpendicular to the value axis)
for (const t of d.ticks) {
const vp = valuePos(t);
if (showGrid()) {
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)
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>
);
}