/* ----------------------------------------------------------------------------- Auteur : Simon-Pierre Boucher — contact@spboucher.ai Fichier : ka-stats/public/app.js Desc. : Ka·Stats — l'explorateur de statistiques du Québec (Groupe KA). SPA vanilla : routeur (History API), sélecteur de période global (une seule rangée au-dessus du contenu, s'applique à tout), 5 vues : Vue d'ensemble · Plateforme (rendu générique du contrat ka-stats v2) · Indicateurs · Comparateur · Palmarès. ----------------------------------------------------------------------------- */ "use strict"; const Q = QCharts; const $app = document.getElementById("app"); const state = { period: localStorage.getItem("qcstats_period") || "30j", from: "", to: "", sites: [], siteById: {}, }; const PERIOD_LABELS = { auj: "Aujourd'hui", "7j": "7 jours", "30j": "30 jours", "3m": "3 mois", "6m": "6 mois", "12m": "12 mois", annee: "Année en cours", tout: "Tout" }; const THEMES = [ { id: "habitation", label: "Habitation", desc: "Loyers, propriétés à vendre et valeur foncière — le marché de l'habitation au complet." }, { id: "mobilite", label: "Mobilité", desc: "Le marché des voitures usagées du Québec." }, { id: "emploi", label: "Emploi", desc: "Les offres des employeurs québécois et leurs salaires affichés." }, { id: "consommation", label: "Consommation", desc: "Épicerie, restos et produits fabriqués au Québec." }, { id: "culture", label: "Culture & découverte", desc: "Sorties, créateurs d'ici et web québécois." }, { id: "infrastructure", label: "Infrastructure", desc: "La donnée de l'écosystème, servie par API." }, ]; /* ---------- réseau (cache client léger) ---------- */ const memo = new Map(); async function getJSON(url) { if (memo.has(url)) { const m = memo.get(url); if (Date.now() - m.at < 5 * 60 * 1000) return m.data; } const r = await fetch(url); if (!r.ok) throw new Error("HTTP " + r.status); const data = await r.json(); memo.set(url, { at: Date.now(), data }); return data; } function periodQS() { let qs = "period=" + encodeURIComponent(state.period); if (state.from && state.to) qs += `&from=${state.from}&to=${state.to}`; return qs; } const apiOverview = () => getJSON("/api/overview?" + periodQS()); const apiDash = (id) => getJSON("/api/dashboard/" + id + "?" + periodQS()); const apiCatalog = () => getJSON("/api/catalog?" + periodQS()); const apiMetric = (ref) => getJSON("/api/metric?ref=" + encodeURIComponent(ref) + "&" + periodQS()); /* ---------- helpers dom ---------- */ const el = Q.el; function link(href, cls, text) { const a = el("a", cls, text); a.href = href; a.dataset.link = "1"; return a; } function section(title, kicker) { const s = el("section", "block"); if (kicker) s.appendChild(el("div", "kicker", kicker)); if (title) s.appendChild(el("h2", null, title)); return s; } function siteChip(site) { const c = el("span", "site-chip"); const dot = el("span", "site-dot"); dot.style.background = site.accent; c.appendChild(dot); c.appendChild(el("span", null, site.wordmark)); return c; } function setAccent(node, site) { node.style.setProperty("--accent", site.accent); node.style.setProperty("--accent-soft", site.accentSoft); node.style.setProperty("--accent-deep", site.accentDeep); node.style.setProperty("--on-accent", site.onAccent); } function updatedLine(iso, stale) { const d = iso ? new Date(iso) : null; const txt = d ? "Mis à jour le " + d.toLocaleString("fr-CA", { day: "numeric", month: "long", hour: "2-digit", minute: "2-digit" }) : ""; return el("span", "klabel fresh" + (stale ? " stale" : ""), txt + (stale ? " · copie en cache" : "")); } /* ---------- sélecteur de période (une rangée, au-dessus de tout) ---------- */ function periodBar() { const bar = el("div", "period-bar"); const inner = el("div", "container period-inner"); inner.appendChild(el("span", "klabel", "Période")); const row = el("div", "period-row"); for (const [id, label] of Object.entries(PERIOD_LABELS)) { const b = el("button", "pill" + (state.period === id && !state.from ? " active" : ""), label); b.addEventListener("click", () => { state.period = id; state.from = ""; state.to = ""; localStorage.setItem("qcstats_period", id); render(); }); row.appendChild(b); } // plage personnalisée const custom = el("span", "period-custom"); const f = el("input", "input date-in"); f.type = "date"; f.value = state.from; const t = el("input", "input date-in"); t.type = "date"; t.value = state.to; const go = el("button", "pill", "Appliquer"); go.addEventListener("click", () => { if (f.value && t.value) { state.from = f.value; state.to = t.value; render(); } }); custom.appendChild(el("span", "klabel", "· personnalisé : du")); custom.appendChild(f); custom.appendChild(el("span", "klabel", "au")); custom.appendChild(t); custom.appendChild(go); row.appendChild(custom); inner.appendChild(row); bar.appendChild(inner); return bar; } /* ---------- cartes de graphique commutables + outils (CSV/PNG/Studio) ---------- */ function studioHrefFor(refs) { return "/studio?c=" + encodeURIComponent(JSON.stringify({ refs, transform: "brut", combine: "aucune", kind: "line" })); } function timeSeriesCard(siteId, s, stroke) { const initial = s.kind === "bar" ? "bar" : s.kind === "area" ? "area" : "line"; const rows = (s.points || []).map((p) => [Q.fmtDateFull(p.t), p.v]); return Q.switchableChart({ kinds: [{ id: "line", label: "Ligne" }, { id: "area", label: "Aire" }, { id: "bar", label: "Barres" }], initial, csv: { name: `ka-stats_${siteId}_${s.id}`, columns: ["Date", s.title], rows }, pngName: `ka-stats_${siteId}_${s.id}`, studioHref: studioHrefFor([`${siteId}|series|${s.id}`]), render(k) { if (k === "bar") return Q.vBarChart({ title: s.title, unit: s.unit, points: s.points, color: stroke }); const series = [{ label: s.title, color: stroke, points: s.points }]; 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 })) }); return Q.lineChart({ title: s.title, unit: s.unit, series, area: k === "area" }); }, }); } function multiSeriesCard(siteId, m) { const subs = (m.series || []).slice(0, 4); const ts = [...new Set(subs.flatMap((s) => (s.points || []).map((p) => p.t)))].sort(); const mapsL = subs.map((s) => new Map((s.points || []).map((p) => [p.t, p.v]))); return Q.switchableChart({ 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) ?? "")]) }, pngName: `ka-stats_${siteId}_${m.id}`, studioHref: studioHrefFor(subs.map((_, i) => `${siteId}|multi|${m.id}|${i}`)), render() { const series = subs.map((s, i) => ({ label: s.label, color: Q.CAT[i], dash: Q.DASHES[i], points: s.points || [] })); return Q.lineChart({ title: m.title, unit: m.unit, series, summary: false }); }, }); } function breakdownCard(siteId, b, stroke) { return Q.switchableChart({ kinds: [{ id: "bars", label: "Barres" }, { id: "donut", label: "Anneau" }], initial: b.kind === "donut" ? "donut" : "bars", csv: { name: `ka-stats_${siteId}_${b.id}`, columns: ["Catégorie", b.unit || "Valeur"], rows: (b.items || []).map((it) => [it.label, it.value]) }, pngName: `ka-stats_${siteId}_${b.id}`, render(k) { 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 }); }, }); } /* ================================================================ VUE D'ENSEMBLE ================================================================ */ async function pageHome(root) { document.title = "Ka·Stats — L'explorateur de statistiques du Québec"; const ov = await apiOverview(); // — héros — const hero = el("header", "hero"); const hc = el("div", "container"); hc.appendChild(el("div", "kicker", "Groupe KA · Observatoire de données")); const h1 = el("h1"); h1.appendChild(document.createTextNode("L'explorateur de ")); h1.appendChild(el("span", "hl", "statistiques")); h1.appendChild(document.createTextNode(" du Québec")); hc.appendChild(h1); 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.")); const heroFig = el("div", "hero-figure"); heroFig.appendChild(el("div", "hero-num", Q.fmtNum(ov.totals.items))); heroFig.appendChild(el("div", "hero-sub", "éléments suivis en direct — " + PERIOD_LABELS[state.period].toLowerCase())); hc.appendChild(heroFig); const chips = el("div", "hero-chips"); chips.appendChild(el("span", "chip chip-accent", ov.totals.sitesLive + "/" + ov.totals.sites + " plateformes en ligne")); chips.appendChild(el("span", "chip", Q.fmtNum(ov.totals.charts) + " indicateurs & graphiques")); chips.appendChild(el("span", "chip", "Données 100 % réelles")); hc.appendChild(chips); const cta = el("div", "hero-cta"); cta.appendChild(link("/studio", "btn btn-primary", "✦ Construire un indicateur sur mesure")); hc.appendChild(cta); hero.appendChild(hc); root.appendChild(hero); const main = el("div", "container"); // — pouls de l'écosystème — const pulse = section("Le pouls de l'écosystème", "Volume agrégé"); pulse.appendChild(el("p", "sect-note", "Somme quotidienne des indicateurs principaux des " + ov.totals.sites + " plateformes (annonces, produits, offres, événements, créateurs, pages…).")); if (ov.totals.spark.length > 1) { const pts = ov.totals.spark; pulse.appendChild(Q.switchableChart({ kinds: [{ id: "line", label: "Ligne" }, { id: "area", label: "Aire" }, { id: "bar", label: "Barres" }], initial: "area", csv: { name: "ka-stats_ecosysteme", columns: ["Date", "Éléments suivis"], rows: pts.map((p) => [Q.fmtDateFull(p.t), p.v]) }, pngName: "ka-stats_ecosysteme", render(k) { if (k === "bar") return Q.vBarChart({ title: "Éléments suivis par jour, toutes plateformes", unit: "éléments", points: pts, color: ACCENT.stroke }); return Q.lineChart({ title: "Éléments suivis par jour, toutes plateformes", unit: "éléments", series: [{ label: "Écosystème Groupe KA", color: ACCENT.stroke, points: pts }], area: k === "area", baselineZero: true, }); }, })); } main.appendChild(pulse); // — grille des plateformes — const grid = section("Les " + ov.totals.sites + " plateformes", "Explorer"); const cards = el("div", "site-grid"); for (const s of ov.sites) { const meta = state.siteById[s.id]; const card = el("article", "site-card card card-hover"); setAccent(card, meta); const band = el("div", "site-band"); band.appendChild(el("span", "site-wordmark", meta.wordmark)); band.appendChild(el("span", "site-tagline", meta.tagline)); card.appendChild(band); const body = el("div", "site-body"); if (s.ok && s.primary) { const prim = el("div", "site-primary"); prim.appendChild(el("div", "klabel", s.primary.label)); const v = el("div", "site-primary-val", Q.fmtNum(s.primary.value, s.primary.unit)); prim.appendChild(v); if (typeof s.primary.delta_pct === "number") { 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") + " %")); } body.appendChild(prim); if (s.spark && s.spark.length > 2) { const sp = el("div", "site-spark"); sp.appendChild(Q.sparkline(s.spark, Q.strokeFor(meta.accent, meta.accentDeep), 220, 44)); body.appendChild(sp); } const minis = el("div", "site-minis"); for (const k of s.kpis.slice(1, 4)) { const mi = el("div", "site-mini"); mi.appendChild(el("b", null, Q.fmtNum(k.value, k.unit))); mi.appendChild(el("span", "klabel", k.label.length > 30 ? k.label.slice(0, 29) + "…" : k.label)); minis.appendChild(mi); } body.appendChild(minis); } else { body.appendChild(el("div", "viz-empty", "Plateforme momentanément injoignable")); } const foot = el("div", "site-foot"); foot.appendChild(link("/site/" + s.id, "btn btn-primary site-btn", "Explorer les stats")); const ext = el("a", "btn btn-ghost site-btn", "Visiter"); ext.href = "https://" + meta.domain; ext.target = "_blank"; ext.rel = "noopener"; foot.appendChild(ext); card.appendChild(body); card.appendChild(foot); cards.appendChild(card); } grid.appendChild(cards); main.appendChild(grid); // — indicateurs en dollars — if (ov.indicators.length) { const ind = section("Les prix du Québec, en direct", "Indicateurs"); const strip = el("div", "ind-grid"); for (const it of ov.indicators.slice(0, 12)) { const meta = state.siteById[it.site]; const c = el("div", "ind-card card"); setAccent(c, meta); c.appendChild(siteChip(meta)); c.appendChild(el("div", "klabel", it.label)); c.appendChild(el("div", "ind-val", Q.fmtNum(it.value, it.unit))); 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") + " %")); if (it.spark && it.spark.length > 2) c.appendChild(Q.sparkline(it.spark, Q.strokeFor(meta.accent, meta.accentDeep), 150, 30)); strip.appendChild(c); } ind.appendChild(strip); const more = el("div", "sect-more"); more.appendChild(link("/indicateurs", "btn", "Tous les indicateurs →")); ind.appendChild(more); main.appendChild(ind); } // — records — if (ov.records.length) { const rec = section("Records & faits marquants", "Palmarès"); rec.appendChild(recordsGrid(ov.records.slice(0, 9))); const more = el("div", "sect-more"); more.appendChild(link("/palmares", "btn", "Le palmarès complet →")); rec.appendChild(more); main.appendChild(rec); } root.appendChild(main); } function recordsGrid(records) { const g = el("div", "rec-grid"); for (const r of records) { const meta = state.siteById[r.site]; const c = el("div", "rec-card card"); if (meta) { setAccent(c, meta); c.appendChild(siteChip(meta)); } c.appendChild(el("div", "rec-val", String(r.value ?? "—"))); c.appendChild(el("div", "rec-label", r.label || "")); if (r.date) c.appendChild(el("div", "klabel", Q.fmtDateFull(r.date))); g.appendChild(c); } return g; } /* ================================================================ PAGE PLATEFORME — rendu générique du contrat ka-stats v2 (ordre SPEC §1) ================================================================ */ async function pageSite(root, id) { const meta = state.siteById[id]; if (!meta) { root.appendChild(el("div", "container viz-empty", "Plateforme inconnue.")); return; } document.title = meta.wordmark + " — Ka·Stats"; const res = await apiDash(id); const wrap = el("div", "site-page"); setAccent(wrap, meta); const head = el("header", "site-head"); const hc = el("div", "container"); const crumb = el("div", "crumb"); crumb.appendChild(link("/", "crumb-link", "← Toutes les plateformes")); hc.appendChild(crumb); const row = el("div", "site-head-row"); const idb = el("div"); idb.appendChild(el("h1", "site-h1", meta.wordmark)); idb.appendChild(el("p", "lead", meta.tagline)); row.appendChild(idb); const act = el("div", "site-actions"); const visit = el("a", "btn", "Visiter " + meta.wordmark + " ↗"); visit.href = "https://" + meta.domain; visit.target = "_blank"; visit.rel = "noopener"; act.appendChild(visit); act.appendChild(pdfMenu(meta)); row.appendChild(act); hc.appendChild(row); head.appendChild(hc); wrap.appendChild(head); const main = el("div", "container"); if (!res.ok || !res.data) { main.appendChild(el("div", "viz-empty big", "Plateforme momentanément injoignable — " + (res.error || ""))); wrap.appendChild(main); root.appendChild(wrap); return; } const d = res.data; const fresh = el("div", "fresh-row"); fresh.appendChild(updatedLine(d.updated, res.stale)); if (d.period && d.period.from) fresh.appendChild(el("span", "klabel", "· du " + Q.fmtDateFull(d.period.from) + " au " + Q.fmtDateFull(d.period.to))); main.appendChild(fresh); const stroke = Q.strokeFor(meta.accent, meta.accentDeep); // 1. bandeau KPI if (d.kpis && d.kpis.length) { const g = el("div", "kpi-grid"); for (const k of d.kpis) g.appendChild(Q.kpiCard(k, meta.accent, meta.accentDeep)); main.appendChild(g); } // 3. jauges if (d.gauges && d.gauges.length) { const g = el("div", "gauge-grid"); 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) })); main.appendChild(g); } // 4. évolutions if (d.series && d.series.length) { const sec = section("Évolution", "Séries temporelles"); for (const s of d.series) { if (!s.points || !s.points.length) continue; sec.appendChild(timeSeriesCard(id, s, stroke)); } main.appendChild(sec); } // multi-courbes ≤ 4 : palette catégorielle + motifs de trait distincts if (d.multiseries && d.multiseries.length) { const sec = section("Comparaisons", "Multi-courbes"); for (const m of d.multiseries) { if ((m.series || []).some((s) => s.points && s.points.length)) sec.appendChild(multiSeriesCard(id, m)); } main.appendChild(sec); } if (d.stacked && d.stacked.length) { const sec = section("Composition dans le temps", "Empilées"); for (const st of d.stacked) sec.appendChild(Q.stackedBar({ title: st.title, unit: st.unit, keys: st.keys, points: st.points })); main.appendChild(sec); } // 5-6. répartitions + distributions + géo const rep = []; for (const b of d.breakdowns || []) { if (!b.items || !b.items.length) continue; rep.push(breakdownCard(id, b, stroke)); } for (const dist of d.distributions || []) { if (!dist.bins || !dist.bins.length) continue; 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 })); } 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 })); if (rep.length) { const sec = section("Répartitions", "Structure"); const g = el("div", "two-col"); rep.forEach((c) => g.appendChild(c)); sec.appendChild(g); main.appendChild(sec); } // 7. calendriers if ((d.heatmap && d.heatmap.cells && d.heatmap.cells.length) || (d.hourly && d.hourly.cells && d.hourly.cells.length)) { const sec = section("Rythmes d'activité", "Calendriers"); 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 })); 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 })); main.appendChild(sec); } // 8. tableaux if (d.tables && d.tables.length) { const sec = section("Tableaux détaillés", "Données"); for (const t of d.tables) sec.appendChild(Q.dataTable({ title: t.title, columns: t.columns, rows: t.rows })); main.appendChild(sec); } // 9. records if (d.records && d.records.length) { const sec = section("Records & faits marquants", "Palmarès " + meta.wordmark); sec.appendChild(recordsGrid(d.records.map((r) => ({ ...r, site: id })))); main.appendChild(sec); } wrap.appendChild(main); root.appendChild(wrap); } function pdfMenu(meta) { const det = el("details", "pdf-menu"); det.appendChild(el("summary", "btn btn-accent", "Rapport PDF ▾")); const box = el("div", "ka-menu pdf-box"); const modes = [["complet", "Rapport complet"], ["synthese", "Synthèse"], ["tendances", "Tendances"], ["repartitions", "Répartitions"], ["donnees", "Données (long)"]]; for (const [m, label] of modes) { const a = el("a", null, label); a.href = `https://${meta.domain}/api/stats/report?${periodQS()}&mode=${m}`; a.target = "_blank"; a.rel = "noopener"; box.appendChild(a); } det.appendChild(box); document.addEventListener("click", (e) => { if (!det.contains(e.target)) det.open = false; }); return det; } /* ================================================================ INDICATEURS — les chiffres du Québec par thème ================================================================ */ async function pageIndicateurs(root) { document.title = "Indicateurs du Québec — Ka·Stats"; const head = el("header", "page-head"); const hc = el("div", "container"); hc.appendChild(el("div", "kicker", "Indicateurs")); const h1 = el("h1"); h1.appendChild(document.createTextNode("Les chiffres du ")); h1.appendChild(el("span", "hl", "Québec")); hc.appendChild(h1); 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.")); head.appendChild(hc); root.appendChild(head); const dashes = await Promise.all(state.sites.map((s) => apiDash(s.id).catch(() => null))); const byId = {}; state.sites.forEach((s, i) => (byId[s.id] = dashes[i])); const main = el("div", "container"); const PRICE_RE = /prix|loyer|salaire|valeur|coût|cout|rabais|\$/i; for (const theme of THEMES) { const members = state.sites.filter((s) => s.theme === theme.id); if (!members.length) continue; const sec = section(theme.label, theme.desc.split("—")[0]); sec.appendChild(el("p", "sect-note", theme.desc)); const kpiRow = el("div", "ind-grid"); let any = false; for (const m of members) { const r = byId[m.id]; if (!r || !r.ok || !r.data) continue; const kpis = (r.data.kpis || []).filter((k) => PRICE_RE.test((k.unit || "") + " " + k.label)).slice(0, 4); const shown = kpis.length ? kpis : (r.data.kpis || []).slice(0, 2); for (const k of shown) { const c = el("div", "ind-card card"); setAccent(c, m); c.appendChild(siteChip(m)); c.appendChild(el("div", "klabel", k.label)); c.appendChild(el("div", "ind-val", Q.fmtNum(k.value, k.unit))); 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") + " %")); if (k.spark && k.spark.length > 2) c.appendChild(Q.sparkline(k.spark, Q.strokeFor(m.accent, m.accentDeep), 150, 30)); kpiRow.appendChild(c); any = true; } } if (any) sec.appendChild(kpiRow); // distributions & multiséries « prix » du thème const charts = el("div", "two-col"); let anyCharts = false; for (const m of members) { const r = byId[m.id]; if (!r || !r.ok || !r.data) continue; const stroke = Q.strokeFor(m.accent, m.accentDeep); const dist = (r.data.distributions || []).find((x) => PRICE_RE.test(x.title || "")) || (r.data.distributions || [])[0]; if (dist && dist.bins && dist.bins.length) { 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 })); anyCharts = true; } const ms = (r.data.multiseries || []).find((x) => PRICE_RE.test(x.title || "")); if (ms && ms.series && ms.series.length) { const series = ms.series.slice(0, 4).map((s, i) => ({ label: s.label, color: Q.CAT[i], dash: Q.DASHES[i], points: s.points || [] })); charts.appendChild(Q.lineChart({ title: m.wordmark + " — " + ms.title, unit: ms.unit, series, summary: false })); anyCharts = true; } } if (anyCharts) sec.appendChild(charts); if (any || anyCharts) main.appendChild(sec); } root.appendChild(main); } /* ================================================================ COMPARATEUR — jusqu'à 4 plateformes, indice base 100 (un seul axe) ================================================================ */ const cmpState = { sel: ["lou-ka", "trouve-ka", "food-ka", "crea-ka"], mode: "indice" }; async function pageComparer(root) { document.title = "Comparateur — Ka·Stats"; const head = el("header", "page-head"); const hc = el("div", "container"); hc.appendChild(el("div", "kicker", "Comparateur")); const h1 = el("h1"); h1.appendChild(el("span", "hl", "Comparer")); h1.appendChild(document.createTextNode(" les plateformes")); hc.appendChild(h1); 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.")); head.appendChild(hc); root.appendChild(head); const ov = await apiOverview(); const main = el("div", "container"); const controls = el("div", "cmp-controls"); const chipsRow = el("div", "cmp-chips"); for (const s of state.sites) { const on = cmpState.sel.includes(s.id); const b = el("button", "pill site-pill" + (on ? " active" : ""), s.wordmark); if (on) { b.style.background = s.accent; b.style.color = s.onAccent; b.style.borderColor = s.accent; } b.addEventListener("click", () => { if (on) cmpState.sel = cmpState.sel.filter((x) => x !== s.id); else if (cmpState.sel.length < 4) cmpState.sel = [...cmpState.sel, s.id]; render(); }); if (!on && cmpState.sel.length >= 4) { b.disabled = true; b.title = "Maximum 4 plateformes à la fois"; } chipsRow.appendChild(b); } controls.appendChild(chipsRow); const modeRow = el("div", "cmp-mode"); for (const [m, label] of [["indice", "Indice (base 100)"], ["brut", "Valeurs brutes"]]) { const b = el("button", "pill" + (cmpState.mode === m ? " active" : ""), label); b.addEventListener("click", () => { cmpState.mode = m; render(); }); modeRow.appendChild(b); } controls.appendChild(modeRow); main.appendChild(controls); if (cmpState.mode === "brut" && cmpState.sel.length > 1) { 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.")); } const chosen = ov.sites.filter((s) => cmpState.sel.includes(s.id) && s.ok && s.spark && s.spark.length > 1); if (!chosen.length) { main.appendChild(el("div", "viz-empty big", "Choisissez au moins une plateforme.")); } else { const series = chosen.map((s, i) => { const meta = state.siteById[s.id]; let pts = s.spark.filter((p) => typeof p.v === "number" && /^\d{4}-\d{2}-\d{2}/.test(String(p.t))); if (cmpState.mode === "indice") { const base = pts.find((p) => p.v > 0); if (base) pts = pts.map((p) => ({ t: p.t, v: Math.round((p.v / base.v) * 1000) / 10 })); } return { label: meta.wordmark + " — " + (s.primary ? s.primary.label : ""), color: Q.strokeFor(meta.accent, meta.accentDeep), dash: Q.DASHES[i], points: pts, noArea: true }; }); main.appendChild(Q.lineChart({ title: cmpState.mode === "indice" ? "Croissance comparée (indice, base 100 = début de période)" : "Valeurs brutes (unités hétérogènes)", unit: cmpState.mode === "indice" ? "indice" : "", series, summary: false, height: 340, })); // cartes de croissance const growth = el("div", "ind-grid"); for (const s of chosen) { const meta = state.siteById[s.id]; const pts = s.spark.filter((p) => typeof p.v === "number" && p.v > 0 && /^\d{4}-\d{2}-\d{2}/.test(String(p.t))); if (pts.length < 2) continue; const pct = ((pts[pts.length - 1].v - pts[0].v) / pts[0].v) * 100; const c = el("div", "ind-card card"); setAccent(c, meta); c.appendChild(siteChip(meta)); c.appendChild(el("div", "klabel", "Croissance sur la période")); c.appendChild(el("div", "ind-val", (pct >= 0 ? "+" : "") + pct.toLocaleString("fr-CA", { maximumFractionDigits: 1 }) + " %")); c.appendChild(el("span", "klabel", Q.fmtNum(pts[0].v) + " → " + Q.fmtNum(pts[pts.length - 1].v))); growth.appendChild(c); } main.appendChild(growth); } root.appendChild(main); } /* ================================================================ PALMARÈS — records + croissances ================================================================ */ async function pagePalmares(root) { document.title = "Palmarès — Ka·Stats"; const head = el("header", "page-head"); const hc = el("div", "container"); hc.appendChild(el("div", "kicker", "Palmarès")); const h1 = el("h1"); h1.appendChild(document.createTextNode("Records & ")); h1.appendChild(el("span", "hl", "faits marquants")); hc.appendChild(h1); 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.")); head.appendChild(hc); root.appendChild(head); const ov = await apiOverview(); const main = el("div", "container"); // croissance par plateforme (une seule série ⇒ une seule couleur) const rows = []; for (const s of ov.sites) { if (!s.ok || !s.spark) continue; const pts = s.spark.filter((p) => typeof p.v === "number" && p.v > 0 && /^\d{4}-\d{2}-\d{2}/.test(String(p.t))); if (pts.length < 2) continue; rows.push({ label: state.siteById[s.id].wordmark, value: Math.round(((pts[pts.length - 1].v - pts[0].v) / pts[0].v) * 1000) / 10 }); } rows.sort((a, b) => b.value - a.value); if (rows.length) { const sec = section("Croissance sur la période", "Classement"); 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 (%).")); sec.appendChild(Q.hBarChart({ title: "Croissance de l'indicateur principal", unit: "%", items: rows, color: ACCENT.stroke, max: 12 })); main.appendChild(sec); } if (ov.records.length) { const sec = section("Tous les records", "Faits marquants"); sec.appendChild(recordsGrid(ov.records)); main.appendChild(sec); } if (ov.indicators.length) { const sec = section("Tous les indicateurs en dollars", "Table"); sec.appendChild(Q.dataTable({ title: "Indicateurs de prix, toutes plateformes", columns: ["Plateforme", "Indicateur", "Valeur", "Δ %"], 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") + " %" : "—"]), })); main.appendChild(sec); } root.appendChild(main); } /* ================================================================ STUDIO — constructeur d'indicateurs sur mesure ================================================================ */ const TRANSFORMS = [ ["brut", "Valeurs brutes"], ["indice", "Indice 100"], ["variation", "Variation % (jour)"], ["mm7", "Moyenne mobile 7 j"], ["cumul", "Cumul"], ]; const COMBINES = [["aucune", "Séries séparées"], ["ratio", "A ÷ B"], ["diff", "A − B"], ["somme", "A + B"]]; const KINDS = [["line", "Ligne"], ["area", "Aire"], ["bar", "Barres"]]; const studio = { refs: [], transform: "brut", combine: "aucune", kind: "line", title: "", search: "", loadedC: null }; function tfPoints(points, mode) { const pts = (points || []).filter((p) => typeof p.v === "number" && /^\d{4}-\d{2}-\d{2}/.test(String(p.t))); const r1 = (v) => Math.round(v * 10) / 10; 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; } 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); 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 }; }); if (mode === "cumul") { let a = 0; return pts.map((p) => ({ t: p.t, v: (a += p.v) })); } return pts; } function combinePoints(A, B, op) { const mb = new Map(B.map((p) => [p.t, p.v])); const out = []; for (const p of A) { const b = mb.get(p.t); if (typeof b !== "number") continue; const v = op === "ratio" ? (b ? p.v / b : null) : op === "diff" ? p.v - b : p.v + b; if (v === null || !isFinite(v)) continue; out.push({ t: p.t, v: Math.round(v * 10000) / 10000 }); } return out; } function studioSaved() { try { return JSON.parse(localStorage.getItem("kastats_studio") || "[]"); } catch { return []; } } function studioStore(list) { localStorage.setItem("kastats_studio", JSON.stringify(list)); } function studioCfg() { return { refs: studio.refs, transform: studio.transform, combine: studio.combine, kind: studio.kind, title: studio.title }; } function studioShareUrl(cfg) { return location.origin + "/studio?c=" + encodeURIComponent(JSON.stringify(cfg)); } /* Construit les séries finales (fetch + combine + transform) d'une config. */ async function buildCustomSeries(cfg) { const metrics = (await Promise.all(cfg.refs.map((r) => apiMetric(r).catch(() => null)))).filter(Boolean); if (!metrics.length) return { series: [], unit: "" }; let baseUnit = metrics[0].unit || ""; let series; if (cfg.combine !== "aucune" && metrics.length === 2) { const pts = combinePoints(tfClean(metrics[0].points), tfClean(metrics[1].points), cfg.combine); const opLbl = cfg.combine === "ratio" ? "÷" : cfg.combine === "diff" ? "−" : "+"; series = [{ label: cfg.title || `${metrics[0].label} ${opLbl} ${metrics[1].label}`, points: pts }]; baseUnit = cfg.combine === "ratio" ? "ratio" : metrics[0].unit === metrics[1].unit ? baseUnit : ""; } else { series = metrics.map((m) => ({ label: `${state.siteById[m.site] ? state.siteById[m.site].wordmark + " — " : ""}${m.label}`, points: m.points })); } series = series.map((s) => ({ ...s, points: tfPoints(s.points, cfg.transform) })).filter((s) => s.points.length > 1); const unit = cfg.transform === "indice" ? "indice" : cfg.transform === "variation" ? "%" : baseUnit; return { series, unit }; } function tfClean(points) { return (points || []).filter((p) => typeof p.v === "number" && /^\d{4}-\d{2}-\d{2}/.test(String(p.t))); } /* Rend la carte finale d'un indicateur custom (avec commutateur + exports). */ function customChartCard(cfg, built, opts) { const { series, unit } = built; if (!series.length) return el("div", "viz-empty", "Aucune donnée pour cette configuration (métriques vides sur la période)."); const colored = series.map((s, i) => ({ ...s, color: Q.CAT[i], dash: Q.DASHES[i], noArea: series.length > 1 })); const title = cfg.title || (opts && opts.fallbackTitle) || "Indicateur sur mesure"; const ts = [...new Set(series.flatMap((s) => s.points.map((p) => p.t)))].sort(); const mapsL = series.map((s) => new Map(s.points.map((p) => [p.t, p.v]))); const kinds = series.length === 1 ? KINDS.map(([id, label]) => ({ id, label })) : KINDS.slice(0, 2).map(([id, label]) => ({ id, label })); return Q.switchableChart({ kinds, initial: series.length > 1 && cfg.kind === "bar" ? "line" : cfg.kind, 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) ?? "")]) }, pngName: "ka-stats_studio", render(k) { if (k === "bar" && series.length === 1) return Q.vBarChart({ title, unit, points: series[0].points, color: Q.CAT[0] }); return Q.lineChart({ title, unit, series: colored, area: k === "area" && series.length === 1, summary: series.length === 1 }); }, }); } async function pageStudio(root) { document.title = "Studio d'indicateurs — Ka·Stats"; // config passée par lien partagé (?c=…) — chargée une seule fois const cParam = new URLSearchParams(location.search).get("c"); if (cParam && cParam !== studio.loadedC) { try { const cfg = JSON.parse(cParam); if (Array.isArray(cfg.refs)) { studio.refs = cfg.refs.slice(0, 4); studio.transform = TRANSFORMS.some(([id]) => id === cfg.transform) ? cfg.transform : "brut"; studio.combine = COMBINES.some(([id]) => id === cfg.combine) ? cfg.combine : "aucune"; studio.kind = KINDS.some(([id]) => id === cfg.kind) ? cfg.kind : "line"; studio.title = typeof cfg.title === "string" ? cfg.title : ""; } studio.loadedC = cParam; } catch { /* config invalide : ignorer */ } } const head = el("header", "page-head"); const hc = el("div", "container"); hc.appendChild(el("div", "kicker", "Studio")); const h1 = el("h1"); h1.appendChild(document.createTextNode("Construisez votre ")); h1.appendChild(el("span", "hl", "indicateur")); hc.appendChild(h1); 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.")); head.appendChild(hc); root.appendChild(head); const main = el("div", "container"); const cat = await apiCatalog(); /* ---- constructeur ---- */ const builder = el("section", "studio card"); const bIn = el("div", "studio-in"); // 1. sélection des métriques bIn.appendChild(el("div", "kicker", "1 · Métriques (max 4)")); const selRow = el("div", "studio-sel"); studio.refs.forEach((ref, i) => { const meta = cat.metrics.find((m) => m.ref === ref); const chip = el("span", "studio-chip"); const key = el("span", "viz-key-line"); key.style.background = Q.CAT[i]; if (Q.DASHES[i]) key.style.backgroundImage = `repeating-linear-gradient(90deg, ${Q.CAT[i]} 0 6px, #fff 6px 9px)`; chip.appendChild(key); chip.appendChild(el("span", null, meta ? `${meta.wordmark} — ${meta.label}` : ref)); const x = el("button", "studio-x", "✕"); x.type = "button"; x.setAttribute("aria-label", "Retirer cette métrique"); x.addEventListener("click", () => { studio.refs = studio.refs.filter((r) => r !== ref); render(); }); chip.appendChild(x); selRow.appendChild(chip); }); if (!studio.refs.length) selRow.appendChild(el("span", "klabel", "Aucune métrique choisie — cherchez ci-dessous.")); bIn.appendChild(selRow); const searchWrap = el("div", "studio-search"); const input = el("input", "input"); input.type = "search"; input.placeholder = `Chercher parmi ${cat.count} métriques (ex. loyer, prix, salaire, Montréal…)`; input.value = studio.search; const results = el("div", "studio-results"); function renderResults() { results.textContent = ""; const q = input.value.trim().toLowerCase(); studio.search = input.value; if (!q) { results.style.display = "none"; return; } const found = cat.metrics.filter((m) => (`${m.wordmark} ${m.label}`).toLowerCase().includes(q) && !studio.refs.includes(m.ref)).slice(0, 24); results.style.display = found.length ? "" : "none"; for (const m of found) { const b = el("button", "studio-result"); b.type = "button"; const meta = state.siteById[m.site]; const dot = el("span", "site-dot"); if (meta) dot.style.background = meta.accent; b.appendChild(dot); b.appendChild(el("span", "studio-r-label", `${m.wordmark} — ${m.label}`)); b.appendChild(el("span", "klabel", `${m.unit || ""} · ${m.n} pts`)); b.disabled = studio.refs.length >= 4; b.addEventListener("click", () => { if (studio.refs.length < 4) { studio.refs = [...studio.refs, m.ref]; render(); } }); results.appendChild(b); } } input.addEventListener("input", renderResults); searchWrap.appendChild(input); searchWrap.appendChild(results); bIn.appendChild(searchWrap); // 2. transformation / combinaison / type const pillsRow = (kicker, entries, key, visible = true) => { if (!visible) return null; const box = el("div", "studio-opt"); box.appendChild(el("div", "kicker", kicker)); const row = el("div", "period-row"); for (const [id, label] of entries) { const b = el("button", "pill" + (studio[key] === id ? " active" : ""), label); b.type = "button"; b.addEventListener("click", () => { studio[key] = id; render(); }); row.appendChild(b); } box.appendChild(row); return box; }; const t = pillsRow("2 · Transformation", TRANSFORMS, "transform"); if (t) bIn.appendChild(t); const c2 = pillsRow("3 · Combinaison (2 métriques)", COMBINES, "combine", studio.refs.length === 2); if (c2) bIn.appendChild(c2); const kindEntries = studio.refs.length > 1 && studio.combine === "aucune" ? KINDS.slice(0, 2) : KINDS; const k = pillsRow("Type de graphique", kindEntries, "kind"); if (k) bIn.appendChild(k); // titre + actions const titleRow = el("div", "studio-titlerow"); const titleIn = el("input", "input"); titleIn.placeholder = "Titre de l'indicateur (optionnel)"; titleIn.value = studio.title; titleIn.addEventListener("change", () => { studio.title = titleIn.value.trim(); render(); }); titleRow.appendChild(titleIn); const saveBtn = el("button", "btn btn-primary", "💾 Enregistrer"); saveBtn.type = "button"; saveBtn.disabled = !studio.refs.length; saveBtn.addEventListener("click", () => { studio.title = titleIn.value.trim(); const list = studioSaved(); list.unshift({ id: "ci" + Math.random().toString(36).slice(2, 8), created: new Date().toISOString(), cfg: studioCfg() }); studioStore(list.slice(0, 30)); render(); }); titleRow.appendChild(saveBtn); const shareBtn = el("button", "btn", "🔗 Copier le lien"); shareBtn.type = "button"; shareBtn.disabled = !studio.refs.length; shareBtn.addEventListener("click", async () => { studio.title = titleIn.value.trim(); try { await navigator.clipboard.writeText(studioShareUrl(studioCfg())); shareBtn.textContent = "✓ Lien copié"; setTimeout(() => (shareBtn.textContent = "🔗 Copier le lien"), 1800); } catch { /* presse-papiers refusé */ } }); titleRow.appendChild(shareBtn); bIn.appendChild(titleRow); builder.appendChild(bIn); main.appendChild(builder); // aperçu live if (studio.refs.length) { const prev = section("Aperçu", "Live"); const built = await buildCustomSeries(studioCfg()); prev.appendChild(customChartCard(studioCfg(), built, { fallbackTitle: "Aperçu de l'indicateur" })); main.appendChild(prev); } /* ---- mes indicateurs enregistrés ---- */ const saved = studioSaved(); if (saved.length) { const sec = section("Mes indicateurs", "Enregistrés"); sec.appendChild(el("p", "sect-note", "Sauvegardés dans ce navigateur, recalculés en direct sur la période choisie.")); for (const item of saved) { const box = el("div", "studio-saveditem"); const bar = el("div", "studio-savedbar"); bar.appendChild(el("b", null, item.cfg.title || "Indicateur sans titre")); const acts = el("span", "studio-savedacts"); const open = el("button", "viz-toolbtn", "✎ Modifier"); open.type = "button"; open.addEventListener("click", () => { 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 || "" }); window.scrollTo(0, 0); render(); }); acts.appendChild(open); const shr = el("button", "viz-toolbtn", "🔗 Lien"); shr.type = "button"; shr.addEventListener("click", async () => { try { await navigator.clipboard.writeText(studioShareUrl(item.cfg)); shr.textContent = "✓"; setTimeout(() => (shr.textContent = "🔗 Lien"), 1500); } catch {} }); acts.appendChild(shr); const del = el("button", "viz-toolbtn danger", "🗑 Supprimer"); del.type = "button"; del.addEventListener("click", () => { studioStore(studioSaved().filter((x) => x.id !== item.id)); render(); }); acts.appendChild(del); bar.appendChild(acts); box.appendChild(bar); try { const built = await buildCustomSeries(item.cfg); box.appendChild(customChartCard(item.cfg, built, { fallbackTitle: "Indicateur enregistré" })); } catch (e) { box.appendChild(el("div", "viz-empty", "Impossible de recalculer cet indicateur : " + (e && e.message ? e.message : e))); } sec.appendChild(box); } main.appendChild(sec); } root.appendChild(main); // refocus recherche après insertion dans le DOM (confort de frappe) if (studio.search) setTimeout(() => { renderResults(); input.focus(); const v = input.value; input.value = ""; input.value = v; }, 0); } /* ================================================================ COQUILLE : header / nav / footer / routeur ================================================================ */ const ACCENT = { stroke: "#095797" }; // bleu Québec — accent Ka·Stats (contraste 8,7:1 sur blanc) function buildHeader() { const h = document.getElementById("site-header"); const inner = el("div", "container header-inner"); const brand = link("/", "brand", ""); brand.textContent = ""; brand.appendChild(el("span", "brand-qc", "Ka")); const dot = el("span", "brand-dot", "·"); brand.appendChild(dot); brand.appendChild(el("span", "brand-stats", "Stats")); inner.appendChild(brand); const nav = el("nav", "main-nav"); nav.setAttribute("aria-label", "Navigation principale"); const links = [["/", "Vue d'ensemble"], ["/indicateurs", "Indicateurs"], ["/comparer", "Comparer"], ["/studio", "Studio"], ["/palmares", "Palmarès"]]; for (const [href, label] of links) nav.appendChild(link(href, "nav-link", label)); inner.appendChild(nav); const badge = el("a", "gk-badge only-desktop"); badge.href = "https://www.groupe-ka.com"; badge.target = "_blank"; badge.rel = "noopener"; badge.appendChild(document.createTextNode("Un service")); const b = el("b", null, "Groupe"); b.appendChild(el("span", "ka", "KA")); badge.appendChild(b); inner.appendChild(badge); // KA Nav v2 (2026-08-25) : hamburger + panneau plein écran (fixed inset:0, // z-modal) — le menu reste visible peu importe la position de scroll. const mbtn = el("button", "ka-mnav-btn"); mbtn.id = "ka-mnav-btn"; mbtn.type = "button"; mbtn.setAttribute("aria-label", "Ouvrir le menu"); mbtn.setAttribute("aria-expanded", "false"); mbtn.setAttribute("aria-controls", "ka-mnav"); for (let i = 0; i < 3; i++) mbtn.appendChild(el("span")); inner.appendChild(mbtn); h.appendChild(inner); const panel = el("div", "ka-mnav"); panel.id = "ka-mnav"; panel.setAttribute("role", "dialog"); panel.setAttribute("aria-modal", "true"); panel.setAttribute("aria-label", "Menu"); const ptop = el("div", "ka-mnav-top"); const pbrand = link("/", "brand", ""); pbrand.appendChild(el("span", "brand-qc", "Ka")); pbrand.appendChild(el("span", "brand-dot", "·")); pbrand.appendChild(el("span", "brand-stats", "Stats")); ptop.appendChild(pbrand); const pclose = el("button", "ka-mnav-close", "✕"); pclose.type = "button"; pclose.setAttribute("aria-label", "Fermer le menu"); ptop.appendChild(pclose); panel.appendChild(ptop); const pnav = el("nav", "ka-mnav-links"); pnav.setAttribute("aria-label", "Navigation principale"); for (const [href, label] of links) { const a = link(href, null, ""); a.appendChild(el("b", null, label)); pnav.appendChild(a); } panel.appendChild(pnav); const pfoot = el("div", "ka-mnav-foot"); const pbadge = el("a", "gk-badge"); pbadge.href = "https://www.groupe-ka.com"; pbadge.target = "_blank"; pbadge.rel = "noopener"; pbadge.appendChild(document.createTextNode("Un service")); const pb = el("b", null, "Groupe"); pb.appendChild(el("span", "ka", "KA")); pbadge.appendChild(pb); pfoot.appendChild(pbadge); panel.appendChild(pfoot); h.after(panel); // frère du header — hors de tout stacking context const setMenu = (open) => { panel.classList.toggle("open", open); mbtn.setAttribute("aria-expanded", open ? "true" : "false"); document.documentElement.classList.toggle("ka-menu-locked", open); }; mbtn.addEventListener("click", () => setMenu(!panel.classList.contains("open"))); panel.addEventListener("click", (e) => { if (e.target.closest("a") || e.target.closest(".ka-mnav-close")) setMenu(false); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") setMenu(false); }); } function buildFooter() { const f = document.getElementById("site-footer"); f.className = "ka-footer"; const c = el("div", "container"); const wm = el("a", "wordmark"); wm.href = "/"; wm.dataset.link = "1"; wm.appendChild(document.createTextNode("Ka·")); wm.appendChild(el("span", "ka", "Stats")); c.appendChild(wm); 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.")); const notice = el("p", "notice"); const nb = el("b", null, "Avis. "); notice.appendChild(nb); 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.")); c.appendChild(notice); const sites = el("ul", "sites"); for (const s of state.sites) { const li = el("li"); const a = el("a", null, s.wordmark); a.href = "https://" + s.domain; a.target = "_blank"; a.rel = "noopener"; li.appendChild(a); sites.appendChild(li); } const hubLi = el("li"); const hubA = el("a", null, "Groupe-KA.com"); hubA.href = "https://www.groupe-ka.com"; hubA.target = "_blank"; hubA.rel = "noopener"; hubLi.appendChild(hubA); sites.appendChild(hubLi); c.appendChild(sites); const contacts = el("div", "contacts"); 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"]]) { const d = el("div"); const a = el("a", null, mail); a.href = "mailto:" + mail; d.appendChild(a); d.appendChild(el("span", null, role)); contacts.appendChild(d); } c.appendChild(contacts); const legal = el("p", "legal"); legal.appendChild(document.createTextNode("© Groupe KA — Simon-Pierre Boucher · ")); 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"]]) { const a = el("a", null, label); a.href = href; a.target = "_blank"; a.rel = "noopener"; legal.appendChild(a); legal.appendChild(document.createTextNode(" · ")); } c.appendChild(legal); f.appendChild(c); } let rendering = false; async function render() { if (rendering) return; rendering = true; $app.classList.add("is-loading"); try { const path = location.pathname.replace(/\/+$/, "") || "/"; const fresh = el("div"); fresh.appendChild(periodBar()); const page = el("main"); fresh.appendChild(page); if (path === "/") await pageHome(page); else if (path.startsWith("/site/")) await pageSite(page, path.slice(6)); else if (path === "/indicateurs") await pageIndicateurs(page); else if (path === "/comparer") await pageComparer(page); else if (path === "/studio") await pageStudio(page); else if (path === "/palmares") await pagePalmares(page); else await pageHome(page); $app.textContent = ""; $app.appendChild(fresh); // met en évidence le lien de nav actif document.querySelectorAll(".nav-link").forEach((a) => { a.classList.toggle("active", a.getAttribute("href") === (path.startsWith("/site/") ? "/" : path)); }); } catch (e) { $app.textContent = ""; $app.appendChild(el("div", "container viz-empty big", "Erreur de chargement : " + (e && e.message ? e.message : e))); } finally { $app.classList.remove("is-loading"); rendering = false; } } document.addEventListener("click", (e) => { const a = e.target.closest("a[data-link]"); if (!a) return; e.preventDefault(); history.pushState(null, "", a.getAttribute("href")); window.scrollTo(0, 0); render(); }); window.addEventListener("popstate", render); (async function init() { const meta = await getJSON("/api/sites"); state.sites = meta.sites; state.siteById = Object.fromEntries(meta.sites.map((s) => [s.id, s])); buildHeader(); buildFooter(); render(); })(); ;/*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){}})();