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
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added
public/fonts/JetBrainsMono-Bold.ttf
+0 −0
Binary file not shown.
added
public/fonts/JetBrainsMono-Regular.ttf
+0 −0
Binary file not shown.
added
public/fonts/SpaceGrotesk-Bold.ttf
+0 −0
Binary file not shown.
added
public/fonts/SpaceGrotesk-Medium.ttf
+0 −0
Binary file not shown.
added
public/fonts/SpaceGrotesk-Regular.ttf
+0 −0
Binary file not shown.
added
public/index.html
+39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added
public/og.png
+0 −0
Binary file not shown.
added
public/robots.txt
+4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added
public/sitemap.xml
+20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added
public/styles.css
+349 −0
Large diff (349 lines) is not loaded by default to keep the page fast.Load diff
added
public/tokens.css
+319 −0
Large diff (319 lines) is not loaded by default to keep the page fast.Load diff
added
server.mjs
+392 −0
Large diff (393 lines) is not loaded by default to keep the page fast.Load diff