/**
* 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 (