// 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 }))} />}
{ const r = (e.currentTarget as SVGSVGElement).getBoundingClientRect(); const fx = ((e.clientX - r.left) / r.width) * W; setHover(Math.round(((fx - PL) / (W - PL - PR)) * (pts.length - 1))); }} onMouseLeave={() => setHover(null)}> {[0, 1, 2, 3, 4].map((g) => { const y = PT + ((H - PT - PB) * g) / 4; const v = vmax - ((vmax - vmin) * g) / 4; return ( {fmtInt(Math.round(v))} ); })} {[0, Math.floor(pts.length / 2), pts.length - 1].map((i) => ( {pts[i].t} ))} {serie.kind === "area" && !hide.cur && ( )} {!hide.cmp && serie.compare && serie.compare.length > 1 && ( )} {!hide.cur && } {hi !== null && ( )} {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] }))} /> ))}
{ const r = (e.currentTarget as SVGSVGElement).getBoundingClientRect(); const fx = ((e.clientX - r.left) / r.width) * W; setHover(Math.round(((fx - PL) / (W - PL - PR)) * (n - 1))); }} onMouseLeave={() => setHover(null)}> {[0, 1, 2, 3, 4].map((g) => { const y = PT + ((H - PT - PB) * g) / 4; const v = vmax - ((vmax - vmin) * g) / 4; return ( {fmtInt(Math.round(v))} ); })} {[0, Math.floor(ref.length / 2), ref.length - 1].map((i) => ( {ref[i]?.t} ))} {series.map((s, i) => off[s.label] ? null : ( `${j ? "L" : "M"}${X(j, s.points.length)},${Y(p.v)}`).join("")} fill="none" stroke={MULTI_STYLES[i].stroke} strokeWidth={MULTI_STYLES[i].width} strokeDasharray={MULTI_STYLES[i].dash} /> ))} {hi !== null && ( )} {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}
setHover(null)}> {[0, 1, 2, 3, 4].map((g) => { const y = PT + ((H - PT - PB) * g) / 4; const v = vmax - (vmax * g) / 4; return ( {fmtInt(Math.round(v))} ); })} {pts.map((p, i) => { const x = PL + ((W - PL - PR) * i) / pts.length; const h = (H - PT - PB) * (p.v / vmax); return ( 0 ? 1.5 : 0)} rx={2} fill="var(--accent)" opacity={hover === null || hover === i ? 1 : 0.45} stroke="var(--ink)" strokeWidth={0.5} onMouseEnter={() => setHover(i)}> {`${p.t} — ${fmtNum(p.v)}${serie.unit ? ` ${serie.unit}` : ""}`} ); })} {[0, Math.floor(pts.length / 2), pts.length - 1].filter((v, i, a) => a.indexOf(v) === i).map((i) => ( {pts[i].t} ))} {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} ))}
setHover(null)}> {[0, 1, 2, 3, 4].map((g) => { const y = PT + ((H - PT - PB) * g) / 4; return ( {fmtInt(Math.round(vmax - (vmax * g) / 4))} ); })} {pts.map((p, i) => { const x = PL + ((W - PL - PR) * i) / pts.length; let yAcc = H - PB; return ( setHover(i)} opacity={hover === null || hover === i ? 1 : 0.5}> {keys.map((k, j) => { const v = p.values[j] || 0; const h = (H - PT - PB) * (v / vmax); yAcc -= h; return v > 0 ? ( {`${p.t} · ${k} — ${fmtNum(v)}${st.unit ? ` ${st.unit}` : ""}`} ) : null; })} ); })} {[0, Math.floor(pts.length / 2), pts.length - 1].filter((v, i, a) => a.indexOf(v) === i).map((i) => ( {pts[i].t} ))} {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) => { const frac = r.value / total; const off = acc; acc += frac; return ( {`${r.label} — ${fmtNum(r.value)} (${((100 * r.value) / total).toFixed(1)} %)`} ); })}
    {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 (
{fmtNum(g.value)}{g.unit ? {g.unit} : null} {(frac * 100).toFixed(0)} % de {fmtNum(g.max)}{g.unit ? ` ${g.unit}` : ""}

{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
{cols.map((col, w) => col.map((c, d) => ( {`${c.date} — ${fmtNum(c.v)}`} )))}
); } /* ---------- 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
{[0, 6, 12, 18, 23].map((h) => ( {h} h ))} {DOW.map((d, i) => ( {d} ))} {Array.from({ length: 7 }, (_, d) => Array.from({ length: 24 }, (_, h) => { const v = grid.get(`${d}-${h}`) ?? 0; return ( {`${DOW[d]} ${h} h — ${fmtNum(v)}`} ); }))}
); } /* ---------- 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) => ( ))} {rows.slice(cur * pageSize, (cur + 1) * pageSize).map((row, ri) => ( {row.map((c, ci) => ( ))} ))}
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 ? "▲" : "▼") : "↕"}
{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" })}

); }