SPB Git

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

ka/stats : copies de référence du kit alignées sur la v3 (kacharts ReportBuilder, kapdf catalog/personnalise)

Simon-Pierre Boucher committed (Aug 23, 2026) parent 2792b05 Browse files

2 changed files +1,003 −38

modified frontend/src/ka/stats/kacharts.tsx +605 −30
@@ -2,22 +2,39 @@
2 // ka-ui/stats/kacharts.tsx — kit de graphiques SVG du module Stats commun 2 // ka-ui/stats/kacharts.tsx — kit de graphiques SVG du module Stats commun
3 // Groupe KA (zéro dépendance, React 18+). Style : design system ka-ui 3 // Groupe KA (zéro dépendance, React 18+). Style : design system ka-ui
4 // (bordures encre, accent de la plateforme via var(--accent)). 4 // (bordures encre, accent de la plateforme via var(--accent)).
5−// Composants : KpiCard, PeriodSelector, LineChart (infobulle + légende 5+// v2 — composants : KpiCard (+sparkline), PeriodSelector, LineChart (ligne/
6−// cliquable + comparaison N-1), BarChart, Donut, CalendarHeatmap, DataTable 6+// aire, infobulle + légende cliquable + comparaison N-1), MultiLineChart
7−// (tri/recherche/pagination), RecordCard, PdfButton, EmptyBlock, Fraicheur. 7+// (≤4 séries, pointillés distincts = jamais la couleur seule), VBarChart
8−import { useMemo, useState } from "react"; 8+// (barres verticales / histogrammes), StackedBarChart, BarChart (horizontal,
9+// deltas), Donut, GaugeCard, CalendarHeatmap, HourHeatmap (7×24),
10+// StatSummary (min/max/moy/méd/σ), DataTable (tri/recherche/pagination),
11+// RecordCard, PdfButton (menu de rapports), EmptyBlock, Fraicheur.
12+import { useEffect, useMemo, useRef, useState } from "react";
9 13
10−/* ---------- types (contrat SPEC.md) ---------- */ 14+/* ---------- types (contrat SPEC.md v2) ---------- */
11 export type Kpi = { 15 export type Kpi = {
12 id: string; label: string; value: number | string; unit?: string; 16 id: string; label: string; value: number | string; unit?: string;
13 delta_pct?: number | null; direction?: "up" | "down"; 17 delta_pct?: number | null; direction?: "up" | "down";
18+ spark?: Point[]; help?: string;
14 }; 19 };
15 export type Point = { t: string; v: number }; 20 export type Point = { t: string; v: number };
16 export type Serie = { 21 export type Serie = {
17− id: string; title: string; unit?: string; kind?: "line" | "bar"; 22+ id: string; title: string; unit?: string;
23+ kind?: "line" | "bar" | "area";
18 points: Point[]; compare?: Point[]; 24 points: Point[]; compare?: Point[];
19 }; 25 };
20−export type BreakItem = { label: string; value: number }; 26+export type MultiSerie = {
27+ id: string; title: string; unit?: string;
28+ series: { label: string; points: Point[] }[]; // ≤ 4 séries
29+};
30+export type StackedSerie = {
31+ id: string; title: string; unit?: string;
32+ keys: string[]; points: { t: string; values: number[] }[];
33+};
34+export type BreakItem = { label: string; value: number; delta_pct?: number | null };
35+export type Distribution = { id: string; title: string; unit?: string; bins: { label: string; value: number }[] };
36+export type Gauge = { id: string; label: string; value: number; max: number; unit?: string; help?: string };
37+export type HourCell = { dow: number; hour: number; value: number }; // dow 0=lun … 6=dim
21 export type TableSpec = { id: string; title: string; columns: string[]; rows: (string | number)[][] }; 38 export type TableSpec = { id: string; title: string; columns: string[]; rows: (string | number)[][] };
22 export type RecordFact = { label: string; value: string; date?: string }; 39 export type RecordFact = { label: string; value: string; date?: string };
23 40
@@ -32,25 +49,63 @@ export const PERIODS: { id: string; label: string }[] = [
32 { id: "tout", label: "Tout" }, 49 { id: "tout", label: "Tout" },
33 ]; 50 ];
34 51
52+export const REPORT_MODES: { id: string; label: string; desc: string }[] = [
53+ { id: "complet", label: "Rapport complet", desc: "Toutes les sections — KPI, tendances, répartitions, tableaux, records" },
54+ { id: "synthese", label: "Synthèse exécutive", desc: "2 pages — indicateurs clés et faits marquants" },
55+ { id: "tendances", label: "Tendances & évolution", desc: "Courbes, comparaisons N-1 et statistiques de séries" },
56+ { id: "repartitions", label: "Répartitions & géographie", desc: "Catégories, distributions, régions et activité" },
57+ { id: "donnees", label: "Données détaillées", desc: "Tous les tableaux, en version longue" },
58+];
59+
35 export const fmtInt = (n: number) => n.toLocaleString("fr-CA"); 60 export const fmtInt = (n: number) => n.toLocaleString("fr-CA");
36 export const fmtNum = (n: number) => 61 export const fmtNum = (n: number) =>
37 Number.isInteger(n) ? fmtInt(n) : n.toLocaleString("fr-CA", { maximumFractionDigits: 2 }); 62 Number.isInteger(n) ? fmtInt(n) : n.toLocaleString("fr-CA", { maximumFractionDigits: 2 });
63+const fmtPct = (n: number) => `${n >= 0 ? "+" : ""}${fmtNum(n)} %`;
64+
65+/* Styles des séries multiples : couleur + motif de trait (l'identité n'est
66+ jamais portée par la couleur seule — règle d'accessibilité). */
67+const MULTI_STYLES = [
68+ { stroke: "var(--accent)", dash: undefined, width: 2.4 },
69+ { stroke: "var(--ink)", dash: undefined, width: 1.6 },
70+ { stroke: "var(--accent-deep, var(--accent))", dash: "6 3", width: 2 },
71+ { stroke: "var(--ink-3)", dash: "2 3", width: 2 },
72+];
38 73
39−/* ---------- KPI ---------- */ 74+/* ---------- KPI (+ sparkline) ---------- */
40 export function KpiCard({ k }: { k: Kpi }) { 75 export function KpiCard({ k }: { k: Kpi }) {
41 const up = (k.direction ?? ((k.delta_pct ?? 0) >= 0 ? "up" : "down")) === "up"; 76 const up = (k.direction ?? ((k.delta_pct ?? 0) >= 0 ? "up" : "down")) === "up";
77+ const sp = (k.spark ?? []).filter((p) => typeof p.v === "number");
78+ const spark = useMemo(() => {
79+ if (sp.length < 2) return null;
80+ const w = 120, h = 30;
81+ const vmax = Math.max(...sp.map((p) => p.v));
82+ const vmin = Math.min(...sp.map((p) => p.v));
83+ const rng = vmax - vmin || 1;
84+ const X = (i: number) => (w * i) / (sp.length - 1);
85+ const Y = (v: number) => 2 + (h - 4) * (1 - (v - vmin) / rng);
86+ const d = sp.map((p, i) => `${i ? "L" : "M"}${X(i).toFixed(1)},${Y(p.v).toFixed(1)}`).join("");
87+ return { w, h, d, area: `${d}L${w},${h}L0,${h}Z` };
88+ }, [k.spark]);
42 return ( 89 return (
43− <article className="card" style={{ padding: "14px 16px", minWidth: 0 }}> 90+ <article className="card" style={{ padding: "14px 16px", minWidth: 0 }} title={k.help}>
44 <p style={{ margin: 0, fontFamily: "var(--font-display)", fontWeight: 700, fontSize: "clamp(22px,2.4vw,30px)", letterSpacing: "-0.02em" }}> 91 <p style={{ margin: 0, fontFamily: "var(--font-display)", fontWeight: 700, fontSize: "clamp(22px,2.4vw,30px)", letterSpacing: "-0.02em" }}>
45 {typeof k.value === "number" ? fmtNum(k.value) : k.value} 92 {typeof k.value === "number" ? fmtNum(k.value) : k.value}
46 {k.unit ? <span style={{ fontSize: "0.6em", color: "var(--ink-2)" }}> {k.unit}</span> : null} 93 {k.unit ? <span style={{ fontSize: "0.6em", color: "var(--ink-2)" }}> {k.unit}</span> : null}
47 </p> 94 </p>
48 <p className="klabel" style={{ margin: "6px 0 0" }}>{k.label}</p> 95 <p className="klabel" style={{ margin: "6px 0 0" }}>{k.label}</p>
49− {k.delta_pct !== undefined && k.delta_pct !== null && ( 96+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", gap: 8 }}>
50− <p style={{ margin: "8px 0 0", fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 700, color: up ? "var(--green)" : "var(--danger)" }}> 97+ {k.delta_pct !== undefined && k.delta_pct !== null ? (
51− {up ? "▲" : "▼"} {k.delta_pct >= 0 ? "+" : ""}{fmtNum(k.delta_pct)} % <span style={{ color: "var(--ink-3)", fontWeight: 500 }}>vs période préc.</span> 98+ <p style={{ margin: "8px 0 0", fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 700, color: up ? "var(--green)" : "var(--danger)" }}>
52− </p> 99+ {up ? "▲" : "▼"} {fmtPct(k.delta_pct)} <span style={{ color: "var(--ink-3)", fontWeight: 500 }}>vs période préc.</span>
53− )} 100+ </p>
101+ ) : <span />}
102+ {spark && (
103+ <svg viewBox={`0 0 ${spark.w} ${spark.h}`} style={{ width: 96, height: 24, flex: "none" }} aria-hidden="true">
104+ <path d={spark.area} fill="var(--accent)" opacity={0.14} />
105+ <path d={spark.d} fill="none" stroke="var(--accent)" strokeWidth={1.8} />
106+ </svg>
107+ )}
108+ </div>
54 </article> 109 </article>
55 ); 110 );
56 } 111 }
@@ -84,12 +139,13 @@ export function PeriodSelector({
84 ); 139 );
85 } 140 }
86 141
87−/* ---------- Courbe ---------- */ 142+/* ---------- Courbe / aire ---------- */
88 export function LineChart({ serie, height = 240 }: { serie: Serie; height?: number }) { 143 export function LineChart({ serie, height = 240 }: { serie: Serie; height?: number }) {
89 const [hide, setHide] = useState<{ cur: boolean; cmp: boolean }>({ cur: false, cmp: false }); 144 const [hide, setHide] = useState<{ cur: boolean; cmp: boolean }>({ cur: false, cmp: false });
90 const [hover, setHover] = useState<number | null>(null); 145 const [hover, setHover] = useState<number | null>(null);
91 const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26; 146 const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
92 const pts = serie.points ?? []; 147 const pts = serie.points ?? [];
148+ if (serie.kind === "bar") return <VBarChart serie={serie} height={height} />;
93 if (pts.length < 2) return <EmptyBlock title={serie.title} />; 149 if (pts.length < 2) return <EmptyBlock title={serie.title} />;
94 const all = [...(hide.cur ? [] : pts), ...(!hide.cmp && serie.compare ? serie.compare : [])]; 150 const all = [...(hide.cur ? [] : pts), ...(!hide.cmp && serie.compare ? serie.compare : [])];
95 const vmax = Math.max(...all.map((p) => p.v), 1); 151 const vmax = Math.max(...all.map((p) => p.v), 1);
@@ -127,6 +183,9 @@ export function LineChart({ serie, height = 240 }: { serie: Serie; height?: numb
127 {[0, Math.floor(pts.length / 2), pts.length - 1].map((i) => ( 183 {[0, Math.floor(pts.length / 2), pts.length - 1].map((i) => (
128 <text key={i} x={X(i, pts.length)} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text> 184 <text key={i} x={X(i, pts.length)} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text>
129 ))} 185 ))}
186+ {serie.kind === "area" && !hide.cur && (
187+ <path d={`${path(pts)}L${X(pts.length - 1, pts.length)},${Y(0)}L${X(0, pts.length)},${Y(0)}Z`} fill="var(--accent)" opacity={0.13} />
188+ )}
130 {!hide.cmp && serie.compare && serie.compare.length > 1 && ( 189 {!hide.cmp && serie.compare && serie.compare.length > 1 && (
131 <path d={path(serie.compare)} fill="none" stroke="var(--ink-3)" strokeWidth={1.4} strokeDasharray="4 4" /> 190 <path d={path(serie.compare)} fill="none" stroke="var(--ink-3)" strokeWidth={1.4} strokeDasharray="4 4" />
132 )} 191 )}
@@ -148,17 +207,211 @@ export function LineChart({ serie, height = 240 }: { serie: Serie; height?: numb
148 ); 207 );
149 } 208 }
150 209
151−function LegendChip({ label, color, off, dashed, onClick }: { label: string; color: string; off: boolean; dashed?: boolean; onClick: () => void }) { 210+function LegendChip({ label, color, off, dashed, onClick }: { label: string; color: string; off?: boolean; dashed?: boolean; onClick?: () => void }) {
152 return ( 211 return (
153− <button type="button" onClick={onClick} aria-pressed={!off} 212+ <button type="button" onClick={onClick} aria-pressed={!off} disabled={!onClick}
154− style={{ display: "inline-flex", alignItems: "center", gap: 6, border: 0, background: "none", cursor: "pointer", opacity: off ? 0.4 : 1, fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", minHeight: 44 }}> 213+ style={{ display: "inline-flex", alignItems: "center", gap: 6, border: 0, background: "none", cursor: onClick ? "pointer" : "default", opacity: off ? 0.4 : 1, fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", minHeight: 44 }}>
155 <span style={{ width: 18, height: 0, borderTop: `3px ${dashed ? "dashed" : "solid"} ${color}` }} /> 214 <span style={{ width: 18, height: 0, borderTop: `3px ${dashed ? "dashed" : "solid"} ${color}` }} />
156 {label} 215 {label}
157 </button> 216 </button>
158 ); 217 );
159 } 218 }
160 219
161−/* ---------- Barres horizontales (répartitions, géo) ---------- */ 220+/* ---------- Multi-courbes (≤ 4 séries, motifs distincts) ---------- */
221+export function MultiLineChart({ ms, height = 260 }: { ms: MultiSerie; height?: number }) {
222+ const series = (ms.series ?? []).filter((s) => (s.points ?? []).length > 1).slice(0, 4);
223+ const [off, setOff] = useState<Record<string, boolean>>({});
224+ const [hover, setHover] = useState<number | null>(null);
225+ if (!series.length) return <EmptyBlock title={ms.title} />;
226+ const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
227+ const n = Math.max(...series.map((s) => s.points.length));
228+ const shown = series.filter((s) => !off[s.label]);
229+ const all = shown.flatMap((s) => s.points.map((p) => p.v));
230+ const vmax = Math.max(...(all.length ? all : [1]), 1);
231+ const vmin = Math.min(0, ...(all.length ? all : [0]));
232+ const X = (i: number, len: number) => PL + ((W - PL - PR) * i) / (len - 1);
233+ const Y = (v: number) => PT + (H - PT - PB) * (1 - (v - vmin) / (vmax - vmin || 1));
234+ const ref = series[0].points;
235+ const hi = hover !== null ? Math.min(n - 1, Math.max(0, hover)) : null;
236+ return (
237+ <figure className="card" style={{ margin: 0, padding: 16 }}>
238+ <figcaption style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>
239+ <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{ms.title}</b>
240+ <span style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
241+ {series.map((s, i) => (
242+ <LegendChip key={s.label} label={s.label} color={MULTI_STYLES[i].stroke}
243+ dashed={!!MULTI_STYLES[i].dash} off={!!off[s.label]}
244+ onClick={() => setOff((o) => ({ ...o, [s.label]: !o[s.label] }))} />
245+ ))}
246+ </span>
247+ </figcaption>
248+ <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10, touchAction: "pan-y" }} role="img" aria-label={ms.title}
249+ onMouseMove={(e) => {
250+ const r = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
251+ const fx = ((e.clientX - r.left) / r.width) * W;
252+ setHover(Math.round(((fx - PL) / (W - PL - PR)) * (n - 1)));
253+ }}
254+ onMouseLeave={() => setHover(null)}>
255+ {[0, 1, 2, 3, 4].map((g) => {
256+ const y = PT + ((H - PT - PB) * g) / 4;
257+ const v = vmax - ((vmax - vmin) * g) / 4;
258+ return (
259+ <g key={g}>
260+ <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />
261+ <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(v))}</text>
262+ </g>
263+ );
264+ })}
265+ {[0, Math.floor(ref.length / 2), ref.length - 1].map((i) => (
266+ <text key={i} x={X(i, ref.length)} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{ref[i]?.t}</text>
267+ ))}
268+ {series.map((s, i) => off[s.label] ? null : (
269+ <path key={s.label}
270+ d={s.points.map((p, j) => `${j ? "L" : "M"}${X(j, s.points.length)},${Y(p.v)}`).join("")}
271+ fill="none" stroke={MULTI_STYLES[i].stroke} strokeWidth={MULTI_STYLES[i].width}
272+ strokeDasharray={MULTI_STYLES[i].dash} />
273+ ))}
274+ {hi !== null && (
275+ <line x1={X(hi, n)} x2={X(hi, n)} y1={PT} y2={H - PB} stroke="var(--ink)" strokeWidth={1} strokeDasharray="2 3" />
276+ )}
277+ </svg>
278+ {hi !== null && (
279+ <p className="chip" style={{ marginTop: 8, display: "inline-flex", gap: 12, flexWrap: "wrap" }}>
280+ <b>{ref[hi]?.t}</b>
281+ {shown.map((s) => (
282+ <span key={s.label}>{s.label} : <b>{s.points[hi] ? fmtNum(s.points[hi].v) : "—"}{ms.unit ? ` ${ms.unit}` : ""}</b></span>
283+ ))}
284+ </p>
285+ )}
286+ </figure>
287+ );
288+}
289+
290+/* ---------- Barres verticales (volumes quotidiens, histogrammes) ---------- */
291+export function VBarChart({ serie, height = 240 }: { serie: Serie; height?: number }) {
292+ const [hover, setHover] = useState<number | null>(null);
293+ const pts = serie.points ?? [];
294+ if (!pts.length) return <EmptyBlock title={serie.title} />;
295+ const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
296+ const vmax = Math.max(...pts.map((p) => p.v), 1);
297+ const bw = Math.max(2, (W - PL - PR) / pts.length - 2);
298+ return (
299+ <figure className="card" style={{ margin: 0, padding: 16 }}>
300+ <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{serie.title}</b></figcaption>
301+ <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10 }} role="img" aria-label={serie.title}
302+ onMouseLeave={() => setHover(null)}>
303+ {[0, 1, 2, 3, 4].map((g) => {
304+ const y = PT + ((H - PT - PB) * g) / 4;
305+ const v = vmax - (vmax * g) / 4;
306+ return (
307+ <g key={g}>
308+ <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />
309+ <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(v))}</text>
310+ </g>
311+ );
312+ })}
313+ {pts.map((p, i) => {
314+ const x = PL + ((W - PL - PR) * i) / pts.length;
315+ const h = (H - PT - PB) * (p.v / vmax);
316+ return (
317+ <rect key={i} x={x + 1} y={H - PB - h} width={bw} height={Math.max(h, p.v > 0 ? 1.5 : 0)} rx={2}
318+ fill="var(--accent)" opacity={hover === null || hover === i ? 1 : 0.45}
319+ stroke="var(--ink)" strokeWidth={0.5}
320+ onMouseEnter={() => setHover(i)}>
321+ <title>{`${p.t} — ${fmtNum(p.v)}${serie.unit ? ` ${serie.unit}` : ""}`}</title>
322+ </rect>
323+ );
324+ })}
325+ {[0, Math.floor(pts.length / 2), pts.length - 1].filter((v, i, a) => a.indexOf(v) === i).map((i) => (
326+ <text key={i} x={PL + ((W - PL - PR) * (i + 0.5)) / pts.length} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text>
327+ ))}
328+ </svg>
329+ {hover !== null && (
330+ <p className="chip" style={{ marginTop: 8 }}>{pts[hover].t} — <b>{fmtNum(pts[hover].v)}{serie.unit ? ` ${serie.unit}` : ""}</b></p>
331+ )}
332+ </figure>
333+ );
334+}
335+
336+/* ---------- Histogramme (distribution) ---------- */
337+export function Histogram({ dist }: { dist: Distribution }) {
338+ const serie: Serie = {
339+ id: dist.id, title: dist.title, unit: dist.unit, kind: "bar",
340+ points: (dist.bins ?? []).map((b) => ({ t: b.label, v: b.value })),
341+ };
342+ return <VBarChart serie={serie} height={220} />;
343+}
344+
345+/* ---------- Barres empilées (composition dans le temps) ---------- */
346+export function StackedBarChart({ st, height = 260 }: { st: StackedSerie; height?: number }) {
347+ const [hover, setHover] = useState<number | null>(null);
348+ const keys = (st.keys ?? []).slice(0, 6);
349+ const pts = st.points ?? [];
350+ if (!keys.length || !pts.length) return <EmptyBlock title={st.title} />;
351+ const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
352+ const totals = pts.map((p) => p.values.slice(0, keys.length).reduce((s, v) => s + (v || 0), 0));
353+ const vmax = Math.max(...totals, 1);
354+ const bw = Math.max(2, (W - PL - PR) / pts.length - 2);
355+ const shades = [1, 0.72, 0.5, 0.34, 0.22, 0.13];
356+ return (
357+ <figure className="card" style={{ margin: 0, padding: 16 }}>
358+ <figcaption style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>
359+ <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{st.title}</b>
360+ <span style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
361+ {keys.map((k, i) => (
362+ <span key={k} style={{ display: "inline-flex", alignItems: "center", gap: 5, fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em" }}>
363+ <span style={{ width: 11, height: 11, borderRadius: 3, border: "1px solid var(--ink)", background: "var(--accent)", opacity: shades[i] }} />
364+ {k}
365+ </span>
366+ ))}
367+ </span>
368+ </figcaption>
369+ <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10 }} role="img" aria-label={st.title}
370+ onMouseLeave={() => setHover(null)}>
371+ {[0, 1, 2, 3, 4].map((g) => {
372+ const y = PT + ((H - PT - PB) * g) / 4;
373+ return (
374+ <g key={g}>
375+ <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />
376+ <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(vmax - (vmax * g) / 4))}</text>
377+ </g>
378+ );
379+ })}
380+ {pts.map((p, i) => {
381+ const x = PL + ((W - PL - PR) * i) / pts.length;
382+ let yAcc = H - PB;
383+ return (
384+ <g key={i} onMouseEnter={() => setHover(i)} opacity={hover === null || hover === i ? 1 : 0.5}>
385+ {keys.map((k, j) => {
386+ const v = p.values[j] || 0;
387+ const h = (H - PT - PB) * (v / vmax);
388+ yAcc -= h;
389+ return v > 0 ? (
390+ <rect key={k} x={x + 1} y={yAcc} width={bw} height={Math.max(h - 1, 0.8)} rx={1.5}
391+ fill="var(--accent)" opacity={shades[j]} stroke="var(--ink)" strokeWidth={0.4}>
392+ <title>{`${p.t} · ${k} — ${fmtNum(v)}${st.unit ? ` ${st.unit}` : ""}`}</title>
393+ </rect>
394+ ) : null;
395+ })}
396+ </g>
397+ );
398+ })}
399+ {[0, Math.floor(pts.length / 2), pts.length - 1].filter((v, i, a) => a.indexOf(v) === i).map((i) => (
400+ <text key={i} x={PL + ((W - PL - PR) * (i + 0.5)) / pts.length} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text>
401+ ))}
402+ </svg>
403+ {hover !== null && (
404+ <p className="chip" style={{ marginTop: 8, display: "inline-flex", gap: 12, flexWrap: "wrap" }}>
405+ <b>{pts[hover].t}</b>
406+ {keys.map((k, j) => <span key={k}>{k} : <b>{fmtNum(pts[hover].values[j] || 0)}</b></span>)}
407+ <span style={{ color: "var(--ink-3)" }}>total {fmtNum(totals[hover])}</span>
408+ </p>
409+ )}
410+ </figure>
411+ );
412+}
413+
414+/* ---------- Barres horizontales (répartitions, géo) — deltas optionnels ---------- */
162 export function BarChart({ title, items, unit }: { title: string; items: BreakItem[]; unit?: string }) { 415 export function BarChart({ title, items, unit }: { title: string; items: BreakItem[]; unit?: string }) {
163 const rows = (items ?? []).slice(0, 14); 416 const rows = (items ?? []).slice(0, 14);
164 if (!rows.length) return <EmptyBlock title={title} />; 417 if (!rows.length) return <EmptyBlock title={title} />;
@@ -169,9 +422,16 @@ export function BarChart({ title, items, unit }: { title: string; items: BreakIt
169 <div style={{ marginTop: 12, display: "grid", gap: 9 }}> 422 <div style={{ marginTop: 12, display: "grid", gap: 9 }}>
170 {rows.map((r) => ( 423 {rows.map((r) => (
171 <div key={r.label} title={`${r.label} — ${fmtNum(r.value)}${unit ? ` ${unit}` : ""}`}> 424 <div key={r.label} title={`${r.label} — ${fmtNum(r.value)}${unit ? ` ${unit}` : ""}`}>
172− <div style={{ display: "flex", justifyContent: "space-between", fontSize: 12.5 }}> 425+ <div style={{ display: "flex", justifyContent: "space-between", gap: 8, fontSize: 12.5 }}>
173 <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span> 426 <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
174− <b style={{ fontFamily: "var(--font-mono)", fontSize: 11.5 }}>{fmtNum(r.value)}{unit ? ` ${unit}` : ""}</b> 427+ <span style={{ display: "inline-flex", gap: 8, alignItems: "baseline", flex: "none" }}>
428+ {r.delta_pct !== undefined && r.delta_pct !== null && (
429+ <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, fontWeight: 700, color: r.delta_pct >= 0 ? "var(--green)" : "var(--danger)" }}>
430+ {r.delta_pct >= 0 ? "▲" : "▼"} {fmtPct(r.delta_pct)}
431+ </span>
432+ )}
433+ <b style={{ fontFamily: "var(--font-mono)", fontSize: 11.5 }}>{fmtNum(r.value)}{unit ? ` ${unit}` : ""}</b>
434+ </span>
175 </div> 435 </div>
176 <div style={{ marginTop: 3, height: 12, background: "rgba(20,24,20,0.06)", borderRadius: "0 3px 3px 0" }}> 436 <div style={{ marginTop: 3, height: 12, background: "rgba(20,24,20,0.06)", borderRadius: "0 3px 3px 0" }}>
177 <div style={{ height: "100%", width: `${Math.max((r.value / max) * 100, 1)}%`, background: "var(--accent)", border: "1px solid var(--ink)", borderRadius: "0 3px 3px 0", boxSizing: "border-box" }} /> 437 <div style={{ height: "100%", width: `${Math.max((r.value / max) * 100, 1)}%`, background: "var(--accent)", border: "1px solid var(--ink)", borderRadius: "0 3px 3px 0", boxSizing: "border-box" }} />
@@ -224,6 +484,26 @@ export function Donut({ title, items }: { title: string; items: BreakItem[] }) {
224 ); 484 );
225 } 485 }
226 486
487+/* ---------- Jauge (taux, complétude, couverture) ---------- */
488+export function GaugeCard({ g }: { g: Gauge }) {
489+ const frac = Math.max(0, Math.min(1, g.max ? g.value / g.max : 0));
490+ const R = 60, C = Math.PI * R;
491+ return (
492+ <article className="card" style={{ padding: "14px 16px", minWidth: 0 }} title={g.help}>
493+ <svg viewBox="0 0 150 84" style={{ width: "100%", maxWidth: 190, display: "block", margin: "0 auto" }} role="img" aria-label={`${g.label} : ${fmtNum(g.value)}${g.unit ?? ""} sur ${fmtNum(g.max)}`}>
494+ <path d={`M15,75 A${R},${R} 0 0 1 135,75`} fill="none" stroke="rgba(20,24,20,0.08)" strokeWidth={13} strokeLinecap="round" />
495+ <path d={`M15,75 A${R},${R} 0 0 1 135,75`} fill="none" stroke="var(--accent)" strokeWidth={13} strokeLinecap="round"
496+ strokeDasharray={`${frac * C} ${C}`} />
497+ <text x={75} y={66} textAnchor="middle" fontFamily="var(--font-display)" fontWeight={700} fontSize={22} fill="var(--ink)">
498+ {fmtNum(g.value)}{g.unit ? <tspan fontSize={12} fill="var(--ink-2)"> {g.unit}</tspan> : null}
499+ </text>
500+ <text x={75} y={80} textAnchor="middle" fontSize={9} fill="var(--ink-3)" fontFamily="var(--font-mono)">{(frac * 100).toFixed(0)} % de {fmtNum(g.max)}{g.unit ? ` ${g.unit}` : ""}</text>
501+ </svg>
502+ <p className="klabel" style={{ margin: "8px 0 0", textAlign: "center" }}>{g.label}</p>
503+ </article>
504+ );
505+}
506+
227 /* ---------- Calendrier de chaleur ---------- */ 507 /* ---------- Calendrier de chaleur ---------- */
228 export function CalendarHeatmap({ title, cells }: { title: string; cells: { date: string; value: number }[] }) { 508 export function CalendarHeatmap({ title, cells }: { title: string; cells: { date: string; value: number }[] }) {
229 if (!cells?.length) return <EmptyBlock title={title} />; 509 if (!cells?.length) return <EmptyBlock title={title} />;
@@ -261,6 +541,64 @@ export function CalendarHeatmap({ title, cells }: { title: string; cells: { date
261 ); 541 );
262 } 542 }
263 543
544+/* ---------- Heatmap horaire 7 × 24 (activité par heure) ---------- */
545+const DOW = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
546+export function HourHeatmap({ title, cells }: { title: string; cells: HourCell[] }) {
547+ if (!cells?.length) return <EmptyBlock title={title} />;
548+ const grid = new Map(cells.map((c) => [`${c.dow}-${c.hour}`, c.value]));
549+ const max = Math.max(...cells.map((c) => c.value), 1);
550+ const CW = 24, CH = 20, LX = 34, LY = 16;
551+ return (
552+ <figure className="card" style={{ margin: 0, padding: 16 }}>
553+ <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b> <span className="klabel">jour × heure</span></figcaption>
554+ <div className="tbl-wrap" style={{ marginTop: 12 }}>
555+ <svg viewBox={`0 0 ${LX + 24 * CW} ${LY + 7 * CH}`} style={{ minWidth: 520, width: "100%", height: "auto" }} role="img" aria-label={title}>
556+ {[0, 6, 12, 18, 23].map((h) => (
557+ <text key={h} x={LX + h * CW + CW / 2} y={11} textAnchor="middle" fontSize={9} fill="var(--ink-3)" fontFamily="var(--font-mono)">{h} h</text>
558+ ))}
559+ {DOW.map((d, i) => (
560+ <text key={d} x={LX - 6} y={LY + i * CH + CH / 2 + 3} textAnchor="end" fontSize={9} fill="var(--ink-3)" fontFamily="var(--font-mono)">{d}</text>
561+ ))}
562+ {Array.from({ length: 7 }, (_, d) => Array.from({ length: 24 }, (_, h) => {
563+ const v = grid.get(`${d}-${h}`) ?? 0;
564+ return (
565+ <rect key={`${d}-${h}`} x={LX + h * CW} y={LY + d * CH} width={CW - 2} height={CH - 2} rx={2.5}
566+ fill={v ? "var(--accent)" : "rgba(20,24,20,0.07)"} fillOpacity={v ? 0.22 + 0.78 * (v / max) : 1}
567+ stroke="rgba(20,24,20,0.15)" strokeWidth={0.5}>
568+ <title>{`${DOW[d]} ${h} h — ${fmtNum(v)}`}</title>
569+ </rect>
570+ );
571+ }))}
572+ </svg>
573+ </div>
574+ </figure>
575+ );
576+}
577+
578+/* ---------- Statistiques de série (min/max/moy/méd/σ) ---------- */
579+export function StatSummary({ serie }: { serie: Serie }) {
580+ const vs = (serie.points ?? []).map((p) => p.v).filter((v) => typeof v === "number");
581+ if (vs.length < 2) return null;
582+ const sorted = [...vs].sort((a, b) => a - b);
583+ const mean = vs.reduce((s, v) => s + v, 0) / vs.length;
584+ const med = sorted[Math.floor(sorted.length / 2)];
585+ const sd = Math.sqrt(vs.reduce((s, v) => s + (v - mean) ** 2, 0) / vs.length);
586+ const items: [string, number][] = [
587+ ["Min", sorted[0]], ["Max", sorted[sorted.length - 1]],
588+ ["Moyenne", Math.round(mean * 100) / 100], ["Médiane", med],
589+ ["Écart-type", Math.round(sd * 100) / 100],
590+ ];
591+ return (
592+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
593+ {items.map(([l, v]) => (
594+ <span key={l} className="chip" style={{ fontSize: 11 }}>
595+ <span style={{ color: "var(--ink-3)" }}>{l}</span> <b style={{ fontFamily: "var(--font-mono)" }}>{fmtNum(v)}</b>
596+ </span>
597+ ))}
598+ </div>
599+ );
600+}
601+
264 /* ---------- Tableau : tri, recherche, pagination ---------- */ 602 /* ---------- Tableau : tri, recherche, pagination ---------- */
265 export function DataTable({ spec, pageSize = 25 }: { spec: TableSpec; pageSize?: number }) { 603 export function DataTable({ spec, pageSize = 25 }: { spec: TableSpec; pageSize?: number }) {
266 const [q, setQ] = useState(""); 604 const [q, setQ] = useState("");
@@ -338,9 +676,20 @@ export function RecordCard({ r }: { r: RecordFact }) {
338 ); 676 );
339 } 677 }
340 678
341−/* ---------- Bouton PDF ---------- */ 679+/* ---------- Menu de rapports PDF (5 rapports + personnalisé v3) ---------- */
342 export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) { 680 export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) {
343− const [busy, setBusy] = useState(false); 681+ const [open, setOpen] = useState(false);
682+ const [busy, setBusy] = useState<string | null>(null);
683+ const [builder, setBuilder] = useState(false);
684+ const box = useRef<HTMLSpanElement>(null);
685+ useEffect(() => {
686+ if (!open) return;
687+ const close = (e: MouseEvent) => {
688+ if (box.current && !box.current.contains(e.target as Node)) setOpen(false);
689+ };
690+ document.addEventListener("mousedown", close);
691+ return () => document.removeEventListener("mousedown", close);
692+ }, [open]);
344 const url = (mode: string) => { 693 const url = (mode: string) => {
345 const p = new URLSearchParams({ period, mode }); 694 const p = new URLSearchParams({ period, mode });
346 if (from) p.set("from", from); 695 if (from) p.set("from", from);
@@ -348,27 +697,253 @@ export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }:
348 return `${endpoint}?${p}`; 697 return `${endpoint}?${p}`;
349 }; 698 };
350 const dl = (mode: string) => { 699 const dl = (mode: string) => {
351− setBusy(true); 700+ setBusy(mode);
701+ setOpen(false);
352 const a = document.createElement("a"); 702 const a = document.createElement("a");
353 a.href = url(mode); 703 a.href = url(mode);
354 a.download = ""; 704 a.download = "";
355 document.body.appendChild(a); 705 document.body.appendChild(a);
356 a.click(); 706 a.click();
357 a.remove(); 707 a.remove();
358− setTimeout(() => setBusy(false), 2500); 708+ setTimeout(() => setBusy(null), 3000);
359 }; 709 };
360 return ( 710 return (
361− <span style={{ display: "inline-flex", gap: 8, flexWrap: "wrap" }}> 711+ <span ref={box} style={{ position: "relative", display: "inline-flex", gap: 8, flexWrap: "wrap" }}>
362− <button type="button" className="btn btn-primary" onClick={() => dl("complet")} disabled={busy}> 712+ <button type="button" className="btn btn-primary" onClick={() => dl("complet")} disabled={!!busy}>
363− {busy ? "Génération…" : "⬇ Télécharger le rapport PDF"} 713+ {busy ? "Génération…" : "⬇ Rapport PDF complet"}
364 </button> 714 </button>
365− <button type="button" className="btn btn-ghost" onClick={() => dl("synthese")} disabled={busy}> 715+ <button type="button" className="btn btn-ghost" onClick={() => setOpen((o) => !o)} disabled={!!busy}
366− Synthèse (2 p.) 716+ aria-haspopup="menu" aria-expanded={open}>
717+ Autres rapports ▾
367 </button> 718 </button>
719+ <button type="button" className="btn btn-ghost" onClick={() => setBuilder(true)} disabled={!!busy}>
720+ 🛠 Rapport personnalisé
721+ </button>
722+ {builder && (
723+ <ReportBuilder period={period} from={from} to={to} endpoint={endpoint}
724+ onClose={() => setBuilder(false)} />
725+ )}
726+ {open && (
727+ <div role="menu" className="card" style={{ position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 50, minWidth: 300, padding: 6, background: "var(--surface)", boxShadow: "0 10px 28px rgba(20,24,20,0.18)" }}>
728+ {REPORT_MODES.map((m) => (
729+ <button key={m.id} type="button" role="menuitem" onClick={() => dl(m.id)}
730+ style={{ display: "block", width: "100%", textAlign: "left", border: 0, background: "none", cursor: "pointer", padding: "9px 10px", borderRadius: 6 }}
731+ onMouseEnter={(e) => (e.currentTarget.style.background = "var(--surface-2)")}
732+ onMouseLeave={(e) => (e.currentTarget.style.background = "none")}>
733+ <b style={{ display: "block", fontSize: 13, fontFamily: "var(--font-display)" }}>{m.label}</b>
734+ <span className="klabel" style={{ fontSize: 11 }}>{m.desc}</span>
735+ </button>
736+ ))}
737+ </div>
738+ )}
368 </span> 739 </span>
369 ); 740 );
370 } 741 }
371 742
743+/* ---------- v3 : constructeur de rapports personnalisés ----------
744+ Compose un PDF bloc par bloc : catalogue dérivé du dashboard
745+ (GET /api/stats/catalog), rendu au choix par bloc, ordre libre, modèles
746+ sauvegardés en localStorage (clé ka-stats-rapports, propre au site).
747+ Contrat : SPEC.md §3bis. Rendu dans PdfButton — aucune modif des pages. */
748+export type CatalogBlock = {
749+ key: string; section: string; title: string;
750+ renders: string[]; default_render: string; count?: number;
751+};
752+type BuilderSel = { key: string; render: string };
753+type BuilderTpl = { name: string; title: string; blocks: BuilderSel[] };
754+
755+const RENDER_LABELS: Record<string, string> = {
756+ line: "Courbe", area: "Aire", bar: "Barres verticales",
757+ bars: "Barres horizontales", donut: "Anneau", lines: "Multi-courbes",
758+ stacked: "Barres empilées", histogram: "Histogramme", heatmap: "Heatmap",
759+ cards: "Cartes", gauges: "Jauges", table: "Tableau",
760+};
761+const SECTION_LABELS: Record<string, string> = {
762+ kpis: "Indicateurs", gauges: "Jauges", series: "Évolution",
763+ multiseries: "Multi-courbes", stacked: "Compositions",
764+ breakdowns: "Répartitions", distributions: "Distributions",
765+ geo: "Géographie", heatmap: "Calendrier", hourly: "Activité horaire",
766+ tables: "Tableaux", records: "Records",
767+};
768+const TPL_KEY = "ka-stats-rapports";
769+
770+function loadTemplates(): BuilderTpl[] {
771+ try { return JSON.parse(localStorage.getItem(TPL_KEY) ?? "[]"); }
772+ catch { return []; }
773+}
774+function saveTemplates(t: BuilderTpl[]) {
775+ try { localStorage.setItem(TPL_KEY, JSON.stringify(t)); } catch { /* plein/privé */ }
776+}
777+
778+export function ReportBuilder({
779+ period, from, to, endpoint = "/api/stats/report", onClose,
780+}: {
781+ period: string; from?: string; to?: string; endpoint?: string; onClose: () => void;
782+}) {
783+ const [cat, setCat] = useState<CatalogBlock[] | null>(null);
784+ const [err, setErr] = useState("");
785+ const [sel, setSel] = useState<BuilderSel[]>([]);
786+ const [title, setTitle] = useState("");
787+ const [busy, setBusy] = useState(false);
788+ const [tpls, setTpls] = useState<BuilderTpl[]>(loadTemplates);
789+ const catalogUrl = endpoint.replace(/\/report$/, "/catalog");
790+
791+ useEffect(() => {
792+ const p = new URLSearchParams({ period });
793+ if (from) p.set("from", from);
794+ if (to) p.set("to", to);
795+ fetch(`${catalogUrl}?${p}`)
796+ .then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
797+ .then((d) => setCat(d.blocks ?? []))
798+ .catch(() => setErr("Catalogue indisponible — réessayez plus tard."));
799+ }, [period, from, to, catalogUrl]);
800+
801+ useEffect(() => {
802+ const esc = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
803+ document.addEventListener("keydown", esc);
804+ const prev = document.body.style.overflow;
805+ document.body.style.overflow = "hidden";
806+ return () => { document.removeEventListener("keydown", esc); document.body.style.overflow = prev; };
807+ }, [onClose]);
808+
809+ const add = (b: CatalogBlock) =>
810+ setSel((s) => s.some((x) => x.key === b.key && x.render === b.default_render)
811+ ? s : [...s, { key: b.key, render: b.default_render }]);
812+ const move = (i: number, d: number) => setSel((s) => {
813+ const j = i + d;
814+ if (j < 0 || j >= s.length) return s;
815+ const n = [...s]; [n[i], n[j]] = [n[j], n[i]]; return n;
816+ });
817+
818+ const generate = async () => {
819+ if (busy || !sel.length) return;
820+ setBusy(true); setErr("");
821+ try {
822+ const body: Record<string, unknown> = { title, period, blocks: sel };
823+ if (from && to) { body.from = from; body.to = to; }
824+ const r = await fetch(`${endpoint}/custom`, {
825+ method: "POST", headers: { "Content-Type": "application/json" },
826+ body: JSON.stringify(body),
827+ });
828+ if (!r.ok) throw new Error(String(r.status));
829+ const blob = await r.blob();
830+ const m = (r.headers.get("Content-Disposition") ?? "").match(/filename="?([^";]+)/);
831+ const a = document.createElement("a");
832+ a.href = URL.createObjectURL(blob);
833+ a.download = m ? m[1] : "rapport-personnalise.pdf";
834+ document.body.appendChild(a); a.click(); a.remove();
835+ setTimeout(() => URL.revokeObjectURL(a.href), 4000);
836+ } catch {
837+ setErr("La génération a échoué — réessayez.");
838+ }
839+ setBusy(false);
840+ };
841+
842+ const groups: [string, CatalogBlock[]][] = [];
843+ for (const b of cat ?? []) {
844+ const g = groups.find(([s]) => s === b.section);
845+ if (g) g[1].push(b); else groups.push([b.section, [b]]);
846+ }
847+ const selKeys = new Set(sel.map((s) => s.key));
848+ const mono: React.CSSProperties = { fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em" };
849+
850+ return (
851+ <div role="dialog" aria-modal="true" aria-label="Rapport personnalisé"
852+ onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
853+ 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" }}>
854+ <div className="card" style={{ width: "min(980px,100%)", maxHeight: "92vh", display: "flex", flexDirection: "column", background: "var(--surface)", padding: 0, textAlign: "left", cursor: "default" }}>
855+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, padding: "16px 20px", borderBottom: "1px solid var(--line)" }}>
856+ <b style={{ fontFamily: "var(--font-display)", fontSize: 18 }}>
857+ Rapport personnalisé <span className="klabel">· période : {from && to ? `${from} → ${to}` : (PERIODS.find((p) => p.id === period)?.label ?? period)}</span>
858+ </b>
859+ <button type="button" className="btn btn-ghost" onClick={onClose}>✕ Fermer</button>
860+ </div>
861+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", overflow: "auto", flex: 1 }}>
862+ <div style={{ padding: "14px 20px", minWidth: 0, borderRight: "1px solid var(--line)" }}>
863+ <h3 style={{ ...mono, color: "var(--ink-3)", margin: "4px 0 10px" }}>Blocs disponibles ({cat?.length ?? "…"})</h3>
864+ {!cat && !err && <p className="klabel">Chargement du catalogue…</p>}
865+ {groups.map(([secId, bs]) => (
866+ <div key={secId}>
867+ <p style={{ ...mono, color: "var(--ink-2)", margin: "12px 0 6px" }}>{SECTION_LABELS[secId] ?? secId}</p>
868+ {bs.map((b) => (
869+ <div key={b.key} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, padding: "7px 10px", border: "1px solid var(--line)", borderRadius: 8, marginBottom: 6, fontSize: 13, opacity: selKeys.has(b.key) ? 0.45 : 1 }}>
870+ <span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={b.title}>{b.title}</span>
871+ <button type="button" className="btn btn-ghost" onClick={() => add(b)} aria-label={`Ajouter ${b.title}`} style={{ flex: "none" }}>+</button>
872+ </div>
873+ ))}
874+ </div>
875+ ))}
876+ </div>
877+ <div style={{ padding: "14px 20px", minWidth: 0 }}>
878+ <h3 style={{ ...mono, color: "var(--ink-3)", margin: "4px 0 10px" }}>Composition du rapport ({sel.length})</h3>
879+ <label className="klabel" htmlFor="rb-title">Titre du rapport</label>
880+ <input id="rb-title" className="input" style={{ width: "100%", margin: "4px 0 12px", boxSizing: "border-box" }}
881+ maxLength={80} placeholder="Ex. : Revue mensuelle" value={title} onChange={(e) => setTitle(e.target.value)} />
882+ {sel.length ? sel.map((s, i) => {
883+ const b = (cat ?? []).find((x) => x.key === s.key) ?? { title: s.key, renders: [s.render] } as CatalogBlock;
884+ return (
885+ <div key={`${s.key}:${s.render}:${i}`} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", border: "1px solid var(--ink)", borderRadius: 8, marginBottom: 6, background: "var(--surface-2)", fontSize: 13 }}>
886+ <button type="button" onClick={() => move(i, -1)} disabled={i === 0} aria-label="Monter" style={{ border: 0, background: "none", cursor: "pointer", opacity: i === 0 ? 0.25 : 1 }}>▲</button>
887+ <button type="button" onClick={() => move(i, 1)} disabled={i === sel.length - 1} aria-label="Descendre" style={{ border: 0, background: "none", cursor: "pointer", opacity: i === sel.length - 1 ? 0.25 : 1 }}>▼</button>
888+ <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={b.title}><b>{i + 1}.</b> {b.title}</span>
889+ {b.renders.length > 1 ? (
890+ <select className="input" value={s.render} aria-label="Rendu" style={{ maxWidth: 150, padding: "4px 6px", fontSize: 12 }}
891+ onChange={(e) => setSel((xs) => xs.map((x, j) => j === i ? { ...x, render: e.target.value } : x))}>
892+ {b.renders.map((r) => <option key={r} value={r}>{RENDER_LABELS[r] ?? r}</option>)}
893+ </select>
894+ ) : <span className="klabel">{RENDER_LABELS[s.render] ?? s.render}</span>}
895+ <button type="button" onClick={() => setSel((xs) => xs.filter((_, j) => j !== i))} aria-label="Retirer" style={{ border: 0, background: "none", cursor: "pointer" }}>✕</button>
896+ </div>
897+ );
898+ }) : (
899+ <div style={{ border: "1px dashed var(--line)", borderRadius: 8, padding: 16, color: "var(--ink-3)", fontSize: 13, textAlign: "center" }}>
900+ Aucun bloc — ajoutez des blocs depuis la colonne de gauche, ou chargez un modèle ci-dessous.
901+ </div>
902+ )}
903+ <p style={{ display: "flex", gap: 8, margin: "10px 0 0" }}>
904+ <button type="button" className="btn btn-ghost" disabled={!cat?.length}
905+ onClick={() => setSel((cat ?? []).map((b) => ({ key: b.key, render: b.default_render })))}>Tout ajouter</button>
906+ <button type="button" className="btn btn-ghost" disabled={!sel.length} onClick={() => setSel([])}>Vider</button>
907+ </p>
908+ {err && <p style={{ color: "var(--danger)", fontSize: 12.5, margin: "6px 0 0" }}>{err}</p>}
909+ </div>
910+ </div>
911+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, flexWrap: "wrap", padding: "14px 20px", borderTop: "1px solid var(--line)" }}>
912+ <span style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
913+ <select className="input" aria-label="Modèles sauvegardés" style={{ maxWidth: 210 }} value=""
914+ onChange={(e) => {
915+ const t = tpls[Number(e.target.value)];
916+ if (!t) return;
917+ setTitle(t.title || t.name);
918+ setSel((t.blocks ?? []).filter((s) => (cat ?? []).some((b) => b.key === s.key)).map((s) => ({ ...s })));
919+ }}>
920+ <option value="">Modèles ({tpls.length})…</option>
921+ {tpls.map((t, i) => <option key={t.name} value={i}>{t.name}</option>)}
922+ </select>
923+ <button type="button" className="btn btn-ghost" disabled={!sel.length}
924+ onClick={() => {
925+ const name = window.prompt("Nom du modèle :", title || "Mon rapport");
926+ if (!name) return;
927+ const next = [...tpls.filter((t) => t.name !== name), { name, title, blocks: sel.map((s) => ({ ...s })) }];
928+ setTpls(next); saveTemplates(next);
929+ }}>💾 Sauvegarder</button>
930+ <button type="button" className="btn btn-ghost" disabled={!tpls.length}
931+ onClick={() => {
932+ const name = window.prompt(`Nom du modèle à supprimer :\n${tpls.map((t) => `· ${t.name}`).join("\n")}`);
933+ if (!name) return;
934+ const next = tpls.filter((t) => t.name !== name);
935+ setTpls(next); saveTemplates(next);
936+ }}>🗑 Supprimer</button>
937+ </span>
938+ <button type="button" className="btn btn-primary" disabled={!sel.length || busy} onClick={generate}>
939+ {busy ? "Génération…" : "⬇ Générer le PDF"}
940+ </button>
941+ </div>
942+ </div>
943+ </div>
944+ );
945+}
946+
372 /* ---------- États ---------- */ 947 /* ---------- États ---------- */
373 export function EmptyBlock({ title }: { title: string }) { 948 export function EmptyBlock({ title }: { title: string }) {
374 return ( 949 return (
modified frontend/src/ka/stats/kapdf.py +398 −8
@@ -1,7 +1,7 @@
1 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai 1 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2−# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v2 2+# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v3
3 # Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit 3 # Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
4−# les rapports estampillés Groupe-KA. 5 modes : 4+# les rapports estampillés Groupe-KA. 5 modes fixes :
5 # complet — toutes les sections (KPI, jauges, séries + stats, multi- 5 # complet — toutes les sections (KPI, jauges, séries + stats, multi-
6 # séries, empilées, distributions, répartitions, géo, 6 # séries, empilées, distributions, répartitions, géo,
7 # heatmap horaire, tableaux, records) 7 # heatmap horaire, tableaux, records)
@@ -9,12 +9,19 @@
9 # tendances — KPI + toutes les séries temporelles + stats de séries 9 # tendances — KPI + toutes les séries temporelles + stats de séries
10 # repartitions — breakdowns, distributions, géo, activité horaire 10 # repartitions — breakdowns, distributions, géo, activité horaire
11 # donnees — tous les tableaux en version longue (400 lignes max) 11 # donnees — tous les tableaux en version longue (400 lignes max)
12+# v3 : mode « personnalise » — l'utilisateur compose son rapport bloc par
13+# bloc (choix des données ET du rendu par bloc : courbe/aire/barres/anneau/
14+# heatmap/tableau…). catalog(dash) expose les blocs disponibles ; le rapport
15+# suit une spec {"title": str, "blocks": [{"key": "series:ajouts",
16+# "render": "bar"}, …]} et respecte l'ordre demandé.
12 # Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque. 17 # Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.
13 # Usage : 18 # Usage :
14−# from kapdf import GroupeKAReport, REPORT_MODES, filename 19+# from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename
15 # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00", 20 # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
16 # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json, 21 # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
17 # mode="complet").build() 22 # mode="complet").build()
23+# pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise",
24+# spec={"title": "Mon rapport", "blocks": [...]}).build()
18 # Dépendance : pip install fpdf2 (aucune autre) 25 # Dépendance : pip install fpdf2 (aucune autre)
19 from __future__ import annotations 26 from __future__ import annotations
20 27
@@ -40,6 +47,100 @@ REPORT_MODES = {
40 "repartitions": "Répartitions & géographie", 47 "repartitions": "Répartitions & géographie",
41 "donnees": "Données détaillées", 48 "donnees": "Données détaillées",
42 } 49 }
50+# v3 — mode composé par l'utilisateur (jamais dans le menu des modes fixes)
51+CUSTOM_MODE = "personnalise"
52+CUSTOM_LABEL = "Rapport personnalisé"
53+
54+# v3 — rendus proposés par type de bloc (le 1er est le rendu par défaut ;
55+# « table » est toujours offert : toute donnée a un équivalent tableau)
56+RENDER_LABELS = {
57+ "line": "Courbe", "area": "Aire", "bar": "Barres verticales",
58+ "bars": "Barres horizontales", "donut": "Anneau",
59+ "lines": "Multi-courbes", "stacked": "Barres empilées",
60+ "histogram": "Histogramme", "heatmap": "Heatmap",
61+ "cards": "Cartes", "gauges": "Jauges", "table": "Tableau",
62+}
63+SECTION_LABELS = {
64+ "kpis": "Indicateurs", "gauges": "Taux & couvertures",
65+ "series": "Évolution", "multiseries": "Comparaisons",
66+ "stacked": "Compositions", "breakdowns": "Répartitions",
67+ "distributions": "Distributions", "geo": "Géographie",
68+ "heatmap": "Calendrier", "hourly": "Activité horaire",
69+ "tables": "Tableaux", "records": "Records",
70+}
71+
72+
73+def catalog(dash: dict) -> list[dict]:
74+ """v3 — blocs composables d'un dashboard : ce que le constructeur de
75+ rapports personnalisés peut inclure, avec les rendus compatibles.
76+ key = section[:id] ; l'ordre renvoyé = ordre naturel du dashboard."""
77+ out: list[dict] = []
78+
79+ def add(key, title, renders, default=None, count=None):
80+ b = {"key": key, "section": key.split(":")[0], "title": title,
81+ "renders": renders, "default_render": default or renders[0]}
82+ if count is not None:
83+ b["count"] = count
84+ out.append(b)
85+
86+ if dash.get("kpis"):
87+ add("kpis", "Indicateurs clés (KPI)", ["cards", "table"],
88+ count=len(dash["kpis"]))
89+ gs = [g for g in (dash.get("gauges") or [])
90+ if isinstance(g.get("value"), (int, float)) and g.get("max")]
91+ if gs:
92+ add("gauges", "Taux & couvertures (jauges)", ["gauges", "table"],
93+ count=len(gs))
94+ for s in dash.get("series") or []:
95+ if len(s.get("points") or []) < 2:
96+ continue
97+ kind = s.get("kind") or "line"
98+ default = kind if kind in ("line", "area", "bar") else "line"
99+ add(f"series:{s.get('id')}", s.get("title", ""),
100+ ["line", "area", "bar", "table"], default,
101+ len(s.get("points") or []))
102+ for ms in dash.get("multiseries") or []:
103+ if not (ms.get("series") or []):
104+ continue
105+ add(f"multiseries:{ms.get('id')}", ms.get("title", ""),
106+ ["lines", "table"], count=len(ms["series"]))
107+ for st in dash.get("stacked") or []:
108+ if not (st.get("points") or []):
109+ continue
110+ add(f"stacked:{st.get('id')}", st.get("title", ""),
111+ ["stacked", "table"], count=len(st.get("keys") or []))
112+ for b in dash.get("breakdowns") or []:
113+ if not (b.get("items") or []):
114+ continue
115+ default = "donut" if b.get("kind") == "donut" else "bars"
116+ add(f"breakdowns:{b.get('id')}", b.get("title", ""),
117+ ["donut", "bars", "table"], default, len(b["items"]))
118+ for d in dash.get("distributions") or []:
119+ if not (d.get("bins") or []):
120+ continue
121+ add(f"distributions:{d.get('id')}", d.get("title", ""),
122+ ["histogram", "table"], count=len(d["bins"]))
123+ geo = dash.get("geo") or {}
124+ if geo.get("items"):
125+ add("geo", geo.get("title", "Répartition géographique"),
126+ ["bars", "table"], count=len(geo["items"]))
127+ hm = dash.get("heatmap") or {}
128+ if hm.get("cells"):
129+ add("heatmap", hm.get("title", "Calendrier d'activité"),
130+ ["heatmap", "table"])
131+ hr = dash.get("hourly") or {}
132+ if hr.get("cells"):
133+ add("hourly", hr.get("title", "Activité par jour et heure"),
134+ ["heatmap", "table"])
135+ for t in dash.get("tables") or []:
136+ if not (t.get("rows") or []):
137+ continue
138+ add(f"tables:{t.get('id')}", t.get("title", ""), ["table"],
139+ count=len(t["rows"]))
140+ if dash.get("records"):
141+ add("records", "Records & faits marquants", ["cards", "table"],
142+ count=len(dash["records"]))
143+ return out
43 144
44 EMAILS = [ 145 EMAILS = [
45 ("contact@groupe-ka.com", "Projets, partenariats & données"), 146 ("contact@groupe-ka.com", "Projets, partenariats & données"),
@@ -127,16 +228,25 @@ class _PDF(FPDF):
127 228
128 229
129 class GroupeKAReport: 230 class GroupeKAReport:
130− def __init__(self, site: dict, dashboard: dict, mode: str = "complet"): 231+ def __init__(self, site: dict, dashboard: dict, mode: str = "complet",
232+ spec: dict | None = None):
131 self.site = site 233 self.site = site
132 self.d = dashboard 234 self.d = dashboard
133− self.mode = mode if mode in REPORT_MODES else "complet" 235+ self.mode = mode if (mode in REPORT_MODES or mode == CUSTOM_MODE) else "complet"
236+ self.spec = spec or {}
134 self.accent = _hex(site.get("accent", "#d9f26b")) 237 self.accent = _hex(site.get("accent", "#d9f26b"))
135 period = dashboard.get("period", {}) or {} 238 period = dashboard.get("period", {}) or {}
136 self.period_label = period.get("label") or "toute la période" 239 self.period_label = period.get("label") or "toute la période"
137 self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label) 240 self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
138 self.toc: list[tuple[str, int]] = [] 241 self.toc: list[tuple[str, int]] = []
139 242
243+ @property
244+ def mode_label(self) -> str:
245+ if self.mode == CUSTOM_MODE:
246+ t = str(self.spec.get("title") or "").strip()
247+ return f"{CUSTOM_LABEL} — {t}" if t else CUSTOM_LABEL
248+ return REPORT_MODES[self.mode]
249+
140 # ---------- primitives ---------- 250 # ---------- primitives ----------
141 def _card(self, x, y, w, h, fill=WHITE): 251 def _card(self, x, y, w, h, fill=WHITE):
142 p = self.pdf 252 p = self.pdf
@@ -213,7 +323,7 @@ class GroupeKAReport:
213 p.set_xy(24, 100) 323 p.set_xy(24, 100)
214 p.set_font("helvetica", "", 13) 324 p.set_font("helvetica", "", 13)
215 p.set_text_color(*INK2) 325 p.set_text_color(*INK2)
216− p.multi_cell(150, 7, f"{REPORT_MODES[self.mode]} — {wm}") 326+ p.multi_cell(150, 7, f"{self.mode_label} — {wm}")
217 now = datetime.now(ZoneInfo("America/Toronto")) 327 now = datetime.now(ZoneInfo("America/Toronto"))
218 per = self.d.get("period", {}) or {} 328 per = self.d.get("period", {}) or {}
219 p.set_xy(24, 125) 329 p.set_xy(24, 125)
@@ -222,7 +332,7 @@ class GroupeKAReport:
222 ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")), 332 ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
223 ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"), 333 ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
224 ("Plateforme", "https://" + self.site.get("domain", "")), 334 ("Plateforme", "https://" + self.site.get("domain", "")),
225− ("Type de rapport", REPORT_MODES[self.mode]), 335+ ("Type de rapport", self.mode_label),
226 ] 336 ]
227 y = 128 337 y = 128
228 for k, v in rows: 338 for k, v in rows:
@@ -685,6 +795,271 @@ class GroupeKAReport:
685 p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF") 795 p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")
686 p.set_y(y0 + ly + 7 * chh + 5) 796 p.set_y(y0 + ly + 7 * chh + 5)
687 797
798+ def _calheat(self, hm):
799+ """v3 — calendrier de chaleur 26 semaines (équivalent PDF du
800+ CalendarHeatmap du kit front) : colonnes = semaines, lignes = jours."""
801+ from datetime import date as _date, timedelta as _td
802+ cells = hm.get("cells") or []
803+ vals = {c.get("date"): c.get("value") or 0 for c in cells if c.get("date")}
804+ if not vals:
805+ return
806+ p = self.pdf
807+ if p.get_y() > 215:
808+ p.add_page()
809+ self._chart_title(hm.get("title", "Calendrier d'activité"))
810+ try:
811+ end = _date.fromisoformat(max(vals))
812+ except ValueError:
813+ return
814+ weeks = 26
815+ start = end - _td(days=weeks * 7 - 1)
816+ start -= _td(days=start.weekday()) # lundi
817+ vmax = max(vals.values()) or 1
818+ x0, y0 = p.l_margin, p.get_y()
819+ cw, lx, ly = 6.3, 10, 4
820+ dows = ["Lun", "", "Mer", "", "Ven", "", "Dim"]
821+ p.set_font("helvetica", "", 5.8)
822+ p.set_text_color(*INK3)
823+ for d in range(7):
824+ if dows[d]:
825+ p.set_xy(x0, y0 + ly + d * cw + 1.2)
826+ p.cell(lx - 1, 3, dows[d], align="R")
827+ for w in range(weeks):
828+ monday = start + _td(days=7 * w)
829+ if monday.day <= 7: # étiquette de mois à la 1re semaine du mois
830+ p.set_xy(x0 + lx + w * cw, y0)
831+ p.cell(cw * 4, 3, monday.strftime("%m"))
832+ for d in range(7):
833+ day = monday + _td(days=d)
834+ v = vals.get(day.isoformat(), 0)
835+ f = 0.15 + 0.85 * (v / vmax) if v else 0.0
836+ col = (tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f)
837+ for j in range(3)) if v else (235, 233, 228))
838+ p.set_fill_color(*col)
839+ p.set_draw_color(215, 213, 207)
840+ p.set_line_width(0.1)
841+ p.rect(x0 + lx + w * cw, y0 + ly + d * cw, cw - 0.5, cw - 0.5,
842+ style="DF")
843+ p.set_y(y0 + ly + 7 * cw + 5)
844+
845+ # ---------- v3 : conversions bloc → tableau ----------
846+ @staticmethod
847+ def _serie_as_table(s):
848+ unit = s.get("unit") or "Valeur"
849+ cols = ["Date", unit.capitalize()]
850+ cmp_ = s.get("compare") or []
851+ if cmp_:
852+ cols.append("Période comparée")
853+ rows = []
854+ for i, pt in enumerate(s.get("points") or []):
855+ row = [str(pt.get("t", "")), pt.get("v", "")]
856+ if cmp_:
857+ row.append(cmp_[i]["v"] if i < len(cmp_) else "")
858+ rows.append(row)
859+ return {"id": s.get("id"), "title": s.get("title", ""),
860+ "columns": cols, "rows": rows}
861+
862+ @staticmethod
863+ def _multi_as_table(ms):
864+ labels = [s.get("label", "") for s in (ms.get("series") or [])][:4]
865+ by_t: dict[str, dict] = {}
866+ for s in (ms.get("series") or [])[:4]:
867+ for pt in s.get("points") or []:
868+ by_t.setdefault(str(pt.get("t", "")), {})[s.get("label", "")] = pt.get("v")
869+ rows = [[t] + [by_t[t].get(lbl, "") for lbl in labels]
870+ for t in sorted(by_t)]
871+ return {"id": ms.get("id"), "title": ms.get("title", ""),
872+ "columns": ["Date"] + labels, "rows": rows}
873+
874+ @staticmethod
875+ def _stacked_as_table(st):
876+ keys = (st.get("keys") or [])[:6]
877+ rows = []
878+ for pt in st.get("points") or []:
879+ vs = [(pt.get("values") or [])[j] if j < len(pt.get("values") or []) else 0
880+ for j in range(len(keys))]
881+ rows.append([str(pt.get("t", ""))] + vs + [sum(v or 0 for v in vs)])
882+ return {"id": st.get("id"), "title": st.get("title", ""),
883+ "columns": ["Date"] + list(keys) + ["Total"], "rows": rows}
884+
885+ @staticmethod
886+ def _items_as_table(id_, title, items, label_col="Libellé"):
887+ items = items or []
888+ with_delta = any(it.get("delta_pct") is not None for it in items)
889+ cols = [label_col, "Valeur"] + (["delta %"] if with_delta else [])
890+ rows = []
891+ for it in items:
892+ row = [str(it.get("label", "")), it.get("value", "")]
893+ if with_delta:
894+ d = it.get("delta_pct")
895+ row.append("" if d is None else f"{'+' if d >= 0 else ''}{d} %")
896+ rows.append(row)
897+ return {"id": id_, "title": title, "columns": cols, "rows": rows}
898+
899+ def _kpis_as_table(self):
900+ rows = []
901+ for k in self.d.get("kpis") or []:
902+ v = k.get("value")
903+ val = (_fr(v) if isinstance(v, (int, float)) else str(v)) + \
904+ ((" " + k["unit"]) if k.get("unit") else "")
905+ d = k.get("delta_pct")
906+ rows.append([str(k.get("label", "")), val,
907+ "" if d is None else f"{'+' if d >= 0 else ''}{d} %"])
908+ return {"id": "kpis", "title": "Indicateurs clés",
909+ "columns": ["Indicateur", "Valeur", "delta %"], "rows": rows}
910+
911+ def _gauges_as_table(self):
912+ rows = [[str(g.get("label", "")),
913+ f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}",
914+ _fr(g["max"]), f"{100.0 * g['value'] / g['max']:.0f} %"]
915+ for g in self.d.get("gauges") or []
916+ if isinstance(g.get("value"), (int, float)) and g.get("max")]
917+ return {"id": "gauges", "title": "Taux & couvertures",
918+ "columns": ["Mesure", "Valeur", "Max", "Part"], "rows": rows}
919+
920+ def _records_as_table(self):
921+ rows = [[str(r.get("label", "")), str(r.get("value", "")),
922+ str(r.get("date", "") or "")]
923+ for r in self.d.get("records") or []]
924+ return {"id": "records", "title": "Records & faits marquants",
925+ "columns": ["Fait marquant", "Valeur", "Date"], "rows": rows}
926+
927+ @staticmethod
928+ def _heatmap_as_table(hm, title):
929+ cells = sorted((hm.get("cells") or []),
930+ key=lambda c: -(c.get("value") or 0))[:40]
931+ return {"id": "heatmap", "title": title + " — jours les plus chargés",
932+ "columns": ["Date", "Valeur"],
933+ "rows": [[c.get("date", ""), c.get("value") or 0] for c in cells]}
934+
935+ @staticmethod
936+ def _hourly_as_table(hr, title):
937+ days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi",
938+ "Dimanche"]
939+ cells = sorted((hr.get("cells") or []),
940+ key=lambda c: -(c.get("value") or 0))[:40]
941+ return {"id": "hourly", "title": title + " — créneaux les plus actifs",
942+ "columns": ["Jour", "Heure", "Valeur"],
943+ "rows": [[days[c["dow"]] if 0 <= c.get("dow", -1) <= 6 else "?",
944+ f"{c.get('hour', '?')} h", c.get("value") or 0]
945+ for c in cells]}
946+
947+ # ---------- v3 : rendu d'un bloc du rapport personnalisé ----------
948+ def _find(self, coll: str, id_: str):
949+ for it in self.d.get(coll) or []:
950+ if str(it.get("id")) == id_:
951+ return it
952+ return None
953+
954+ def _toc_mark(self, title: str):
955+ """Blocs graphiques du mode personnalisé : entrée de sommaire sans
956+ _section_title (le graphique porte déjà son titre)."""
957+ if self.pdf.get_y() > 235:
958+ self.pdf.add_page()
959+ self.toc.append((title, self.pdf.page_no()))
960+
961+ def _render_block(self, key: str, render: str):
962+ section, _, id_ = key.partition(":")
963+ if section == "kpis":
964+ self._table(self._kpis_as_table()) if render == "table" else self._kpis()
965+ elif section == "gauges":
966+ self._table(self._gauges_as_table()) if render == "table" else self._gauges()
967+ elif section == "records":
968+ self._table(self._records_as_table()) if render == "table" else self._records()
969+ elif section == "series":
970+ s = self._find("series", id_)
971+ if not s:
972+ return
973+ if render == "table":
974+ self._table(self._serie_as_table(s), max_rows=400)
975+ else:
976+ s2 = dict(s)
977+ if render in ("line", "area", "bar"):
978+ s2["kind"] = render
979+ self._toc_mark(s2.get("title", ""))
980+ if s2.get("kind") == "bar":
981+ self._vbars(s2)
982+ else:
983+ self._line_chart(s2, with_stats=True)
984+ elif section == "multiseries":
985+ ms = self._find("multiseries", id_)
986+ if not ms:
987+ return
988+ if render == "table":
989+ self._table(self._multi_as_table(ms), max_rows=400)
990+ else:
991+ self._toc_mark(ms.get("title", ""))
992+ self._multiline(ms)
993+ elif section == "stacked":
994+ st = self._find("stacked", id_)
995+ if not st:
996+ return
997+ if render == "table":
998+ self._table(self._stacked_as_table(st), max_rows=400)
999+ else:
1000+ self._toc_mark(st.get("title", ""))
1001+ self._stacked(st)
1002+ elif section == "breakdowns":
1003+ b = self._find("breakdowns", id_)
1004+ if not b:
1005+ return
1006+ if render == "table":
1007+ self._table(self._items_as_table(id_, b.get("title", ""),
1008+ b.get("items")), max_rows=400)
1009+ else:
1010+ self._toc_mark(b.get("title", ""))
1011+ if render == "donut":
1012+ self._donut(b)
1013+ else:
1014+ self._bars(b.get("title", ""), b.get("items"))
1015+ elif section == "distributions":
1016+ d = self._find("distributions", id_)
1017+ if not d:
1018+ return
1019+ if render == "table":
1020+ bins = [{"label": bn.get("label"), "value": bn.get("value")}
1021+ for bn in d.get("bins") or []]
1022+ self._table(self._items_as_table(id_, d.get("title", ""), bins,
1023+ label_col="Tranche"))
1024+ else:
1025+ self._toc_mark(d.get("title", ""))
1026+ self._vbars(d)
1027+ elif section == "geo":
1028+ geo = self.d.get("geo") or {}
1029+ if not geo.get("items"):
1030+ return
1031+ title = geo.get("title", "Répartition géographique")
1032+ if render == "table":
1033+ self._table(self._items_as_table("geo", title, geo["items"],
1034+ label_col="Zone"), max_rows=400)
1035+ else:
1036+ self._toc_mark(title)
1037+ self._bars(title, geo["items"])
1038+ elif section == "heatmap":
1039+ hm = self.d.get("heatmap") or {}
1040+ if not hm.get("cells"):
1041+ return
1042+ title = hm.get("title", "Calendrier d'activité")
1043+ if render == "table":
1044+ self._table(self._heatmap_as_table(hm, title))
1045+ else:
1046+ self._toc_mark(title)
1047+ self._calheat(hm)
1048+ elif section == "hourly":
1049+ hr = self.d.get("hourly") or {}
1050+ if not hr.get("cells"):
1051+ return
1052+ title = hr.get("title", "Activité par jour et heure")
1053+ if render == "table":
1054+ self._table(self._hourly_as_table(hr, title))
1055+ else:
1056+ self._toc_mark(title)
1057+ self._hourly()
1058+ elif section == "tables":
1059+ t = self._find("tables", id_)
1060+ if t:
1061+ self._table(t, max_rows=400)
1062+
688 def _table(self, t, max_rows=200): 1063 def _table(self, t, max_rows=200):
689 p = self.pdf 1064 p = self.pdf
690 cols = t.get("columns") or [] 1065 cols = t.get("columns") or []
@@ -809,7 +1184,7 @@ class GroupeKAReport:
809 p = self.pdf 1184 p = self.pdf
810 p.alias_nb_pages() 1185 p.alias_nb_pages()
811 self._cover() 1186 self._cover()
812− with_toc = self.mode in ("complet", "donnees") 1187+ with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE)
813 toc_page_no = None 1188 toc_page_no = None
814 if self.mode == "synthese": 1189 if self.mode == "synthese":
815 p.add_page() 1190 p.add_page()
@@ -835,6 +1210,21 @@ class GroupeKAReport:
835 for t in self.d.get("tables") or []: 1210 for t in self.d.get("tables") or []:
836 self._table(t, max_rows=400) 1211 self._table(t, max_rows=400)
837 self._final_page() 1212 self._final_page()
1213+ elif self.mode == CUSTOM_MODE:
1214+ p.add_page()
1215+ toc_page_no = p.page_no()
1216+ p.add_page()
1217+ known = {b["key"]: b for b in catalog(self.d)}
1218+ for blk in self.spec.get("blocks") or []:
1219+ key = str(blk.get("key", ""))
1220+ b = known.get(key)
1221+ if not b:
1222+ continue
1223+ render = str(blk.get("render") or "")
1224+ if render not in b["renders"]:
1225+ render = b["default_render"]
1226+ self._render_block(key, render)
1227+ self._final_page()
838 else: # complet 1228 else: # complet
839 p.add_page() 1229 p.add_page()
840 toc_page_no = p.page_no() 1230 toc_page_no = p.page_no()
841 1231
Menu

Theme