SPB Git

Repository Public

Commit

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
19 changed files +3,314 −0 Browse files
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