/** * MiniLineChart — small SVG line/area chart for analytics trend tiles. * * Designed to fit inline next to a number tile or above a list. NOT a * general-purpose chart library — supports just the shapes we need: * * • Single series → pass `data` as [{date, value}] * • Multi-series → pass `series` as [{name, color?, points: [{date, value}]}] * * Renders an SVG with: * - x-axis: evenly spaced date ticks (first, middle, last labeled) * - y-axis: derived from data range with a 0 floor; no left ticks (keeps it small) * - Hover tooltip shows the date and per-series values * - Optional fill under the line for the single-series variant (looks like a * "spark area" rather than a line) * * Why hand-rolled SVG and not Recharts? * The bundle is already 285 kB and we render maybe 4 of these. Recharts * would add ~120 kB gzipped for a fancier version of the same thing. * * Props: * data single-series: [{date: "2026-05-12", value: 4}] * series multi-series: [{name, color?, points: [{date, value}]}] * width default 320 * height default 80 * color single-series color (default brand orange) * fillUnder single-series only — fill area below line (default true) * yLabel optional y-axis text label (rendered as title attribute) * formatValue optional (v) => string for tooltip values */ import { useMemo, useState } from "react"; const DEFAULT_COLORS = [ "#d76a35", // brand orange "#3b82c4", // blue "#5fa86b", // green "#b87cb8", // purple "#d4a017", // gold ]; export default function MiniLineChart({ data = null, series = null, width = 320, height = 80, color = "#d76a35", fillUnder = true, yLabel = "", formatValue = (v) => String(v), showLegend = false, }) { // ── Normalize to internal multi-series shape ────────────────────────── const normSeries = useMemo(() => { if (series && series.length) { return series.map((s, i) => ({ name: s.name, color: s.color || DEFAULT_COLORS[i % DEFAULT_COLORS.length], points: s.points || [], })); } if (data && data.length) { return [{ name: yLabel || "value", color, points: data.map((d) => ({ date: d.date, value: d.value ?? d.count ?? 0 })) }]; } return []; }, [data, series, color, yLabel]); // ── Date axis (union of all dates) ──────────────────────────────────── const allDates = useMemo(() => { const s = new Set(); for (const ser of normSeries) { for (const p of ser.points) s.add(p.date); } return Array.from(s).sort(); }, [normSeries]); // ── Y range ─────────────────────────────────────────────────────────── const yMax = useMemo(() => { let m = 0; for (const ser of normSeries) { for (const p of ser.points) { const v = Number(p.value ?? p.count ?? 0); if (v > m) m = v; } } return m || 1; }, [normSeries]); // ── Hover state ─────────────────────────────────────────────────────── const [hoverIdx, setHoverIdx] = useState(null); if (allDates.length === 0) { return (
no data yet
); } // ── Geometry ────────────────────────────────────────────────────────── const padL = 8; const padR = 8; const padT = 6; const padB = 18; const innerW = width - padL - padR; const innerH = height - padT - padB; // x positions per date index (evenly spaced) const n = allDates.length; const xAt = (i) => padL + (n <= 1 ? innerW / 2 : (i / (n - 1)) * innerW); const yAt = (v) => padT + innerH - (v / yMax) * innerH; // For each series → SVG path const lookup = (ser) => { const m = new Map(); for (const p of ser.points) m.set(p.date, Number(p.value ?? p.count ?? 0)); return m; }; const seriesPaths = normSeries.map((ser) => { const m = lookup(ser); let d = ""; let started = false; allDates.forEach((date, i) => { const v = m.get(date); if (v == null) return; const x = xAt(i); const y = yAt(v); d += (started ? " L " : "M ") + x.toFixed(1) + " " + y.toFixed(1); started = true; }); // Closed area path (only used for single-series fill) let area = ""; if (started && fillUnder && normSeries.length === 1) { const firstIdx = allDates.findIndex((dt) => m.has(dt)); const lastIdx = allDates.length - 1 - [...allDates].reverse().findIndex((dt) => m.has(dt)); area = `M ${xAt(firstIdx).toFixed(1)} ${yAt(0).toFixed(1)} ` + d.replace(/^M /, "L ") + ` L ${xAt(lastIdx).toFixed(1)} ${yAt(0).toFixed(1)} Z`; } return { ser, path: d, area }; }); // Date tick labels (first, middle, last) const tickIdxs = n <= 1 ? [0] : n <= 3 ? allDates.map((_, i) => i) : [0, Math.floor((n - 1) / 2), n - 1]; // Tooltip content for hovered index const tip = hoverIdx == null ? null : (() => { const date = allDates[hoverIdx]; const rows = normSeries.map((ser) => { const v = lookup(ser).get(date); return v == null ? null : { name: ser.name, color: ser.color, value: v }; }).filter(Boolean); return { date, rows }; })(); return (
setHoverIdx(null)} > {/* Faint y-baseline */} {/* Area fill (single series, optional) */} {seriesPaths.map(({ ser, area }, i) => area ? ( ) : null )} {/* Lines */} {seriesPaths.map(({ ser, path }, i) => ( ))} {/* Hover dot (per series at hovered index) */} {hoverIdx != null && normSeries.map((ser, i) => { const v = lookup(ser).get(allDates[hoverIdx]); if (v == null) return null; return ( ); })} {/* Hover vertical guide */} {hoverIdx != null && ( )} {/* Invisible mouse-area rects per data point */} {allDates.map((_, i) => { const left = i === 0 ? 0 : (xAt(i - 1) + xAt(i)) / 2; const right = i === n - 1 ? width : (xAt(i) + xAt(i + 1)) / 2; return ( setHoverIdx(i)} /> ); })} {/* x-axis ticks */} {tickIdxs.map((i) => ( {fmtTick(allDates[i])} ))} {/* Tooltip */} {tip && (
{tip.date}
{tip.rows.map((r) => (
{r.name} {formatValue(r.value)}
))}
)} {/* Legend */} {showLegend && normSeries.length > 1 && (
{normSeries.map((ser) => ( {ser.name} ))}
)}
); } function fmtTick(isoDate) { // "2026-05-12" → "May 12" if (!isoDate || isoDate.length < 10) return isoDate || ""; const [, m, d] = isoDate.split("-"); const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; return `${months[parseInt(m, 10) - 1]} ${parseInt(d, 10)}`; }