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 . // // 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 // / elements. The Go-native Solid compiler namespaces an element as SVG only // when it is written literally inside an in the same template; a produced by // control flow (, a callback) is created in the HTML namespace and never paints. // Setting innerHTML on an parses the string in the SVG namespace — the same trick // uikit/Icons.tsx uses. Pointer handlers ride the 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"; interface HorizontalLegend { orientation: "horizontal"; position: | "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"; } interface VerticalLegend { orientation: "vertical"; position: | "top-left" | "center-left" | "bottom-left" | "top-right" | "center-right" | "bottom-right"; } type ChartLegend = HorizontalLegend | VerticalLegend; 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 | ChartLegend; // 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 === "&" ? "&" : 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 `` + `` + ``; } 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 legendVisible = () => { if (props.legend === false) return false; if (props.legend === true || props.legend === undefined) return isRadial() || props.series.length > 1; return true; // an explicit ChartLegend object always shows the legend }; // `true`/undefined carries no orientation or position — null tells the layout below to // keep the original placement (a left-aligned wrapping row under the plot) untouched. // Only a caller-supplied ChartLegend object turns on the orientation/position logic. const legendConfig = (): ChartLegend | null => props.legend && typeof props.legend === "object" ? props.legend : null; // Horizontal legends stack above/below the plot; vertical legends sit beside it. const legendBefore = () => { const cfg = legendConfig(); if (!cfg) return false; // default position is "after", matching the original layout return cfg.orientation === "horizontal" ? cfg.position.startsWith("top") : cfg.position.endsWith("left"); }; const containerClass = () => { const cfg = legendConfig(); if (!cfg) return "w-full"; return cfg.orientation === "vertical" ? "flex w-full items-stretch gap-4" : "flex flex-col w-full gap-3"; }; // 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>(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 (
{props.title}
{/* 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. */}
); } interface SubProps { props: ChartProps; width: number; height: number; colorOf: (i: number) => string; fmt: (v: number) => string; hidden: () => Set; // 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(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 ? `` : ``); } if (showAxes()) { out.push(h ? `${esc(p.fmt(t))}` : `${esc(p.fmt(t))}`); } } // baseline (the value-0 line), a touch stronger than the grid const bv = baseValue(); out.push(h ? `` : ``); // category labels (along the category axis) if (showAxes()) { labels().forEach((lab, i) => out.push(h ? `${esc(lab)}` : `${esc(lab)}`)); } // 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(``); } // 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(``); } } // 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(``); } for (const pth of paths()) { out.push(``); } // 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(``); } } 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 ( <> setHover(null)} /> ); } 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; 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 (
{p.label}
{(row) => (
{row.s.name} {p.fmt(row.s.data[p.index] ?? 0)}
)}
); } // ── radial (pie / donut) ────────────────────────────────────────────────────────── function RadialChart(p: SubProps): JSXElement { const [hover, setHover] = createSignal(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, 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(``); } } if (single) { const s = positive[0]; out.push(``); if (g.rIn > 0) out.push(``); } else { for (const s of slices()) { if (s.a1 <= s.a0) continue; const op = hv === null || hv === s.idx ? 1 : 0.55; out.push(``); } } 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 ( <> setHover(null)} />
{labels()[hover()!] ?? ""}
{p.fmt(values()[hover()!] ?? 0)} {total() > 0 ? ((Math.max(0, values()[hover()!] ?? 0) / total()) * 100).toFixed(1) + "%" : ""}
); } // ── legend ───────────────────────────────────────────────────────────────────── function Legend(p: { props: ChartProps; legend: ChartLegend | null; colorOf: (i: number) => string; hidden: Set; 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 })); // No legend config (bare `true`/default): the original layout — a left-aligned wrapping // row under the plot. An explicit ChartLegend picks the row/column axis (orientation) // and where along it the legend sits (position). const containerClass = () => { const cfg = p.legend; if (!cfg) return "mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5"; if (cfg.orientation === "vertical") { const justify = cfg.position.startsWith("top") ? "justify-start" : cfg.position.startsWith("bottom") ? "justify-end" : "justify-center"; return `flex flex-col ${justify} gap-1.5 shrink-0`; } const justify = cfg.position.endsWith("left") ? "justify-start" : cfg.position.endsWith("right") ? "justify-end" : "justify-center"; return `flex flex-wrap items-center gap-x-4 gap-y-1.5 ${justify}`; }; return (
{(it) => { const off = () => p.hidden.has(it.i); return ( ); }}
); }