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>
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | |
| 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 | ||