SPB Git

spb/ka-stats

Public

Import initial ka-stats + Nav mobile v2 (panneau plein écran fixed)

Le repo de prod M1M32 n'était pas versionné (déployé par rsync) — import
initial dans le bare spbgit ka-stats.git, avec le standard KA Nav v2 2026-08-25 :
hamburger 44px + panneau position:fixed inset:0 construit dans buildHeader,
verrou de scroll, fermeture lien/✕/Escape (la nav scrollable passait en pleine
largeur sous 768px).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed (Aug 25, 2026) Browse files

19 changed files +3,314 −0

added .gitignore +4 −0
@@ -0,0 +1,4 @@
1 +data/cache.json
2 +node_modules/
3 +.DS_Store
4 +*.log
added README.md +46 −0
@@ -0,0 +1,46 @@
1 +# Ka·Stats — l'explorateur de statistiques du Québec
2 +
3 +**www.ka-stats.com** · Un service **Groupe KA**
4 +
5 +Observatoire de données de l'écosystème : agrège en direct les dashboards
6 +`GET /api/stats/dashboard` (contrat **ka-stats v2**) des 12 plateformes
7 +publiques (Lou·Ka, Immo·Ka, Vrai-Prix, Auto·Ka, Job·Ka, Food·Ka, Resto·Ka,
8 +Fabri·Ka, Sorti·Ka, Créa·Ka, Trouve·Ka, API·Ka) et les rend explorables :
9 +
10 +- **Vue d'ensemble** — pouls de l'écosystème, grille des plateformes, prix du
11 + Québec en direct, records.
12 +- **/site/:id** — rendu générique et complet du contrat v2 : KPI + sparklines,
13 + jauges, séries (crosshair + infobulles), multi-courbes (motifs de trait),
14 + empilées, anneaux, histogrammes, géo, heatmaps calendrier & horaire,
15 + tableaux triables/filtrables/paginés, records + liens vers les 5 rapports
16 + PDF de la plateforme.
17 +- **/indicateurs** — les chiffres du Québec par thème (habitation, mobilité,
18 + emploi, consommation, culture, infrastructure).
19 +- **/comparer** — jusqu'à 4 plateformes sur un seul axe, indice base 100.
20 +- **/palmares** — records agrégés + classement de croissance.
21 +
22 +## Architecture
23 +
24 +- `server.mjs` — Node **zéro dépendance** : statique + `/api/sites`,
25 + `/api/overview`, `/api/dashboard/:id` (proxy agrégateur, cache mémoire +
26 + disque 10 min, *stale-while-error* : satellite down ⇒ dernière bonne copie).
27 + Enveloppe `{success,data}` d'API·Ka dépliée automatiquement.
28 +- `public/charts.js` — **QCharts**, graphiques SVG vanilla conformes au
29 + design system ka-ui + règles dataviz (marques fines, gaps de surface,
30 + grilles hairline, légende dès 2 séries, vue tableau jumelle sur chaque
31 + graphique, palette catégorielle validée en ordre fixe, rampes mono-teinte).
32 +- `public/app.js` — SPA vanilla, routeur History API, sélecteur de période
33 + global (auj/7j/30j/3m/6m/12m/année/tout + plage libre) relayé aux
34 + satellites.
35 +- Design system : `tokens.css` de ka-ui vendorisé + accent Ka·Stats
36 + **#095797** (bleu Québec).
37 +
38 +## Exploitation
39 +
40 +```bash
41 +node server.mjs 8140 # healthcheck : /healthz
42 +```
43 +
44 +Déployé sur **M1M32** (`~/apps/ka-stats`) sous PM2 (`ka-stats` +
45 +`ka-stats-ngrok` → https://www.ka-stats.com). Registre :
46 +`~/Desktop/cluster-skill/cluster-deployments.json`.
added package.json +10 −0
@@ -0,0 +1,10 @@
1 +{
2 + "name": "ka-stats",
3 + "version": "1.0.0",
4 + "description": "Ka·Stats — l'explorateur de statistiques du Québec (Groupe KA). Agrège les dashboards ka-stats v2 des 12 plateformes. Zéro dépendance.",
5 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
6 + "type": "module",
7 + "scripts": {
8 + "start": "node server.mjs 8140"
9 + }
10 +}
added public/app.js +1131 −0
@@ -0,0 +1,1131 @@
1 +/* -----------------------------------------------------------------------------
2 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 + Fichier : ka-stats/public/app.js
4 + Desc. : Ka·Stats — l'explorateur de statistiques du Québec (Groupe KA).
5 + SPA vanilla : routeur (History API), sélecteur de période global
6 + (une seule rangée au-dessus du contenu, s'applique à tout),
7 + 5 vues : Vue d'ensemble · Plateforme (rendu générique du contrat
8 + ka-stats v2) · Indicateurs · Comparateur · Palmarès.
9 +----------------------------------------------------------------------------- */
10 +"use strict";
11 +
12 +const Q = QCharts;
13 +const $app = document.getElementById("app");
14 +const state = {
15 + period: localStorage.getItem("qcstats_period") || "30j",
16 + from: "", to: "",
17 + sites: [], siteById: {},
18 +};
19 +const PERIOD_LABELS = { auj: "Aujourd'hui", "7j": "7 jours", "30j": "30 jours", "3m": "3 mois", "6m": "6 mois", "12m": "12 mois", annee: "Année en cours", tout: "Tout" };
20 +const THEMES = [
21 + { id: "habitation", label: "Habitation", desc: "Loyers, propriétés à vendre et valeur foncière — le marché de l'habitation au complet." },
22 + { id: "mobilite", label: "Mobilité", desc: "Le marché des voitures usagées du Québec." },
23 + { id: "emploi", label: "Emploi", desc: "Les offres des employeurs québécois et leurs salaires affichés." },
24 + { id: "consommation", label: "Consommation", desc: "Épicerie, restos et produits fabriqués au Québec." },
25 + { id: "culture", label: "Culture & découverte", desc: "Sorties, créateurs d'ici et web québécois." },
26 + { id: "infrastructure", label: "Infrastructure", desc: "La donnée de l'écosystème, servie par API." },
27 +];
28 +
29 +/* ---------- réseau (cache client léger) ---------- */
30 +const memo = new Map();
31 +async function getJSON(url) {
32 + if (memo.has(url)) {
33 + const m = memo.get(url);
34 + if (Date.now() - m.at < 5 * 60 * 1000) return m.data;
35 + }
36 + const r = await fetch(url);
37 + if (!r.ok) throw new Error("HTTP " + r.status);
38 + const data = await r.json();
39 + memo.set(url, { at: Date.now(), data });
40 + return data;
41 +}
42 +function periodQS() {
43 + let qs = "period=" + encodeURIComponent(state.period);
44 + if (state.from && state.to) qs += `&from=${state.from}&to=${state.to}`;
45 + return qs;
46 +}
47 +const apiOverview = () => getJSON("/api/overview?" + periodQS());
48 +const apiDash = (id) => getJSON("/api/dashboard/" + id + "?" + periodQS());
49 +const apiCatalog = () => getJSON("/api/catalog?" + periodQS());
50 +const apiMetric = (ref) => getJSON("/api/metric?ref=" + encodeURIComponent(ref) + "&" + periodQS());
51 +
52 +/* ---------- helpers dom ---------- */
53 +const el = Q.el;
54 +function link(href, cls, text) {
55 + const a = el("a", cls, text);
56 + a.href = href;
57 + a.dataset.link = "1";
58 + return a;
59 +}
60 +function section(title, kicker) {
61 + const s = el("section", "block");
62 + if (kicker) s.appendChild(el("div", "kicker", kicker));
63 + if (title) s.appendChild(el("h2", null, title));
64 + return s;
65 +}
66 +function siteChip(site) {
67 + const c = el("span", "site-chip");
68 + const dot = el("span", "site-dot");
69 + dot.style.background = site.accent;
70 + c.appendChild(dot);
71 + c.appendChild(el("span", null, site.wordmark));
72 + return c;
73 +}
74 +function setAccent(node, site) {
75 + node.style.setProperty("--accent", site.accent);
76 + node.style.setProperty("--accent-soft", site.accentSoft);
77 + node.style.setProperty("--accent-deep", site.accentDeep);
78 + node.style.setProperty("--on-accent", site.onAccent);
79 +}
80 +function updatedLine(iso, stale) {
81 + const d = iso ? new Date(iso) : null;
82 + const txt = d ? "Mis à jour le " + d.toLocaleString("fr-CA", { day: "numeric", month: "long", hour: "2-digit", minute: "2-digit" }) : "";
83 + return el("span", "klabel fresh" + (stale ? " stale" : ""), txt + (stale ? " · copie en cache" : ""));
84 +}
85 +
86 +/* ---------- sélecteur de période (une rangée, au-dessus de tout) ---------- */
87 +function periodBar() {
88 + const bar = el("div", "period-bar");
89 + const inner = el("div", "container period-inner");
90 + inner.appendChild(el("span", "klabel", "Période"));
91 + const row = el("div", "period-row");
92 + for (const [id, label] of Object.entries(PERIOD_LABELS)) {
93 + const b = el("button", "pill" + (state.period === id && !state.from ? " active" : ""), label);
94 + b.addEventListener("click", () => {
95 + state.period = id; state.from = ""; state.to = "";
96 + localStorage.setItem("qcstats_period", id);
97 + render();
98 + });
99 + row.appendChild(b);
100 + }
101 + // plage personnalisée
102 + const custom = el("span", "period-custom");
103 + const f = el("input", "input date-in"); f.type = "date"; f.value = state.from;
104 + const t = el("input", "input date-in"); t.type = "date"; t.value = state.to;
105 + const go = el("button", "pill", "Appliquer");
106 + go.addEventListener("click", () => {
107 + if (f.value && t.value) { state.from = f.value; state.to = t.value; render(); }
108 + });
109 + custom.appendChild(el("span", "klabel", "· personnalisé : du"));
110 + custom.appendChild(f);
111 + custom.appendChild(el("span", "klabel", "au"));
112 + custom.appendChild(t);
113 + custom.appendChild(go);
114 + row.appendChild(custom);
115 + inner.appendChild(row);
116 + bar.appendChild(inner);
117 + return bar;
118 +}
119 +
120 +/* ---------- cartes de graphique commutables + outils (CSV/PNG/Studio) ---------- */
121 +function studioHrefFor(refs) {
122 + return "/studio?c=" + encodeURIComponent(JSON.stringify({ refs, transform: "brut", combine: "aucune", kind: "line" }));
123 +}
124 +function timeSeriesCard(siteId, s, stroke) {
125 + const initial = s.kind === "bar" ? "bar" : s.kind === "area" ? "area" : "line";
126 + const rows = (s.points || []).map((p) => [Q.fmtDateFull(p.t), p.v]);
127 + return Q.switchableChart({
128 + kinds: [{ id: "line", label: "Ligne" }, { id: "area", label: "Aire" }, { id: "bar", label: "Barres" }],
129 + initial,
130 + csv: { name: `ka-stats_${siteId}_${s.id}`, columns: ["Date", s.title], rows },
131 + pngName: `ka-stats_${siteId}_${s.id}`,
132 + studioHref: studioHrefFor([`${siteId}|series|${s.id}`]),
133 + render(k) {
134 + if (k === "bar") return Q.vBarChart({ title: s.title, unit: s.unit, points: s.points, color: stroke });
135 + const series = [{ label: s.title, color: stroke, points: s.points }];
136 + if (s.compare && s.compare.length) series.push({ label: "Période précédente", color: stroke, dash: "7 4", faded: true, noArea: true, points: s.compare.map((p, i) => ({ t: (s.points[i] || {}).t || p.t, v: p.v })) });
137 + return Q.lineChart({ title: s.title, unit: s.unit, series, area: k === "area" });
138 + },
139 + });
140 +}
141 +function multiSeriesCard(siteId, m) {
142 + const subs = (m.series || []).slice(0, 4);
143 + const ts = [...new Set(subs.flatMap((s) => (s.points || []).map((p) => p.t)))].sort();
144 + const mapsL = subs.map((s) => new Map((s.points || []).map((p) => [p.t, p.v])));
145 + return Q.switchableChart({
146 + csv: { name: `ka-stats_${siteId}_${m.id}`, columns: ["Date", ...subs.map((s) => s.label)], rows: ts.map((t) => [Q.fmtDateFull(t), ...mapsL.map((mp) => mp.get(t) ?? "")]) },
147 + pngName: `ka-stats_${siteId}_${m.id}`,
148 + studioHref: studioHrefFor(subs.map((_, i) => `${siteId}|multi|${m.id}|${i}`)),
149 + render() {
150 + const series = subs.map((s, i) => ({ label: s.label, color: Q.CAT[i], dash: Q.DASHES[i], points: s.points || [] }));
151 + return Q.lineChart({ title: m.title, unit: m.unit, series, summary: false });
152 + },
153 + });
154 +}
155 +function breakdownCard(siteId, b, stroke) {
156 + return Q.switchableChart({
157 + kinds: [{ id: "bars", label: "Barres" }, { id: "donut", label: "Anneau" }],
158 + initial: b.kind === "donut" ? "donut" : "bars",
159 + csv: { name: `ka-stats_${siteId}_${b.id}`, columns: ["Catégorie", b.unit || "Valeur"], rows: (b.items || []).map((it) => [it.label, it.value]) },
160 + pngName: `ka-stats_${siteId}_${b.id}`,
161 + render(k) {
162 + return k === "donut" ? Q.donut({ title: b.title, unit: b.unit, items: b.items }) : Q.hBarChart({ title: b.title, unit: b.unit, items: b.items, color: stroke });
163 + },
164 + });
165 +}
166 +
167 +/* ================================================================
168 + VUE D'ENSEMBLE
169 + ================================================================ */
170 +async function pageHome(root) {
171 + document.title = "Ka·Stats — L'explorateur de statistiques du Québec";
172 + const ov = await apiOverview();
173 +
174 + // — héros —
175 + const hero = el("header", "hero");
176 + const hc = el("div", "container");
177 + hc.appendChild(el("div", "kicker", "Groupe KA · Observatoire de données"));
178 + const h1 = el("h1");
179 + h1.appendChild(document.createTextNode("L'explorateur de "));
180 + h1.appendChild(el("span", "hl", "statistiques"));
181 + h1.appendChild(document.createTextNode(" du Québec"));
182 + hc.appendChild(h1);
183 + hc.appendChild(el("p", "lead", "Loyers, propriétés, autos, emplois, épicerie, restos, sorties, créateurs — les chiffres vivants des " + ov.totals.sites + " plateformes du Groupe KA, agrégés en un seul tableau de bord."));
184 + const heroFig = el("div", "hero-figure");
185 + heroFig.appendChild(el("div", "hero-num", Q.fmtNum(ov.totals.items)));
186 + heroFig.appendChild(el("div", "hero-sub", "éléments suivis en direct — " + PERIOD_LABELS[state.period].toLowerCase()));
187 + hc.appendChild(heroFig);
188 + const chips = el("div", "hero-chips");
189 + chips.appendChild(el("span", "chip chip-accent", ov.totals.sitesLive + "/" + ov.totals.sites + " plateformes en ligne"));
190 + chips.appendChild(el("span", "chip", Q.fmtNum(ov.totals.charts) + " indicateurs & graphiques"));
191 + chips.appendChild(el("span", "chip", "Données 100 % réelles"));
192 + hc.appendChild(chips);
193 + const cta = el("div", "hero-cta");
194 + cta.appendChild(link("/studio", "btn btn-primary", "✦ Construire un indicateur sur mesure"));
195 + hc.appendChild(cta);
196 + hero.appendChild(hc);
197 + root.appendChild(hero);
198 +
199 + const main = el("div", "container");
200 +
201 + // — pouls de l'écosystème —
202 + const pulse = section("Le pouls de l'écosystème", "Volume agrégé");
203 + pulse.appendChild(el("p", "sect-note", "Somme quotidienne des indicateurs principaux des " + ov.totals.sites + " plateformes (annonces, produits, offres, événements, créateurs, pages…)."));
204 + if (ov.totals.spark.length > 1) {
205 + const pts = ov.totals.spark;
206 + pulse.appendChild(Q.switchableChart({
207 + kinds: [{ id: "line", label: "Ligne" }, { id: "area", label: "Aire" }, { id: "bar", label: "Barres" }],
208 + initial: "area",
209 + csv: { name: "ka-stats_ecosysteme", columns: ["Date", "Éléments suivis"], rows: pts.map((p) => [Q.fmtDateFull(p.t), p.v]) },
210 + pngName: "ka-stats_ecosysteme",
211 + render(k) {
212 + if (k === "bar") return Q.vBarChart({ title: "Éléments suivis par jour, toutes plateformes", unit: "éléments", points: pts, color: ACCENT.stroke });
213 + return Q.lineChart({
214 + title: "Éléments suivis par jour, toutes plateformes", unit: "éléments",
215 + series: [{ label: "Écosystème Groupe KA", color: ACCENT.stroke, points: pts }],
216 + area: k === "area", baselineZero: true,
217 + });
218 + },
219 + }));
220 + }
221 + main.appendChild(pulse);
222 +
223 + // — grille des plateformes —
224 + const grid = section("Les " + ov.totals.sites + " plateformes", "Explorer");
225 + const cards = el("div", "site-grid");
226 + for (const s of ov.sites) {
227 + const meta = state.siteById[s.id];
228 + const card = el("article", "site-card card card-hover");
229 + setAccent(card, meta);
230 + const band = el("div", "site-band");
231 + band.appendChild(el("span", "site-wordmark", meta.wordmark));
232 + band.appendChild(el("span", "site-tagline", meta.tagline));
233 + card.appendChild(band);
234 + const body = el("div", "site-body");
235 + if (s.ok && s.primary) {
236 + const prim = el("div", "site-primary");
237 + prim.appendChild(el("div", "klabel", s.primary.label));
238 + const v = el("div", "site-primary-val", Q.fmtNum(s.primary.value, s.primary.unit));
239 + prim.appendChild(v);
240 + if (typeof s.primary.delta_pct === "number") {
241 + prim.appendChild(el("span", "viz-delta " + (s.primary.delta_pct >= 0 ? "up" : "down"), (s.primary.delta_pct >= 0 ? "▲ " : "▼ ") + Math.abs(s.primary.delta_pct).toLocaleString("fr-CA") + " %"));
242 + }
243 + body.appendChild(prim);
244 + if (s.spark && s.spark.length > 2) {
245 + const sp = el("div", "site-spark");
246 + sp.appendChild(Q.sparkline(s.spark, Q.strokeFor(meta.accent, meta.accentDeep), 220, 44));
247 + body.appendChild(sp);
248 + }
249 + const minis = el("div", "site-minis");
250 + for (const k of s.kpis.slice(1, 4)) {
251 + const mi = el("div", "site-mini");
252 + mi.appendChild(el("b", null, Q.fmtNum(k.value, k.unit)));
253 + mi.appendChild(el("span", "klabel", k.label.length > 30 ? k.label.slice(0, 29) + "…" : k.label));
254 + minis.appendChild(mi);
255 + }
256 + body.appendChild(minis);
257 + } else {
258 + body.appendChild(el("div", "viz-empty", "Plateforme momentanément injoignable"));
259 + }
260 + const foot = el("div", "site-foot");
261 + foot.appendChild(link("/site/" + s.id, "btn btn-primary site-btn", "Explorer les stats"));
262 + const ext = el("a", "btn btn-ghost site-btn", "Visiter");
263 + ext.href = "https://" + meta.domain;
264 + ext.target = "_blank"; ext.rel = "noopener";
265 + foot.appendChild(ext);
266 + card.appendChild(body);
267 + card.appendChild(foot);
268 + cards.appendChild(card);
269 + }
270 + grid.appendChild(cards);
271 + main.appendChild(grid);
272 +
273 + // — indicateurs en dollars —
274 + if (ov.indicators.length) {
275 + const ind = section("Les prix du Québec, en direct", "Indicateurs");
276 + const strip = el("div", "ind-grid");
277 + for (const it of ov.indicators.slice(0, 12)) {
278 + const meta = state.siteById[it.site];
279 + const c = el("div", "ind-card card");
280 + setAccent(c, meta);
281 + c.appendChild(siteChip(meta));
282 + c.appendChild(el("div", "klabel", it.label));
283 + c.appendChild(el("div", "ind-val", Q.fmtNum(it.value, it.unit)));
284 + if (typeof it.delta_pct === "number") c.appendChild(el("span", "viz-delta " + (it.delta_pct >= 0 ? "up" : "down"), (it.delta_pct >= 0 ? "▲ " : "▼ ") + Math.abs(it.delta_pct).toLocaleString("fr-CA") + " %"));
285 + if (it.spark && it.spark.length > 2) c.appendChild(Q.sparkline(it.spark, Q.strokeFor(meta.accent, meta.accentDeep), 150, 30));
286 + strip.appendChild(c);
287 + }
288 + ind.appendChild(strip);
289 + const more = el("div", "sect-more");
290 + more.appendChild(link("/indicateurs", "btn", "Tous les indicateurs →"));
291 + ind.appendChild(more);
292 + main.appendChild(ind);
293 + }
294 +
295 + // — records —
296 + if (ov.records.length) {
297 + const rec = section("Records & faits marquants", "Palmarès");
298 + rec.appendChild(recordsGrid(ov.records.slice(0, 9)));
299 + const more = el("div", "sect-more");
300 + more.appendChild(link("/palmares", "btn", "Le palmarès complet →"));
301 + rec.appendChild(more);
302 + main.appendChild(rec);
303 + }
304 +
305 + root.appendChild(main);
306 +}
307 +
308 +function recordsGrid(records) {
309 + const g = el("div", "rec-grid");
310 + for (const r of records) {
311 + const meta = state.siteById[r.site];
312 + const c = el("div", "rec-card card");
313 + if (meta) { setAccent(c, meta); c.appendChild(siteChip(meta)); }
314 + c.appendChild(el("div", "rec-val", String(r.value ?? "—")));
315 + c.appendChild(el("div", "rec-label", r.label || ""));
316 + if (r.date) c.appendChild(el("div", "klabel", Q.fmtDateFull(r.date)));
317 + g.appendChild(c);
318 + }
319 + return g;
320 +}
321 +
322 +/* ================================================================
323 + PAGE PLATEFORME — rendu générique du contrat ka-stats v2 (ordre SPEC §1)
324 + ================================================================ */
325 +async function pageSite(root, id) {
326 + const meta = state.siteById[id];
327 + if (!meta) { root.appendChild(el("div", "container viz-empty", "Plateforme inconnue.")); return; }
328 + document.title = meta.wordmark + " — Ka·Stats";
329 + const res = await apiDash(id);
330 + const wrap = el("div", "site-page");
331 + setAccent(wrap, meta);
332 +
333 + const head = el("header", "site-head");
334 + const hc = el("div", "container");
335 + const crumb = el("div", "crumb");
336 + crumb.appendChild(link("/", "crumb-link", "← Toutes les plateformes"));
337 + hc.appendChild(crumb);
338 + const row = el("div", "site-head-row");
339 + const idb = el("div");
340 + idb.appendChild(el("h1", "site-h1", meta.wordmark));
341 + idb.appendChild(el("p", "lead", meta.tagline));
342 + row.appendChild(idb);
343 + const act = el("div", "site-actions");
344 + const visit = el("a", "btn", "Visiter " + meta.wordmark + " ↗");
345 + visit.href = "https://" + meta.domain; visit.target = "_blank"; visit.rel = "noopener";
346 + act.appendChild(visit);
347 + act.appendChild(pdfMenu(meta));
348 + row.appendChild(act);
349 + hc.appendChild(row);
350 + head.appendChild(hc);
351 + wrap.appendChild(head);
352 +
353 + const main = el("div", "container");
354 + if (!res.ok || !res.data) {
355 + main.appendChild(el("div", "viz-empty big", "Plateforme momentanément injoignable — " + (res.error || "")));
356 + wrap.appendChild(main);
357 + root.appendChild(wrap);
358 + return;
359 + }
360 + const d = res.data;
361 + const fresh = el("div", "fresh-row");
362 + fresh.appendChild(updatedLine(d.updated, res.stale));
363 + if (d.period && d.period.from) fresh.appendChild(el("span", "klabel", "· du " + Q.fmtDateFull(d.period.from) + " au " + Q.fmtDateFull(d.period.to)));
364 + main.appendChild(fresh);
365 +
366 + const stroke = Q.strokeFor(meta.accent, meta.accentDeep);
367 +
368 + // 1. bandeau KPI
369 + if (d.kpis && d.kpis.length) {
370 + const g = el("div", "kpi-grid");
371 + for (const k of d.kpis) g.appendChild(Q.kpiCard(k, meta.accent, meta.accentDeep));
372 + main.appendChild(g);
373 + }
374 + // 3. jauges
375 + if (d.gauges && d.gauges.length) {
376 + const g = el("div", "gauge-grid");
377 + for (const ga of d.gauges) g.appendChild(Q.gauge({ label: ga.label, value: ga.value, max: ga.max || 100, unit: ga.unit || "", color: stroke, track: Q.mix("#ffffff", stroke, 0.16) }));
378 + main.appendChild(g);
379 + }
380 + // 4. évolutions
381 + if (d.series && d.series.length) {
382 + const sec = section("Évolution", "Séries temporelles");
383 + for (const s of d.series) {
384 + if (!s.points || !s.points.length) continue;
385 + sec.appendChild(timeSeriesCard(id, s, stroke));
386 + }
387 + main.appendChild(sec);
388 + }
389 + // multi-courbes ≤ 4 : palette catégorielle + motifs de trait distincts
390 + if (d.multiseries && d.multiseries.length) {
391 + const sec = section("Comparaisons", "Multi-courbes");
392 + for (const m of d.multiseries) {
393 + if ((m.series || []).some((s) => s.points && s.points.length)) sec.appendChild(multiSeriesCard(id, m));
394 + }
395 + main.appendChild(sec);
396 + }
397 + if (d.stacked && d.stacked.length) {
398 + const sec = section("Composition dans le temps", "Empilées");
399 + for (const st of d.stacked) sec.appendChild(Q.stackedBar({ title: st.title, unit: st.unit, keys: st.keys, points: st.points }));
400 + main.appendChild(sec);
401 + }
402 + // 5-6. répartitions + distributions + géo
403 + const rep = [];
404 + for (const b of d.breakdowns || []) {
405 + if (!b.items || !b.items.length) continue;
406 + rep.push(breakdownCard(id, b, stroke));
407 + }
408 + for (const dist of d.distributions || []) {
409 + if (!dist.bins || !dist.bins.length) continue;
410 + rep.push(Q.vBarChart({ title: dist.title, unit: dist.unit, points: dist.bins.map((b) => ({ label: b.label, v: b.value })), color: stroke, isDate: false, rotateLabels: true }));
411 + }
412 + if (d.geo && d.geo.items && d.geo.items.length) rep.push(Q.hBarChart({ title: d.geo.title || "Répartition géographique", items: d.geo.items, color: stroke, max: 16 }));
413 + if (rep.length) {
414 + const sec = section("Répartitions", "Structure");
415 + const g = el("div", "two-col");
416 + rep.forEach((c) => g.appendChild(c));
417 + sec.appendChild(g);
418 + main.appendChild(sec);
419 + }
420 + // 7. calendriers
421 + if ((d.heatmap && d.heatmap.cells && d.heatmap.cells.length) || (d.hourly && d.hourly.cells && d.hourly.cells.length)) {
422 + const sec = section("Rythmes d'activité", "Calendriers");
423 + if (d.heatmap && d.heatmap.cells && d.heatmap.cells.length) sec.appendChild(Q.calendarHeatmap({ title: d.heatmap.title || "Activité (26 semaines)", cells: d.heatmap.cells, accent: stroke }));
424 + if (d.hourly && d.hourly.cells && d.hourly.cells.length) sec.appendChild(Q.hourHeatmap({ title: d.hourly.title || "Activité par heure (7 j × 24 h)", cells: d.hourly.cells, accent: stroke }));
425 + main.appendChild(sec);
426 + }
427 + // 8. tableaux
428 + if (d.tables && d.tables.length) {
429 + const sec = section("Tableaux détaillés", "Données");
430 + for (const t of d.tables) sec.appendChild(Q.dataTable({ title: t.title, columns: t.columns, rows: t.rows }));
431 + main.appendChild(sec);
432 + }
433 + // 9. records
434 + if (d.records && d.records.length) {
435 + const sec = section("Records & faits marquants", "Palmarès " + meta.wordmark);
436 + sec.appendChild(recordsGrid(d.records.map((r) => ({ ...r, site: id }))));
437 + main.appendChild(sec);
438 + }
439 + wrap.appendChild(main);
440 + root.appendChild(wrap);
441 +}
442 +
443 +function pdfMenu(meta) {
444 + const det = el("details", "pdf-menu");
445 + det.appendChild(el("summary", "btn btn-accent", "Rapport PDF ▾"));
446 + const box = el("div", "ka-menu pdf-box");
447 + const modes = [["complet", "Rapport complet"], ["synthese", "Synthèse"], ["tendances", "Tendances"], ["repartitions", "Répartitions"], ["donnees", "Données (long)"]];
448 + for (const [m, label] of modes) {
449 + const a = el("a", null, label);
450 + a.href = `https://${meta.domain}/api/stats/report?${periodQS()}&mode=${m}`;
451 + a.target = "_blank"; a.rel = "noopener";
452 + box.appendChild(a);
453 + }
454 + det.appendChild(box);
455 + document.addEventListener("click", (e) => { if (!det.contains(e.target)) det.open = false; });
456 + return det;
457 +}
458 +
459 +/* ================================================================
460 + INDICATEURS — les chiffres du Québec par thème
461 + ================================================================ */
462 +async function pageIndicateurs(root) {
463 + document.title = "Indicateurs du Québec — Ka·Stats";
464 + const head = el("header", "page-head");
465 + const hc = el("div", "container");
466 + hc.appendChild(el("div", "kicker", "Indicateurs"));
467 + const h1 = el("h1");
468 + h1.appendChild(document.createTextNode("Les chiffres du "));
469 + h1.appendChild(el("span", "hl", "Québec"));
470 + hc.appendChild(h1);
471 + hc.appendChild(el("p", "lead", "Prix, salaires, loyers et volumes, extraits en direct des données des plateformes Groupe KA — regroupés par grand thème."));
472 + head.appendChild(hc);
473 + root.appendChild(head);
474 +
475 + const dashes = await Promise.all(state.sites.map((s) => apiDash(s.id).catch(() => null)));
476 + const byId = {};
477 + state.sites.forEach((s, i) => (byId[s.id] = dashes[i]));
478 + const main = el("div", "container");
479 + const PRICE_RE = /prix|loyer|salaire|valeur|coût|cout|rabais|\$/i;
480 +
481 + for (const theme of THEMES) {
482 + const members = state.sites.filter((s) => s.theme === theme.id);
483 + if (!members.length) continue;
484 + const sec = section(theme.label, theme.desc.split("—")[0]);
485 + sec.appendChild(el("p", "sect-note", theme.desc));
486 + const kpiRow = el("div", "ind-grid");
487 + let any = false;
488 + for (const m of members) {
489 + const r = byId[m.id];
490 + if (!r || !r.ok || !r.data) continue;
491 + const kpis = (r.data.kpis || []).filter((k) => PRICE_RE.test((k.unit || "") + " " + k.label)).slice(0, 4);
492 + const shown = kpis.length ? kpis : (r.data.kpis || []).slice(0, 2);
493 + for (const k of shown) {
494 + const c = el("div", "ind-card card");
495 + setAccent(c, m);
496 + c.appendChild(siteChip(m));
497 + c.appendChild(el("div", "klabel", k.label));
498 + c.appendChild(el("div", "ind-val", Q.fmtNum(k.value, k.unit)));
499 + if (typeof k.delta_pct === "number") c.appendChild(el("span", "viz-delta " + (k.delta_pct >= 0 ? "up" : "down"), (k.delta_pct >= 0 ? "▲ " : "▼ ") + Math.abs(k.delta_pct).toLocaleString("fr-CA") + " %"));
500 + if (k.spark && k.spark.length > 2) c.appendChild(Q.sparkline(k.spark, Q.strokeFor(m.accent, m.accentDeep), 150, 30));
501 + kpiRow.appendChild(c);
502 + any = true;
503 + }
504 + }
505 + if (any) sec.appendChild(kpiRow);
506 + // distributions & multiséries « prix » du thème
507 + const charts = el("div", "two-col");
508 + let anyCharts = false;
509 + for (const m of members) {
510 + const r = byId[m.id];
511 + if (!r || !r.ok || !r.data) continue;
512 + const stroke = Q.strokeFor(m.accent, m.accentDeep);
513 + const dist = (r.data.distributions || []).find((x) => PRICE_RE.test(x.title || "")) || (r.data.distributions || [])[0];
514 + if (dist && dist.bins && dist.bins.length) {
515 + charts.appendChild(Q.vBarChart({ title: m.wordmark + " — " + dist.title, unit: dist.unit, points: dist.bins.map((b) => ({ label: b.label, v: b.value })), color: stroke, isDate: false, rotateLabels: true }));
516 + anyCharts = true;
517 + }
518 + const ms = (r.data.multiseries || []).find((x) => PRICE_RE.test(x.title || ""));
519 + if (ms && ms.series && ms.series.length) {
520 + const series = ms.series.slice(0, 4).map((s, i) => ({ label: s.label, color: Q.CAT[i], dash: Q.DASHES[i], points: s.points || [] }));
521 + charts.appendChild(Q.lineChart({ title: m.wordmark + " — " + ms.title, unit: ms.unit, series, summary: false }));
522 + anyCharts = true;
523 + }
524 + }
525 + if (anyCharts) sec.appendChild(charts);
526 + if (any || anyCharts) main.appendChild(sec);
527 + }
528 + root.appendChild(main);
529 +}
530 +
531 +/* ================================================================
532 + COMPARATEUR — jusqu'à 4 plateformes, indice base 100 (un seul axe)
533 + ================================================================ */
534 +const cmpState = { sel: ["lou-ka", "trouve-ka", "food-ka", "crea-ka"], mode: "indice" };
535 +async function pageComparer(root) {
536 + document.title = "Comparateur — Ka·Stats";
537 + const head = el("header", "page-head");
538 + const hc = el("div", "container");
539 + hc.appendChild(el("div", "kicker", "Comparateur"));
540 + const h1 = el("h1");
541 + h1.appendChild(el("span", "hl", "Comparer"));
542 + h1.appendChild(document.createTextNode(" les plateformes"));
543 + hc.appendChild(h1);
544 + hc.appendChild(el("p", "lead", "L'évolution de l'indicateur principal de chaque plateforme, sur un seul axe. En indice (base 100 = début de période), des univers différents deviennent comparables ; jamais de double échelle."));
545 + head.appendChild(hc);
546 + root.appendChild(head);
547 +
548 + const ov = await apiOverview();
549 + const main = el("div", "container");
550 + const controls = el("div", "cmp-controls");
551 + const chipsRow = el("div", "cmp-chips");
552 + for (const s of state.sites) {
553 + const on = cmpState.sel.includes(s.id);
554 + const b = el("button", "pill site-pill" + (on ? " active" : ""), s.wordmark);
555 + if (on) { b.style.background = s.accent; b.style.color = s.onAccent; b.style.borderColor = s.accent; }
556 + b.addEventListener("click", () => {
557 + if (on) cmpState.sel = cmpState.sel.filter((x) => x !== s.id);
558 + else if (cmpState.sel.length < 4) cmpState.sel = [...cmpState.sel, s.id];
559 + render();
560 + });
561 + if (!on && cmpState.sel.length >= 4) { b.disabled = true; b.title = "Maximum 4 plateformes à la fois"; }
562 + chipsRow.appendChild(b);
563 + }
564 + controls.appendChild(chipsRow);
565 + const modeRow = el("div", "cmp-mode");
566 + for (const [m, label] of [["indice", "Indice (base 100)"], ["brut", "Valeurs brutes"]]) {
567 + const b = el("button", "pill" + (cmpState.mode === m ? " active" : ""), label);
568 + b.addEventListener("click", () => { cmpState.mode = m; render(); });
569 + modeRow.appendChild(b);
570 + }
571 + controls.appendChild(modeRow);
572 + main.appendChild(controls);
573 + if (cmpState.mode === "brut" && cmpState.sel.length > 1) {
574 + main.appendChild(el("p", "sect-note warn", "⚠ En valeurs brutes, les plateformes ont des unités différentes (annonces, produits, pages…) — l'indice base 100 est la lecture recommandée."));
575 + }
576 +
577 + const chosen = ov.sites.filter((s) => cmpState.sel.includes(s.id) && s.ok && s.spark && s.spark.length > 1);
578 + if (!chosen.length) {
579 + main.appendChild(el("div", "viz-empty big", "Choisissez au moins une plateforme."));
580 + } else {
581 + const series = chosen.map((s, i) => {
582 + const meta = state.siteById[s.id];
583 + let pts = s.spark.filter((p) => typeof p.v === "number" && /^\d{4}-\d{2}-\d{2}/.test(String(p.t)));
584 + if (cmpState.mode === "indice") {
585 + const base = pts.find((p) => p.v > 0);
586 + if (base) pts = pts.map((p) => ({ t: p.t, v: Math.round((p.v / base.v) * 1000) / 10 }));
587 + }
588 + return { label: meta.wordmark + " — " + (s.primary ? s.primary.label : ""), color: Q.strokeFor(meta.accent, meta.accentDeep), dash: Q.DASHES[i], points: pts, noArea: true };
589 + });
590 + main.appendChild(Q.lineChart({
591 + title: cmpState.mode === "indice" ? "Croissance comparée (indice, base 100 = début de période)" : "Valeurs brutes (unités hétérogènes)",
592 + unit: cmpState.mode === "indice" ? "indice" : "",
593 + series, summary: false, height: 340,
594 + }));
595 + // cartes de croissance
596 + const growth = el("div", "ind-grid");
597 + for (const s of chosen) {
598 + const meta = state.siteById[s.id];
599 + const pts = s.spark.filter((p) => typeof p.v === "number" && p.v > 0 && /^\d{4}-\d{2}-\d{2}/.test(String(p.t)));
600 + if (pts.length < 2) continue;
601 + const pct = ((pts[pts.length - 1].v - pts[0].v) / pts[0].v) * 100;
602 + const c = el("div", "ind-card card");
603 + setAccent(c, meta);
604 + c.appendChild(siteChip(meta));
605 + c.appendChild(el("div", "klabel", "Croissance sur la période"));
606 + c.appendChild(el("div", "ind-val", (pct >= 0 ? "+" : "") + pct.toLocaleString("fr-CA", { maximumFractionDigits: 1 }) + " %"));
607 + c.appendChild(el("span", "klabel", Q.fmtNum(pts[0].v) + " → " + Q.fmtNum(pts[pts.length - 1].v)));
608 + growth.appendChild(c);
609 + }
610 + main.appendChild(growth);
611 + }
612 + root.appendChild(main);
613 +}
614 +
615 +/* ================================================================
616 + PALMARÈS — records + croissances
617 + ================================================================ */
618 +async function pagePalmares(root) {
619 + document.title = "Palmarès — Ka·Stats";
620 + const head = el("header", "page-head");
621 + const hc = el("div", "container");
622 + hc.appendChild(el("div", "kicker", "Palmarès"));
623 + const h1 = el("h1");
624 + h1.appendChild(document.createTextNode("Records & "));
625 + h1.appendChild(el("span", "hl", "faits marquants"));
626 + hc.appendChild(h1);
627 + hc.appendChild(el("p", "lead", "Les jours records, les plus fortes croissances et les faits saillants détectés dans les données de chaque plateforme."));
628 + head.appendChild(hc);
629 + root.appendChild(head);
630 +
631 + const ov = await apiOverview();
632 + const main = el("div", "container");
633 +
634 + // croissance par plateforme (une seule série ⇒ une seule couleur)
635 + const rows = [];
636 + for (const s of ov.sites) {
637 + if (!s.ok || !s.spark) continue;
638 + const pts = s.spark.filter((p) => typeof p.v === "number" && p.v > 0 && /^\d{4}-\d{2}-\d{2}/.test(String(p.t)));
639 + if (pts.length < 2) continue;
640 + rows.push({ label: state.siteById[s.id].wordmark, value: Math.round(((pts[pts.length - 1].v - pts[0].v) / pts[0].v) * 1000) / 10 });
641 + }
642 + rows.sort((a, b) => b.value - a.value);
643 + if (rows.length) {
644 + const sec = section("Croissance sur la période", "Classement");
645 + sec.appendChild(el("p", "sect-note", "Variation de l'indicateur principal de chaque plateforme entre le début et la fin de la période (%)."));
646 + sec.appendChild(Q.hBarChart({ title: "Croissance de l'indicateur principal", unit: "%", items: rows, color: ACCENT.stroke, max: 12 }));
647 + main.appendChild(sec);
648 + }
649 + if (ov.records.length) {
650 + const sec = section("Tous les records", "Faits marquants");
651 + sec.appendChild(recordsGrid(ov.records));
652 + main.appendChild(sec);
653 + }
654 + if (ov.indicators.length) {
655 + const sec = section("Tous les indicateurs en dollars", "Table");
656 + sec.appendChild(Q.dataTable({
657 + title: "Indicateurs de prix, toutes plateformes",
658 + columns: ["Plateforme", "Indicateur", "Valeur", "Δ %"],
659 + rows: ov.indicators.map((it) => [state.siteById[it.site].wordmark, it.label, Q.fmtNum(it.value, it.unit), typeof it.delta_pct === "number" ? it.delta_pct.toLocaleString("fr-CA") + " %" : "—"]),
660 + }));
661 + main.appendChild(sec);
662 + }
663 + root.appendChild(main);
664 +}
665 +
666 +/* ================================================================
667 + STUDIO — constructeur d'indicateurs sur mesure
668 + ================================================================ */
669 +const TRANSFORMS = [
670 + ["brut", "Valeurs brutes"], ["indice", "Indice 100"], ["variation", "Variation % (jour)"],
671 + ["mm7", "Moyenne mobile 7 j"], ["cumul", "Cumul"],
672 +];
673 +const COMBINES = [["aucune", "Séries séparées"], ["ratio", "A ÷ B"], ["diff", "A − B"], ["somme", "A + B"]];
674 +const KINDS = [["line", "Ligne"], ["area", "Aire"], ["bar", "Barres"]];
675 +const studio = { refs: [], transform: "brut", combine: "aucune", kind: "line", title: "", search: "", loadedC: null };
676 +
677 +function tfPoints(points, mode) {
678 + const pts = (points || []).filter((p) => typeof p.v === "number" && /^\d{4}-\d{2}-\d{2}/.test(String(p.t)));
679 + const r1 = (v) => Math.round(v * 10) / 10;
680 + if (mode === "indice") { const b = pts.find((p) => p.v); return b ? pts.map((p) => ({ t: p.t, v: r1((p.v / b.v) * 100) })) : pts; }
681 + if (mode === "variation") return pts.map((p, i) => (i && pts[i - 1].v ? { t: p.t, v: r1(((p.v - pts[i - 1].v) / Math.abs(pts[i - 1].v)) * 100) } : null)).filter(Boolean);
682 + if (mode === "mm7") return pts.map((p, i) => { const w = pts.slice(Math.max(0, i - 6), i + 1); return { t: p.t, v: Math.round((w.reduce((a, x) => a + x.v, 0) / w.length) * 100) / 100 }; });
683 + if (mode === "cumul") { let a = 0; return pts.map((p) => ({ t: p.t, v: (a += p.v) })); }
684 + return pts;
685 +}
686 +function combinePoints(A, B, op) {
687 + const mb = new Map(B.map((p) => [p.t, p.v]));
688 + const out = [];
689 + for (const p of A) {
690 + const b = mb.get(p.t);
691 + if (typeof b !== "number") continue;
692 + const v = op === "ratio" ? (b ? p.v / b : null) : op === "diff" ? p.v - b : p.v + b;
693 + if (v === null || !isFinite(v)) continue;
694 + out.push({ t: p.t, v: Math.round(v * 10000) / 10000 });
695 + }
696 + return out;
697 +}
698 +function studioSaved() { try { return JSON.parse(localStorage.getItem("kastats_studio") || "[]"); } catch { return []; } }
699 +function studioStore(list) { localStorage.setItem("kastats_studio", JSON.stringify(list)); }
700 +function studioCfg() { return { refs: studio.refs, transform: studio.transform, combine: studio.combine, kind: studio.kind, title: studio.title }; }
701 +function studioShareUrl(cfg) { return location.origin + "/studio?c=" + encodeURIComponent(JSON.stringify(cfg)); }
702 +
703 +/* Construit les séries finales (fetch + combine + transform) d'une config. */
704 +async function buildCustomSeries(cfg) {
705 + const metrics = (await Promise.all(cfg.refs.map((r) => apiMetric(r).catch(() => null)))).filter(Boolean);
706 + if (!metrics.length) return { series: [], unit: "" };
707 + let baseUnit = metrics[0].unit || "";
708 + let series;
709 + if (cfg.combine !== "aucune" && metrics.length === 2) {
710 + const pts = combinePoints(tfClean(metrics[0].points), tfClean(metrics[1].points), cfg.combine);
711 + const opLbl = cfg.combine === "ratio" ? "÷" : cfg.combine === "diff" ? "−" : "+";
712 + series = [{ label: cfg.title || `${metrics[0].label} ${opLbl} ${metrics[1].label}`, points: pts }];
713 + baseUnit = cfg.combine === "ratio" ? "ratio" : metrics[0].unit === metrics[1].unit ? baseUnit : "";
714 + } else {
715 + series = metrics.map((m) => ({ label: `${state.siteById[m.site] ? state.siteById[m.site].wordmark + " — " : ""}${m.label}`, points: m.points }));
716 + }
717 + series = series.map((s) => ({ ...s, points: tfPoints(s.points, cfg.transform) })).filter((s) => s.points.length > 1);
718 + const unit = cfg.transform === "indice" ? "indice" : cfg.transform === "variation" ? "%" : baseUnit;
719 + return { series, unit };
720 +}
721 +function tfClean(points) { return (points || []).filter((p) => typeof p.v === "number" && /^\d{4}-\d{2}-\d{2}/.test(String(p.t))); }
722 +
723 +/* Rend la carte finale d'un indicateur custom (avec commutateur + exports). */
724 +function customChartCard(cfg, built, opts) {
725 + const { series, unit } = built;
726 + if (!series.length) return el("div", "viz-empty", "Aucune donnée pour cette configuration (métriques vides sur la période).");
727 + const colored = series.map((s, i) => ({ ...s, color: Q.CAT[i], dash: Q.DASHES[i], noArea: series.length > 1 }));
728 + const title = cfg.title || (opts && opts.fallbackTitle) || "Indicateur sur mesure";
729 + const ts = [...new Set(series.flatMap((s) => s.points.map((p) => p.t)))].sort();
730 + const mapsL = series.map((s) => new Map(s.points.map((p) => [p.t, p.v])));
731 + const kinds = series.length === 1 ? KINDS.map(([id, label]) => ({ id, label })) : KINDS.slice(0, 2).map(([id, label]) => ({ id, label }));
732 + return Q.switchableChart({
733 + kinds,
734 + initial: series.length > 1 && cfg.kind === "bar" ? "line" : cfg.kind,
735 + csv: { name: "ka-stats_studio", columns: ["Date", ...series.map((s) => s.label)], rows: ts.map((t) => [Q.fmtDateFull(t), ...mapsL.map((mp) => mp.get(t) ?? "")]) },
736 + pngName: "ka-stats_studio",
737 + render(k) {
738 + if (k === "bar" && series.length === 1) return Q.vBarChart({ title, unit, points: series[0].points, color: Q.CAT[0] });
739 + return Q.lineChart({ title, unit, series: colored, area: k === "area" && series.length === 1, summary: series.length === 1 });
740 + },
741 + });
742 +}
743 +
744 +async function pageStudio(root) {
745 + document.title = "Studio d'indicateurs — Ka·Stats";
746 + // config passée par lien partagé (?c=…) — chargée une seule fois
747 + const cParam = new URLSearchParams(location.search).get("c");
748 + if (cParam && cParam !== studio.loadedC) {
749 + try {
750 + const cfg = JSON.parse(cParam);
751 + if (Array.isArray(cfg.refs)) {
752 + studio.refs = cfg.refs.slice(0, 4);
753 + studio.transform = TRANSFORMS.some(([id]) => id === cfg.transform) ? cfg.transform : "brut";
754 + studio.combine = COMBINES.some(([id]) => id === cfg.combine) ? cfg.combine : "aucune";
755 + studio.kind = KINDS.some(([id]) => id === cfg.kind) ? cfg.kind : "line";
756 + studio.title = typeof cfg.title === "string" ? cfg.title : "";
757 + }
758 + studio.loadedC = cParam;
759 + } catch { /* config invalide : ignorer */ }
760 + }
761 +
762 + const head = el("header", "page-head");
763 + const hc = el("div", "container");
764 + hc.appendChild(el("div", "kicker", "Studio"));
765 + const h1 = el("h1");
766 + h1.appendChild(document.createTextNode("Construisez votre "));
767 + h1.appendChild(el("span", "hl", "indicateur"));
768 + hc.appendChild(h1);
769 + hc.appendChild(el("p", "lead", "Choisissez jusqu'à 4 métriques parmi toutes celles des 12 plateformes, appliquez une transformation (indice 100, variation, moyenne mobile, cumul) ou combinez-les (ratio, écart), choisissez le type de graphique — puis enregistrez ou partagez votre indicateur."));
770 + head.appendChild(hc);
771 + root.appendChild(head);
772 +
773 + const main = el("div", "container");
774 + const cat = await apiCatalog();
775 +
776 + /* ---- constructeur ---- */
777 + const builder = el("section", "studio card");
778 + const bIn = el("div", "studio-in");
779 +
780 + // 1. sélection des métriques
781 + bIn.appendChild(el("div", "kicker", "1 · Métriques (max 4)"));
782 + const selRow = el("div", "studio-sel");
783 + studio.refs.forEach((ref, i) => {
784 + const meta = cat.metrics.find((m) => m.ref === ref);
785 + const chip = el("span", "studio-chip");
786 + const key = el("span", "viz-key-line");
787 + key.style.background = Q.CAT[i];
788 + if (Q.DASHES[i]) key.style.backgroundImage = `repeating-linear-gradient(90deg, ${Q.CAT[i]} 0 6px, #fff 6px 9px)`;
789 + chip.appendChild(key);
790 + chip.appendChild(el("span", null, meta ? `${meta.wordmark} — ${meta.label}` : ref));
791 + const x = el("button", "studio-x", "✕");
792 + x.type = "button";
793 + x.setAttribute("aria-label", "Retirer cette métrique");
794 + x.addEventListener("click", () => { studio.refs = studio.refs.filter((r) => r !== ref); render(); });
795 + chip.appendChild(x);
796 + selRow.appendChild(chip);
797 + });
798 + if (!studio.refs.length) selRow.appendChild(el("span", "klabel", "Aucune métrique choisie — cherchez ci-dessous."));
799 + bIn.appendChild(selRow);
800 +
801 + const searchWrap = el("div", "studio-search");
802 + const input = el("input", "input");
803 + input.type = "search";
804 + input.placeholder = `Chercher parmi ${cat.count} métriques (ex. loyer, prix, salaire, Montréal…)`;
805 + input.value = studio.search;
806 + const results = el("div", "studio-results");
807 + function renderResults() {
808 + results.textContent = "";
809 + const q = input.value.trim().toLowerCase();
810 + studio.search = input.value;
811 + if (!q) { results.style.display = "none"; return; }
812 + const found = cat.metrics.filter((m) => (`${m.wordmark} ${m.label}`).toLowerCase().includes(q) && !studio.refs.includes(m.ref)).slice(0, 24);
813 + results.style.display = found.length ? "" : "none";
814 + for (const m of found) {
815 + const b = el("button", "studio-result");
816 + b.type = "button";
817 + const meta = state.siteById[m.site];
818 + const dot = el("span", "site-dot");
819 + if (meta) dot.style.background = meta.accent;
820 + b.appendChild(dot);
821 + b.appendChild(el("span", "studio-r-label", `${m.wordmark} — ${m.label}`));
822 + b.appendChild(el("span", "klabel", `${m.unit || ""} · ${m.n} pts`));
823 + b.disabled = studio.refs.length >= 4;
824 + b.addEventListener("click", () => {
825 + if (studio.refs.length < 4) { studio.refs = [...studio.refs, m.ref]; render(); }
826 + });
827 + results.appendChild(b);
828 + }
829 + }
830 + input.addEventListener("input", renderResults);
831 + searchWrap.appendChild(input);
832 + searchWrap.appendChild(results);
833 + bIn.appendChild(searchWrap);
834 +
835 + // 2. transformation / combinaison / type
836 + const pillsRow = (kicker, entries, key, visible = true) => {
837 + if (!visible) return null;
838 + const box = el("div", "studio-opt");
839 + box.appendChild(el("div", "kicker", kicker));
840 + const row = el("div", "period-row");
841 + for (const [id, label] of entries) {
842 + const b = el("button", "pill" + (studio[key] === id ? " active" : ""), label);
843 + b.type = "button";
844 + b.addEventListener("click", () => { studio[key] = id; render(); });
845 + row.appendChild(b);
846 + }
847 + box.appendChild(row);
848 + return box;
849 + };
850 + const t = pillsRow("2 · Transformation", TRANSFORMS, "transform");
851 + if (t) bIn.appendChild(t);
852 + const c2 = pillsRow("3 · Combinaison (2 métriques)", COMBINES, "combine", studio.refs.length === 2);
853 + if (c2) bIn.appendChild(c2);
854 + const kindEntries = studio.refs.length > 1 && studio.combine === "aucune" ? KINDS.slice(0, 2) : KINDS;
855 + const k = pillsRow("Type de graphique", kindEntries, "kind");
856 + if (k) bIn.appendChild(k);
857 +
858 + // titre + actions
859 + const titleRow = el("div", "studio-titlerow");
860 + const titleIn = el("input", "input");
861 + titleIn.placeholder = "Titre de l'indicateur (optionnel)";
862 + titleIn.value = studio.title;
863 + titleIn.addEventListener("change", () => { studio.title = titleIn.value.trim(); render(); });
864 + titleRow.appendChild(titleIn);
865 + const saveBtn = el("button", "btn btn-primary", "💾 Enregistrer");
866 + saveBtn.type = "button";
867 + saveBtn.disabled = !studio.refs.length;
868 + saveBtn.addEventListener("click", () => {
869 + studio.title = titleIn.value.trim();
870 + const list = studioSaved();
871 + list.unshift({ id: "ci" + Math.random().toString(36).slice(2, 8), created: new Date().toISOString(), cfg: studioCfg() });
872 + studioStore(list.slice(0, 30));
873 + render();
874 + });
875 + titleRow.appendChild(saveBtn);
876 + const shareBtn = el("button", "btn", "🔗 Copier le lien");
877 + shareBtn.type = "button";
878 + shareBtn.disabled = !studio.refs.length;
879 + shareBtn.addEventListener("click", async () => {
880 + studio.title = titleIn.value.trim();
881 + try { await navigator.clipboard.writeText(studioShareUrl(studioCfg())); shareBtn.textContent = "✓ Lien copié"; setTimeout(() => (shareBtn.textContent = "🔗 Copier le lien"), 1800); } catch { /* presse-papiers refusé */ }
882 + });
883 + titleRow.appendChild(shareBtn);
884 + bIn.appendChild(titleRow);
885 + builder.appendChild(bIn);
886 + main.appendChild(builder);
887 +
888 + // aperçu live
889 + if (studio.refs.length) {
890 + const prev = section("Aperçu", "Live");
891 + const built = await buildCustomSeries(studioCfg());
892 + prev.appendChild(customChartCard(studioCfg(), built, { fallbackTitle: "Aperçu de l'indicateur" }));
893 + main.appendChild(prev);
894 + }
895 +
896 + /* ---- mes indicateurs enregistrés ---- */
897 + const saved = studioSaved();
898 + if (saved.length) {
899 + const sec = section("Mes indicateurs", "Enregistrés");
900 + sec.appendChild(el("p", "sect-note", "Sauvegardés dans ce navigateur, recalculés en direct sur la période choisie."));
901 + for (const item of saved) {
902 + const box = el("div", "studio-saveditem");
903 + const bar = el("div", "studio-savedbar");
904 + bar.appendChild(el("b", null, item.cfg.title || "Indicateur sans titre"));
905 + const acts = el("span", "studio-savedacts");
906 + const open = el("button", "viz-toolbtn", "✎ Modifier");
907 + open.type = "button";
908 + open.addEventListener("click", () => {
909 + Object.assign(studio, { refs: item.cfg.refs.slice(0, 4), transform: item.cfg.transform, combine: item.cfg.combine, kind: item.cfg.kind, title: item.cfg.title || "" });
910 + window.scrollTo(0, 0);
911 + render();
912 + });
913 + acts.appendChild(open);
914 + const shr = el("button", "viz-toolbtn", "🔗 Lien");
915 + shr.type = "button";
916 + shr.addEventListener("click", async () => { try { await navigator.clipboard.writeText(studioShareUrl(item.cfg)); shr.textContent = "✓"; setTimeout(() => (shr.textContent = "🔗 Lien"), 1500); } catch {} });
917 + acts.appendChild(shr);
918 + const del = el("button", "viz-toolbtn danger", "🗑 Supprimer");
919 + del.type = "button";
920 + del.addEventListener("click", () => { studioStore(studioSaved().filter((x) => x.id !== item.id)); render(); });
921 + acts.appendChild(del);
922 + bar.appendChild(acts);
923 + box.appendChild(bar);
924 + try {
925 + const built = await buildCustomSeries(item.cfg);
926 + box.appendChild(customChartCard(item.cfg, built, { fallbackTitle: "Indicateur enregistré" }));
927 + } catch (e) {
928 + box.appendChild(el("div", "viz-empty", "Impossible de recalculer cet indicateur : " + (e && e.message ? e.message : e)));
929 + }
930 + sec.appendChild(box);
931 + }
932 + main.appendChild(sec);
933 + }
934 + root.appendChild(main);
935 + // refocus recherche après insertion dans le DOM (confort de frappe)
936 + if (studio.search) setTimeout(() => { renderResults(); input.focus(); const v = input.value; input.value = ""; input.value = v; }, 0);
937 +}
938 +
939 +/* ================================================================
940 + COQUILLE : header / nav / footer / routeur
941 + ================================================================ */
942 +const ACCENT = { stroke: "#095797" }; // bleu Québec — accent Ka·Stats (contraste 8,7:1 sur blanc)
943 +
944 +function buildHeader() {
945 + const h = document.getElementById("site-header");
946 + const inner = el("div", "container header-inner");
947 + const brand = link("/", "brand", "");
948 + brand.textContent = "";
949 + brand.appendChild(el("span", "brand-qc", "Ka"));
950 + const dot = el("span", "brand-dot", "·");
951 + brand.appendChild(dot);
952 + brand.appendChild(el("span", "brand-stats", "Stats"));
953 + inner.appendChild(brand);
954 + const nav = el("nav", "main-nav");
955 + nav.setAttribute("aria-label", "Navigation principale");
956 + const links = [["/", "Vue d'ensemble"], ["/indicateurs", "Indicateurs"], ["/comparer", "Comparer"], ["/studio", "Studio"], ["/palmares", "Palmarès"]];
957 + for (const [href, label] of links) nav.appendChild(link(href, "nav-link", label));
958 + inner.appendChild(nav);
959 + const badge = el("a", "gk-badge only-desktop");
960 + badge.href = "https://www.groupe-ka.com";
961 + badge.target = "_blank"; badge.rel = "noopener";
962 + badge.appendChild(document.createTextNode("Un service"));
963 + const b = el("b", null, "Groupe");
964 + b.appendChild(el("span", "ka", "KA"));
965 + badge.appendChild(b);
966 + inner.appendChild(badge);
967 +
968 + // KA Nav v2 (2026-08-25) : hamburger + panneau plein écran (fixed inset:0,
969 + // z-modal) — le menu reste visible peu importe la position de scroll.
970 + const mbtn = el("button", "ka-mnav-btn");
971 + mbtn.id = "ka-mnav-btn";
972 + mbtn.type = "button";
973 + mbtn.setAttribute("aria-label", "Ouvrir le menu");
974 + mbtn.setAttribute("aria-expanded", "false");
975 + mbtn.setAttribute("aria-controls", "ka-mnav");
976 + for (let i = 0; i < 3; i++) mbtn.appendChild(el("span"));
977 + inner.appendChild(mbtn);
978 + h.appendChild(inner);
979 +
980 + const panel = el("div", "ka-mnav");
981 + panel.id = "ka-mnav";
982 + panel.setAttribute("role", "dialog");
983 + panel.setAttribute("aria-modal", "true");
984 + panel.setAttribute("aria-label", "Menu");
985 + const ptop = el("div", "ka-mnav-top");
986 + const pbrand = link("/", "brand", "");
987 + pbrand.appendChild(el("span", "brand-qc", "Ka"));
988 + pbrand.appendChild(el("span", "brand-dot", "·"));
989 + pbrand.appendChild(el("span", "brand-stats", "Stats"));
990 + ptop.appendChild(pbrand);
991 + const pclose = el("button", "ka-mnav-close", "✕");
992 + pclose.type = "button";
993 + pclose.setAttribute("aria-label", "Fermer le menu");
994 + ptop.appendChild(pclose);
995 + panel.appendChild(ptop);
996 + const pnav = el("nav", "ka-mnav-links");
997 + pnav.setAttribute("aria-label", "Navigation principale");
998 + for (const [href, label] of links) {
999 + const a = link(href, null, "");
1000 + a.appendChild(el("b", null, label));
1001 + pnav.appendChild(a);
1002 + }
1003 + panel.appendChild(pnav);
1004 + const pfoot = el("div", "ka-mnav-foot");
1005 + const pbadge = el("a", "gk-badge");
1006 + pbadge.href = "https://www.groupe-ka.com";
1007 + pbadge.target = "_blank"; pbadge.rel = "noopener";
1008 + pbadge.appendChild(document.createTextNode("Un service"));
1009 + const pb = el("b", null, "Groupe");
1010 + pb.appendChild(el("span", "ka", "KA"));
1011 + pbadge.appendChild(pb);
1012 + pfoot.appendChild(pbadge);
1013 + panel.appendChild(pfoot);
1014 + h.after(panel); // frère du header — hors de tout stacking context
1015 +
1016 + const setMenu = (open) => {
1017 + panel.classList.toggle("open", open);
1018 + mbtn.setAttribute("aria-expanded", open ? "true" : "false");
1019 + document.documentElement.classList.toggle("ka-menu-locked", open);
1020 + };
1021 + mbtn.addEventListener("click", () => setMenu(!panel.classList.contains("open")));
1022 + panel.addEventListener("click", (e) => {
1023 + if (e.target.closest("a") || e.target.closest(".ka-mnav-close")) setMenu(false);
1024 + });
1025 + document.addEventListener("keydown", (e) => { if (e.key === "Escape") setMenu(false); });
1026 +}
1027 +
1028 +function buildFooter() {
1029 + const f = document.getElementById("site-footer");
1030 + f.className = "ka-footer";
1031 + const c = el("div", "container");
1032 + const wm = el("a", "wordmark");
1033 + wm.href = "/"; wm.dataset.link = "1";
1034 + wm.appendChild(document.createTextNode("Ka·"));
1035 + wm.appendChild(el("span", "ka", "Stats"));
1036 + c.appendChild(wm);
1037 + c.appendChild(el("p", "desc", "Ka·Stats est l'observatoire de données du Groupe KA : les statistiques, tendances et palmarès des 12 plateformes de l'écosystème — habitation, mobilité, emploi, consommation, culture — mis à jour en continu."));
1038 + const notice = el("p", "notice");
1039 + const nb = el("b", null, "Avis. ");
1040 + notice.appendChild(nb);
1041 + notice.appendChild(document.createTextNode("Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction. Les statistiques reflètent les données collectées par nos plateformes."));
1042 + c.appendChild(notice);
1043 + const sites = el("ul", "sites");
1044 + for (const s of state.sites) {
1045 + const li = el("li");
1046 + const a = el("a", null, s.wordmark);
1047 + a.href = "https://" + s.domain; a.target = "_blank"; a.rel = "noopener";
1048 + li.appendChild(a);
1049 + sites.appendChild(li);
1050 + }
1051 + const hubLi = el("li");
1052 + const hubA = el("a", null, "Groupe-KA.com");
1053 + hubA.href = "https://www.groupe-ka.com"; hubA.target = "_blank"; hubA.rel = "noopener";
1054 + hubLi.appendChild(hubA);
1055 + sites.appendChild(hubLi);
1056 + c.appendChild(sites);
1057 + const contacts = el("div", "contacts");
1058 + for (const [mail, role] of [["contact@groupe-ka.com", "Projets, partenariats & données"], ["info@groupe-ka.com", "Médias & questions générales"], ["admin@groupe-ka.com", "Légal, vie privée & Loi 25"]]) {
1059 + const d = el("div");
1060 + const a = el("a", null, mail);
1061 + a.href = "mailto:" + mail;
1062 + d.appendChild(a);
1063 + d.appendChild(el("span", null, role));
1064 + contacts.appendChild(d);
1065 + }
1066 + c.appendChild(contacts);
1067 + const legal = el("p", "legal");
1068 + legal.appendChild(document.createTextNode("© Groupe KA — Simon-Pierre Boucher · "));
1069 + for (const [label, href] of [["Conditions d'utilisation", "https://www.groupe-ka.com/conditions"], ["Confidentialité", "https://www.groupe-ka.com/confidentialite"], ["Loi 25", "https://www.groupe-ka.com/loi-25"]]) {
1070 + const a = el("a", null, label);
1071 + a.href = href; a.target = "_blank"; a.rel = "noopener";
1072 + legal.appendChild(a);
1073 + legal.appendChild(document.createTextNode(" · "));
1074 + }
1075 + c.appendChild(legal);
1076 + f.appendChild(c);
1077 +}
1078 +
1079 +let rendering = false;
1080 +async function render() {
1081 + if (rendering) return;
1082 + rendering = true;
1083 + $app.classList.add("is-loading");
1084 + try {
1085 + const path = location.pathname.replace(/\/+$/, "") || "/";
1086 + const fresh = el("div");
1087 + fresh.appendChild(periodBar());
1088 + const page = el("main");
1089 + fresh.appendChild(page);
1090 + if (path === "/") await pageHome(page);
1091 + else if (path.startsWith("/site/")) await pageSite(page, path.slice(6));
1092 + else if (path === "/indicateurs") await pageIndicateurs(page);
1093 + else if (path === "/comparer") await pageComparer(page);
1094 + else if (path === "/studio") await pageStudio(page);
1095 + else if (path === "/palmares") await pagePalmares(page);
1096 + else await pageHome(page);
1097 + $app.textContent = "";
1098 + $app.appendChild(fresh);
1099 + // met en évidence le lien de nav actif
1100 + document.querySelectorAll(".nav-link").forEach((a) => {
1101 + a.classList.toggle("active", a.getAttribute("href") === (path.startsWith("/site/") ? "/" : path));
1102 + });
1103 + } catch (e) {
1104 + $app.textContent = "";
1105 + $app.appendChild(el("div", "container viz-empty big", "Erreur de chargement : " + (e && e.message ? e.message : e)));
1106 + } finally {
1107 + $app.classList.remove("is-loading");
1108 + rendering = false;
1109 + }
1110 +}
1111 +
1112 +document.addEventListener("click", (e) => {
1113 + const a = e.target.closest("a[data-link]");
1114 + if (!a) return;
1115 + e.preventDefault();
1116 + history.pushState(null, "", a.getAttribute("href"));
1117 + window.scrollTo(0, 0);
1118 + render();
1119 +});
1120 +window.addEventListener("popstate", render);
1121 +
1122 +(async function init() {
1123 + const meta = await getJSON("/api/sites");
1124 + state.sites = meta.sites;
1125 + state.siteById = Object.fromEntries(meta.sites.map((s) => [s.id, s]));
1126 + buildHeader();
1127 + buildFooter();
1128 + render();
1129 +})();
1130 +
1131 +;/*KA_ANALYTICS*/(function(){try{if(window.__kaAn)return;window.__kaAn=1;var k="KA_VID",v=localStorage.getItem(k);if(!v){v=Date.now().toString(36)+Math.random().toString(36).slice(2,8);localStorage.setItem(k,v);}var b=function(){var i=new Image();i.src="https://www.administration-ka.com/collect?site=ka-stats&vid="+v+"&p="+encodeURIComponent(location.pathname)+"&t="+Date.now();};b();setInterval(b,60000);}catch(e){}})();
added public/apple-touch-icon.png +0 −0

Binary file not shown.

added public/charts.js +995 −0
@@ -0,0 +1,995 @@
1 +/* -----------------------------------------------------------------------------
2 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 + Fichier : ka-stats/public/charts.js
4 + Desc. : QCharts — bibliothèque de graphiques SVG vanilla de Ka·Stats
5 + (contrat ka-stats v2). Règles dataviz appliquées : marques fines
6 + (lignes 2 px, barres ≤ 24 px à bout arrondi 4 px), écarts de surface
7 + 2 px, grilles hairline pleines, légende dès 2 séries, texte toujours
8 + en encre, infobulle crosshair (lignes) ou par-marque (barres/
9 + cellules), vue tableau jumelle sur CHAQUE graphique, rampe
10 + séquentielle mono-teinte, palette catégorielle validée en ordre fixe.
11 +----------------------------------------------------------------------------- */
12 +"use strict";
13 +
14 +const QCharts = (() => {
15 + /* Palette catégorielle validée (validate_palette.js — ordre fixe, jamais cyclée) */
16 + const CAT = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#4a3aa7", "#e34948"];
17 + const DASHES = ["", "7 4", "2 3", "10 3 2 3"]; // identité multi-courbes ≤ 4 (jamais couleur seule)
18 + const INK = "#141814", INK2 = "#4d5551", INK3 = "#8b928c";
19 + const GRID = "rgba(20,24,20,0.10)", AXIS = "rgba(20,24,20,0.28)";
20 + const GREEN = "#1c5c41", DANGER = "#b3423a";
21 +
22 + /* ---------- utilitaires ---------- */
23 + const NBSP = " ";
24 + const nfInt = new Intl.NumberFormat("fr-CA", { maximumFractionDigits: 0 });
25 + const nf1 = new Intl.NumberFormat("fr-CA", { maximumFractionDigits: 1, minimumFractionDigits: 0 });
26 + const nf2 = new Intl.NumberFormat("fr-CA", { maximumFractionDigits: 2 });
27 +
28 + function fmtNum(v, unit) {
29 + if (v === null || v === undefined || Number.isNaN(v)) return "—";
30 + if (typeof v !== "number") return String(v);
31 + let s;
32 + const av = Math.abs(v);
33 + if (Number.isInteger(v)) s = nfInt.format(v);
34 + else s = (av < 10 ? nf2 : nf1).format(v);
35 + if (unit) {
36 + const u = String(unit).trim();
37 + if (u === "%") return s + NBSP + "%";
38 + if (u === "$") return s + NBSP + "$";
39 + return s + NBSP + u;
40 + }
41 + return s;
42 + }
43 + function fmtCompact(v) {
44 + const av = Math.abs(v);
45 + if (av >= 1e6) return nf1.format(v / 1e6) + NBSP + "M";
46 + if (av >= 10000) return nfInt.format(Math.round(v / 1000)) + NBSP + "k";
47 + if (av >= 1000) return nf1.format(v / 1000) + NBSP + "k";
48 + return Number.isInteger(v) ? nfInt.format(v) : nf1.format(v);
49 + }
50 + const MONTHS = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."];
51 + const DOWS = ["lun", "mar", "mer", "jeu", "ven", "sam", "dim"];
52 + function fmtDate(t) {
53 + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(t));
54 + if (!m) return String(t);
55 + return `${Number(m[3])} ${MONTHS[Number(m[2]) - 1]}`;
56 + }
57 + function fmtDateFull(t) {
58 + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(t));
59 + if (!m) return String(t);
60 + return `${Number(m[3])} ${MONTHS[Number(m[2]) - 1]} ${m[1]}`;
61 + }
62 +
63 + function el(tag, cls, text) {
64 + const n = document.createElement(tag);
65 + if (cls) n.className = cls;
66 + if (text !== undefined) n.textContent = text; // jamais innerHTML avec des libellés externes
67 + return n;
68 + }
69 + function svgEl(tag, attrs) {
70 + const n = document.createElementNS("http://www.w3.org/2000/svg", tag);
71 + for (const k in attrs || {}) n.setAttribute(k, attrs[k]);
72 + return n;
73 + }
74 +
75 + /* couleur : garde de contraste + rampe mono-teinte */
76 + function hexRgb(h) {
77 + const s = h.replace("#", "");
78 + return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)];
79 + }
80 + function rgbHex(r, g, b) {
81 + const c = (x) => Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, "0");
82 + return "#" + c(r) + c(g) + c(b);
83 + }
84 + function mix(h1, h2, t) {
85 + const a = hexRgb(h1), b = hexRgb(h2);
86 + return rgbHex(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t);
87 + }
88 + function relLum(h) {
89 + const [r, g, b] = hexRgb(h).map((v) => {
90 + const s = v / 255;
91 + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
92 + });
93 + return 0.2126 * r + 0.7152 * g + 0.0722 * b;
94 + }
95 + function contrastWhite(h) {
96 + return 1.05 / (relLum(h) + 0.05);
97 + }
98 + /* trait de série : l'accent si assez contrasté sur blanc, sinon sa version deep */
99 + function strokeFor(accent, deep) {
100 + return contrastWhite(accent) >= 3 ? accent : deep || mix(accent, INK, 0.45);
101 + }
102 + /* rampe séquentielle mono-teinte (clair → foncé), monotone en luminosité */
103 + function seqRamp(accent) {
104 + const base = contrastWhite(accent) >= 2 ? accent : mix(accent, INK, 0.35);
105 + return (t) => (t <= 0 ? "#f1efe8" : mix(mix("#ffffff", base, 0.16 + 0.84 * t), INK, Math.max(0, t - 0.72) * 0.9));
106 + }
107 +
108 + function niceTicks(min, max, n = 4) {
109 + if (!(max > min)) max = min + 1;
110 + const span = max - min;
111 + const step0 = Math.pow(10, Math.floor(Math.log10(span / n)));
112 + const err = span / n / step0;
113 + const step = step0 * (err >= 7.5 ? 10 : err >= 3.5 ? 5 : err >= 1.5 ? 2 : 1);
114 + const lo = Math.floor(min / step) * step;
115 + const hi = Math.ceil(max / step) * step;
116 + const ticks = [];
117 + for (let v = lo; v <= hi + step / 2; v += step) ticks.push(Math.round(v * 1e6) / 1e6);
118 + return { lo, hi, ticks };
119 + }
120 +
121 + /* ---------- coquille de graphique : carte + infobulle + vue tableau ---------- */
122 + function shell(opts) {
123 + const card = el("figure", "viz-card");
124 + if (opts.title) {
125 + const head = el("figcaption", "viz-head");
126 + head.appendChild(el("div", "viz-title", opts.title));
127 + if (opts.unit) head.appendChild(el("span", "klabel viz-unit", opts.unit));
128 + card.appendChild(head);
129 + }
130 + const holder = el("div", "viz-plot");
131 + card.appendChild(holder);
132 + const tt = el("div", "viz-tt");
133 + tt.setAttribute("role", "status");
134 + holder.appendChild(tt);
135 + return { card, holder, tt };
136 + }
137 + function showTT(holder, tt, xPct, yPct) {
138 + tt.style.display = "block";
139 + const r = holder.getBoundingClientRect();
140 + const tw = tt.offsetWidth, th = tt.offsetHeight;
141 + let x = (xPct / 100) * r.width + 14;
142 + if (x + tw > r.width - 4) x = (xPct / 100) * r.width - tw - 14;
143 + let y = (yPct / 100) * r.height - th - 10;
144 + if (y < 0) y = 4;
145 + tt.style.left = Math.max(2, x) + "px";
146 + tt.style.top = y + "px";
147 + }
148 + function hideTT(tt) { tt.style.display = "none"; }
149 + function ttRows(tt, title, rows) {
150 + tt.textContent = "";
151 + const h = el("div", "viz-tt-title", title);
152 + tt.appendChild(h);
153 + for (const r of rows) {
154 + const row = el("div", "viz-tt-row");
155 + if (r.color) {
156 + const key = el("span", "viz-tt-key");
157 + key.style.background = r.color;
158 + if (r.dash) key.style.backgroundImage = `repeating-linear-gradient(90deg, ${r.color} 0 5px, #fff 5px 8px)`;
159 + row.appendChild(key);
160 + }
161 + row.appendChild(el("b", "viz-tt-val", r.value));
162 + row.appendChild(el("span", "viz-tt-lbl", r.label || ""));
163 + tt.appendChild(row);
164 + }
165 + }
166 + /* vue tableau jumelle (l'infobulle ne « garde » jamais une valeur) */
167 + function twinTable(card, columns, rows) {
168 + const det = el("details", "viz-data");
169 + det.appendChild(el("summary", null, "Données"));
170 + const wrap = el("div", "tbl-wrap");
171 + const tbl = el("table", "viz-table");
172 + const thead = el("thead");
173 + const trh = el("tr");
174 + for (const c of columns) trh.appendChild(el("th", null, c));
175 + thead.appendChild(trh);
176 + tbl.appendChild(thead);
177 + const tb = el("tbody");
178 + for (const r of rows.slice(0, 400)) {
179 + const tr = el("tr");
180 + r.forEach((c, i) => {
181 + const td = el("td", i > 0 ? "num" : null, typeof c === "number" ? fmtNum(c) : String(c ?? "—"));
182 + tr.appendChild(td);
183 + });
184 + tb.appendChild(tr);
185 + }
186 + tbl.appendChild(tb);
187 + wrap.appendChild(tbl);
188 + det.appendChild(wrap);
189 + card.appendChild(det);
190 + return det;
191 + }
192 + function legend(card, entries, kind) {
193 + const lg = el("div", "viz-legend");
194 + for (const e of entries) {
195 + const it = el("span", "viz-legend-item");
196 + const key = el("span", kind === "line" ? "viz-key-line" : "viz-key-rect");
197 + key.style.background = e.color;
198 + if (e.dash && kind === "line") key.style.backgroundImage = `repeating-linear-gradient(90deg, ${e.color} 0 6px, #fff 6px 9px)`;
199 + it.appendChild(key);
200 + it.appendChild(el("span", null, e.label));
201 + lg.appendChild(it);
202 + }
203 + card.insertBefore(lg, card.querySelector(".viz-plot"));
204 + return lg;
205 + }
206 +
207 + /* ---------- sparkline (mini tendance de KPI) ---------- */
208 + function sparkline(points, color, w = 110, h = 30) {
209 + const vs = points.map((p) => p.v).filter((v) => typeof v === "number");
210 + if (!vs.length) return svgEl("svg", { width: w, height: h });
211 + const min = Math.min(...vs), max = Math.max(...vs);
212 + const span = max - min || 1;
213 + const svg = svgEl("svg", { viewBox: `0 0 ${w} ${h}`, class: "spark", "aria-hidden": "true" });
214 + const n = points.length;
215 + const px = (i) => 2 + (i / Math.max(1, n - 1)) * (w - 8);
216 + const py = (v) => h - 4 - ((v - min) / span) * (h - 9);
217 + let d = "";
218 + points.forEach((p, i) => { d += (i ? "L" : "M") + px(i).toFixed(1) + "," + py(p.v).toFixed(1); });
219 + svg.appendChild(svgEl("path", { d, fill: "none", stroke: color, "stroke-width": 2, "stroke-linecap": "round", "stroke-linejoin": "round" }));
220 + const last = points[n - 1];
221 + svg.appendChild(svgEl("circle", { cx: px(n - 1), cy: py(last.v), r: 3.2, fill: color, stroke: "#fff", "stroke-width": 2 }));
222 + return svg;
223 + }
224 +
225 + /* ---------- courbes / aires — INTERACTIF ----------
226 + · crosshair + infobulle toutes séries
227 + · zoom par sélection horizontale (glisser), double-clic = réinitialiser
228 + · légende cliquable (≥ 2 séries) pour masquer/afficher une série */
229 + function lineChart(opts) {
230 + const { card, holder, tt } = shell(opts);
231 + const W = 860, H = opts.height || 280;
232 + const padL = 56, padR = 18, padT = 14, padB = 34;
233 + const plotW = W - padL - padR, plotH = H - padT - padB;
234 + const all = opts.series.filter((s) => s.points && s.points.length);
235 + if (!all.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; }
236 + const tsetAll = new Set();
237 + all.forEach((s) => s.points.forEach((p) => tsetAll.add(p.t)));
238 + const tsAll = [...tsetAll].sort();
239 + const maps = all.map((s) => new Map(s.points.map((p) => [p.t, p.v])));
240 + const hidden = new Set();
241 + let z0 = 0, z1 = tsAll.length - 1;
242 +
243 + const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg", role: "img" });
244 + svg.setAttribute("aria-label", opts.title || "graphique en courbes");
245 + svg.style.touchAction = "pan-y";
246 + holder.appendChild(svg);
247 +
248 + // légende interactive
249 + let legendItems = [];
250 + if (all.length >= 2) {
251 + const lg = el("div", "viz-legend");
252 + all.forEach((s, si) => {
253 + const it = el("button", "viz-legend-item viz-legend-btn");
254 + it.type = "button";
255 + it.title = "Afficher/masquer la série";
256 + const key = el("span", "viz-key-line");
257 + key.style.background = s.color;
258 + if (s.dash) key.style.backgroundImage = `repeating-linear-gradient(90deg, ${s.color} 0 6px, #fff 6px 9px)`;
259 + it.appendChild(key);
260 + it.appendChild(el("span", null, s.label));
261 + it.addEventListener("click", () => {
262 + if (hidden.has(si)) hidden.delete(si);
263 + else if (hidden.size < all.length - 1) hidden.add(si); // toujours ≥ 1 série visible
264 + it.classList.toggle("dim", hidden.has(si));
265 + draw();
266 + });
267 + lg.appendChild(it);
268 + legendItems.push(it);
269 + });
270 + card.insertBefore(lg, holder);
271 + }
272 + // bandeau zoom (visible quand zoomé)
273 + const zoomBar = el("div", "viz-zoombar");
274 + const zoomLbl = el("span", "klabel");
275 + const zoomReset = el("button", "pill viz-zreset", "Réinitialiser le zoom");
276 + zoomReset.type = "button";
277 + zoomReset.addEventListener("click", () => { z0 = 0; z1 = tsAll.length - 1; draw(); });
278 + zoomBar.appendChild(zoomLbl);
279 + zoomBar.appendChild(zoomReset);
280 + zoomBar.style.display = "none";
281 + card.insertBefore(zoomBar, holder);
282 +
283 + function draw() {
284 + svg.textContent = "";
285 + const ts = tsAll.slice(z0, z1 + 1);
286 + const visIdx = all.map((_, i) => i).filter((i) => !hidden.has(i));
287 + let vmin = Infinity, vmax = -Infinity;
288 + for (const si of visIdx) for (const t of ts) {
289 + const v = maps[si].get(t);
290 + if (typeof v === "number") { vmin = Math.min(vmin, v); vmax = Math.max(vmax, v); }
291 + }
292 + if (!isFinite(vmin)) { vmin = 0; vmax = 1; }
293 + let lo0 = vmin, hi0 = vmax;
294 + if (opts.baselineZero || (vmin >= 0 && vmax > 0 && vmin / vmax < 0.35)) lo0 = 0;
295 + const { lo, hi, ticks } = niceTicks(lo0, hi0, 4);
296 + const X = (i) => padL + (ts.length === 1 ? plotW / 2 : (i / (ts.length - 1)) * plotW);
297 + const Y = (v) => padT + plotH - ((v - lo) / (hi - lo || 1)) * plotH;
298 + for (const tk of ticks) {
299 + svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(tk), y2: Y(tk), stroke: GRID, "stroke-width": 1 }));
300 + const lbl = svgEl("text", { x: padL - 8, y: Y(tk) + 3.5, "text-anchor": "end", class: "viz-tick" });
301 + lbl.textContent = fmtCompact(tk);
302 + svg.appendChild(lbl);
303 + }
304 + svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(lo), y2: Y(lo), stroke: AXIS, "stroke-width": 1 }));
305 + [0, Math.floor((ts.length - 1) / 2), ts.length - 1].forEach((i, k) => {
306 + if (i < 0 || (k > 0 && i === 0)) return;
307 + const lbl = svgEl("text", { x: X(i), y: H - 12, "text-anchor": k === 0 ? "start" : k === 2 ? "end" : "middle", class: "viz-tick" });
308 + lbl.textContent = fmtDate(ts[i]);
309 + svg.appendChild(lbl);
310 + });
311 + for (const si of visIdx) {
312 + const s = all[si];
313 + let d = "", started = false;
314 + ts.forEach((t, i) => {
315 + const v = maps[si].get(t);
316 + if (typeof v !== "number") { started = false; return; }
317 + d += (started ? "L" : "M") + X(i).toFixed(1) + "," + Y(v).toFixed(1);
318 + started = true;
319 + });
320 + if ((opts.area || visIdx.length === 1) && !s.noArea) {
321 + let ad = "", st = false, firstI = null, lastI = null;
322 + ts.forEach((t, i) => {
323 + const v = maps[si].get(t);
324 + if (typeof v !== "number") return;
325 + if (firstI === null) firstI = i;
326 + lastI = i;
327 + ad += (st ? "L" : "M") + X(i).toFixed(1) + "," + Y(v).toFixed(1);
328 + st = true;
329 + });
330 + if (firstI !== null) {
331 + ad += `L${X(lastI).toFixed(1)},${Y(lo).toFixed(1)}L${X(firstI).toFixed(1)},${Y(lo).toFixed(1)}Z`;
332 + svg.appendChild(svgEl("path", { d: ad, fill: s.color, opacity: 0.1 }));
333 + }
334 + }
335 + const attrs = { d, fill: "none", stroke: s.color, "stroke-width": 2, "stroke-linecap": "round", "stroke-linejoin": "round" };
336 + if (s.dash) attrs["stroke-dasharray"] = s.dash;
337 + if (s.faded) attrs.opacity = 0.55;
338 + svg.appendChild(svgEl("path", attrs));
339 + for (let i = ts.length - 1; i >= 0; i--) {
340 + const v = maps[si].get(ts[i]);
341 + if (typeof v === "number") {
342 + svg.appendChild(svgEl("circle", { cx: X(i), cy: Y(v), r: 4, fill: s.color, stroke: "#fff", "stroke-width": 2 }));
343 + if (visIdx.length <= 2 && !s.faded) {
344 + const lbl = svgEl("text", { x: Math.min(X(i), W - padR - 2), y: Math.max(12, Y(v) - 9), "text-anchor": "end", class: "viz-endlbl" });
345 + lbl.textContent = fmtNum(v, opts.unitShort || (opts.unit === "$" ? "$" : ""));
346 + svg.appendChild(lbl);
347 + }
348 + break;
349 + }
350 + }
351 + }
352 + // crosshair + sélection de zoom
353 + const cross = svgEl("line", { y1: padT, y2: padT + plotH, stroke: AXIS, "stroke-width": 1, style: "display:none" });
354 + svg.appendChild(cross);
355 + const selRect = svgEl("rect", { y: padT, height: plotH, fill: "rgba(9,87,151,0.12)", stroke: "rgba(9,87,151,0.5)", "stroke-width": 1, style: "display:none" });
356 + svg.appendChild(selRect);
357 + const hit = svgEl("rect", { x: padL, y: padT, width: plotW, height: plotH, fill: "transparent", style: "cursor: crosshair" });
358 + svg.appendChild(hit);
359 + const idxAt = (clientX) => {
360 + const r = svg.getBoundingClientRect();
361 + const mx = ((clientX - r.left) / r.width) * W;
362 + return Math.max(0, Math.min(ts.length - 1, Math.round(((mx - padL) / plotW) * (ts.length - 1))));
363 + };
364 + let dragStart = null;
365 + hit.addEventListener("pointerdown", (ev) => {
366 + if (ts.length < 3) return;
367 + dragStart = idxAt(ev.clientX);
368 + hit.setPointerCapture(ev.pointerId);
369 + });
370 + hit.addEventListener("pointermove", (ev) => {
371 + const i = idxAt(ev.clientX);
372 + if (dragStart !== null) {
373 + const a = Math.min(dragStart, i), b = Math.max(dragStart, i);
374 + selRect.setAttribute("x", X(a));
375 + selRect.setAttribute("width", Math.max(1, X(b) - X(a)));
376 + selRect.style.display = "";
377 + hideTT(tt);
378 + return;
379 + }
380 + cross.setAttribute("x1", X(i)); cross.setAttribute("x2", X(i));
381 + cross.style.display = "";
382 + const rows = visIdx.map((si) => {
383 + const s = all[si];
384 + const v = maps[si].get(ts[i]);
385 + return { color: s.color, dash: !!s.dash, value: typeof v === "number" ? fmtNum(v, opts.unit === "$" ? "$" : "") : "—", label: s.label };
386 + });
387 + ttRows(tt, fmtDateFull(ts[i]), rows);
388 + showTT(holder, tt, (X(i) / W) * 100, (padT / H) * 100 + 8);
389 + });
390 + hit.addEventListener("pointerup", (ev) => {
391 + if (dragStart === null) return;
392 + const i = idxAt(ev.clientX);
393 + const a = Math.min(dragStart, i), b = Math.max(dragStart, i);
394 + dragStart = null;
395 + selRect.style.display = "none";
396 + if (b - a >= 2) { z0 = z0 + a; z1 = z0 + (b - a); draw(); }
397 + });
398 + hit.addEventListener("pointerleave", () => { cross.style.display = "none"; hideTT(tt); if (dragStart !== null) { dragStart = null; selRect.style.display = "none"; } });
399 + hit.addEventListener("dblclick", () => { z0 = 0; z1 = tsAll.length - 1; draw(); });
400 + // bandeau zoom
401 + const zoomed = z0 > 0 || z1 < tsAll.length - 1;
402 + zoomBar.style.display = zoomed ? "" : "none";
403 + if (zoomed) zoomLbl.textContent = `Zoom : ${fmtDateFull(tsAll[z0])} → ${fmtDateFull(tsAll[z1])} (glisser pour zoomer · double-clic pour tout revoir)`;
404 + }
405 + draw();
406 + if (opts.summary !== false && all.length === 1) card.appendChild(statSummary(all[0].points, opts.unit));
407 + twinTable(card, ["Date", ...all.map((s) => s.label)], tsAll.map((t) => [fmtDateFull(t), ...all.map((_, si) => maps[si].get(t) ?? "—")]));
408 + return card;
409 + }
410 +
411 + /* ---------- barres verticales (volumes quotidiens, histogrammes) ---------- */
412 + function vBarChart(opts) {
413 + // opts: {title, unit, points:[{t|label, v}], color, isDate}
414 + const { card, holder, tt } = shell(opts);
415 + const pts = (opts.points || []).filter((p) => typeof p.v === "number");
416 + if (!pts.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; }
417 + const W = 860, H = opts.height || 250;
418 + const padL = 56, padR = 14, padT = 12, padB = opts.rotateLabels ? 66 : 34;
419 + const plotW = W - padL - padR, plotH = H - padT - padB;
420 + const vmax = Math.max(...pts.map((p) => p.v), 1);
421 + const { hi, ticks } = niceTicks(0, vmax, 4);
422 + const n = pts.length;
423 + const slot = plotW / n;
424 + const bw = Math.min(24, Math.max(2, slot - 2)); // écart de surface 2 px
425 + const X = (i) => padL + i * slot + (slot - bw) / 2;
426 + const Y = (v) => padT + plotH - (v / (hi || 1)) * plotH;
427 + const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg", role: "img" });
428 + svg.setAttribute("aria-label", opts.title || "graphique en barres");
429 + for (const tk of ticks) {
430 + svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(tk), y2: Y(tk), stroke: GRID, "stroke-width": 1 }));
431 + const lbl = svgEl("text", { x: padL - 8, y: Y(tk) + 3.5, "text-anchor": "end", class: "viz-tick" });
432 + lbl.textContent = fmtCompact(tk);
433 + svg.appendChild(lbl);
434 + }
435 + svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(0), y2: Y(0), stroke: AXIS, "stroke-width": 1 }));
436 + const color = opts.color;
437 + pts.forEach((p, i) => {
438 + const x = X(i), y = Y(p.v), h = Y(0) - y;
439 + const r = Math.min(4, bw / 2, h); // bout arrondi côté donnée, carré à la base
440 + const d = h <= 0.5
441 + ? `M${x},${Y(0)}h${bw}v-0.5h-${bw}Z`
442 + : `M${x},${Y(0)} L${x},${y + r} Q${x},${y} ${x + r},${y} L${x + bw - r},${y} Q${x + bw},${y} ${x + bw},${y + r} L${x + bw},${Y(0)} Z`;
443 + const bar = svgEl("path", { d, fill: color, class: "viz-bar" });
444 + const label = opts.isDate === false ? String(p.label ?? p.t) : fmtDateFull(p.t);
445 + bar.addEventListener("pointerenter", () => {
446 + bar.style.opacity = "0.75";
447 + ttRows(tt, label, [{ color, value: fmtNum(p.v, opts.unit === "$" ? "$" : ""), label: opts.unit && opts.unit !== "$" ? opts.unit : "" }]);
448 + showTT(holder, tt, ((x + bw / 2) / W) * 100, (y / H) * 100);
449 + });
450 + bar.addEventListener("pointerleave", () => { bar.style.opacity = ""; hideTT(tt); });
451 + svg.appendChild(bar);
452 + });
453 + // repères x
454 + if (opts.isDate === false) {
455 + const step = Math.ceil(n / (opts.rotateLabels ? 14 : 8));
456 + pts.forEach((p, i) => {
457 + if (i % step) return;
458 + const lbl = svgEl("text", {
459 + x: X(i) + bw / 2, y: H - (opts.rotateLabels ? 8 : 12), class: "viz-tick",
460 + "text-anchor": opts.rotateLabels ? "end" : "middle",
461 + transform: opts.rotateLabels ? `rotate(-35 ${X(i) + bw / 2} ${H - 8})` : "",
462 + });
463 + lbl.textContent = String(p.label ?? p.t).slice(0, 16);
464 + svg.appendChild(lbl);
465 + });
466 + } else {
467 + [0, Math.floor((n - 1) / 2), n - 1].forEach((i, k) => {
468 + const lbl = svgEl("text", { x: X(i) + bw / 2, y: H - 12, "text-anchor": k === 0 ? "start" : k === 2 ? "end" : "middle", class: "viz-tick" });
469 + lbl.textContent = fmtDate(pts[i].t);
470 + svg.appendChild(lbl);
471 + });
472 + }
473 + holder.appendChild(svg);
474 + twinTable(card, [opts.isDate === false ? "Catégorie" : "Date", opts.unit || "Valeur"], pts.map((p) => [opts.isDate === false ? String(p.label ?? p.t) : fmtDateFull(p.t), p.v]));
475 + return card;
476 + }
477 +
478 + /* ---------- barres horizontales (répartitions, géo) ---------- */
479 + function hBarChart(opts) {
480 + // opts: {title, unit, items:[{label, value, delta_pct}], color, max}
481 + const { card, holder } = shell(opts);
482 + const items = (opts.items || []).filter((it) => typeof it.value === "number").slice(0, opts.max || 14);
483 + if (!items.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; }
484 + const vmax = Math.max(...items.map((it) => it.value), 1);
485 + const list = el("div", "viz-hbars");
486 + for (const it of items) {
487 + const row = el("div", "viz-hbar-row");
488 + const top = el("div", "viz-hbar-top");
489 + top.appendChild(el("span", "viz-hbar-label", it.label));
490 + const right = el("span", "viz-hbar-val");
491 + right.appendChild(el("b", null, fmtNum(it.value, opts.unit === "$" ? "$" : "")));
492 + if (typeof it.delta_pct === "number") {
493 + const d = el("span", "viz-delta " + (it.delta_pct >= 0 ? "up" : "down"), (it.delta_pct >= 0 ? "▲ " : "▼ ") + nf1.format(Math.abs(it.delta_pct)) + NBSP + "%");
494 + right.appendChild(d);
495 + }
496 + top.appendChild(right);
497 + row.appendChild(top);
498 + const track = el("div", "viz-hbar-track");
499 + const bar = el("div", "viz-hbar-fill");
500 + bar.style.width = Math.max(0.8, (it.value / vmax) * 100) + "%";
501 + bar.style.background = opts.color;
502 + track.appendChild(bar);
503 + row.appendChild(track);
504 + list.appendChild(row);
505 + }
506 + holder.appendChild(list);
507 + twinTable(card, ["Catégorie", opts.unit || "Valeur", "Δ %"], items.map((it) => [it.label, it.value, typeof it.delta_pct === "number" ? nf1.format(it.delta_pct) + " %" : "—"]));
508 + return card;
509 + }
510 +
511 + /* ---------- anneau (part-du-tout, ≤ 6 segments + Autres) ---------- */
512 + function donut(opts) {
513 + // opts: {title, unit, items:[{label, value}]}
514 + const { card, holder, tt } = shell(opts);
515 + let items = (opts.items || []).filter((it) => typeof it.value === "number" && it.value > 0);
516 + if (!items.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; }
517 + items = [...items].sort((a, b) => b.value - a.value);
518 + if (items.length > 6) {
519 + const rest = items.slice(5).reduce((a, it) => a + it.value, 0);
520 + items = [...items.slice(0, 5), { label: "Autres", value: rest }];
521 + }
522 + const total = items.reduce((a, it) => a + it.value, 0);
523 + const W = 420, H = 230, cx = 115, cy = 115, R = 88, TH = 30;
524 + const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg viz-donut", role: "img" });
525 + svg.setAttribute("aria-label", opts.title || "anneau");
526 + const PAD = 0.024; // écart de surface entre arcs
527 + let a0 = -Math.PI / 2;
528 + const arcs = [];
529 + items.forEach((it, i) => {
530 + const frac = it.value / total;
531 + const a1 = a0 + frac * Math.PI * 2;
532 + const s = a0 + PAD / 2, e = Math.max(s + 0.005, a1 - PAD / 2);
533 + const large = e - s > Math.PI ? 1 : 0;
534 + const p = (a, r) => [cx + Math.cos(a) * r, cy + Math.sin(a) * r];
535 + const [x1, y1] = p(s, R), [x2, y2] = p(e, R), [x3, y3] = p(e, R - TH), [x4, y4] = p(s, R - TH);
536 + const d = `M${x1},${y1} A${R},${R} 0 ${large} 1 ${x2},${y2} L${x3},${y3} A${R - TH},${R - TH} 0 ${large} 0 ${x4},${y4} Z`;
537 + const color = i === 5 && it.label === "Autres" ? "#b9b6ac" : CAT[i];
538 + const path = svgEl("path", { d, fill: color, class: "viz-arc" });
539 + const pct = nf1.format(frac * 100) + NBSP + "%";
540 + path.addEventListener("pointerenter", () => {
541 + path.style.opacity = "0.78";
542 + ttRows(tt, it.label, [{ color, value: fmtNum(it.value, opts.unit === "$" ? "$" : ""), label: pct }]);
543 + showTT(holder, tt, ((cx + Math.cos((s + e) / 2) * R) / W) * 100, ((cy + Math.sin((s + e) / 2) * R) / H) * 100);
544 + });
545 + path.addEventListener("pointerleave", () => { path.style.opacity = ""; hideTT(tt); });
546 + svg.appendChild(path);
547 + arcs.push({ ...it, color, pct });
548 + a0 = a1;
549 + });
550 + const ctr = svgEl("text", { x: cx, y: cy - 2, "text-anchor": "middle", class: "viz-donut-total" });
551 + ctr.textContent = fmtCompact(total);
552 + svg.appendChild(ctr);
553 + const ctr2 = svgEl("text", { x: cx, y: cy + 16, "text-anchor": "middle", class: "viz-tick" });
554 + ctr2.textContent = "total";
555 + svg.appendChild(ctr2);
556 + // légende à droite (identité par pastille + libellé, jamais couleur seule)
557 + arcs.forEach((a, i) => {
558 + const y = 28 + i * 32;
559 + svg.appendChild(svgEl("rect", { x: 232, y: y - 10, width: 12, height: 12, rx: 3, fill: a.color }));
560 + const l1 = svgEl("text", { x: 252, y, class: "viz-legend-txt" });
561 + l1.textContent = a.label.length > 20 ? a.label.slice(0, 19) + "…" : a.label;
562 + svg.appendChild(l1);
563 + const l2 = svgEl("text", { x: W - 6, y, "text-anchor": "end", class: "viz-legend-num" });
564 + l2.textContent = a.pct;
565 + svg.appendChild(l2);
566 + });
567 + holder.appendChild(svg);
568 + twinTable(card, ["Catégorie", opts.unit || "Valeur", "Part"], arcs.map((a) => [a.label, a.value, a.pct]));
569 + return card;
570 + }
571 +
572 + /* ---------- barres empilées (composition dans le temps) ---------- */
573 + function stackedBar(opts) {
574 + // opts: {title, unit, keys:[...], points:[{t, values:[...]}]}
575 + const { card, holder, tt } = shell(opts);
576 + let keys = opts.keys || [];
577 + let pts = (opts.points || []).filter((p) => Array.isArray(p.values));
578 + if (!keys.length || !pts.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; }
579 + // repli > 8 clés dans « Autres » (jamais de 9e teinte)
580 + if (keys.length > 8) {
581 + const totals = keys.map((_, ki) => pts.reduce((a, p) => a + (p.values[ki] || 0), 0));
582 + const order = totals.map((v, i) => [v, i]).sort((a, b) => b[0] - a[0]).map((x) => x[1]);
583 + const kept = order.slice(0, 7), rest = order.slice(7);
584 + keys = [...kept.map((i) => keys[i]), "Autres"];
585 + pts = pts.map((p) => ({ t: p.t, values: [...kept.map((i) => p.values[i] || 0), rest.reduce((a, i) => a + (p.values[i] || 0), 0)] }));
586 + }
587 + const W = 860, H = opts.height || 280;
588 + const padL = 56, padR = 14, padT = 12, padB = 34;
589 + const plotW = W - padL - padR, plotH = H - padT - padB;
590 + const totals = pts.map((p) => p.values.reduce((a, v) => a + (v || 0), 0));
591 + const { hi, ticks } = niceTicks(0, Math.max(...totals, 1), 4);
592 + const n = pts.length, slot = plotW / n;
593 + const bw = Math.min(24, Math.max(2, slot - 2));
594 + const X = (i) => padL + i * slot + (slot - bw) / 2;
595 + const Y = (v) => padT + plotH - (v / (hi || 1)) * plotH;
596 + const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg", role: "img" });
597 + svg.setAttribute("aria-label", opts.title || "barres empilées");
598 + for (const tk of ticks) {
599 + svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(tk), y2: Y(tk), stroke: GRID, "stroke-width": 1 }));
600 + const lbl = svgEl("text", { x: padL - 8, y: Y(tk) + 3.5, "text-anchor": "end", class: "viz-tick" });
601 + lbl.textContent = fmtCompact(tk);
602 + svg.appendChild(lbl);
603 + }
604 + svg.appendChild(svgEl("line", { x1: padL, x2: W - padR, y1: Y(0), y2: Y(0), stroke: AXIS, "stroke-width": 1 }));
605 + const colorOf = (ki) => (keys[ki] === "Autres" ? "#b9b6ac" : CAT[ki]);
606 + pts.forEach((p, i) => {
607 + let acc = 0;
608 + const segs = [];
609 + p.values.forEach((v, ki) => {
610 + if (!v || v <= 0) return;
611 + const y1 = Y(acc + v), y0 = Y(acc);
612 + // écart de surface 2 px entre segments (dans l'espace écran ≈ viewBox)
613 + const rect = svgEl("rect", { x: X(i), y: y1 + 1, width: bw, height: Math.max(0.6, y0 - y1 - 2), fill: colorOf(ki), class: "viz-bar" });
614 + svg.appendChild(rect);
615 + segs.push(rect);
616 + acc += v;
617 + });
618 + const label = fmtDateFull(p.t);
619 + const onEnter = () => {
620 + segs.forEach((s) => (s.style.opacity = "0.8"));
621 + ttRows(tt, label, keys.map((k, ki) => ({ color: colorOf(ki), value: fmtNum(p.values[ki] || 0), label: k })).filter((r) => r.value !== "0"));
622 + showTT(holder, tt, ((X(i) + bw / 2) / W) * 100, (Y(acc) / H) * 100);
623 + };
624 + const onLeave = () => { segs.forEach((s) => (s.style.opacity = "")); hideTT(tt); };
625 + segs.forEach((s) => { s.addEventListener("pointerenter", onEnter); s.addEventListener("pointerleave", onLeave); });
626 + });
627 + [0, Math.floor((n - 1) / 2), n - 1].forEach((i, k) => {
628 + const lbl = svgEl("text", { x: X(i) + bw / 2, y: H - 12, "text-anchor": k === 0 ? "start" : k === 2 ? "end" : "middle", class: "viz-tick" });
629 + lbl.textContent = fmtDate(pts[i].t);
630 + svg.appendChild(lbl);
631 + });
632 + holder.appendChild(svg);
633 + legend(card, keys.map((k, ki) => ({ label: k, color: colorOf(ki) })), "rect");
634 + twinTable(card, ["Date", ...keys], pts.map((p) => [fmtDateFull(p.t), ...keys.map((_, ki) => p.values[ki] || 0)]));
635 + return card;
636 + }
637 +
638 + /* ---------- jauge demi-arc ---------- */
639 + function gauge(opts) {
640 + // opts: {label, value, max, unit, color, track}
641 + const wrap = el("div", "viz-gauge card");
642 + const W = 180, H = 108, cx = 90, cy = 96, R = 72, TH = 14;
643 + const frac = Math.max(0, Math.min(1, (opts.value || 0) / (opts.max || 100)));
644 + const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": `${opts.label} : ${fmtNum(opts.value, opts.unit)}` });
645 + const arc = (a0, a1, color, width) => {
646 + const p = (a) => [cx + Math.cos(a) * R, cy + Math.sin(a) * R];
647 + const [x1, y1] = p(a0), [x2, y2] = p(a1);
648 + return svgEl("path", { d: `M${x1},${y1} A${R},${R} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${x2},${y2}`, fill: "none", stroke: color, "stroke-width": width, "stroke-linecap": "round" });
649 + };
650 + svg.appendChild(arc(Math.PI, Math.PI * 2, opts.track, TH)); // piste = pas clair de la même rampe
651 + if (frac > 0.005) svg.appendChild(arc(Math.PI, Math.PI + frac * Math.PI, opts.color, TH));
652 + const val = svgEl("text", { x: cx, y: cy - 6, "text-anchor": "middle", class: "viz-gauge-val" });
653 + val.textContent = fmtNum(opts.value, opts.unit);
654 + svg.appendChild(val);
655 + wrap.appendChild(svg);
656 + wrap.appendChild(el("div", "klabel viz-gauge-lbl", opts.label));
657 + return wrap;
658 + }
659 +
660 + /* ---------- heatmap calendrier (26 semaines) ---------- */
661 + function calendarHeatmap(opts) {
662 + // opts: {title, cells:[{date, value}], accent}
663 + const { card, holder, tt } = shell(opts);
664 + const cells = (opts.cells || []).filter((c) => c && c.date);
665 + if (!cells.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; }
666 + const byDate = new Map(cells.map((c) => [c.date.slice(0, 10), c.value]));
667 + const vmax = Math.max(...cells.map((c) => c.value || 0), 1);
668 + const ramp = seqRamp(opts.accent);
669 + const last = new Date(cells.map((c) => c.date.slice(0, 10)).sort().at(-1) + "T12:00:00");
670 + const CS = 13, GAP = 3, weeks = 26;
671 + const W = 40 + weeks * (CS + GAP) + 8, H = 26 + 7 * (CS + GAP) + 6;
672 + const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg", role: "img", "aria-label": opts.title || "calendrier d'activité" });
673 + // aligne la dernière colonne sur la semaine du dernier jour (lundi = rangée 0)
674 + const dow = (d) => (d.getDay() + 6) % 7;
675 + const end = new Date(last); end.setDate(end.getDate() + (6 - dow(last)));
676 + DOWS.forEach((d, i) => {
677 + if (i % 2) return;
678 + const lbl = svgEl("text", { x: 32, y: 26 + i * (CS + GAP) + CS - 3, "text-anchor": "end", class: "viz-tick" });
679 + lbl.textContent = d;
680 + svg.appendChild(lbl);
681 + });
682 + let lastMonth = -1;
683 + for (let w = 0; w < weeks; w++) {
684 + for (let r = 0; r < 7; r++) {
685 + const d = new Date(end);
686 + d.setDate(end.getDate() - (weeks - 1 - w) * 7 - (6 - r));
687 + const iso = d.toISOString().slice(0, 10);
688 + const v = byDate.get(iso);
689 + const x = 40 + w * (CS + GAP), y = 22 + r * (CS + GAP);
690 + if (r === 0 && d.getMonth() !== lastMonth) {
691 + lastMonth = d.getMonth();
692 + const ml = svgEl("text", { x, y: 12, class: "viz-tick" });
693 + ml.textContent = MONTHS[lastMonth];
694 + svg.appendChild(ml);
695 + }
696 + const rect = svgEl("rect", { x, y, width: CS, height: CS, rx: 3, fill: typeof v === "number" && v > 0 ? ramp(Math.sqrt(v / vmax)) : "#efede6", class: "viz-cell" });
697 + if (typeof v === "number") {
698 + rect.addEventListener("pointerenter", () => {
699 + rect.setAttribute("stroke", INK); rect.setAttribute("stroke-width", "1.4");
700 + ttRows(tt, fmtDateFull(iso), [{ color: opts.accent, value: fmtNum(v), label: opts.unit || "" }]);
701 + showTT(holder, tt, (x / W) * 100, (y / H) * 100);
702 + });
703 + rect.addEventListener("pointerleave", () => { rect.removeAttribute("stroke"); hideTT(tt); });
704 + }
705 + svg.appendChild(rect);
706 + }
707 + }
708 + holder.appendChild(svg);
709 + const sorted = [...byDate.entries()].sort();
710 + twinTable(card, ["Date", opts.unit || "Valeur"], sorted.map(([d, v]) => [fmtDateFull(d), v]));
711 + return card;
712 + }
713 +
714 + /* ---------- heatmap horaire 7 × 24 (dow 0 = lundi) ---------- */
715 + function hourHeatmap(opts) {
716 + const { card, holder, tt } = shell(opts);
717 + const cells = (opts.cells || []).filter((c) => c && typeof c.dow === "number" && typeof c.hour === "number");
718 + if (!cells.length) { holder.appendChild(el("div", "viz-empty", "Pas encore mesuré")); return card; }
719 + const grid = new Map(cells.map((c) => [c.dow + ":" + c.hour, c.value]));
720 + const vmax = Math.max(...cells.map((c) => c.value || 0), 1);
721 + const ramp = seqRamp(opts.accent);
722 + const CS = 26, CH = 20, GAP = 3;
723 + const W = 46 + 24 * (CS + GAP), H = 24 + 7 * (CH + GAP) + 6;
724 + const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz-svg", role: "img", "aria-label": opts.title || "activité horaire" });
725 + for (let h = 0; h < 24; h += 3) {
726 + const lbl = svgEl("text", { x: 46 + h * (CS + GAP) + CS / 2, y: 12, "text-anchor": "middle", class: "viz-tick" });
727 + lbl.textContent = h + " h";
728 + svg.appendChild(lbl);
729 + }
730 + for (let r = 0; r < 7; r++) {
731 + const lbl = svgEl("text", { x: 38, y: 22 + r * (CH + GAP) + CH - 5, "text-anchor": "end", class: "viz-tick" });
732 + lbl.textContent = DOWS[r];
733 + svg.appendChild(lbl);
734 + for (let h = 0; h < 24; h++) {
735 + const v = grid.get(r + ":" + h);
736 + const x = 46 + h * (CS + GAP), y = 18 + r * (CH + GAP);
737 + const rect = svgEl("rect", { x, y, width: CS, height: CH, rx: 3, fill: typeof v === "number" && v > 0 ? ramp(Math.sqrt(v / vmax)) : "#efede6", class: "viz-cell" });
738 + if (typeof v === "number") {
739 + rect.addEventListener("pointerenter", () => {
740 + rect.setAttribute("stroke", INK); rect.setAttribute("stroke-width", "1.4");
741 + ttRows(tt, `${DOWS[r]} ${h} h`, [{ color: opts.accent, value: fmtNum(v), label: opts.unit || "" }]);
742 + showTT(holder, tt, (x / W) * 100, (y / H) * 100);
743 + });
744 + rect.addEventListener("pointerleave", () => { rect.removeAttribute("stroke"); hideTT(tt); });
745 + }
746 + svg.appendChild(rect);
747 + }
748 + }
749 + holder.appendChild(svg);
750 + twinTable(card, ["Jour", "Heure", "Valeur"], cells.slice(0, 400).map((c) => [DOWS[c.dow] || c.dow, c.hour + " h", c.value]));
751 + return card;
752 + }
753 +
754 + /* ---------- résumé statistique ---------- */
755 + function statSummary(points, unit) {
756 + const vs = (points || []).map((p) => p.v).filter((v) => typeof v === "number").sort((a, b) => a - b);
757 + const box = el("div", "viz-summary");
758 + if (vs.length < 2) return box;
759 + const mean = vs.reduce((a, v) => a + v, 0) / vs.length;
760 + const med = vs.length % 2 ? vs[(vs.length - 1) / 2] : (vs[vs.length / 2 - 1] + vs[vs.length / 2]) / 2;
761 + const sd = Math.sqrt(vs.reduce((a, v) => a + (v - mean) ** 2, 0) / vs.length);
762 + const u = unit === "$" ? "$" : "";
763 + const items = [["min", vs[0]], ["max", vs[vs.length - 1]], ["moy", mean], ["méd", med], ["σ", sd]];
764 + for (const [k, v] of items) {
765 + const chip = el("span", "viz-stat");
766 + chip.appendChild(el("span", "klabel", k));
767 + chip.appendChild(el("b", null, fmtNum(Math.round(v * 10) / 10, u)));
768 + box.appendChild(chip);
769 + }
770 + return box;
771 + }
772 +
773 + /* ---------- carte KPI ---------- */
774 + function kpiCard(k, accent, deep) {
775 + const card = el("div", "kpi card");
776 + card.appendChild(el("div", "klabel kpi-label", k.label));
777 + const val = el("div", "kpi-value", fmtNum(k.value, k.unit));
778 + card.appendChild(val);
779 + const foot = el("div", "kpi-foot");
780 + if (typeof k.delta_pct === "number") {
781 + foot.appendChild(el("span", "viz-delta " + (k.delta_pct >= 0 ? "up" : "down"), (k.delta_pct >= 0 ? "▲ " : "▼ ") + nf1.format(Math.abs(k.delta_pct)) + NBSP + "% vs période préc."));
782 + }
783 + card.appendChild(foot);
784 + if (Array.isArray(k.spark) && k.spark.length > 2) {
785 + const sp = el("div", "kpi-spark");
786 + sp.appendChild(sparkline(k.spark, strokeFor(accent, deep)));
787 + card.appendChild(sp);
788 + }
789 + return card;
790 + }
791 +
792 + /* ---------- tableau interactif (tri, recherche, pagination 25) ---------- */
793 + function dataTable(opts) {
794 + // opts: {title, columns, rows}
795 + const card = el("section", "viz-card viz-tablecard");
796 + const head = el("div", "viz-head");
797 + head.appendChild(el("div", "viz-title", opts.title || "Tableau"));
798 + const search = el("input", "input viz-search");
799 + search.type = "search";
800 + search.placeholder = "Filtrer…";
801 + search.setAttribute("aria-label", "Filtrer le tableau " + (opts.title || ""));
802 + head.appendChild(search);
803 + card.appendChild(head);
804 + const wrap = el("div", "tbl-wrap");
805 + const tbl = el("table", "viz-table viz-table-lg");
806 + wrap.appendChild(tbl);
807 + card.appendChild(wrap);
808 + const pager = el("div", "viz-pager");
809 + card.appendChild(pager);
810 + const cols = opts.columns || [];
811 + let rows = (opts.rows || []).map((r) => r.map((c) => c));
812 + let sortCol = -1, sortDir = 1, page = 0, query = "";
813 + const PAGE = 25;
814 + const numVal = (c) => {
815 + if (typeof c === "number") return c;
816 + const m = String(c).replace(/[\s  %$,]/g, "").replace(",", ".");
817 + const f = parseFloat(m);
818 + return Number.isNaN(f) ? null : f;
819 + };
820 + function render() {
821 + tbl.textContent = "";
822 + const thead = el("thead");
823 + const trh = el("tr");
824 + cols.forEach((c, i) => {
825 + const th = el("th");
826 + const btn = el("button", "viz-th", c + (sortCol === i ? (sortDir > 0 ? " ↑" : " ↓") : ""));
827 + btn.addEventListener("click", () => { if (sortCol === i) sortDir *= -1; else { sortCol = i; sortDir = -1; } page = 0; render(); });
828 + th.appendChild(btn);
829 + if (sortCol === i) th.setAttribute("aria-sort", sortDir > 0 ? "ascending" : "descending");
830 + trh.appendChild(th);
831 + });
832 + thead.appendChild(trh);
833 + tbl.appendChild(thead);
834 + let view = rows;
835 + if (query) {
836 + const q = query.toLowerCase();
837 + view = rows.filter((r) => r.some((c) => String(c).toLowerCase().includes(q)));
838 + }
839 + if (sortCol >= 0) {
840 + view = [...view].sort((a, b) => {
841 + const na = numVal(a[sortCol]), nb = numVal(b[sortCol]);
842 + if (na !== null && nb !== null) return (na - nb) * sortDir;
843 + return String(a[sortCol]).localeCompare(String(b[sortCol]), "fr") * sortDir;
844 + });
845 + }
846 + const pages = Math.max(1, Math.ceil(view.length / PAGE));
847 + page = Math.min(page, pages - 1);
848 + const tb = el("tbody");
849 + for (const r of view.slice(page * PAGE, page * PAGE + PAGE)) {
850 + const tr = el("tr");
851 + r.forEach((c, i) => {
852 + const isNum = numVal(c) !== null && i > 0;
853 + tr.appendChild(el("td", isNum ? "num" : null, typeof c === "number" ? fmtNum(c) : String(c ?? "—")));
854 + });
855 + tb.appendChild(tr);
856 + }
857 + tbl.appendChild(tb);
858 + pager.textContent = "";
859 + pager.appendChild(el("span", "klabel", `${nfInt.format(view.length)} lignes`));
860 + if (pages > 1) {
861 + const nav = el("span", "viz-pager-nav");
862 + const prev = el("button", "btn btn-ghost viz-pgbtn", "←");
863 + prev.disabled = page === 0;
864 + prev.addEventListener("click", () => { page--; render(); });
865 + const next = el("button", "btn btn-ghost viz-pgbtn", "→");
866 + next.disabled = page >= pages - 1;
867 + next.addEventListener("click", () => { page++; render(); });
868 + nav.appendChild(prev);
869 + nav.appendChild(el("span", "klabel", ` ${page + 1}/${pages} `));
870 + nav.appendChild(next);
871 + pager.appendChild(nav);
872 + }
873 + }
874 + search.addEventListener("input", () => { query = search.value.trim(); page = 0; render(); });
875 + render();
876 + return card;
877 + }
878 +
879 + /* ---------- exports PNG / CSV ---------- */
880 + function downloadBlob(name, blob) {
881 + const a = document.createElement("a");
882 + a.href = URL.createObjectURL(blob);
883 + a.download = name;
884 + document.body.appendChild(a);
885 + a.click();
886 + setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 800);
887 + }
888 + function exportCSV(name, columns, rows) {
889 + const esc = (c) => {
890 + const s = String(c ?? "");
891 + return /[",;\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
892 + };
893 + const lines = [columns.map(esc).join(";"), ...rows.map((r) => r.map(esc).join(";"))];
894 + downloadBlob(name + ".csv", new Blob(["" + lines.join("\n")], { type: "text/csv;charset=utf-8" }));
895 + }
896 + const SVG_EXPORT_CSS = `
897 + text{font-family:system-ui,-apple-system,sans-serif;}
898 + .viz-tick{font-family:ui-monospace,monospace;font-size:10.5px;fill:#8b928c;}
899 + .viz-endlbl{font-family:ui-monospace,monospace;font-size:11px;font-weight:700;fill:#141814;paint-order:stroke;stroke:#fff;stroke-width:3px;}
900 + .viz-donut-total{font-weight:700;font-size:26px;fill:#141814;}
901 + .viz-legend-txt{font-size:12.5px;fill:#4d5551;}
902 + .viz-legend-num{font-family:ui-monospace,monospace;font-size:11.5px;font-weight:700;fill:#141814;}
903 + .viz-gauge-val{font-weight:700;font-size:24px;fill:#141814;}
904 + `;
905 + function exportPNG(container, name) {
906 + const src = container.querySelector("svg.viz-svg");
907 + if (!src) return;
908 + const clone = src.cloneNode(true);
909 + const style = document.createElementNS("http://www.w3.org/2000/svg", "style");
910 + style.textContent = SVG_EXPORT_CSS;
911 + clone.insertBefore(style, clone.firstChild);
912 + const vb = (src.getAttribute("viewBox") || "0 0 860 280").split(/\s+/).map(Number);
913 + const w = vb[2], h = vb[3];
914 + clone.setAttribute("width", w);
915 + clone.setAttribute("height", h);
916 + const xml = new XMLSerializer().serializeToString(clone);
917 + const url = URL.createObjectURL(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }));
918 + const img = new Image();
919 + img.onload = () => {
920 + const canvas = document.createElement("canvas");
921 + canvas.width = w * 2; canvas.height = h * 2;
922 + const ctx = canvas.getContext("2d");
923 + ctx.fillStyle = "#ffffff";
924 + ctx.fillRect(0, 0, canvas.width, canvas.height);
925 + ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
926 + URL.revokeObjectURL(url);
927 + canvas.toBlob((b) => b && downloadBlob(name + ".png", b), "image/png");
928 + };
929 + img.src = url;
930 + }
931 +
932 + /* ---------- carte à type commutable + outils (CSV/PNG/Studio) ---------- */
933 + function switchableChart(spec) {
934 + // spec: {kinds:[{id,label}], initial, render(kindId)=>node, csv:{name,columns,rows}, pngName, studioHref}
935 + const wrap = el("div", "viz-switch");
936 + const bar = el("div", "viz-toolbar");
937 + let current = spec.initial || (spec.kinds && spec.kinds[0] && spec.kinds[0].id);
938 + const slot = el("div", "viz-slot");
939 + const pillEls = [];
940 + if (spec.kinds && spec.kinds.length > 1) {
941 + const kinds = el("div", "viz-kindrow");
942 + kinds.setAttribute("role", "group");
943 + kinds.setAttribute("aria-label", "Type de graphique");
944 + for (const k of spec.kinds) {
945 + const b = el("button", "pill viz-kindpill" + (k.id === current ? " active" : ""), k.label);
946 + b.type = "button";
947 + b.addEventListener("click", () => {
948 + current = k.id;
949 + pillEls.forEach((p) => p.classList.toggle("active", p === b));
950 + show();
951 + });
952 + pillEls.push(b);
953 + kinds.appendChild(b);
954 + }
955 + bar.appendChild(kinds);
956 + }
957 + const tools = el("div", "viz-tools");
958 + if (spec.csv) {
959 + const b = el("button", "viz-toolbtn", "⭳ CSV");
960 + b.type = "button"; b.title = "Exporter les données (CSV)";
961 + b.addEventListener("click", () => exportCSV(spec.csv.name, spec.csv.columns, spec.csv.rows));
962 + tools.appendChild(b);
963 + }
964 + if (spec.pngName) {
965 + const b = el("button", "viz-toolbtn", "⭳ PNG");
966 + b.type = "button"; b.title = "Exporter l'image (PNG)";
967 + b.addEventListener("click", () => exportPNG(slot, spec.pngName));
968 + tools.appendChild(b);
969 + }
970 + if (spec.studioHref) {
971 + const a = el("a", "viz-toolbtn", "✦ Studio");
972 + a.href = spec.studioHref;
973 + a.dataset.link = "1";
974 + a.title = "Ouvrir cette métrique dans le Studio d'indicateurs";
975 + tools.appendChild(a);
976 + }
977 + if (tools.childNodes.length) bar.appendChild(tools);
978 + if (bar.childNodes.length) wrap.appendChild(bar);
979 + wrap.appendChild(slot);
980 + function show() {
981 + slot.textContent = "";
982 + slot.appendChild(spec.render(current));
983 + }
984 + show();
985 + return wrap;
986 + }
987 +
988 + return {
989 + CAT, DASHES, GREEN, DANGER,
990 + fmtNum, fmtCompact, fmtDate, fmtDateFull, el, strokeFor, seqRamp, mix, contrastWhite,
991 + sparkline, lineChart, vBarChart, hBarChart, donut, stackedBar, gauge,
992 + calendarHeatmap, hourHeatmap, statSummary, kpiCard, dataTable,
993 + exportCSV, exportPNG, switchableChart,
994 + };
995 +})();
added public/favicon.svg +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added public/fonts/JetBrainsMono-Bold.ttf +0 −0

Binary file not shown.

added public/fonts/JetBrainsMono-Regular.ttf +0 −0

Binary file not shown.

added public/fonts/SpaceGrotesk-Bold.ttf +0 −0

Binary file not shown.

added public/fonts/SpaceGrotesk-Medium.ttf +0 −0

Binary file not shown.

added public/fonts/SpaceGrotesk-Regular.ttf +0 −0

Binary file not shown.

added public/index.html +39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added public/og.png +0 −0

Binary file not shown.

added public/robots.txt +4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added public/sitemap.xml +20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added public/styles.css +349 −0
Large diff (349 lines) is not loaded by default to keep the page fast.Load diff
added public/tokens.css +319 −0
Large diff (319 lines) is not loaded by default to keep the page fast.Load diff
added server.mjs +392 −0
Large diff (393 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme