// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
// ka-ui/stats/kacharts.tsx — kit de graphiques SVG du module Stats commun
// Groupe KA (zéro dépendance, React 18+). Style : design system ka-ui
// (bordures encre, accent de la plateforme via var(--accent)).
// v2 — composants : KpiCard (+sparkline), PeriodSelector, LineChart (ligne/
// aire, infobulle + légende cliquable + comparaison N-1), MultiLineChart
// (≤4 séries, pointillés distincts = jamais la couleur seule), VBarChart
// (barres verticales / histogrammes), StackedBarChart, BarChart (horizontal,
// deltas), Donut, GaugeCard, CalendarHeatmap, HourHeatmap (7×24),
// StatSummary (min/max/moy/méd/σ), DataTable (tri/recherche/pagination),
// RecordCard, PdfButton (menu de rapports), EmptyBlock, Fraicheur.
import { useEffect, useMemo, useRef, useState } from "react";
/* ---------- types (contrat SPEC.md v2) ---------- */
export type Kpi = {
id: string; label: string; value: number | string; unit?: string;
delta_pct?: number | null; direction?: "up" | "down";
spark?: Point[]; help?: string;
};
export type Point = { t: string; v: number };
export type Serie = {
id: string; title: string; unit?: string;
kind?: "line" | "bar" | "area";
points: Point[]; compare?: Point[];
};
export type MultiSerie = {
id: string; title: string; unit?: string;
series: { label: string; points: Point[] }[]; // ≤ 4 séries
};
export type StackedSerie = {
id: string; title: string; unit?: string;
keys: string[]; points: { t: string; values: number[] }[];
};
export type BreakItem = { label: string; value: number; delta_pct?: number | null };
export type Distribution = { id: string; title: string; unit?: string; bins: { label: string; value: number }[] };
export type Gauge = { id: string; label: string; value: number; max: number; unit?: string; help?: string };
export type HourCell = { dow: number; hour: number; value: number }; // dow 0=lun … 6=dim
export type TableSpec = { id: string; title: string; columns: string[]; rows: (string | number)[][] };
export type RecordFact = { label: string; value: string; date?: string };
export const PERIODS: { id: string; label: string }[] = [
{ id: "auj", label: "Today" },
{ id: "7j", label: "7 days" },
{ id: "30j", label: "30 days" },
{ id: "3m", label: "3 months" },
{ id: "6m", label: "6 months" },
{ id: "12m", label: "12 months" },
{ id: "annee", label: "Year to date" },
{ id: "tout", label: "All" },
];
export const REPORT_MODES: { id: string; label: string; desc: string }[] = [
{ id: "complet", label: "Full report", desc: "All sections — KPIs, trends, breakdowns, tables, records" },
{ id: "synthese", label: "Executive summary", desc: "2 pages — key indicators and highlights" },
{ id: "tendances", label: "Trends & evolution", desc: "Curves, year-over-year comparisons and series statistics" },
{ id: "repartitions", label: "Breakdowns & geography", desc: "Categories, distributions, regions and activity" },
{ id: "donnees", label: "Detailed data", desc: "All tables, in long form" },
];
export const fmtInt = (n: number) => n.toLocaleString("en-CA");
export const fmtNum = (n: number) =>
Number.isInteger(n) ? fmtInt(n) : n.toLocaleString("en-CA", { maximumFractionDigits: 2 });
const fmtPct = (n: number) => `${n >= 0 ? "+" : ""}${fmtNum(n)} %`;
/* Styles des séries multiples : couleur + motif de trait (l'identité n'est
jamais portée par la couleur seule — règle d'accessibilité). */
const MULTI_STYLES = [
{ stroke: "var(--accent)", dash: undefined, width: 2.4 },
{ stroke: "var(--ink)", dash: undefined, width: 1.6 },
{ stroke: "var(--accent-deep, var(--accent))", dash: "6 3", width: 2 },
{ stroke: "var(--ink-3)", dash: "2 3", width: 2 },
];
/* ---------- KPI (+ sparkline) ---------- */
export function KpiCard({ k }: { k: Kpi }) {
const up = (k.direction ?? ((k.delta_pct ?? 0) >= 0 ? "up" : "down")) === "up";
const sp = (k.spark ?? []).filter((p) => typeof p.v === "number");
const spark = useMemo(() => {
if (sp.length < 2) return null;
const w = 120, h = 30;
const vmax = Math.max(...sp.map((p) => p.v));
const vmin = Math.min(...sp.map((p) => p.v));
const rng = vmax - vmin || 1;
const X = (i: number) => (w * i) / (sp.length - 1);
const Y = (v: number) => 2 + (h - 4) * (1 - (v - vmin) / rng);
const d = sp.map((p, i) => `${i ? "L" : "M"}${X(i).toFixed(1)},${Y(p.v).toFixed(1)}`).join("");
return { w, h, d, area: `${d}L${w},${h}L0,${h}Z` };
}, [k.spark]);
return (
{typeof k.value === "number" ? fmtNum(k.value) : k.value}
{k.unit ? {k.unit} : null}
{k.label}
{k.delta_pct !== undefined && k.delta_pct !== null ? (
{up ? "▲" : "▼"} {fmtPct(k.delta_pct)} vs previous period
) :
}
{spark && (
)}
);
}
/* ---------- Sélecteur de période ---------- */
export function PeriodSelector({
value, onChange, custom, onCustom,
}: {
value: string; onChange: (p: string) => void;
custom?: { from: string; to: string }; onCustom?: (from: string, to: string) => void;
}) {
return (
{PERIODS.map((p) => (
))}
{onCustom && (
onCustom(e.target.value, custom?.to ?? "")} />
au
onCustom(custom?.from ?? "", e.target.value)} />
)}
);
}
/* ---------- Courbe / aire ---------- */
export function LineChart({ serie, height = 240 }: { serie: Serie; height?: number }) {
const [hide, setHide] = useState<{ cur: boolean; cmp: boolean }>({ cur: false, cmp: false });
const [hover, setHover] = useState(null);
const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
const pts = serie.points ?? [];
if (serie.kind === "bar") return ;
if (pts.length < 2) return ;
const all = [...(hide.cur ? [] : pts), ...(!hide.cmp && serie.compare ? serie.compare : [])];
const vmax = Math.max(...all.map((p) => p.v), 1);
const vmin = Math.min(0, ...all.map((p) => p.v));
const X = (i: number, n: number) => PL + ((W - PL - PR) * i) / (n - 1);
const Y = (v: number) => PT + (H - PT - PB) * (1 - (v - vmin) / (vmax - vmin || 1));
const path = (s: Point[]) => s.map((p, i) => `${i ? "L" : "M"}${X(i, s.length)},${Y(p.v)}`).join("");
const hi = hover !== null ? Math.min(pts.length - 1, Math.max(0, hover)) : null;
return (
{serie.title}
setHide((h) => ({ ...h, cur: !h.cur }))} />
{serie.compare && setHide((h) => ({ ...h, cmp: !h.cmp }))} />}
{hi !== null && (
{pts[hi].t} — {fmtNum(pts[hi].v)}{serie.unit ? ` ${serie.unit}` : ""}
{serie.compare?.[hi] && !hide.cmp ? · N-1 : {fmtNum(serie.compare[hi].v)} : null}
)}
);
}
function LegendChip({ label, color, off, dashed, onClick }: { label: string; color: string; off?: boolean; dashed?: boolean; onClick?: () => void }) {
return (
);
}
/* ---------- Multi-courbes (≤ 4 séries, motifs distincts) ---------- */
export function MultiLineChart({ ms, height = 260 }: { ms: MultiSerie; height?: number }) {
const series = (ms.series ?? []).filter((s) => (s.points ?? []).length > 1).slice(0, 4);
const [off, setOff] = useState>({});
const [hover, setHover] = useState(null);
if (!series.length) return ;
const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
const n = Math.max(...series.map((s) => s.points.length));
const shown = series.filter((s) => !off[s.label]);
const all = shown.flatMap((s) => s.points.map((p) => p.v));
const vmax = Math.max(...(all.length ? all : [1]), 1);
const vmin = Math.min(0, ...(all.length ? all : [0]));
const X = (i: number, len: number) => PL + ((W - PL - PR) * i) / (len - 1);
const Y = (v: number) => PT + (H - PT - PB) * (1 - (v - vmin) / (vmax - vmin || 1));
const ref = series[0].points;
const hi = hover !== null ? Math.min(n - 1, Math.max(0, hover)) : null;
return (
{ms.title}
{series.map((s, i) => (
setOff((o) => ({ ...o, [s.label]: !o[s.label] }))} />
))}
{hi !== null && (
{ref[hi]?.t}
{shown.map((s) => (
{s.label} : {s.points[hi] ? fmtNum(s.points[hi].v) : "—"}{ms.unit ? ` ${ms.unit}` : ""}
))}
)}
);
}
/* ---------- Barres verticales (volumes quotidiens, histogrammes) ---------- */
export function VBarChart({ serie, height = 240 }: { serie: Serie; height?: number }) {
const [hover, setHover] = useState(null);
const pts = serie.points ?? [];
if (!pts.length) return ;
const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
const vmax = Math.max(...pts.map((p) => p.v), 1);
const bw = Math.max(2, (W - PL - PR) / pts.length - 2);
return (
{serie.title}
{hover !== null && (
{pts[hover].t} — {fmtNum(pts[hover].v)}{serie.unit ? ` ${serie.unit}` : ""}
)}
);
}
/* ---------- Histogramme (distribution) ---------- */
export function Histogram({ dist }: { dist: Distribution }) {
const serie: Serie = {
id: dist.id, title: dist.title, unit: dist.unit, kind: "bar",
points: (dist.bins ?? []).map((b) => ({ t: b.label, v: b.value })),
};
return ;
}
/* ---------- Barres empilées (composition dans le temps) ---------- */
export function StackedBarChart({ st, height = 260 }: { st: StackedSerie; height?: number }) {
const [hover, setHover] = useState(null);
const keys = (st.keys ?? []).slice(0, 6);
const pts = st.points ?? [];
if (!keys.length || !pts.length) return ;
const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
const totals = pts.map((p) => p.values.slice(0, keys.length).reduce((s, v) => s + (v || 0), 0));
const vmax = Math.max(...totals, 1);
const bw = Math.max(2, (W - PL - PR) / pts.length - 2);
const shades = [1, 0.72, 0.5, 0.34, 0.22, 0.13];
return (
{st.title}
{keys.map((k, i) => (
{k}
))}
{hover !== null && (
{pts[hover].t}
{keys.map((k, j) => {k} : {fmtNum(pts[hover].values[j] || 0)})}
total {fmtNum(totals[hover])}
)}
);
}
/* ---------- Barres horizontales (répartitions, géo) — deltas optionnels ---------- */
export function BarChart({ title, items, unit }: { title: string; items: BreakItem[]; unit?: string }) {
const rows = (items ?? []).slice(0, 14);
if (!rows.length) return ;
const max = Math.max(...rows.map((r) => r.value), 1);
return (
{title}
{rows.map((r) => (
{r.label}
{r.delta_pct !== undefined && r.delta_pct !== null && (
= 0 ? "var(--green)" : "var(--danger)" }}>
{r.delta_pct >= 0 ? "▲" : "▼"} {fmtPct(r.delta_pct)}
)}
{fmtNum(r.value)}{unit ? ` ${unit}` : ""}
))}
);
}
/* ---------- Anneau ---------- */
export function Donut({ title, items }: { title: string; items: BreakItem[] }) {
const rows = (items ?? []).filter((i) => i.value > 0).slice(0, 8);
const total = rows.reduce((s, r) => s + r.value, 0);
if (!total) return ;
const R = 74, C = 2 * Math.PI * R;
let acc = 0;
const shades = [1, 0.78, 0.58, 0.42, 0.3, 0.22, 0.15, 0.1];
return (
{title}
{rows.map((r, i) => (
-
{r.label}
{((100 * r.value) / total).toFixed(1)} %
))}
);
}
/* ---------- Jauge (taux, complétude, couverture) ---------- */
export function GaugeCard({ g }: { g: Gauge }) {
const frac = Math.max(0, Math.min(1, g.max ? g.value / g.max : 0));
const R = 60, C = Math.PI * R;
return (
{g.label}
);
}
/* ---------- Calendrier de chaleur ---------- */
export function CalendarHeatmap({ title, cells }: { title: string; cells: { date: string; value: number }[] }) {
if (!cells?.length) return ;
const byDate = new Map(cells.map((c) => [c.date, c.value]));
const dates = cells.map((c) => c.date).sort();
const end = new Date(dates[dates.length - 1] + "T12:00:00");
const max = Math.max(...cells.map((c) => c.value), 1);
const weeks = 26, cols: { date: string; v: number }[][] = [];
const cur = new Date(end);
cur.setDate(cur.getDate() - (weeks * 7 - 1));
for (let w = 0; w < weeks; w++) {
const col: { date: string; v: number }[] = [];
for (let d = 0; d < 7; d++) {
const iso = cur.toISOString().slice(0, 10);
col.push({ date: iso, v: byDate.get(iso) ?? 0 });
cur.setDate(cur.getDate() + 1);
}
cols.push(col);
}
return (
{title} last 26 weeks
);
}
/* ---------- Heatmap horaire 7 × 24 (activité par heure) ---------- */
const DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
export function HourHeatmap({ title, cells }: { title: string; cells: HourCell[] }) {
if (!cells?.length) return ;
const grid = new Map(cells.map((c) => [`${c.dow}-${c.hour}`, c.value]));
const max = Math.max(...cells.map((c) => c.value), 1);
const CW = 24, CH = 20, LX = 34, LY = 16;
return (
{title} jour × heure
);
}
/* ---------- Statistiques de série (min/max/moy/méd/σ) ---------- */
export function StatSummary({ serie }: { serie: Serie }) {
const vs = (serie.points ?? []).map((p) => p.v).filter((v) => typeof v === "number");
if (vs.length < 2) return null;
const sorted = [...vs].sort((a, b) => a - b);
const mean = vs.reduce((s, v) => s + v, 0) / vs.length;
const med = sorted[Math.floor(sorted.length / 2)];
const sd = Math.sqrt(vs.reduce((s, v) => s + (v - mean) ** 2, 0) / vs.length);
const items: [string, number][] = [
["Min", sorted[0]], ["Max", sorted[sorted.length - 1]],
["Average", Math.round(mean * 100) / 100], ["Median", med],
["Écart-type", Math.round(sd * 100) / 100],
];
return (
{items.map(([l, v]) => (
{l} {fmtNum(v)}
))}
);
}
/* ---------- Tableau : tri, recherche, pagination ---------- */
export function DataTable({ spec, pageSize = 25 }: { spec: TableSpec; pageSize?: number }) {
const [q, setQ] = useState("");
const [sort, setSort] = useState<{ col: number; dir: 1 | -1 } | null>(null);
const [page, setPage] = useState(0);
const rows = useMemo(() => {
let r = spec.rows ?? [];
if (q) r = r.filter((row) => row.some((c) => String(c).toLowerCase().includes(q.toLowerCase())));
if (sort) r = [...r].sort((a, b) => {
const x = a[sort.col], y = b[sort.col];
const nx = typeof x === "number" ? x : parseFloat(String(x).replace(/[^\d.,-]/g, "").replace(",", "."));
const ny = typeof y === "number" ? y : parseFloat(String(y).replace(/[^\d.,-]/g, "").replace(",", "."));
if (!Number.isNaN(nx) && !Number.isNaN(ny)) return (nx - ny) * sort.dir;
return String(x).localeCompare(String(y), "fr") * sort.dir;
});
return r;
}, [spec.rows, q, sort]);
const pages = Math.max(1, Math.ceil(rows.length / pageSize));
const cur = Math.min(page, pages - 1);
return (
{spec.title}
{ setQ(e.target.value); setPage(0); }} aria-label={`Search in ${spec.title}`} />
{spec.columns.map((c, i) => (
| setSort((s) => ({ col: i, dir: s?.col === i && s.dir === 1 ? -1 : 1 }))}
style={{ cursor: "pointer", textAlign: "left", padding: "8px 10px", background: "var(--ink)", color: "var(--paper)", fontFamily: "var(--font-mono)", fontSize: 10.5, textTransform: "uppercase", letterSpacing: "0.06em", whiteSpace: "nowrap", userSelect: "none" }}
aria-sort={sort?.col === i ? (sort.dir === 1 ? "ascending" : "descending") : "none"}>
{c} {sort?.col === i ? (sort.dir === 1 ? "▲" : "▼") : "↕"}
|
))}
{rows.slice(cur * pageSize, (cur + 1) * pageSize).map((row, ri) => (
{row.map((c, ci) => (
|
{typeof c === "number" ? fmtNum(c) : c}
|
))}
))}
{fmtInt(rows.length)} rows
{cur + 1} / {pages}
);
}
/* ---------- Records / faits marquants ---------- */
export function RecordCard({ r }: { r: RecordFact }) {
return (
{r.label}
{r.value}
{r.date && {r.date}}
);
}
/* ---------- Menu de rapports PDF (5 rapports + personnalisé v3) ---------- */
export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(null);
const [builder, setBuilder] = useState(false);
const box = useRef(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (box.current && !box.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);
const url = (mode: string) => {
const p = new URLSearchParams({ period, mode });
if (from) p.set("from", from);
if (to) p.set("to", to);
return `${endpoint}?${p}`;
};
const dl = (mode: string) => {
setBusy(mode);
setOpen(false);
const a = document.createElement("a");
a.href = url(mode);
a.download = "";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => setBusy(null), 3000);
};
return (
{builder && (
setBuilder(false)} />
)}
{open && (
{REPORT_MODES.map((m) => (
))}
)}
);
}
/* ---------- v3 : constructeur de rapports personnalisés ----------
Compose un PDF bloc par bloc : catalogue dérivé du dashboard
(GET /api/stats/catalog), rendu au choix par bloc, ordre libre, modèles
sauvegardés en localStorage (clé ka-stats-rapports, propre au site).
Contrat : SPEC.md §3bis. Rendu dans PdfButton — aucune modif des pages. */
export type CatalogBlock = {
key: string; section: string; title: string;
renders: string[]; default_render: string; count?: number;
};
type BuilderSel = { key: string; render: string };
type BuilderTpl = { name: string; title: string; blocks: BuilderSel[] };
const RENDER_LABELS: Record = {
line: "Courbe", area: "Aire", bar: "Barres verticales",
bars: "Barres horizontales", donut: "Anneau", lines: "Multi-courbes",
stacked: "Stacked bars", histogram: "Histogram", heatmap: "Heatmap",
cards: "Cartes", gauges: "Jauges", table: "Tableau",
};
const SECTION_LABELS: Record = {
kpis: "Indicateurs", gauges: "Jauges", series: "Évolution",
multiseries: "Multi-courbes", stacked: "Compositions",
breakdowns: "Breakdowns", distributions: "Distributions",
geo: "Geography", heatmap: "Calendar", hourly: "Hourly activity",
tables: "Tableaux", records: "Records",
};
const TPL_KEY = "ka-stats-rapports";
function loadTemplates(): BuilderTpl[] {
try { return JSON.parse(localStorage.getItem(TPL_KEY) ?? "[]"); }
catch { return []; }
}
function saveTemplates(t: BuilderTpl[]) {
try { localStorage.setItem(TPL_KEY, JSON.stringify(t)); } catch { /* plein/privé */ }
}
export function ReportBuilder({
period, from, to, endpoint = "/api/stats/report", onClose,
}: {
period: string; from?: string; to?: string; endpoint?: string; onClose: () => void;
}) {
const [cat, setCat] = useState(null);
const [err, setErr] = useState("");
const [sel, setSel] = useState([]);
const [title, setTitle] = useState("");
const [busy, setBusy] = useState(false);
const [tpls, setTpls] = useState(loadTemplates);
const catalogUrl = endpoint.replace(/\/report$/, "/catalog");
useEffect(() => {
const p = new URLSearchParams({ period });
if (from) p.set("from", from);
if (to) p.set("to", to);
fetch(`${catalogUrl}?${p}`)
.then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
.then((d) => setCat(d.blocks ?? []))
.catch(() => setErr("Catalog unavailable — try again later."));
}, [period, from, to, catalogUrl]);
useEffect(() => {
const esc = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", esc);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.removeEventListener("keydown", esc); document.body.style.overflow = prev; };
}, [onClose]);
const add = (b: CatalogBlock) =>
setSel((s) => s.some((x) => x.key === b.key && x.render === b.default_render)
? s : [...s, { key: b.key, render: b.default_render }]);
const move = (i: number, d: number) => setSel((s) => {
const j = i + d;
if (j < 0 || j >= s.length) return s;
const n = [...s]; [n[i], n[j]] = [n[j], n[i]]; return n;
});
const generate = async () => {
if (busy || !sel.length) return;
setBusy(true); setErr("");
try {
const body: Record = { title, period, blocks: sel };
if (from && to) { body.from = from; body.to = to; }
const r = await fetch(`${endpoint}/custom`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(String(r.status));
const blob = await r.blob();
const m = (r.headers.get("Content-Disposition") ?? "").match(/filename="?([^";]+)/);
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = m ? m[1] : "rapport-personnalise.pdf";
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
} catch {
setErr("Generation failed — try again.");
}
setBusy(false);
};
const groups: [string, CatalogBlock[]][] = [];
for (const b of cat ?? []) {
const g = groups.find(([s]) => s === b.section);
if (g) g[1].push(b); else groups.push([b.section, [b]]);
}
const selKeys = new Set(sel.map((s) => s.key));
const mono: React.CSSProperties = { fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em" };
return (
{ if (e.target === e.currentTarget) onClose(); }}
style={{ position: "fixed", inset: 0, zIndex: "var(--z-modal, 900)" as never, background: "rgba(20,24,20,0.45)", display: "flex", alignItems: "flex-start", justifyContent: "center", padding: "4vh 14px", overflow: "auto" }}>
Custom report · period: {from && to ? `${from} → ${to}` : (PERIODS.find((p) => p.id === period)?.label ?? period)}
Blocs disponibles ({cat?.length ?? "…"})
{!cat && !err &&
Chargement du catalogue…
}
{groups.map(([secId, bs]) => (
{SECTION_LABELS[secId] ?? secId}
{bs.map((b) => (
{b.title}
))}
))}
Composition du rapport ({sel.length})
setTitle(e.target.value)} />
{sel.length ? sel.map((s, i) => {
const b = (cat ?? []).find((x) => x.key === s.key) ?? { title: s.key, renders: [s.render] } as CatalogBlock;
return (
{i + 1}. {b.title}
{b.renders.length > 1 ? (
) : {RENDER_LABELS[s.render] ?? s.render}}
);
}) : (
No blocks yet — add blocks from the left column, or load a template below.
)}
{err &&
{err}
}
);
}
/* ---------- États ---------- */
export function EmptyBlock({ title }: { title: string }) {
return (
{title}
Not measured yet — no data available for this period.
);
}
export function Fraicheur({ updated, onRefresh }: { updated: string; onRefresh: () => void }) {
return (
Updated {new Date(updated).toLocaleString("en-CA", { dateStyle: "medium", timeStyle: "short" })}
);
}