SPB Git forge

spb/ka-stats

Public
8commits 1branches 0releases
2.2 MBsize
maindefault branch
27 days agolast push
JavaScript 73.4% CSS 25.3% HTML 1.3%
53.1 KB · 1,132 lines javascript
Raw Blame History
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