/* * QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 * Auteur : Simon-Pierre Boucher * Contact : contact@spboucher.ai * https://www.qc-election.com */ /* Utilitaires partagés — design system « QC Signal » */ const API = "/api"; /* ---- thème (sombre par défaut) ---- */ const THEME = localStorage.getItem("qce-theme") || "dark"; document.documentElement.dataset.theme = THEME; function toggleTheme() { localStorage.setItem("qce-theme", THEME === "dark" ? "light" : "dark"); location.reload(); // recharge : les graphiques reprennent les couleurs du thème } const PARTY_ORDER = ["PQ", "PLQ", "CAQ", "PCQ", "QS", "AUT"]; // jamais utilisé pour l'affichage : tri dynamique par résultat const FALLBACK_COLORS = { CAQ: "#0097cf", PLQ: "#e5484d", PQ: "#3f7ad6", QS: "#e06c1f", PCQ: "#7069d6", AUT: "#ad8226" }; let META = null; /* ---- visuels des partis (logos + chefs) — fichiers locaux, source Wikimedia ---- */ const PARTY_IMG = { CAQ: { logo: "/assets/img/logo_caq.png", leaders: ["/assets/img/leader_caq.jpg"] }, PLQ: { logo: "/assets/img/logo_plq.png", leaders: ["/assets/img/leader_plq.jpg"] }, PQ: { logo: "/assets/img/logo_pq.png", leaders: ["/assets/img/leader_pq.jpg"] }, QS: { logo: "/assets/img/logo_qs.png", leaders: ["/assets/img/leader_qs1.jpg", "/assets/img/leader_qs2.jpg"] }, PCQ: { logo: "/assets/img/logo_pcq.png", leaders: ["/assets/img/leader_pcq.jpg"] }, }; function partyLogo(p) { return (PARTY_IMG[p] || {}).logo || null; } function leaderPhotos(p) { return (PARTY_IMG[p] || {}).leaders || []; } function avatarHTML(p, size = 40) { const photos = leaderPhotos(p); if (!photos.length) return ""; return photos.map(src => `${partyLeader(p) || partyName(p)}`).join(""); } function initialsAvatar(name, color, size = 30) { const init = (name || "?").split(/\s+/).map(w => w[0]).slice(0, 2).join("").toUpperCase(); return `${init}`; } async function getJSON(url, opts) { const r = await fetch(url, opts); if (!r.ok) throw new Error(`${url}: ${r.status}`); return r.json(); } async function loadMeta() { if (!META) META = await getJSON(`${API}/meta`); return META; } function partyColor(p) { return (META && META.parties[p] && META.parties[p].color) || FALLBACK_COLORS[p] || "#888"; } function partyName(p) { return (META && META.parties[p] && META.parties[p].name) || p; } function partyLeader(p) { return (META && META.parties[p] && META.parties[p].leader) || ""; } function fmtPct(x, d = 0) { return (x * 100).toFixed(d) + " %"; } function fmtDate(iso) { return new Date(iso + (iso.length === 10 ? "T12:00:00" : "")).toLocaleDateString("fr-CA", { year: "numeric", month: "short", day: "numeric" }); } /* Tri neutre : par probabilité décroissante dans le run courant (aucun parti favorisé par un ordre arbitraire fixe). */ function sortParties(seats) { return Object.keys(seats).filter(p => p !== "AUT") .sort((a, b) => seats[b].prob_most - seats[a].prob_most || seats[b].mean - seats[a].mean); } /* ============================================================================ Grammaire des données — helpers d'affichage partagés (QC Signal) ========================================================================== */ /* Indicateur de variation : flèche + valeur, JAMAIS vert/rouge (une hausse d'un parti n'est pas « positive » universellement). Couleur du parti ou neutre. d = variation (déjà dans l'unité affichée). */ function deltaHTML(d, { unit = " pt", digits = 1, color = null, thresh = 0.05, title = "" } = {}) { if (d == null || Number.isNaN(d)) return ""; const flat = Math.abs(d) < thresh; const arr = flat ? "·" : d > 0 ? "▲" : "▼"; const txt = flat ? "stable" : Math.abs(d).toFixed(digits) + unit; const st = color && !flat ? `style="color:${color}"` : ""; return `` + `${txt}`; } /* Intervalle visuel lo ───●─── hi (dot = valeur centrale, mark = repère). */ function rangeHTML({ lo, hi, val, min = 0, max = 127, color = "#8a93a8", mark = null, fmt = v => Math.round(v) } = {}) { const pc = v => Math.max(0, Math.min(100, (v - min) / (max - min) * 100)); return ``; } /* État d'erreur propre — jamais de « undefined » à l'écran. */ function emptyStateHTML(msg = "Données temporairement indisponibles.") { return `
${msg}
`; } /* ============================================================================ Hémicycle SVG — 127 sièges en demi-cercle, composant réutilisable. seats : { PARTI: { mean, p50?, p05, p95 } } ; meta : /api/meta. Retourne un élément .hemi prêt à insérer (hover : parti / sièges / intervalle). ========================================================================== */ function hemicycleSVG(seats, meta, { legend = true } = {}) { const total = (meta && meta.election && meta.election.total_seats) || 127; const majority = (meta && meta.election && meta.election.majority_seats) || Math.floor(total / 2) + 1; /* -- répartition entière des sièges (p50 sinon moyenne), reste au plus fort -- */ const order = sortParties(seats).concat(Object.keys(seats).includes("AUT") ? ["AUT"] : []); const target = order.map(p => ({ p, x: seats[p].p50 != null ? seats[p].p50 : seats[p].mean })); const sumX = target.reduce((a, t) => a + t.x, 0) || 1; let alloc = target.map(t => ({ p: t.p, n: Math.floor(t.x / sumX * total), frac: (t.x / sumX * total) % 1 })); let left = total - alloc.reduce((a, t) => a + t.n, 0); alloc.sort((a, b) => b.frac - a.frac); for (let i = 0; left > 0; i = (i + 1) % alloc.length, left--) alloc[i].n++; const nOf = Object.fromEntries(alloc.map(a => [a.p, a.n])); /* -- géométrie : rangées concentriques, sièges ∝ rayon -- */ const ROWS = 6, W = 640, R0 = 118, DR = 34, SEAT = 8.6; const H = R0 + (ROWS - 1) * DR + 52; // marge haute : ligne + étiquette de majorité const CX = W / 2, CY = H - 10; const radii = Array.from({ length: ROWS }, (_, i) => R0 + i * DR); const wSum = radii.reduce((a, r) => a + r, 0); let perRow = radii.map(r => Math.floor(r / wSum * total)); let rem = total - perRow.reduce((a, b) => a + b, 0); for (let i = ROWS - 1; rem > 0; i = (i - 1 + ROWS) % ROWS, rem--) perRow[i]++; const pos = []; perRow.forEach((n, row) => { const r = radii[row]; for (let k = 0; k < n; k++) { const a = Math.PI - (n === 1 ? Math.PI / 2 : k * Math.PI / (n - 1)); // 180° → 0° pos.push({ x: CX + r * Math.cos(a), y: CY - r * Math.sin(a), a }); } }); pos.sort((u, v) => v.a - u.a); // balayage gauche → droite /* -- attribution des sièges dans l'ordre du balayage -- */ const seatParty = []; for (const p of order) for (let i = 0; i < (nOf[p] || 0); i++) seatParty.push(p); const circles = pos.map((pt, i) => { const p = seatParty[i] || "AUT"; return ``; }).join(""); /* -- marqueur de majorité : à l'angle du 64e siège du balayage -- */ const mAngle = (pos[majority - 1].a + pos[majority].a) / 2; const mx1 = CX + (R0 - 22) * Math.cos(mAngle), my1 = CY - (R0 - 22) * Math.sin(mAngle); const mx2 = CX + (radii[ROWS - 1] + 16) * Math.cos(mAngle), my2 = CY - (radii[ROWS - 1] + 16) * Math.sin(mAngle); const el = document.createElement("div"); el.className = "hemi"; el.innerHTML = ` ${circles} majorité ${majority} ${total} sièges
${legend ? `
${order.filter(p => nOf[p] > 0).map(p => ` ${p} ${nOf[p]} [${seats[p].p05}–${seats[p].p95}]`).join("")}
` : ""}`; /* -- interactions : hover = met le parti en évidence + info-bulle -- */ const tip = el.querySelector(".hemi-tip"); const allSeats = () => el.querySelectorAll(".seat"); function focus(p) { allSeats().forEach(c => c.classList.toggle("dim", p != null && c.dataset.p !== p)); } function showTip(p, ev) { const s = seats[p]; if (!s) return; tip.innerHTML = `${p} — ${nOf[p]} sièges attendus [${s.p05}–${s.p95}]`; tip.style.display = "block"; const r = el.getBoundingClientRect(); const tw = tip.offsetWidth, th = tip.offsetHeight; let x = ev.clientX - r.left + 12, y = ev.clientY - r.top - th - 10; x = Math.max(4, Math.min(x, r.width - tw - 4)); if (y < 0) y = ev.clientY - r.top + 14; tip.style.left = x + "px"; tip.style.top = y + "px"; } el.addEventListener("mousemove", ev => { const t = ev.target.closest("[data-p]"); if (t) { focus(t.dataset.p); showTip(t.dataset.p, ev); } else { focus(null); tip.style.display = "none"; } }); el.addEventListener("mouseleave", () => { focus(null); tip.style.display = "none"; }); return el; } /* ============================================================================ Coquille partagée : header + forecast strip + drawer + footer ========================================================================== */ const NAV_LINKS = [ ["/", "Aperçu"], ["/aujourdhui", "Le point du jour"], ["/circonscriptions", "Circonscriptions"], ["/carte", "Carte"], ["/sondages", "Sondages"], ["/signaux", "Signaux"], ["/simulateur", "Simulateur"], ["/intelligence", "Intelligence"], ["/backtest", "Calibration"], ["/methodologie", "Méthodologie"], ]; /* Fleur-de-lys — icône vectorielle nette (pas d'emoji, pas de pastille). */ const FLEUR_SVG = ``; const SUN_SVG = ``; const MOON_SVG = ``; function headerHTML(active) { return `
${FLEUR_SVG}QC Élection Forecast
`; } /* Forecast strip — fine barre sous la nav, sur toutes les pages. P(1er) publiée + variation depuis la veille. Couleur du parti sur le sigle, flèche neutre. /api/today, repli /api/forecast/latest. */ async function mountStrip() { const host = document.getElementById("fstrip"); if (!host) return; let items = [], right = ""; try { const T = await getJSON(`${API}/today`); const order = Object.keys(T.prob_most).filter(p => p !== "AUT") .sort((a, b) => T.prob_most[b] - T.prob_most[a]); items = order.map(p => { const d = T.delta ? T.delta.prob_most[p] * 100 : null; const flat = d == null || Math.abs(d) < 0.05; return ` ${p} ${(T.prob_most[p] * 100).toFixed(0)} `; }); right = `J−${T.days_to_election}`; } catch { try { const fc = await getJSON(`${API}/forecast/latest`); const seats = fc.seats.per_party; const blended = (fc.seats.ensemble || {}).blended || {}; const order = sortParties(seats); items = order.map(p => ` ${p} ${((blended[p] ?? seats[p].prob_most) * 100).toFixed(0)}`); } catch { return; } /* pas de forecast : la barre reste masquée */ } host.innerHTML = `
P(premier)${items.join("")} ${right ? `Scrutin 5 oct. · ${right}` : ""}
`; host.hidden = false; } /* Tiroir de navigation mobile (hamburger) — verrou de défilement, fermeture par fond, lien ou Escape. */ function mountDrawer(active) { const drawer = document.createElement("div"); drawer.className = "drawer"; drawer.setAttribute("role", "dialog"); drawer.setAttribute("aria-label", "Menu de navigation"); drawer.innerHTML = `
${FLEUR_SVG}QC Élection
qc-election.com
`; const backdrop = document.createElement("div"); backdrop.className = "nav-backdrop"; document.body.append(backdrop, drawer); const toggle = (open) => { document.body.classList.toggle("nav-open", open); const b = document.getElementById("nav-burger"); if (b) b.setAttribute("aria-expanded", String(open)); }; document.getElementById("nav-burger")?.addEventListener("click", () => toggle(!document.body.classList.contains("nav-open"))); backdrop.addEventListener("click", () => toggle(false)); drawer.querySelectorAll("nav a").forEach(a => a.addEventListener("click", () => toggle(false))); document.addEventListener("keydown", e => { if (e.key === "Escape") toggle(false); }); } function footerHTML() { return `
Plateforme indépendante et politiquement neutre de prévision électorale. Le forecast combine sondages, votes réels des partielles, fondamentaux et veille web continue — chaque couche est décomposée et traçable. Toutes les valeurs sont des probabilités, jamais des certitudes. Les mêmes règles statistiques sont appliquées à tous les partis. · Méthodologie complète · API
Données officielles : Élections Québec (carte, candidatures, dates du scrutin). Photos des chef·fe·s et logos des partis : Wikimedia Commons / Wikipédia (identification seulement). Contact : Simon-Pierre Boucher · contact@spboucher.ai
`; } async function initShell(active) { document.querySelector("header.site").innerHTML = headerHTML(active); document.querySelector("footer.site").innerHTML = footerHTML(); mountDrawer(active); mountStrip(); // asynchrone, non bloquant const meta = await loadMeta(); if (meta.last_update) { const maj = "Dernière MAJ " + new Date(meta.last_update + "Z").toLocaleTimeString("fr-CA", { hour: "2-digit", minute: "2-digit" }); document.getElementById("hdr-updated").textContent = maj; const du = document.getElementById("drawer-updated"); if (du) du.textContent = maj; } return meta; } /* ============================================================================ Thème ECharts « qc-signal » : grille recessive, typo Inter, tooltips premium ========================================================================== */ const DARKMODE = THEME === "dark"; const GRID_COLOR = DARKMODE ? "#2b3247" : "#c9d2e2"; // lignes de référence (markLine…) const SPLIT_COLOR = DARKMODE ? "#1c2231" : "#e6eaf2"; // grille de fond, très légère const INK2 = DARKMODE ? "#aab2c5" : "#414d68"; const MUTED = DARKMODE ? "#7d8699" : "#67718a"; const CHART_FONT = "Inter, -apple-system, 'Segoe UI', sans-serif"; function baseChart(el) { const c = echarts.init(el, null, { renderer: "canvas" }); const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; c.setOption({ textStyle: { fontFamily: CHART_FONT }, animationDuration: reduce ? 0 : 420, animationEasing: "cubicOut" }); if (window.ResizeObserver) new ResizeObserver(() => c.resize()).observe(el); else window.addEventListener("resize", () => c.resize()); return c; } /* Dégradé vertical « signature » sous une courbe (remplace les aplats). */ function areaGrad(color, top = 0.22, bottom = 0.0) { return { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: hexA(color, top) }, { offset: 1, color: hexA(color, bottom) }]) }; } /* Trait de série : arrondi + halo discret de sa propre couleur. */ function lineGlow(color, width = 2.4) { return { width, cap: "round", join: "round", shadowColor: hexA(color, DARKMODE ? 0.3 : 0.18), shadowBlur: 6, shadowOffsetY: 3 }; } function hexA(hex, a) { const n = parseInt(hex.slice(1), 16); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; } const AXIS_STYLE = { axisLine: { show: false }, axisTick: { show: false }, axisLabel: { color: MUTED, fontSize: 11, fontFamily: CHART_FONT }, splitLine: { lineStyle: { color: SPLIT_COLOR, width: 1 } }, }; const TOOLTIP = { trigger: "axis", backgroundColor: DARKMODE ? "rgba(22,27,39,.97)" : "rgba(255,255,255,.98)", borderColor: DARKMODE ? "rgba(170,188,218,.22)" : "rgba(38,54,92,.18)", borderWidth: 1, padding: [9, 13], textStyle: { color: DARKMODE ? "#edeff5" : "#161b26", fontSize: 12.5, fontFamily: CHART_FONT }, extraCssText: "border-radius:12px;box-shadow:0 10px 32px -10px rgba(0,0,0,.45);font-variant-numeric:tabular-nums;", axisPointer: { type: "cross", lineStyle: { color: DARKMODE ? "#39415a" : "#b9c2d4", type: [4, 4] }, crossStyle: { color: DARKMODE ? "#39415a" : "#b9c2d4" }, label: { backgroundColor: DARKMODE ? "#2c3346" : "#414d68", borderRadius: 5, fontSize: 10.5, padding: [3, 7] } }, }; const LEGEND = { textStyle: { color: INK2, fontSize: 12, fontFamily: CHART_FONT }, itemWidth: 10, itemHeight: 10, icon: "circle", top: 0, itemGap: 16, inactiveColor: DARKMODE ? "#3a4157" : "#c6cedf" };