// 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: "Aujourd'hui" },
{ id: "7j", label: "7 jours" },
{ id: "30j", label: "30 jours" },
{ id: "3m", label: "3 mois" },
{ id: "6m", label: "6 mois" },
{ id: "12m", label: "12 mois" },
{ id: "annee", label: "Année en cours" },
{ id: "tout", label: "Tout" },
];
export const REPORT_MODES: { id: string; label: string; desc: string }[] = [
{ id: "complet", label: "Rapport complet", desc: "Toutes les sections — KPI, tendances, répartitions, tableaux, records" },
{ id: "synthese", label: "Synthèse exécutive", desc: "2 pages — indicateurs clés et faits marquants" },
{ id: "tendances", label: "Tendances & évolution", desc: "Courbes, comparaisons N-1 et statistiques de séries" },
{ id: "repartitions", label: "Répartitions & géographie", desc: "Catégories, distributions, régions et activité" },
{ id: "donnees", label: "Données détaillées", desc: "Tous les tableaux, en version longue" },
];
export const fmtInt = (n: number) => n.toLocaleString("fr-CA");
export const fmtNum = (n: number) =>
Number.isInteger(n) ? fmtInt(n) : n.toLocaleString("fr-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 période préc.
) :
}
{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} 26 dernières semaines
);
}
/* ---------- Heatmap horaire 7 × 24 (activité par heure) ---------- */
const DOW = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
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]],
["Moyenne", Math.round(mean * 100) / 100], ["Médiane", 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={`Rechercher dans ${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)} lignes
{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) ---------- */
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 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 (
{open && (
{REPORT_MODES.map((m) => (
))}
)}
);
}
/* ---------- États ---------- */
export function EmptyBlock({ title }: { title: string }) {
return (
{title}
Pas encore mesuré — aucune donnée disponible pour cette période.
);
}
export function Fraicheur({ updated, onRefresh }: { updated: string; onRefresh: () => void }) {
return (
Mis à jour le {new Date(updated).toLocaleString("fr-CA", { dateStyle: "medium", timeStyle: "short" })}
);
}