JavaScript 73.4%
CSS 25.3%
HTML 1.3%
1/* -----------------------------------------------------------------------------2 Auteur : Simon-Pierre Boucher — contact@spboucher.ai3 Fichier : ka-stats/public/app.js4 Desc. : Ka·Stats — l'explorateur de statistiques du Québec (Groupe KA).5 SPA vanilla : routeur (History API), sélecteur de période global6 (une seule rangée au-dessus du contenu, s'applique à tout),7 5 vues : Vue d'ensemble · Plateforme (rendu générique du contrat8 ka-stats v2) · Indicateurs · Comparateur · Palmarès.9----------------------------------------------------------------------------- */10"use strict";1112const Q = QCharts;13const $app = document.getElementById("app");14const state = {15 period: localStorage.getItem("qcstats_period") || "30j",16 from: "", to: "",17 sites: [], siteById: {},18};19const 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" };20const 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];2829/* ---------- réseau (cache client léger) ---------- */30const memo = new Map();31async 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}42function 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}47const apiOverview = () => getJSON("/api/overview?" + periodQS());48const apiDash = (id) => getJSON("/api/dashboard/" + id + "?" + periodQS());49const apiCatalog = () => getJSON("/api/catalog?" + periodQS());50const apiMetric = (ref) => getJSON("/api/metric?ref=" + encodeURIComponent(ref) + "&" + periodQS());5152/* ---------- helpers dom ---------- */53const el = Q.el;54function link(href, cls, text) {55 const a = el("a", cls, text);56 a.href = href;57 a.dataset.link = "1";58 return a;59}60function 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}66function 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}74function 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}80function 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}8586/* ---------- sélecteur de période (une rangée, au-dessus de tout) ---------- */87function 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ée102 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}119120/* ---------- cartes de graphique commutables + outils (CSV/PNG/Studio) ---------- */121function studioHrefFor(refs) {122 return "/studio?c=" + encodeURIComponent(JSON.stringify({ refs, transform: "brut", combine: "aucune", kind: "line" }));123}124function 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}141function 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}155function 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}166167/* ================================================================168 VUE D'ENSEMBLE169 ================================================================ */170async function pageHome(root) {171 document.title = "Ka·Stats — L'explorateur de statistiques du Québec";172 const ov = await apiOverview();173174 // — 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);198199 const main = el("div", "container");200201 // — 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);222223 // — 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);272273 // — 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 }294295 // — 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 }304305 root.appendChild(main);306}307308function 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}321322/* ================================================================323 PAGE PLATEFORME — rendu générique du contrat ka-stats v2 (ordre SPEC §1)324 ================================================================ */325async 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);332333 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);352353 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);365366 const stroke = Q.strokeFor(meta.accent, meta.accentDeep);367368 // 1. bandeau KPI369 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. jauges375 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. évolutions381 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 distincts390 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éo403 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. calendriers421 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. tableaux428 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. records434 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}442443function 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}458459/* ================================================================460 INDICATEURS — les chiffres du Québec par thème461 ================================================================ */462async 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);474475 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;480481 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ème507 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}530531/* ================================================================532 COMPARATEUR — jusqu'à 4 plateformes, indice base 100 (un seul axe)533 ================================================================ */534const cmpState = { sel: ["lou-ka", "trouve-ka", "food-ka", "crea-ka"], mode: "indice" };535async 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);547548 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 }576577 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 croissance596 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}614615/* ================================================================616 PALMARÈS — records + croissances617 ================================================================ */618async 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);630631 const ov = await apiOverview();632 const main = el("div", "container");633634 // 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}665666/* ================================================================667 STUDIO — constructeur d'indicateurs sur mesure668 ================================================================ */669const TRANSFORMS = [670 ["brut", "Valeurs brutes"], ["indice", "Indice 100"], ["variation", "Variation % (jour)"],671 ["mm7", "Moyenne mobile 7 j"], ["cumul", "Cumul"],672];673const COMBINES = [["aucune", "Séries séparées"], ["ratio", "A ÷ B"], ["diff", "A − B"], ["somme", "A + B"]];674const KINDS = [["line", "Ligne"], ["area", "Aire"], ["bar", "Barres"]];675const studio = { refs: [], transform: "brut", combine: "aucune", kind: "line", title: "", search: "", loadedC: null };676677function 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}686function 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}698function studioSaved() { try { return JSON.parse(localStorage.getItem("kastats_studio") || "[]"); } catch { return []; } }699function studioStore(list) { localStorage.setItem("kastats_studio", JSON.stringify(list)); }700function studioCfg() { return { refs: studio.refs, transform: studio.transform, combine: studio.combine, kind: studio.kind, title: studio.title }; }701function studioShareUrl(cfg) { return location.origin + "/studio?c=" + encodeURIComponent(JSON.stringify(cfg)); }702703/* Construit les séries finales (fetch + combine + transform) d'une config. */704async 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}721function tfClean(points) { return (points || []).filter((p) => typeof p.v === "number" && /^\d{4}-\d{2}-\d{2}/.test(String(p.t))); }722723/* Rend la carte finale d'un indicateur custom (avec commutateur + exports). */724function 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}743744async function pageStudio(root) {745 document.title = "Studio d'indicateurs — Ka·Stats";746 // config passée par lien partagé (?c=…) — chargée une seule fois747 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 }761762 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 13 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);772773 const main = el("div", "container");774 const cat = await apiCatalog();775776 /* ---- constructeur ---- */777 const builder = el("section", "studio card");778 const bIn = el("div", "studio-in");779780 // 1. sélection des métriques781 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);800801 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);834835 // 2. transformation / combinaison / type836 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);857858 // titre + actions859 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);887888 // aperçu live889 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 }895896 /* ---- 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}938939/* ================================================================940 COQUILLE : header / nav / footer / routeur941 ================================================================ */942const ACCENT = { stroke: "#095797" }; // bleu Québec — accent Ka·Stats (contraste 8,7:1 sur blanc)943944function 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);967968 // 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);979980 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 context10151016 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}10271028function 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 13 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}10781079let rendering = false;1080async 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 actif1100 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}11111112document.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});1120window.addEventListener("popstate", render);11211122(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})();11301131;/*KA_ANALYTICS*/(function(){try{if(window.__kaAnL)return;window.__kaAnL=1;window.__kaSite="ka-stats";var s=document.createElement('script');s.async=1;s.src="https://www.administration-ka.com/ka-a.js";(document.head||document.documentElement).appendChild(s);}catch(e){}})();1132