SPB Git forge

spb/ka-stats

Public
8commits 1branches 0releases
2.2 MBsize
maindefault branch
26 days agolast push
JavaScript 73.4% CSS 25.3% HTML 1.3%

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 1 mo ago (Aug 25, 2026)

19 changed files +3,314 −0

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

Binary file not shown.

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

Binary file not shown.

added public/robots.txt +4 −0
@@ -0,0 +1,4 @@
1 +User-agent: *
2 +Allow: /
3 +Disallow: /api/
4 +Sitemap: https://www.ka-stats.com/sitemap.xml
added public/sitemap.xml +20 −0
@@ -0,0 +1,20 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3 + <url><loc>https://www.ka-stats.com/</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>
4 + <url><loc>https://www.ka-stats.com/indicateurs</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>
5 + <url><loc>https://www.ka-stats.com/comparer</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
6 + <url><loc>https://www.ka-stats.com/studio</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>
7 + <url><loc>https://www.ka-stats.com/palmares</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
8 + <url><loc>https://www.ka-stats.com/site/lou-ka</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>
9 + <url><loc>https://www.ka-stats.com/site/immo-ka</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>
10 + <url><loc>https://www.ka-stats.com/site/vrai-prix</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
11 + <url><loc>https://www.ka-stats.com/site/auto-ka</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>
12 + <url><loc>https://www.ka-stats.com/site/job-ka</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>
13 + <url><loc>https://www.ka-stats.com/site/food-ka</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>
14 + <url><loc>https://www.ka-stats.com/site/resto-ka</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>
15 + <url><loc>https://www.ka-stats.com/site/fabri-ka</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>
16 + <url><loc>https://www.ka-stats.com/site/sorti-ka</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>
17 + <url><loc>https://www.ka-stats.com/site/crea-ka</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>
18 + <url><loc>https://www.ka-stats.com/site/trouve-ka</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>
19 + <url><loc>https://www.ka-stats.com/site/api-ka</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>
20 +</urlset>
added public/styles.css +349 −0
@@ -0,0 +1,349 @@
1 +/* -----------------------------------------------------------------------------
2 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 + Fichier : ka-stats/public/styles.css
4 + Desc. : Ka·Stats — styles applicatifs par-dessus le design system ka-ui
5 + (tokens.css). Accent Ka·Stats = bleu Québec #095797.
6 +----------------------------------------------------------------------------- */
7 +
8 +@font-face { font-family: "Space Grotesk"; src: url("/fonts/SpaceGrotesk-Regular.ttf") format("truetype"); font-weight: 400; font-display: swap; }
9 +@font-face { font-family: "Space Grotesk"; src: url("/fonts/SpaceGrotesk-Medium.ttf") format("truetype"); font-weight: 500; font-display: swap; }
10 +@font-face { font-family: "Space Grotesk"; src: url("/fonts/SpaceGrotesk-Bold.ttf") format("truetype"); font-weight: 700; font-display: swap; }
11 +@font-face { font-family: "JetBrains Mono"; src: url("/fonts/JetBrainsMono-Regular.ttf") format("truetype"); font-weight: 400 500; font-display: swap; }
12 +@font-face { font-family: "JetBrains Mono"; src: url("/fonts/JetBrainsMono-Bold.ttf") format("truetype"); font-weight: 700; font-display: swap; }
13 +
14 +/* Accent Ka·Stats (bleu Québec) — seule surcharge de marque, comme les satellites */
15 +:root {
16 + --accent: #095797;
17 + --accent-soft: #e2edf6;
18 + --accent-deep: #063a63;
19 + --on-accent: #ffffff;
20 +}
21 +
22 +/* ---------- header ---------- */
23 +#site-header {
24 + position: sticky; top: 0; z-index: var(--z-header);
25 + background: var(--paper); border-bottom: 1.5px solid var(--ink);
26 +}
27 +.header-inner { display: flex; align-items: center; gap: var(--sp-4); min-height: 62px; }
28 +.brand { display: inline-flex; align-items: baseline; font-family: var(--font-display); font-weight: 700; font-size: 24px; letter-spacing: -0.04em; text-decoration: none; }
29 +.brand-qc { color: var(--ink); }
30 +.brand-dot { color: var(--ink-3); margin: 0 1px; }
31 +.brand-stats { background: var(--accent); color: var(--on-accent); border-radius: 7px; padding: 0 8px 2px; transform: rotate(-2deg); }
32 +.brand:hover .brand-stats { transform: rotate(0); }
33 +.main-nav { display: flex; gap: 2px; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; flex: 1; }
34 +.main-nav::-webkit-scrollbar { display: none; }
35 +.nav-link {
36 + display: inline-flex; align-items: center; min-height: var(--touch); padding: 6px 13px;
37 + font-family: var(--font-display); font-weight: 700; font-size: 14px; white-space: nowrap;
38 + text-decoration: none; border-radius: var(--r-ctl); color: var(--ink-2);
39 +}
40 +.nav-link:hover { color: var(--ink); background: var(--surface); }
41 +.nav-link.active { color: var(--ink); box-shadow: inset 0 -3px 0 var(--accent); }
42 +
43 +/* ===== KA Nav v2 — menu mobile plein écran (standard 2026-08-25) =====
44 + Panneau position:fixed inset:0 (z-modal) → visible peu importe le scroll. */
45 +.ka-mnav-btn {
46 + display: none; width: 44px; height: 44px; margin-left: auto; flex: none;
47 + flex-direction: column; align-items: center; justify-content: center; gap: 5px;
48 + background: var(--paper); border: 1.5px solid var(--ink); border-radius: 999px;
49 + cursor: pointer; padding: 0;
50 +}
51 +.ka-mnav-btn span {
52 + display: block; width: 18px; height: 2px; background: var(--ink);
53 + border-radius: 2px; transition: transform 0.18s ease, opacity 0.14s ease;
54 +}
55 +.ka-mnav-btn[aria-expanded="true"] span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
56 +.ka-mnav-btn[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
57 +.ka-mnav-btn[aria-expanded="true"] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
58 +.ka-mnav {
59 + position: fixed; inset: 0; z-index: var(--z-modal, 900); background: var(--paper);
60 + display: none; flex-direction: column; overflow-y: auto; overscroll-behavior: contain;
61 +}
62 +.ka-mnav.open { display: flex; animation: ka-mnav-in 0.16s ease; }
63 +@keyframes ka-mnav-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
64 +.ka-mnav-top {
65 + display: flex; align-items: center; justify-content: space-between; flex: none;
66 + min-height: 62px; padding: 0 18px; border-bottom: 2px solid var(--ink); background: var(--paper);
67 +}
68 +.ka-mnav-close {
69 + width: 44px; height: 44px; flex: none; display: flex; align-items: center;
70 + justify-content: center; border: 1.5px solid var(--ink); border-radius: 999px;
71 + background: var(--ink); color: var(--paper); font-size: 17px; font-weight: 700;
72 + cursor: pointer; padding: 0; line-height: 1;
73 +}
74 +.ka-mnav-links { display: flex; flex-direction: column; justify-content: center; flex: 1; padding: 28px 24px; counter-reset: mnav; }
75 +.ka-mnav-links a {
76 + display: flex; align-items: baseline; gap: 14px; padding: 17px 0;
77 + border-bottom: 1.5px dashed rgba(20, 24, 20, 0.18); color: var(--ink); text-decoration: none;
78 +}
79 +.ka-mnav-links a:last-child { border-bottom: 0; }
80 +.ka-mnav-links a::before {
81 + counter-increment: mnav; content: "0" counter(mnav);
82 + font-family: var(--font-mono); font-size: 12px; font-weight: 700; color: var(--accent-deep);
83 +}
84 +.ka-mnav-links a b {
85 + font-family: var(--font-display); font-weight: 700; font-size: 26px;
86 + line-height: 1; letter-spacing: -0.03em; text-transform: uppercase;
87 +}
88 +.ka-mnav-foot {
89 + flex: none; border-top: 2px solid var(--ink); padding: 16px 24px;
90 + padding-bottom: max(20px, env(safe-area-inset-bottom));
91 +}
92 +html.ka-menu-locked, html.ka-menu-locked body { overflow: hidden; }
93 +
94 +/* ---------- sélecteur de période (une rangée au-dessus de tout) ---------- */
95 +.period-bar { background: var(--surface); border-bottom: 1.5px solid var(--line); }
96 +.period-inner { display: flex; align-items: center; gap: var(--sp-3); padding-top: 10px; padding-bottom: 10px; flex-wrap: wrap; }
97 +.period-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
98 +.pill {
99 + min-height: 34px; padding: 4px 13px; cursor: pointer;
100 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; letter-spacing: 0.03em;
101 + border: 1.5px solid var(--line); border-radius: var(--r-pill); background: var(--surface); color: var(--ink-2);
102 +}
103 +.pill:hover { border-color: var(--ink); color: var(--ink); }
104 +.pill.active { background: var(--ink); border-color: var(--ink); color: var(--accent-soft); }
105 +.pill:disabled { opacity: 0.4; cursor: not-allowed; }
106 +.period-custom { display: inline-flex; align-items: center; gap: 6px; margin-left: 4px; }
107 +.date-in { width: 132px; min-height: 34px; padding: 3px 8px; font-size: 12.5px; border-color: var(--line); }
108 +
109 +/* ---------- héros ---------- */
110 +.hero { padding: var(--sp-7) 0 var(--sp-6); border-bottom: 1.5px solid var(--ink); background:
111 + linear-gradient(180deg, var(--accent-soft) 0%, var(--paper) 78%); }
112 +.lead { max-width: 720px; color: var(--ink-2); font-size: 16.5px; }
113 +.hero-figure { margin-top: var(--sp-5); }
114 +.hero-num { font-family: var(--font-display); font-weight: 700; font-size: clamp(52px, 8vw, 108px); line-height: 1; letter-spacing: -0.045em; color: var(--ink); }
115 +.hero-sub { margin-top: 6px; font-family: var(--font-mono); font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.09em; color: var(--accent); }
116 +.hero-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: var(--sp-5); }
117 +
118 +.page-head { padding: var(--sp-6) 0 var(--sp-4); border-bottom: 1.5px solid var(--ink); background: linear-gradient(180deg, var(--accent-soft) 0%, var(--paper) 85%); }
119 +.site-head { padding: var(--sp-5) 0 var(--sp-4); border-bottom: 1.5px solid var(--ink); background: linear-gradient(180deg, var(--accent-soft) 0%, var(--paper) 85%); }
120 +.site-head-row { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: var(--sp-4); }
121 +.site-h1 { margin-bottom: 4px; }
122 +.site-actions { display: flex; gap: 10px; align-items: center; position: relative; }
123 +.crumb { margin-bottom: var(--sp-3); }
124 +.crumb-link { font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; text-decoration: none; color: var(--ink-2); }
125 +.crumb-link:hover { color: var(--ink); }
126 +.fresh-row { display: flex; flex-wrap: wrap; gap: 8px; margin: var(--sp-4) 0 0; }
127 +.fresh.stale { color: var(--danger); }
128 +
129 +/* ---------- sections ---------- */
130 +.block { margin: var(--sp-7) 0; }
131 +.sect-note { max-width: 720px; color: var(--ink-2); margin-top: -4px; }
132 +.sect-note.warn { color: var(--danger); }
133 +.sect-more { margin-top: var(--sp-4); }
134 +
135 +/* ---------- cartes de graphique ---------- */
136 +.viz-card { margin: var(--sp-4) 0 var(--sp-5); padding: var(--sp-4) var(--sp-4) var(--sp-3); background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); }
137 +figure.viz-card { margin-left: 0; margin-right: 0; }
138 +.viz-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-3); margin-bottom: var(--sp-2); }
139 +.viz-title { font-family: var(--font-display); font-weight: 700; font-size: 16px; letter-spacing: -0.02em; }
140 +.viz-plot { position: relative; }
141 +.viz-svg { width: 100%; height: auto; display: block; }
142 +.viz-tick { font-family: var(--font-mono); font-size: 10.5px; fill: var(--ink-3); font-variant-numeric: tabular-nums; }
143 +.viz-endlbl { font-family: var(--font-mono); font-size: 11px; font-weight: 700; fill: var(--ink); paint-order: stroke; stroke: #fff; stroke-width: 3px; }
144 +.viz-bar { cursor: default; }
145 +.viz-cell { cursor: default; }
146 +.viz-arc { cursor: default; }
147 +.viz-donut-total { font-family: var(--font-display); font-weight: 700; font-size: 26px; fill: var(--ink); }
148 +.viz-legend-txt { font-size: 12.5px; fill: var(--ink-2); }
149 +.viz-legend-num { font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; fill: var(--ink); font-variant-numeric: tabular-nums; }
150 +.viz-empty { padding: var(--sp-6); text-align: center; color: var(--ink-3); background: var(--surface-2); border-radius: var(--r-ctl); font-size: 14px; }
151 +.viz-empty.big { margin: var(--sp-6) 0; padding: var(--sp-7); }
152 +
153 +/* légende */
154 +.viz-legend { display: flex; flex-wrap: wrap; gap: 8px 18px; margin: 0 0 var(--sp-2); }
155 +.viz-legend-item { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--ink-2); }
156 +.viz-key-line { width: 20px; height: 3px; border-radius: 2px; }
157 +.viz-key-rect { width: 12px; height: 12px; border-radius: 3px; }
158 +
159 +/* infobulle */
160 +.viz-tt {
161 + display: none; position: absolute; z-index: var(--z-dropdown); pointer-events: none;
162 + min-width: 130px; max-width: 260px; padding: 8px 11px;
163 + background: var(--ink); color: var(--paper); border-radius: 8px;
164 + font-size: 12px; line-height: 1.45; box-shadow: var(--shadow-off-mid);
165 +}
166 +.viz-tt-title { font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: rgba(245,243,238,0.65); margin-bottom: 4px; }
167 +.viz-tt-row { display: flex; align-items: center; gap: 7px; }
168 +.viz-tt-key { flex: 0 0 14px; height: 3px; border-radius: 2px; }
169 +.viz-tt-val { font-variant-numeric: tabular-nums; }
170 +.viz-tt-lbl { color: rgba(245,243,238,0.7); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
171 +
172 +/* vue tableau jumelle */
173 +.viz-data { margin-top: var(--sp-2); border-top: 1px solid var(--line); }
174 +.viz-data > summary { cursor: pointer; padding: 8px 0 4px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); list-style: none; }
175 +.viz-data > summary::before { content: "⊞ "; }
176 +.viz-data[open] > summary { color: var(--ink); }
177 +.viz-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
178 +.viz-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-3); padding: 6px 10px; border-bottom: 1.5px solid var(--line); white-space: nowrap; }
179 +.viz-table td { padding: 5px 10px; border-bottom: 1px solid var(--line); }
180 +.viz-table td.num { text-align: right; font-variant-numeric: tabular-nums; }
181 +.viz-table tbody tr:nth-child(even) { background: var(--surface-2); }
182 +.viz-data .tbl-wrap { max-height: 320px; overflow: auto; }
183 +
184 +/* résumé statistique */
185 +.viz-summary { display: flex; flex-wrap: wrap; gap: 7px; margin-top: var(--sp-2); }
186 +.viz-stat { display: inline-flex; align-items: baseline; gap: 6px; padding: 3px 10px; background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r-pill); font-size: 12.5px; }
187 +.viz-stat b { font-variant-numeric: tabular-nums; }
188 +
189 +/* deltas ▲▼ */
190 +.viz-delta { font-family: var(--font-mono); font-size: 11px; font-weight: 700; white-space: nowrap; }
191 +.viz-delta.up { color: var(--green); }
192 +.viz-delta.down { color: var(--danger); }
193 +
194 +/* barres horizontales */
195 +.viz-hbars { display: grid; gap: 12px; }
196 +.viz-hbar-top { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-3); margin-bottom: 3px; }
197 +.viz-hbar-label { font-size: 13px; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
198 +.viz-hbar-val { display: inline-flex; gap: 8px; align-items: baseline; }
199 +.viz-hbar-val b { font-variant-numeric: tabular-nums; font-size: 13px; }
200 +.viz-hbar-track { height: 12px; background: var(--surface-2); border-radius: 0 6px 6px 0; }
201 +.viz-hbar-fill { height: 100%; border-radius: 0 6px 6px 0; min-width: 3px; }
202 +
203 +/* ---------- KPI ---------- */
204 +.kpi-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: var(--sp-4); margin: var(--sp-5) 0; }
205 +.kpi { padding: var(--sp-4); box-shadow: var(--shadow-off-mid); }
206 +.kpi-label { display: block; margin-bottom: 6px; }
207 +.kpi-value { font-family: var(--font-display); font-weight: 700; font-size: clamp(24px, 2.4vw, 34px); letter-spacing: -0.03em; line-height: 1.05; }
208 +.kpi-foot { margin-top: 5px; min-height: 16px; }
209 +.kpi-spark { margin-top: 8px; }
210 +.spark { display: block; }
211 +
212 +/* jauges */
213 +.gauge-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: var(--sp-4); margin: var(--sp-4) 0 var(--sp-5); }
214 +.viz-gauge { padding: var(--sp-4); text-align: center; box-shadow: var(--shadow-off-mid); }
215 +.viz-gauge svg { width: 100%; max-width: 200px; margin: 0 auto; }
216 +.viz-gauge-val { font-family: var(--font-display); font-weight: 700; font-size: 24px; fill: var(--ink); }
217 +.viz-gauge-lbl { margin-top: 4px; }
218 +
219 +/* ---------- grille des plateformes ---------- */
220 +.site-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--sp-5); margin-top: var(--sp-4); }
221 +.site-card { display: flex; flex-direction: column; }
222 +.site-band { background: var(--accent); color: var(--on-accent); padding: 12px 16px; display: flex; flex-direction: column; gap: 1px; }
223 +.site-wordmark { font-family: var(--font-display); font-weight: 700; font-size: 20px; letter-spacing: -0.03em; }
224 +.site-tagline { font-size: 12px; opacity: 0.9; }
225 +.site-body { padding: 14px 16px 6px; flex: 1; }
226 +.site-primary-val { font-family: var(--font-display); font-weight: 700; font-size: 30px; letter-spacing: -0.03em; line-height: 1.05; margin: 2px 0; }
227 +.site-spark { margin: 8px 0 4px; }
228 +.site-minis { display: grid; gap: 6px; margin-top: 8px; }
229 +.site-mini { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; border-top: 1px solid var(--line); padding-top: 6px; }
230 +.site-mini b { font-variant-numeric: tabular-nums; font-size: 13.5px; white-space: nowrap; }
231 +.site-mini .klabel { text-align: right; }
232 +.site-foot { display: flex; gap: 8px; padding: 12px 16px 16px; }
233 +.site-btn { flex: 1; font-size: 13px; padding: 8px 10px; }
234 +
235 +/* pastille + chip de site */
236 +.site-chip { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-2); }
237 +.site-dot { width: 9px; height: 9px; border-radius: 50%; border: 1px solid rgba(20,24,20,0.25); }
238 +
239 +/* indicateurs */
240 +.ind-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: var(--sp-4); margin-top: var(--sp-4); }
241 +.ind-card { padding: var(--sp-4); display: flex; flex-direction: column; gap: 5px; align-items: flex-start; box-shadow: var(--shadow-off-mid); }
242 +.ind-val { font-family: var(--font-display); font-weight: 700; font-size: 27px; letter-spacing: -0.03em; line-height: 1.05; }
243 +
244 +/* records */
245 +.rec-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: var(--sp-4); margin-top: var(--sp-4); }
246 +.rec-card { padding: var(--sp-4); display: flex; flex-direction: column; gap: 5px; align-items: flex-start; box-shadow: var(--shadow-off-mid); border-left: 5px solid var(--accent); }
247 +.rec-val { font-family: var(--font-display); font-weight: 700; font-size: 21px; letter-spacing: -0.02em; }
248 +.rec-label { font-size: 13px; color: var(--ink-2); }
249 +
250 +/* deux colonnes de graphiques */
251 +.two-col { display: grid; gap: var(--sp-4); }
252 +@media (min-width: 1024px) { .two-col { grid-template-columns: 1fr 1fr; } .two-col .viz-card { margin: 0; } }
253 +
254 +/* tableaux interactifs */
255 +.viz-tablecard .viz-head { flex-wrap: wrap; }
256 +.viz-search { max-width: 230px; min-height: 36px; padding: 6px 12px; font-size: 13px; border-color: var(--line); }
257 +.viz-th { background: none; border: 0; padding: 0; font: inherit; font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-3); cursor: pointer; }
258 +.viz-th:hover { color: var(--ink); }
259 +.viz-pager { display: flex; justify-content: space-between; align-items: center; margin-top: var(--sp-2); }
260 +.viz-pager-nav { display: inline-flex; align-items: center; gap: 4px; }
261 +.viz-pgbtn { min-height: 32px; padding: 2px 12px; font-size: 13px; }
262 +.viz-table-lg { font-size: 13px; }
263 +
264 +/* menu PDF */
265 +.pdf-menu { position: relative; }
266 +.pdf-menu > summary { list-style: none; }
267 +.pdf-menu > summary::-webkit-details-marker { display: none; }
268 +.pdf-box { top: calc(100% + 6px); right: 0; }
269 +.pdf-box a { color: var(--ink); font-size: 13.5px; }
270 +.pdf-box a:hover { background: var(--accent-soft); }
271 +
272 +/* comparateur */
273 +.cmp-controls { margin: var(--sp-5) 0 var(--sp-4); display: grid; gap: var(--sp-3); }
274 +.cmp-chips, .cmp-mode { display: flex; flex-wrap: wrap; gap: 7px; }
275 +.site-pill.active { font-weight: 700; }
276 +
277 +/* ---------- cartes commutables : barre d'outils (type + exports) ---------- */
278 +.viz-switch { margin: var(--sp-4) 0 var(--sp-5); }
279 +.viz-switch .viz-card { margin: 0; }
280 +.viz-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: 6px; }
281 +.viz-kindrow { display: flex; gap: 5px; }
282 +.viz-kindpill { min-height: 28px; padding: 2px 11px; font-size: 10.5px; }
283 +.viz-tools { display: flex; gap: 5px; margin-left: auto; }
284 +.viz-toolbtn {
285 + display: inline-flex; align-items: center; gap: 4px; min-height: 28px; padding: 2px 10px;
286 + font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; letter-spacing: 0.03em;
287 + border: 1.5px solid var(--line); border-radius: var(--r-pill); background: var(--surface);
288 + color: var(--ink-2); cursor: pointer; text-decoration: none;
289 +}
290 +.viz-toolbtn:hover { border-color: var(--ink); color: var(--ink); }
291 +.viz-toolbtn.danger:hover { border-color: var(--danger); color: var(--danger); }
292 +.two-col .viz-switch { margin: 0; }
293 +
294 +/* légende interactive + bandeau zoom */
295 +.viz-legend-btn { background: none; border: 0; padding: 2px 4px; font: inherit; cursor: pointer; border-radius: 6px; }
296 +.viz-legend-btn:hover { background: var(--surface-2); }
297 +.viz-legend-btn.dim { opacity: 0.35; text-decoration: line-through; }
298 +.viz-zoombar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: 6px; }
299 +.viz-zreset { min-height: 28px; padding: 2px 11px; font-size: 10.5px; }
300 +
301 +/* ---------- Studio ---------- */
302 +.studio { padding: var(--sp-5); margin-top: var(--sp-5); overflow: visible; }
303 +.studio-in { display: grid; gap: var(--sp-4); }
304 +.studio-sel { display: flex; flex-wrap: wrap; gap: 8px; }
305 +.studio-chip {
306 + display: inline-flex; align-items: center; gap: 8px; padding: 6px 8px 6px 12px;
307 + border: 1.5px solid var(--ink); border-radius: var(--r-pill); background: var(--surface-2);
308 + font-size: 13px;
309 +}
310 +.studio-x { border: 0; background: var(--ink); color: var(--paper); border-radius: 50%; width: 20px; height: 20px; font-size: 10px; cursor: pointer; line-height: 1; }
311 +.studio-x:hover { background: var(--danger); }
312 +.studio-search { position: relative; }
313 +.studio-results {
314 + position: absolute; z-index: var(--z-dropdown); left: 0; right: 0; top: calc(100% + 4px);
315 + max-height: 340px; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
316 + background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-mid);
317 +}
318 +.studio-result {
319 + display: flex; align-items: center; gap: 10px; width: 100%; min-height: var(--touch);
320 + padding: 8px 14px; border: 0; border-bottom: 1px solid var(--line); background: none;
321 + font: inherit; font-size: 13px; text-align: left; cursor: pointer;
322 +}
323 +.studio-result:hover { background: var(--accent-soft); }
324 +.studio-result:disabled { opacity: 0.4; cursor: not-allowed; }
325 +.studio-r-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
326 +.studio-opt .period-row { margin-top: 6px; }
327 +.studio-titlerow { display: flex; gap: 10px; flex-wrap: wrap; }
328 +.studio-titlerow .input { flex: 1; min-width: 220px; }
329 +.studio-saveditem { margin: var(--sp-5) 0; }
330 +.studio-savedbar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: 6px; }
331 +.studio-savedacts { display: flex; gap: 5px; }
332 +.hero-cta { margin-top: var(--sp-5); }
333 +
334 +/* chargement : on garde le rendu précédent en opacité réduite (pas de squelette) */
335 +#app.is-loading { opacity: 0.55; pointer-events: none; transition: opacity 0.2s; }
336 +#app { transition: opacity 0.2s; }
337 +
338 +/* mobile */
339 +@media (max-width: 767px) {
340 + .header-inner { flex-wrap: wrap; padding-top: 8px; padding-bottom: 4px; }
341 + .brand { font-size: 21px; }
342 + /* KA Nav v2 (2026-08-25) : la barre scrollable cède la place au hamburger
343 + + panneau plein écran fixed — menu visible peu importe le scroll */
344 + .main-nav { display: none; }
345 + .ka-mnav-btn { display: flex; }
346 + .site-head-row { align-items: flex-start; }
347 + .hero { padding-top: var(--sp-6); }
348 + .viz-data .tbl-wrap { max-height: 260px; }
349 +}
added public/tokens.css +319 −0
@@ -0,0 +1,319 @@
1 +/* -----------------------------------------------------------------------------
2 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 + Fichier : ka-ui/tokens.css — SOURCE CANONIQUE (repo ka-ui sur spbgit)
4 + Desc. : Design system commun GROUPE KA « éditorial sharp » — tokens + socle
5 + de composants partagés par les 12 plateformes. Chaque site importe ce
6 + fichier PUIS surcharge uniquement son accent (voir accents.css).
7 +
8 + · Typo : display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
9 + · Palette: papier #f5f3ee · encre #141814 · vert profond #1c5c41 + ACCENT du site
10 + · Signature : bordures encre 1,5–2 px + ombres décalées dures, grain de film,
11 + surlignés d'accent inclinés (néo-brutalisme raffiné)
12 + · Breakpoints communs : 360 / 768 / 1024 / 1440 px (mobile-first)
13 + · Zones tactiles ≥ 44×44 px, safe-areas iOS, typographie fluide (clamp)
14 +----------------------------------------------------------------------------- */
15 +
16 +:root {
17 + /* ---- Palette fixe Groupe KA (identique sur les 12 sites) ---- */
18 + --paper: #f5f3ee;
19 + --surface: #ffffff;
20 + --surface-2: #faf9f5;
21 + --ink: #141814;
22 + --ink-2: #4d5551;
23 + --ink-3: #8b928c;
24 + --line: rgba(20, 24, 20, 0.14);
25 + --line-strong: rgba(20, 24, 20, 0.85);
26 + --green: #1c5c41;
27 + --green-deep: #123f2e;
28 + --amber: #e8a33d;
29 + --amber-soft: #fdf3e2;
30 + --danger: #b3423a;
31 + --danger-soft: #fbe9e7;
32 +
33 + /* ---- ACCENT du site — SEULES variables surchargées par marque ---- */
34 + --accent: #d9f26b; /* défaut : lime Groupe KA */
35 + --accent-soft: #f0f9d2;
36 + --accent-deep: #123f2e;
37 + --on-accent: var(--ink); /* couleur du texte posé SUR l'accent */
38 +
39 + /* alias rétro-compatibles (les satellites historiques utilisent --lime) */
40 + --lime: var(--accent);
41 + --lime-soft: var(--accent-soft);
42 +
43 + /* ---- Géométrie sharp ---- */
44 + --r-card: 10px;
45 + --r-ctl: 6px;
46 + --r-pill: 999px;
47 +
48 + /* ---- Ombres décalées — la signature du groupe ---- */
49 + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
50 + --shadow-off: 6px 6px 0 var(--ink);
51 + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
52 + --shadow-off-mid: 4px 4px 0 rgba(20, 24, 20, 0.18);
53 +
54 + /* ---- Typographie ---- */
55 + --font-display: "Space Grotesk", system-ui, sans-serif;
56 + --font-body: "Inter", system-ui, sans-serif;
57 + --font-mono: "JetBrains Mono", ui-monospace, monospace;
58 +
59 + /* Échelle fluide (mobile-first, clamp) */
60 + --fs-h1: clamp(30px, 3.2vw + 18px, 54px);
61 + --fs-h2: clamp(23px, 1.8vw + 14px, 34px);
62 + --fs-h3: clamp(17px, 0.9vw + 12px, 22px);
63 + --fs-body: 15px;
64 + --fs-small: 13px;
65 +
66 + /* Espacements */
67 + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
68 + --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px;
69 +
70 + /* Cible tactile minimale */
71 + --touch: 44px;
72 +
73 + /* ---- Échelle z-index COMMUNE (obligatoire — aucune valeur arbitraire) ----
74 + grain de film body::before = 9999 (pointer-events:none, toujours au-dessus,
75 + purement visuel). Tout composant interactif se place sous 9999 : */
76 + --z-content: 1; /* contenu positionné ordinaire */
77 + --z-sticky: 300; /* éléments sticky de contenu (sous-nav…) */
78 + --z-header: 500; /* header du site */
79 + --z-bottombar: 600; /* tab bar / barre d'action basse */
80 + --z-dropdown: 700; /* menus déroulants (au-dessus des barres) */
81 + --z-overlay: 800; /* voile sous modale/panneau */
82 + --z-modal: 900; /* modales, panneaux de filtres, galeries */
83 + --z-toast: 950; /* notifications */
84 +
85 + /* ---- Hauteur de viewport DYNAMIQUE (barre du navigateur mobile qui se
86 + replie au scroll) : toujours var(--vh100), JAMAIS 100vh en dur. ---- */
87 + --vh100: 100vh;
88 +}
89 +@supports (height: 100dvh) { :root { --vh100: 100dvh; } }
90 +
91 +/* ---- Socle ---- */
92 +* { box-sizing: border-box; }
93 +/* overflow-x: clip sur html ET body — sur WebKit iOS, clip posé sur body seul
94 + ne bloque PAS le défilement latéral du viewport (quirk de propagation) ;
95 + clip (≠ hidden) ne casse pas position:sticky. */
96 +html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; overflow-x: clip; }
97 +body {
98 + margin: 0;
99 + background: var(--paper);
100 + color: var(--ink);
101 + font-family: var(--font-body);
102 + font-size: var(--fs-body);
103 + line-height: 1.55;
104 + -webkit-font-smoothing: antialiased;
105 + padding-bottom: env(safe-area-inset-bottom);
106 + overflow-x: clip; /* jamais de débordement horizontal */
107 +}
108 +img, svg, video { max-width: 100%; height: auto; display: block; }
109 +h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0 0 0.5em; }
110 +h1 { font-size: var(--fs-h1); line-height: 1.06; }
111 +h2 { font-size: var(--fs-h2); line-height: 1.15; }
112 +h3 { font-size: var(--fs-h3); line-height: 1.25; }
113 +a { color: inherit; }
114 +::selection { background: var(--accent); color: var(--on-accent); }
115 +:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
116 +
117 +/* Grain de film pleine page — ⚠️ jamais de z-index sur body > * (position:
118 + relative seulement), sinon les utilitaires z-* sont écrasés. */
119 +body::before {
120 + content: "";
121 + position: fixed; inset: 0; pointer-events: none; opacity: 0.35; z-index: 9999;
122 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
123 +}
124 +body > * { position: relative; }
125 +
126 +/* ---- Conteneur commun ---- */
127 +.container { max-width: 1152px; margin: 0 auto; padding: 0 var(--sp-4); }
128 +@media (min-width: 768px) { .container { padding: 0 var(--sp-5); } }
129 +
130 +/* ---- Micro-typographie signature ---- */
131 +.kicker {
132 + display: inline-flex; align-items: center; gap: 10px;
133 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
134 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--green);
135 +}
136 +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
137 +.klabel {
138 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
139 + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);
140 +}
141 +.hl {
142 + display: inline-block; background: var(--accent); color: var(--on-accent);
143 + border-radius: 8px; padding: 0 10px 2px; transform: rotate(-1deg);
144 +}
145 +.outline-txt { color: transparent; -webkit-text-stroke: 2px var(--ink); }
146 +
147 +/* ---- Boutons (cible ≥ 44 px) ---- */
148 +.btn {
149 + display: inline-flex; align-items: center; justify-content: center; gap: 8px;
150 + min-height: var(--touch); padding: 10px 18px;
151 + font-family: var(--font-display); font-weight: 700; font-size: 14px;
152 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
153 + background: var(--surface); color: var(--ink);
154 + cursor: pointer; text-decoration: none;
155 + transition: transform 0.15s, box-shadow 0.15s, background 0.15s, color 0.15s;
156 +}
157 +.btn:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); }
158 +.btn:active { transform: translate(0, 0); box-shadow: none; }
159 +.btn:disabled { opacity: 0.45; pointer-events: none; }
160 +.btn-primary { background: var(--ink); color: var(--accent); }
161 +.btn-primary:hover { background: var(--accent-deep); }
162 +.btn-accent { background: var(--accent); color: var(--on-accent); }
163 +.btn-ghost { background: transparent; border-color: var(--line); }
164 +.btn-ghost:hover { border-color: var(--ink); }
165 +
166 +/* ---- Cartes ---- */
167 +.card {
168 + background: var(--surface); border: 1.5px solid var(--ink);
169 + border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);
170 + overflow: hidden;
171 +}
172 +.card-hover { transition: transform 0.15s, box-shadow 0.15s; }
173 +.card-hover:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); }
174 +
175 +/* ---- Chips / badges ---- */
176 +.chip {
177 + display: inline-flex; align-items: center; gap: 6px;
178 + padding: 4px 11px; font-family: var(--font-mono); font-size: 11px; font-weight: 700;
179 + text-transform: uppercase; letter-spacing: 0.06em;
180 + border: 1.5px solid var(--ink); border-radius: var(--r-pill);
181 + background: var(--surface); color: var(--ink);
182 +}
183 +.chip-accent { background: var(--accent); color: var(--on-accent); }
184 +.chip-soft { background: var(--accent-soft); border-color: var(--line); }
185 +
186 +/* ---- Champs ---- */
187 +.input, .select, .textarea {
188 + width: 100%; min-height: var(--touch); padding: 10px 14px;
189 + font: inherit; color: var(--ink); background: var(--surface);
190 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
191 +}
192 +.input:focus, .select:focus, .textarea:focus {
193 + outline: none; box-shadow: 3px 3px 0 var(--accent); border-color: var(--ink);
194 +}
195 +.input::placeholder { color: var(--ink-3); }
196 +
197 +/* ---- Badge « Un service Groupe KA » (header, cliquable → hub) ---- */
198 +.gk-badge {
199 + display: inline-flex; align-items: center; gap: 7px;
200 + min-height: 30px; padding: 3px 10px 4px;
201 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
202 + letter-spacing: 0.08em; text-transform: uppercase; text-decoration: none;
203 + border: 1.5px solid var(--ink); border-radius: var(--r-pill);
204 + background: var(--surface); color: var(--ink-2); white-space: nowrap;
205 +}
206 +.gk-badge b {
207 + font-family: var(--font-display); font-size: 12px; letter-spacing: -0.02em;
208 + text-transform: none; color: var(--ink);
209 +}
210 +.gk-badge b .ka {
211 + display: inline-block; background: var(--ink); color: var(--accent);
212 + border-radius: 5px; padding: 0 5px 1px; margin-left: 3px; transform: rotate(-2deg);
213 +}
214 +.gk-badge:hover .ka { transform: rotate(0); }
215 +.gk-badge--dark { background: transparent; border-color: rgba(245,243,238,0.4); color: rgba(245,243,238,0.75); }
216 +.gk-badge--dark b { color: var(--paper); }
217 +
218 +/* ---- Footer commun (fond encre — voir react/KaFooter.tsx) ---- */
219 +.ka-footer { margin-top: var(--sp-8); background: var(--ink); padding: 44px 0; font-size: 13px; color: rgba(245,243,238,0.75); }
220 +.ka-footer a { text-decoration: none; }
221 +.ka-footer .wordmark { font-family: var(--font-display); font-weight: 700; font-size: 30px; letter-spacing: -0.04em; color: var(--paper); text-decoration: none; }
222 +.ka-footer .wordmark .ka { color: var(--accent); }
223 +.ka-footer .desc { max-width: 640px; margin-top: var(--sp-4); }
224 +.ka-footer .notice { max-width: 640px; margin-top: var(--sp-4); border-left: 2px solid var(--accent); padding-left: var(--sp-4); }
225 +.ka-footer .notice b { color: var(--paper); }
226 +.ka-footer .sites { list-style: none; display: flex; flex-wrap: wrap; gap: 10px 26px; margin: 26px 0 0; padding: 0; font-family: var(--font-mono); font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
227 +.ka-footer .sites a { color: rgba(245,243,238,0.65); }
228 +.ka-footer .sites a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
229 +.ka-footer .contacts { display: grid; gap: 14px 32px; border-top: 1px solid rgba(245,243,238,0.15); margin-top: 30px; padding-top: 26px; }
230 +@media (min-width: 768px) { .ka-footer .contacts { grid-template-columns: repeat(3, 1fr); } }
231 +.ka-footer .contacts a { font-family: var(--font-mono); font-weight: 700; font-size: 12px; color: rgba(245,243,238,0.85); }
232 +.ka-footer .contacts a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
233 +.ka-footer .contacts span { display: block; margin-top: 3px; font-size: 11px; color: rgba(245,243,238,0.5); }
234 +.ka-footer .legal { margin-top: 30px; font-family: var(--font-mono); font-size: 11px; color: rgba(245,243,238,0.45); }
235 +.ka-footer .legal a { color: inherit; }
236 +.ka-footer .legal a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
237 +
238 +/* ---- Tableaux → cartes empilées sous 768 px ---- */
239 +.tbl-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
240 +@media (max-width: 767px) {
241 + .tbl-stack thead { display: none; }
242 + .tbl-stack tr { display: block; border: 1.5px solid var(--ink); border-radius: var(--r-card); margin-bottom: var(--sp-3); background: var(--surface); }
243 + .tbl-stack td { display: flex; justify-content: space-between; gap: var(--sp-3); padding: 8px 12px; border: 0; }
244 + .tbl-stack td::before { content: attr(data-label); font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); }
245 +}
246 +
247 +/* ---- Utilitaires responsive ---- */
248 +.only-mobile { display: initial; } .only-desktop { display: none; }
249 +@media (min-width: 768px) { .only-mobile { display: none; } .only-desktop { display: initial; } }
250 +
251 +/* =============================================================================
252 + SOCLE MOBILE OBLIGATOIRE (2026-08-19) — règles communes aux 13 sites.
253 + RÈGLES pour tout composant futur :
254 + · TAP, jamais :hover, pour ouvrir un menu (le survol n'existe pas au doigt) ;
255 + le :hover ne sert qu'aux effets décoratifs, sous @media (hover: hover).
256 + · var(--vh100) (dvh) et JAMAIS 100vh pour tout élément calé sur le viewport.
257 + · position:fixed exige qu'AUCUN ancêtre n'ait transform/filter/perspective/
258 + will-change/backdrop-filter — sinon monter l'élément à la racine (body).
259 + · Barre basse fixe = .ka-bottombar + classe has-bottombar sur <body>.
260 + · Menu/panneau ouvert = .ka-scroll-lock sur <html> (scroll arrière-plan gelé).
261 + · z-index : uniquement l'échelle --z-* ci-dessus.
262 + · Zones tactiles ≥ var(--touch) (44 px), champs ≥ 16 px (zoom iOS).
263 + ========================================================================== */
264 +
265 +/* Anti-zoom iOS : au doigt, aucun champ sous 16 px (le focus d'un champ <16 px
266 + déclenche un zoom automatique de page sur Safari iOS). !important assumé :
267 + c'est un filet d'accessibilité, un champ plus petit casse le zoom quoi
268 + qu'il arrive — ne s'applique qu'aux écrans tactiles. */
269 +@media (hover: none) and (pointer: coarse) {
270 + input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
271 + select, textarea { font-size: max(16px, 1em) !important; }
272 +}
273 +
274 +/* Zone tactile du badge Groupe KA étendue à ≥44 px au doigt, sans changer
275 + son rendu (débord de la zone cliquable via pseudo-élément). */
276 +@media (pointer: coarse) {
277 + .gk-badge { position: relative; }
278 + .gk-badge::after { content: ""; position: absolute; inset: -8px; }
279 +}
280 +
281 +/* Hauteurs viewport dynamiques */
282 +.h-viewport { height: 100vh; height: 100dvh; }
283 +.min-h-viewport { min-height: 100vh; min-height: 100dvh; }
284 +
285 +/* Barre fixe basse fiable : safe-area iPhone incluse, immunisée contre les
286 + transforms accidentels, et compensation d'espace via body.has-bottombar. */
287 +.ka-bottombar {
288 + position: fixed; left: 0; right: 0; bottom: 0;
289 + z-index: var(--z-bottombar);
290 + padding-bottom: env(safe-area-inset-bottom, 0px);
291 + background: var(--surface);
292 + border-top: 1.5px solid var(--ink);
293 + transform: none !important; filter: none !important;
294 +}
295 +body.has-bottombar { padding-bottom: calc(var(--bottombar-h, 64px) + env(safe-area-inset-bottom, 0px)); }
296 +
297 +/* Verrou de scroll d'arrière-plan (menu mobile, modale, panneau de filtres
298 + ouverts) : ajouter/retirer .ka-scroll-lock sur <html>. */
299 +html.ka-scroll-lock, html.ka-scroll-lock body { overflow: hidden !important; overscroll-behavior: none; }
300 +
301 +/* Menu déroulant de référence : au-dessus de tout contenu (cartes Mapbox
302 + incluses), défilement interne avec élan, jamais plus haut que l'écran. */
303 +.ka-menu {
304 + position: absolute; z-index: var(--z-dropdown);
305 + min-width: 180px;
306 + max-height: min(60vh, 420px); max-height: min(60dvh, 420px);
307 + overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
308 + background: var(--surface); border: 1.5px solid var(--ink);
309 + border-radius: var(--r-card); box-shadow: var(--shadow-off-mid);
310 +}
311 +.ka-menu a, .ka-menu button, .ka-menu label, .ka-menu [role="menuitem"], .ka-menu [role="option"] {
312 + display: flex; align-items: center; min-height: var(--touch);
313 + padding: 10px 14px; width: 100%; text-decoration: none;
314 +}
315 +/* Voile plein écran sous une modale / un panneau */
316 +.ka-overlay {
317 + position: fixed; inset: 0; z-index: var(--z-overlay);
318 + background: rgba(20, 24, 20, 0.45);
319 +}
added server.mjs +392 −0
@@ -0,0 +1,392 @@
1 +/* -----------------------------------------------------------------------------
2 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 + Fichier : ka-stats/server.mjs
4 + Desc. : Ka·Stats — l'explorateur de statistiques du Québec (Groupe KA).
5 + Serveur Node ZÉRO dépendance : sert le front statique et agrège les
6 + dashboards /api/stats/dashboard (contrat ka-stats v2) des 12
7 + plateformes du Groupe KA, avec cache mémoire + disque et
8 + stale-while-error (un satellite injoignable ⇒ dernière bonne copie).
9 + Usage : node server.mjs [port] (défaut 8140) — healthcheck: /healthz
10 +----------------------------------------------------------------------------- */
11 +import http from "node:http";
12 +import fs from "node:fs";
13 +import path from "node:path";
14 +import { fileURLToPath } from "node:url";
15 +
16 +const PORT = Number(process.argv[2] || process.env.PORT || 8140);
17 +const ROOT = path.dirname(fileURLToPath(import.meta.url));
18 +const PUB = path.join(ROOT, "public");
19 +const DATA = path.join(ROOT, "data");
20 +const CACHE_FILE = path.join(DATA, "cache.json");
21 +const TTL_MS = 10 * 60 * 1000; // 10 min par (site, période)
22 +fs.mkdirSync(DATA, { recursive: true });
23 +
24 +/* ---- Les 12 plateformes publiques du Groupe KA (contrat ka-stats v2) ----
25 + Accents = ecosystem.json de ka-ui (job-ka relevé sur le site). */
26 +const SITES = [
27 + { id: "lou-ka", wordmark: "Lou·Ka", domain: "www.lou-ka.com", accent: "#ff6a00", accentSoft: "#fff1e6", accentDeep: "#cc5500", onAccent: "#ffffff", tagline: "Tous les logements à louer", theme: "habitation" },
28 + { id: "immo-ka", wordmark: "Immo·Ka", domain: "www.immo-ka.com", accent: "#e23744", accentSoft: "#fbe0e2", accentDeep: "#a8232e", onAccent: "#ffffff", tagline: "Toutes les propriétés à vendre", theme: "habitation" },
29 + { id: "vrai-prix", wordmark: "Vrai-Prix", domain: "www.vrai-prix.com", accent: "#ff5148", accentSoft: "#ffe3e0", accentDeep: "#9e2a25", onAccent: "#ffffff", tagline: "La valeur réelle de chaque propriété", theme: "habitation" },
30 + { id: "auto-ka", wordmark: "Auto·Ka", domain: "www.auto-ka.com", accent: "#ff5a2a", accentSoft: "#ffe8de", accentDeep: "#cc3f16", onAccent: "#ffffff", tagline: "Les voitures usagées du Québec", theme: "mobilite" },
31 + { id: "job-ka", wordmark: "Job·Ka", domain: "www.job-ka.com", accent: "#0c8599", accentSoft: "#def0f4", accentDeep: "#095c6b", onAccent: "#ffffff", tagline: "Tous les emplois des employeurs québécois", theme: "emploi" },
32 + { id: "food-ka", wordmark: "Food·Ka", domain: "www.food-ka.com", accent: "#1f9d55", accentSoft: "#e2f5ea", accentDeep: "#157a40", onAccent: "#ffffff", tagline: "Les prix d'épicerie, suivis à la source", theme: "consommation" },
33 + { id: "resto-ka", wordmark: "Resto·Ka", domain: "www.resto-ka.com", accent: "#f08c00", accentSoft: "#fdeed7", accentDeep: "#b96a00", onAccent: "#141814", tagline: "Chaque resto, chaque plat, chaque prix", theme: "consommation" },
34 + { id: "fabri-ka", wordmark: "Fabri·Ka", domain: "www.fabri-ka.com", accent: "#c4532e", accentSoft: "#f7e3da", accentDeep: "#a94525", onAccent: "#ffffff", tagline: "Les produits fabriqués au Québec", theme: "consommation" },
35 + { id: "sorti-ka", wordmark: "Sorti·Ka", domain: "www.sorti-ka.com", accent: "#d6336c", accentSoft: "#fbe0eb", accentDeep: "#a12551", onAccent: "#ffffff", tagline: "Toutes les sorties, dans les 17 régions", theme: "culture" },
36 + { id: "crea-ka", wordmark: "Créa·Ka", domain: "www.crea-ka.com", accent: "#7048e8", accentSoft: "#ece5fc", accentDeep: "#5433b8", onAccent: "#ffffff", tagline: "Les créateurs d'ici, tous leurs liens", theme: "culture" },
37 + { id: "trouve-ka", wordmark: "Trouve·Ka", domain: "www.trouve-ka.com", accent: "#1c7ed6", accentSoft: "#e7f2fd", accentDeep: "#14508f", onAccent: "#ffffff", tagline: "Le moteur de recherche du web québécois", theme: "culture" },
38 + { id: "api-ka", wordmark: "API·Ka", domain: "www.api-ka.com", accent: "#3b5bdb", accentSoft: "#e4eafb", accentDeep: "#2b44a8", onAccent: "#ffffff", tagline: "La donnée de l'écosystème, par API", theme: "infrastructure" },
39 +];
40 +const SITE_BY_ID = Object.fromEntries(SITES.map((s) => [s.id, s]));
41 +
42 +const PERIODS = new Set(["auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"]);
43 +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
44 +
45 +/* ---- Cache (mémoire + disque) ---- */
46 +let cache = {};
47 +try { cache = JSON.parse(fs.readFileSync(CACHE_FILE, "utf8")); } catch { cache = {}; }
48 +let persistTimer = null;
49 +function persistCache() {
50 + clearTimeout(persistTimer);
51 + persistTimer = setTimeout(() => {
52 + // On ne persiste que les périodes standard (pas les plages custom, illimitées)
53 + const keep = {};
54 + for (const [k, v] of Object.entries(cache)) if (!k.includes("from=")) keep[k] = v;
55 + fs.writeFile(CACHE_FILE, JSON.stringify(keep), () => {});
56 + }, 2000);
57 +}
58 +
59 +const inflight = new Map();
60 +
61 +function buildQuery(q) {
62 + const period = PERIODS.has(q.period) ? q.period : "30j";
63 + const parts = [`period=${period}`];
64 + if (DATE_RE.test(q.from || "") && DATE_RE.test(q.to || "")) {
65 + parts.push(`from=${q.from}`, `to=${q.to}`);
66 + }
67 + return parts.join("&");
68 +}
69 +
70 +async function fetchRemote(site, query) {
71 + const url = `https://${site.domain}/api/stats/dashboard?${query}`;
72 + const ac = new AbortController();
73 + const timer = setTimeout(() => ac.abort(), 45000);
74 + try {
75 + const r = await fetch(url, {
76 + signal: ac.signal,
77 + headers: { "User-Agent": "ka-stats/1.0 (Groupe KA; +https://www.ka-stats.com)", Accept: "application/json" },
78 + });
79 + if (!r.ok) throw new Error(`HTTP ${r.status}`);
80 + let j = await r.json();
81 + if (j && j.success === true && j.data) j = j.data; // enveloppe api-ka
82 + if (!j || typeof j !== "object" || (!j.kpis && !j.series)) throw new Error("payload inattendu");
83 + return j;
84 + } finally {
85 + clearTimeout(timer);
86 + }
87 +}
88 +
89 +async function getDashboard(siteId, q) {
90 + const site = SITE_BY_ID[siteId];
91 + if (!site) return null;
92 + const query = buildQuery(q);
93 + const key = `${siteId}|${query}`;
94 + const hit = cache[key];
95 + const now = Date.now();
96 + if (hit && now - hit.at < TTL_MS) return { site: siteId, ok: true, stale: false, fetched: hit.at, data: hit.data };
97 + if (inflight.has(key)) return inflight.get(key);
98 + const p = (async () => {
99 + try {
100 + const data = await fetchRemote(site, query);
101 + cache[key] = { at: Date.now(), data };
102 + persistCache();
103 + return { site: siteId, ok: true, stale: false, fetched: cache[key].at, data };
104 + } catch (e) {
105 + if (hit) return { site: siteId, ok: true, stale: true, fetched: hit.at, error: String(e.message || e), data: hit.data };
106 + return { site: siteId, ok: false, stale: false, error: String(e.message || e), data: null };
107 + } finally {
108 + inflight.delete(key);
109 + }
110 + })();
111 + inflight.set(key, p);
112 + return p;
113 +}
114 +
115 +/* ---- Vue d'ensemble consolidée (calculée serveur, légère) ---- */
116 +const MONEY_RE = /\$/;
117 +function summarize(res) {
118 + const site = SITE_BY_ID[res.site];
119 + if (!res.ok || !res.data) return { id: res.site, ok: false, error: res.error || "injoignable" };
120 + const d = res.data;
121 + const kpis = Array.isArray(d.kpis) ? d.kpis : [];
122 + const primary = kpis[0] || null;
123 + const spark = (primary && primary.spark) || (d.series && d.series[0] && d.series[0].points) || [];
124 + const money = kpis.filter((k) => MONEY_RE.test(k.unit || "")).slice(0, 4)
125 + .map((k) => ({ label: k.label, value: k.value, unit: k.unit, delta_pct: k.delta_pct ?? null, spark: k.spark || null }));
126 + return {
127 + id: res.site, ok: true, stale: !!res.stale, updated: d.updated || null, fetched: res.fetched,
128 + period: d.period || null,
129 + primary: primary ? { id: primary.id, label: primary.label, value: primary.value, unit: primary.unit || "", delta_pct: primary.delta_pct ?? null } : null,
130 + spark: spark.slice(-90),
131 + kpis: kpis.slice(0, 8).map((k) => ({ id: k.id, label: k.label, value: k.value, unit: k.unit || "", delta_pct: k.delta_pct ?? null })),
132 + money,
133 + gauges: (d.gauges || []).slice(0, 3),
134 + records: (d.records || []).slice(0, 4),
135 + sections: {
136 + series: (d.series || []).length, multiseries: (d.multiseries || []).length,
137 + stacked: (d.stacked || []).length, breakdowns: (d.breakdowns || []).length,
138 + distributions: (d.distributions || []).length, tables: (d.tables || []).length,
139 + geo: d.geo ? 1 : 0, heatmap: d.heatmap ? 1 : 0, hourly: d.hourly ? 1 : 0,
140 + records: (d.records || []).length, kpis: kpis.length, gauges: (d.gauges || []).length,
141 + },
142 + theme: site.theme,
143 + };
144 +}
145 +
146 +async function getOverview(q) {
147 + const results = await Promise.all(SITES.map((s) => getDashboard(s.id, q)));
148 + const sites = results.map(summarize);
149 + // Volume agrégé par jour — même règle que le hub : seuls les points datés au
150 + // JOUR comptent (vrai-prix publie une série annuelle → exclue), un site doit
151 + // avoir ≥ 8 points quotidiens, on reporte la dernière valeur connue (stocks)
152 + // et on ne garde que les jours couverts par ≥ 70 % des sites inclus.
153 + const daily = sites
154 + .filter((s) => s.ok && Array.isArray(s.spark))
155 + .map((s) => ({ id: s.id, pts: s.spark.filter((p) => p && DATE_RE.test(String(p.t)) && typeof p.v === "number") }))
156 + .filter((s) => s.pts.length >= 8);
157 + const dates = [...new Set(daily.flatMap((s) => s.pts.map((p) => p.t)))].sort();
158 + const totalSpark = [];
159 + const lastVal = new Map();
160 + const ptMaps = daily.map((s) => new Map(s.pts.map((p) => [p.t, p.v])));
161 + for (const t of dates) {
162 + let sum = 0, covered = 0;
163 + daily.forEach((s, i) => {
164 + const v = ptMaps[i].get(t);
165 + if (typeof v === "number") lastVal.set(s.id, v);
166 + if (lastVal.has(s.id)) { sum += lastVal.get(s.id); covered++; }
167 + });
168 + if (covered >= Math.ceil(daily.length * 0.7)) totalSpark.push({ t, v: Math.round(sum) });
169 + }
170 + const totalItems = sites.reduce((acc, s) => acc + (s.ok && s.primary && typeof s.primary.value === "number" ? s.primary.value : 0), 0);
171 + const indicators = [];
172 + for (const s of sites) {
173 + if (!s.ok) continue;
174 + for (const m of s.money) indicators.push({ site: s.id, ...m });
175 + }
176 + const records = [];
177 + for (const [i, s] of sites.entries()) {
178 + if (!s.ok) continue;
179 + for (const r of s.records) records.push({ site: s.id, ...r });
180 + }
181 + const chartsTotal = sites.reduce((acc, s) => {
182 + if (!s.ok || !s.sections) return acc;
183 + const c = s.sections;
184 + return acc + c.series + c.multiseries + c.stacked + c.breakdowns + c.distributions + c.geo + c.heatmap + c.hourly + c.kpis + c.gauges;
185 + }, 0);
186 + return {
187 + updated: new Date().toISOString(),
188 + period: sites.find((s) => s.ok && s.period)?.period || null,
189 + totals: { items: totalItems, sitesLive: sites.filter((s) => s.ok).length, sites: SITES.length, charts: chartsTotal, spark: totalSpark },
190 + sites, indicators, records,
191 + };
192 +}
193 +
194 +/* ---- Catalogue de métriques (pour le Studio d'indicateurs sur mesure) ----
195 + Aplati tout ce qui est traçable dans les 12 dashboards en références
196 + adressables : site|kpi|<id> · site|series|<id> · site|multi|<id>|<i> ·
197 + site|stacked|<id>|<i>. /api/catalog = métadonnées ; /api/metric = points. */
198 +function catalogOf(siteId, d) {
199 + const site = SITE_BY_ID[siteId];
200 + const out = [];
201 + const push = (ref, label, unit, n) => out.push({ ref, site: siteId, wordmark: site.wordmark, label, unit: unit || "", n });
202 + for (const k of d.kpis || []) {
203 + if (Array.isArray(k.spark) && k.spark.length > 2) push(`${siteId}|kpi|${k.id}`, k.label, k.unit, k.spark.length);
204 + }
205 + for (const s of d.series || []) {
206 + if (Array.isArray(s.points) && s.points.length > 1) push(`${siteId}|series|${s.id}`, s.title, s.unit, s.points.length);
207 + }
208 + for (const m of d.multiseries || []) {
209 + (m.series || []).forEach((sub, i) => {
210 + if (Array.isArray(sub.points) && sub.points.length > 1) push(`${siteId}|multi|${m.id}|${i}`, `${m.title} — ${sub.label}`, m.unit, sub.points.length);
211 + });
212 + }
213 + for (const st of d.stacked || []) {
214 + (st.keys || []).forEach((key, i) => {
215 + const pts = (st.points || []).filter((p) => Array.isArray(p.values) && typeof p.values[i] === "number");
216 + if (pts.length > 1) push(`${siteId}|stacked|${st.id}|${i}`, `${st.title} — ${key}`, st.unit, pts.length);
217 + });
218 + }
219 + return out;
220 +}
221 +
222 +function resolveMetric(d, kind, id, sub) {
223 + if (kind === "kpi") {
224 + const k = (d.kpis || []).find((x) => x.id === id);
225 + return k && k.spark ? { points: k.spark, unit: k.unit || "", label: k.label } : null;
226 + }
227 + if (kind === "series") {
228 + const s = (d.series || []).find((x) => x.id === id);
229 + return s ? { points: s.points || [], unit: s.unit || "", label: s.title } : null;
230 + }
231 + if (kind === "multi") {
232 + const m = (d.multiseries || []).find((x) => x.id === id);
233 + const s = m && (m.series || [])[Number(sub)];
234 + return s ? { points: s.points || [], unit: m.unit || "", label: `${m.title} — ${s.label}` } : null;
235 + }
236 + if (kind === "stacked") {
237 + const st = (d.stacked || []).find((x) => x.id === id);
238 + if (!st) return null;
239 + const i = Number(sub);
240 + const key = (st.keys || [])[i];
241 + if (key === undefined) return null;
242 + const points = (st.points || []).filter((p) => Array.isArray(p.values)).map((p) => ({ t: p.t, v: p.values[i] ?? 0 }));
243 + return { points, unit: st.unit || "", label: `${st.title} — ${key}` };
244 + }
245 + return null;
246 +}
247 +
248 +async function getCatalog(q) {
249 + const results = await Promise.all(SITES.map((s) => getDashboard(s.id, q)));
250 + const metrics = [];
251 + for (const r of results) if (r.ok && r.data) metrics.push(...catalogOf(r.site, r.data));
252 + return { updated: new Date().toISOString(), count: metrics.length, metrics };
253 +}
254 +
255 +async function getMetric(ref, q) {
256 + const [siteId, kind, id, sub] = String(ref).split("|");
257 + if (!SITE_BY_ID[siteId]) return null;
258 + const r = await getDashboard(siteId, q);
259 + if (!r.ok || !r.data) return null;
260 + const m = resolveMetric(r.data, kind, id, sub);
261 + if (!m) return null;
262 + return { ref, site: siteId, ...m, stale: !!r.stale };
263 +}
264 +
265 +/* ---- HTTP ---- */
266 +const MIME = {
267 + ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8",
268 + ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon",
269 + ".ttf": "font/ttf", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8", ".xml": "application/xml; charset=utf-8",
270 + ".webmanifest": "application/manifest+json; charset=utf-8",
271 +};
272 +
273 +function sendJSON(res, code, obj) {
274 + const body = JSON.stringify(obj);
275 + res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "public, max-age=120", "Access-Control-Allow-Origin": "*" });
276 + res.end(body);
277 +}
278 +
279 +/* ---- SEO : SSR léger des metas — title/description/canonical/og + JSON-LD
280 + par route, injectés dans index.html (SPA rendue client, metas rendues
281 + serveur — même approche que les autres sites Ka). ---- */
282 +const BASE_URL = "https://www.ka-stats.com";
283 +const SEO_ROUTES = {
284 + "/": {
285 + title: "Ka·Stats — L'explorateur de statistiques du Québec",
286 + desc: "Loyers, propriétés, autos, emplois, épicerie, restos, sorties, créateurs : les statistiques vivantes du Québec, agrégées en direct depuis les 12 plateformes du Groupe KA. Graphiques interactifs, tendances, palmarès et rapports PDF.",
287 + },
288 + "/indicateurs": {
289 + title: "Les chiffres du Québec en direct — loyers, prix, salaires | Ka·Stats",
290 + desc: "Les indicateurs du Québec par thème : loyer moyen, prix des propriétés, valeur foncière, prix des voitures usagées, salaires affichés, prix d'épicerie — extraits en direct des plateformes du Groupe KA.",
291 + },
292 + "/comparer": {
293 + title: "Comparer les plateformes du Groupe KA — indice base 100 | Ka·Stats",
294 + desc: "Comparez la croissance des plateformes du Groupe KA sur un seul axe (indice base 100) : logements, propriétés, autos, emplois, produits, événements, créateurs, pages indexées.",
295 + },
296 + "/studio": {
297 + title: "Studio d'indicateurs — construisez vos graphiques sur mesure | Ka·Stats",
298 + desc: "Construisez votre propre indicateur du Québec : choisissez jusqu'à 4 métriques parmi plus de 200, appliquez une transformation (indice 100, variation, moyenne mobile, cumul) ou un ratio, choisissez le type de graphique, enregistrez et partagez.",
299 + },
300 + "/palmares": {
301 + title: "Palmarès & records des données du Québec | Ka·Stats",
302 + desc: "Jours records, plus fortes croissances et faits marquants détectés dans les données des 12 plateformes du Groupe KA : logements, propriétés, autos, emplois, épicerie, restos, sorties et plus.",
303 + },
304 +};
305 +function seoFor(pathname) {
306 + const clean = pathname.replace(/\/+$/, "") || "/";
307 + if (SEO_ROUTES[clean]) return { ...SEO_ROUTES[clean], path: clean === "/" ? "/" : clean };
308 + const m = /^\/site\/([a-z0-9-]+)$/.exec(clean);
309 + if (m && SITE_BY_ID[m[1]]) {
310 + const s = SITE_BY_ID[m[1]];
311 + return {
312 + title: `Statistiques ${s.wordmark} — ${s.tagline} | Ka·Stats`,
313 + desc: `Le tableau de bord statistique complet de ${s.wordmark} (${s.tagline.charAt(0).toLowerCase()}${s.tagline.slice(1)}) : indicateurs clés, évolution quotidienne, répartitions, distributions, records et rapports PDF — mis à jour en continu par Ka·Stats, l'observatoire du Groupe KA.`,
314 + path: clean,
315 + };
316 + }
317 + return { ...SEO_ROUTES["/"], path: "/" };
318 +}
319 +const escHtml = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
320 +let indexCache = { mtime: 0, html: "" };
321 +function renderIndex(pathname) {
322 + const file = path.join(PUB, "index.html");
323 + const mtime = fs.statSync(file).mtimeMs;
324 + if (mtime !== indexCache.mtime) indexCache = { mtime, html: fs.readFileSync(file, "utf8") };
325 + const m = seoFor(pathname);
326 + const url = BASE_URL + (m.path === "/" ? "/" : m.path);
327 + const jsonld = JSON.stringify({
328 + "@context": "https://schema.org",
329 + "@graph": [
330 + { "@type": "Organization", "@id": "https://www.groupe-ka.com/#org", name: "Groupe KA", url: "https://www.groupe-ka.com", logo: BASE_URL + "/apple-touch-icon.png", email: "contact@groupe-ka.com", areaServed: "CA-QC" },
331 + { "@type": "WebSite", "@id": BASE_URL + "/#website", name: "Ka·Stats", alternateName: "Ka-Stats — L'explorateur de statistiques du Québec", url: BASE_URL + "/", inLanguage: "fr-CA", publisher: { "@id": "https://www.groupe-ka.com/#org" } },
332 + { "@type": "WebPage", "@id": url + "#webpage", url, name: m.title, description: m.desc, inLanguage: "fr-CA", isPartOf: { "@id": BASE_URL + "/#website" }, primaryImageOfPage: BASE_URL + "/og.png" },
333 + ],
334 + });
335 + return indexCache.html
336 + .replace(/<title>[^<]*<\/title>/, `<title>${escHtml(m.title)}</title>`)
337 + .replace(/(<meta name="description" content=")[^"]*(")/, `$1${escHtml(m.desc)}$2`)
338 + .replace(/(<meta property="og:title" content=")[^"]*(")/, `$1${escHtml(m.title)}$2`)
339 + .replace(/(<meta property="og:description" content=")[^"]*(")/, `$1${escHtml(m.desc)}$2`)
340 + .replace(/(<meta name="twitter:title" content=")[^"]*(")/, `$1${escHtml(m.title)}$2`)
341 + .replace(/(<meta property="og:url" content=")[^"]*(")/, `$1${url}$2`)
342 + .replace("<!--SEO-->", `<link rel="canonical" href="${url}" />\n <script type="application/ld+json">${jsonld}</script>`);
343 +}
344 +
345 +function sendFile(res, file) {
346 + const ext = path.extname(file).toLowerCase();
347 + const mime = MIME[ext] || "application/octet-stream";
348 + const cacheCtl = ext === ".html" ? "no-cache" : "public, max-age=3600";
349 + fs.readFile(file, (err, buf) => {
350 + if (err) { res.writeHead(404, { "Content-Type": "text/plain" }); res.end("404"); return; }
351 + res.writeHead(200, { "Content-Type": mime, "Cache-Control": cacheCtl });
352 + res.end(buf);
353 + });
354 +}
355 +
356 +const server = http.createServer(async (req, res) => {
357 + const u = new URL(req.url, "http://x");
358 + const p = u.pathname;
359 + const q = Object.fromEntries(u.searchParams.entries());
360 + try {
361 + if (p === "/healthz" || p === "/api/health") return sendJSON(res, 200, { ok: true, app: "ka-stats", sites: SITES.length, uptime: process.uptime() });
362 + if (p === "/api/sites") return sendJSON(res, 200, { sites: SITES });
363 + if (p === "/api/overview") return sendJSON(res, 200, await getOverview(q));
364 + if (p === "/api/catalog") return sendJSON(res, 200, await getCatalog(q));
365 + if (p === "/api/metric") {
366 + const m = await getMetric(q.ref || "", q);
367 + return m ? sendJSON(res, 200, m) : sendJSON(res, 404, { error: "métrique introuvable", ref: q.ref || "" });
368 + }
369 + if (p.startsWith("/api/dashboard/")) {
370 + const id = p.slice("/api/dashboard/".length).replace(/[^a-z0-9-]/g, "");
371 + if (!SITE_BY_ID[id]) return sendJSON(res, 404, { error: "plateforme inconnue", id });
372 + const r = await getDashboard(id, q);
373 + return sendJSON(res, r.ok ? 200 : 502, r);
374 + }
375 + if (p.startsWith("/api/")) return sendJSON(res, 404, { error: "route inconnue" });
376 + // Statique + repli SPA avec metas SSR (routes /, /site/:id, /indicateurs, /comparer, /studio, /palmares)
377 + const file = path.normalize(path.join(PUB, p === "/" ? "index.html" : p));
378 + if (!file.startsWith(PUB)) { res.writeHead(403); return res.end(); }
379 + if (p === "/" || p === "/index.html" || !path.extname(file) || !fs.existsSync(file)) {
380 + const html = renderIndex(p);
381 + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
382 + return res.end(html);
383 + }
384 + return sendFile(res, file);
385 + } catch (e) {
386 + return sendJSON(res, 500, { error: String((e && e.message) || e) });
387 + }
388 +});
389 +
390 +server.listen(PORT, "0.0.0.0", () => {
391 + console.log(`[ka-stats] en écoute sur :${PORT} — ${SITES.length} plateformes agrégées, cache ${TTL_MS / 60000} min`);
392 +});
393