583 lines
29 KiB
TypeScript
583 lines
29 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;
|
|
// 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;
|
|
|
|
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;
|
|
|
|
// Override the whole categorical palette (else the --color-chart-1..8 tokens).
|
|
palette?: string[];
|
|
// Format a value for the y-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 y 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 SEG_GAP = 2; // the surface gap between touching marks (stacked segments)
|
|
const MARK_R = 4; // hover marker radius (8px mark)
|
|
|
|
// ── 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 column with the two corners at its VALUE end rounded and the baseline end square —
|
|
// the mark spec. Handles growing up or down from the baseline.
|
|
function columnPath(x: number, w: number, yBase: number, yVal: number, r: number): string {
|
|
const h = Math.abs(yBase - yVal);
|
|
const rr = Math.max(0, Math.min(r, w / 2, h));
|
|
if (yVal <= yBase) {
|
|
const t = yVal;
|
|
return `M${x},${yBase} L${x},${t + rr} Q${x},${t} ${x + rr},${t} L${x + w - rr},${t} Q${x + w},${t} ${x + w},${t + rr} L${x + w},${yBase} Z`;
|
|
}
|
|
const b = yVal;
|
|
return `M${x},${yBase} L${x},${b - rr} Q${x},${b} ${x + rr},${b} L${x + w - rr},${b} Q${x + w},${b} ${x + w},${b - rr} L${x + w},${yBase} Z`;
|
|
}
|
|
|
|
function linePathD(pts: [number, number][]): string {
|
|
if (!pts.length) return "";
|
|
return pts.map((p, i) => `${i ? "L" : "M"}${p[0]},${p[1]}`).join(" ");
|
|
}
|
|
|
|
// Catmull-Rom → cubic bezier, so the curve passes through every point (a plain bezier
|
|
// smoothing would miss them).
|
|
function smoothPathD(pts: [number, number][]): string {
|
|
if (pts.length < 3) return linePathD(pts);
|
|
let d = `M${pts[0][0]},${pts[0][1]}`;
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
const p0 = pts[i - 1] ?? pts[i];
|
|
const p1 = pts[i];
|
|
const p2 = pts[i + 1];
|
|
const p3 = pts[i + 2] ?? p2;
|
|
const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;
|
|
const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;
|
|
d += ` C${c1x},${c1y} ${c2x},${c2y} ${p2[0]},${p2[1]}`;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
function pointOnCircle(cx: number, cy: number, r: number, deg: number): [number, number] {
|
|
const a = (deg - 90) * Math.PI / 180; // 0° at 12 o'clock, clockwise
|
|
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
|
|
}
|
|
|
|
// One pie/donut slice from a0 to a1 degrees. rIn === 0 gives a pie wedge.
|
|
function slicePathD(cx: number, cy: number, rOut: number, rIn: number, a0: number, a1: number): string {
|
|
const large = a1 - a0 > 180 ? 1 : 0;
|
|
const [ox0, oy0] = pointOnCircle(cx, cy, rOut, a0);
|
|
const [ox1, oy1] = pointOnCircle(cx, cy, rOut, a1);
|
|
if (rIn <= 0) {
|
|
return `M${cx},${cy} L${ox0},${oy0} A${rOut},${rOut} 0 ${large} 1 ${ox1},${oy1} Z`;
|
|
}
|
|
const [ix1, iy1] = pointOnCircle(cx, cy, rIn, a1);
|
|
const [ix0, iy0] = pointOnCircle(cx, cy, rIn, a0);
|
|
return `M${ox0},${oy0} A${rOut},${rOut} 0 ${large} 1 ${ox1},${oy1} L${ix1},${iy1} A${rIn},${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);
|
|
|
|
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} />
|
|
</Show>
|
|
<Show when={showLegend()}>
|
|
<Legend props={props} colorOf={colorOf} />
|
|
</Show>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface SubProps {
|
|
props: ChartProps;
|
|
width: number;
|
|
height: number;
|
|
colorOf: (i: number) => string;
|
|
fmt: (v: number) => string;
|
|
}
|
|
|
|
// ── cartesian (line / area / bar) ─────────────────────────────────────────────────
|
|
|
|
function CartesianChart(p: SubProps): JSXElement {
|
|
const [hover, setHover] = createSignal<number | null>(null);
|
|
const [pointerY, setPointerY] = createSignal(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 y 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 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 (const s of series) {
|
|
const v = 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); }
|
|
}
|
|
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;
|
|
});
|
|
|
|
// Left margin follows the widest y tick, so labels never clip and never float.
|
|
const layout = createMemo(() => {
|
|
const d = domain();
|
|
const showAxes = p.props.axes ?? true;
|
|
const tickW = showAxes ? Math.max(...d.ticks.map((t) => p.fmt(t).length)) * 7 + 12 : 8;
|
|
const left = Math.max(28, tickW);
|
|
const top = 12;
|
|
const bottom = showAxes ? 28 : 8;
|
|
const right = 12;
|
|
return {
|
|
left, top, right, bottom,
|
|
plotW: Math.max(0, p.width - left - right),
|
|
plotH: Math.max(0, p.height - top - bottom),
|
|
};
|
|
});
|
|
|
|
const yToPx = (v: number) => {
|
|
const d = domain(), l = layout();
|
|
const t = (v - d.min) / (d.max - d.min || 1);
|
|
return l.top + l.plotH * (1 - t);
|
|
};
|
|
const bandW = () => layout().plotW / Math.max(1, n());
|
|
const bandCenter = (i: number) => layout().left + bandW() * (i + 0.5);
|
|
const baselineY = () => yToPx(clamp(0, domain().min, domain().max));
|
|
|
|
// Grouped bar geometry: the series share a centred group that occupies ~72% of the
|
|
// band; each bar is capped at BAR_MAX_W with a SEG_GAP of air between neighbours.
|
|
const bars = createMemo(() => {
|
|
if (p.props.kind !== "bar") return [] as BarMark[];
|
|
const out: BarMark[] = [];
|
|
const count = n(), bw = bandW(), base = baselineY();
|
|
const series = p.props.series;
|
|
if (p.props.stacked) {
|
|
const colW = Math.min(BAR_MAX_W, bw * 0.72);
|
|
for (let i = 0; i < count; i++) {
|
|
const x = layout().left + bw * i + (bw - colW) / 2;
|
|
// The rounded data-end belongs to the OUTERMOST segment of each arm; the
|
|
// interior boundaries are separated by the surface gap, not by rounding.
|
|
let lastPos = -1, lastNeg = -1;
|
|
for (let s = 0; s < series.length; s++) {
|
|
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++) {
|
|
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 : SEG_GAP; // shrink toward the baseline for the 2px gap
|
|
const yFrom = yToPx(from); // baseline-side edge
|
|
const yVal = v >= 0 ? yToPx(to) + inset : yToPx(to) - inset;
|
|
out.push({ x, w: colW, yBase: yFrom, yVal, seriesIdx: s, catIdx: i, value: v, round: isEnd });
|
|
}
|
|
}
|
|
} else {
|
|
const nS = Math.max(1, series.length);
|
|
const groupW = Math.min(bw * 0.72, (BAR_MAX_W + SEG_GAP) * nS);
|
|
const each = Math.max(1, Math.min(BAR_MAX_W, groupW / nS - SEG_GAP));
|
|
for (let i = 0; i < count; i++) {
|
|
const gx = layout().left + bw * i + (bw - groupW) / 2;
|
|
for (let s = 0; s < nS; s++) {
|
|
const v = series[s].data[i] ?? 0;
|
|
const x = gx + s * (groupW / nS) + (groupW / nS - each) / 2;
|
|
out.push({ x, w: each, yBase: base, yVal: yToPx(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 = baselineY();
|
|
const smooth = p.props.curve === "smooth";
|
|
const stackAcc = new Array(count).fill(0);
|
|
return p.props.series.map((s, si) => {
|
|
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([bandCenter(i), yToPx(yTop)]);
|
|
lowerPts.push([bandCenter(i), p.props.stacked ? yToPx(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";
|
|
return { seriesIdx: si, line, area, pts };
|
|
});
|
|
});
|
|
|
|
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();
|
|
const out: string[] = [];
|
|
|
|
for (const t of d.ticks) {
|
|
const y = yToPx(t);
|
|
if (showGrid()) out.push(`<line x1="${l.left}" x2="${l.left + l.plotW}" y1="${y}" y2="${y}" stroke="var(--color-line)" stroke-width="1"/>`);
|
|
if (showAxes()) out.push(`<text x="${l.left - 8}" y="${y}" 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>`);
|
|
}
|
|
|
|
const by = baselineY();
|
|
out.push(`<line x1="${l.left}" x2="${l.left + l.plotW}" y1="${by}" y2="${by}" stroke="var(--color-line-strong)" stroke-width="1"/>`);
|
|
|
|
if (showAxes()) {
|
|
labels().forEach((lab, i) =>
|
|
out.push(`<text x="${bandCenter(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 x = bandCenter(hv);
|
|
out.push(`<line x1="${x}" x2="${x}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line-strong)" stroke-width="1"/>`);
|
|
}
|
|
|
|
for (const b of bars()) {
|
|
const op = hv === null || hv === b.catIdx ? 1 : 0.5;
|
|
out.push(`<path d="${columnPath(b.x, b.w, b.yBase, b.yVal, b.round ? BAR_RADIUS : 0)}" fill="${esc(p.colorOf(b.seriesIdx))}" fill-opacity="${op}"/>`);
|
|
}
|
|
|
|
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 idx = clamp(Math.floor((e.clientX - rect.left - layout().left) / bandW()), 0, Math.max(0, n() - 1));
|
|
setHover(idx);
|
|
setPointerY(e.clientY - rect.top);
|
|
};
|
|
|
|
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}
|
|
index={hover()!} label={labels()[hover()!] ?? ""}
|
|
anchorX={bandCenter(hover()!)} anchorY={pointerY()} width={p.width} height={p.height} />
|
|
</Show>
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface BarMark { x: number; w: number; yBase: number; yVal: number; seriesIdx: number; catIdx: number; value: number; round: boolean; }
|
|
interface LineMark { seriesIdx: number; line: string; area: string; pts: [number, number][]; }
|
|
|
|
function CartesianTooltip(p: {
|
|
props: ChartProps; colorOf: (i: number) => string; fmt: (v: number) => string;
|
|
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%)`,
|
|
});
|
|
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) => (
|
|
<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="ml-auto font-semibold text-ink" style={{ "font-variant-numeric": "tabular-nums" }}>
|
|
{p.fmt(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 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 geo = () => {
|
|
const cx = p.width / 2, cy = p.height / 2;
|
|
const rOut = Math.max(0, Math.min(p.width, p.height) / 2 - 8);
|
|
const rIn = p.props.kind === "donut" ? rOut * (p.props.donutRatio ?? 0.6) : 0;
|
|
return { cx, cy, rOut, rIn };
|
|
};
|
|
|
|
// Slices with their angular spans. A lone value becomes a full ring (drawn as a
|
|
// circle, 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 sweep = t > 0 ? (Math.max(0, v) / t) * 360 : 0;
|
|
out.push({ idx: i, a0: a, a1: a + sweep, value: Math.max(0, v) });
|
|
a += sweep;
|
|
});
|
|
return out;
|
|
});
|
|
|
|
const body = createMemo(() => {
|
|
const g = geo(), hv = hover();
|
|
const out: string[] = [];
|
|
const positive = slices().filter((s) => s.value > 0);
|
|
if (positive.length === 1) {
|
|
// one value: a full ring, since a 360° arc collapses to nothing
|
|
const s = positive[0];
|
|
out.push(`<circle cx="${g.cx}" cy="${g.cy}" r="${g.rOut}" fill="${esc(p.colorOf(s.idx))}"/>`);
|
|
if (g.rIn > 0) out.push(`<circle cx="${g.cx}" cy="${g.cy}" r="${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)}" fill="${esc(p.colorOf(s.idx))}" fill-opacity="${op}" stroke="var(--color-surface)" stroke-width="${SEG_GAP}"/>`);
|
|
}
|
|
}
|
|
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;
|
|
const dist = Math.hypot(dx, dy);
|
|
if (dist > g.rOut || (g.rIn > 0 && dist < g.rIn)) { setHover(null); return; }
|
|
// pointOnCircle maps deg (from 12 o'clock, clockwise) to (cos(deg-90), sin(deg-90));
|
|
// invert it: the pointer's slice angle is atan2(dy,dx) shifted back by 90°.
|
|
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-xs 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 }): 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.
|
|
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) => (
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|