spb/qc-election
Public
Python 66.6%
HTML 24.8%
CSS 4.9%
JavaScript 3.6%
1/*2 * QC Élection Forecast — Plateforme de prévision électorale du Québec 20263 * Auteur : Simon-Pierre Boucher4 * Contact : contact@spboucher.ai5 * https://www.qc-election.com6 */7/* Utilitaires partagés — design system « QC Signal » */8const API = "/api";910/* ---- thème (sombre par défaut) ---- */11const THEME = localStorage.getItem("qce-theme") || "dark";12document.documentElement.dataset.theme = THEME;13function toggleTheme() {14 localStorage.setItem("qce-theme", THEME === "dark" ? "light" : "dark");15 location.reload(); // recharge : les graphiques reprennent les couleurs du thème16}17const PARTY_ORDER = ["PQ", "PLQ", "CAQ", "PCQ", "QS", "AUT"]; // jamais utilisé pour l'affichage : tri dynamique par résultat18const FALLBACK_COLORS = { CAQ: "#0097cf", PLQ: "#e5484d", PQ: "#3f7ad6", QS: "#e06c1f", PCQ: "#7069d6", AUT: "#ad8226" };1920let META = null;2122/* ---- visuels des partis (logos + chefs) — fichiers locaux, source Wikimedia ---- */23const PARTY_IMG = {24 CAQ: { logo: "/assets/img/logo_caq.png", leaders: ["/assets/img/leader_caq.jpg"] },25 PLQ: { logo: "/assets/img/logo_plq.png", leaders: ["/assets/img/leader_plq.jpg"] },26 PQ: { logo: "/assets/img/logo_pq.png", leaders: ["/assets/img/leader_pq.jpg"] },27 QS: { logo: "/assets/img/logo_qs.png", leaders: ["/assets/img/leader_qs1.jpg", "/assets/img/leader_qs2.jpg"] },28 PCQ: { logo: "/assets/img/logo_pcq.png", leaders: ["/assets/img/leader_pcq.jpg"] },29};30function partyLogo(p) { return (PARTY_IMG[p] || {}).logo || null; }31function leaderPhotos(p) { return (PARTY_IMG[p] || {}).leaders || []; }32function avatarHTML(p, size = 40) {33 const photos = leaderPhotos(p);34 if (!photos.length) return "";35 return photos.map(src =>36 `<img class="avatar" src="${src}" alt="${partyLeader(p) || partyName(p)}"37 style="width:${size}px;height:${size}px;border-color:${partyColor(p)}"38 loading="lazy">`).join("");39}40function initialsAvatar(name, color, size = 30) {41 const init = (name || "?").split(/\s+/).map(w => w[0]).slice(0, 2).join("").toUpperCase();42 return `<span class="avatar avatar-init" style="width:${size}px;height:${size}px;43 border-color:${color};color:${color};font-size:${Math.round(size * 0.36)}px">${init}</span>`;44}4546async function getJSON(url, opts) {47 const r = await fetch(url, opts);48 if (!r.ok) throw new Error(`${url}: ${r.status}`);49 return r.json();50}5152async function loadMeta() {53 if (!META) META = await getJSON(`${API}/meta`);54 return META;55}5657function partyColor(p) {58 return (META && META.parties[p] && META.parties[p].color) || FALLBACK_COLORS[p] || "#888";59}60function partyName(p) {61 return (META && META.parties[p] && META.parties[p].name) || p;62}63function partyLeader(p) {64 return (META && META.parties[p] && META.parties[p].leader) || "";65}66function fmtPct(x, d = 0) { return (x * 100).toFixed(d) + " %"; }67function fmtDate(iso) {68 return new Date(iso + (iso.length === 10 ? "T12:00:00" : "")).toLocaleDateString("fr-CA",69 { year: "numeric", month: "short", day: "numeric" });70}7172/* Tri neutre : par probabilité décroissante dans le run courant (aucun parti favorisé73 par un ordre arbitraire fixe). */74function sortParties(seats) {75 return Object.keys(seats).filter(p => p !== "AUT")76 .sort((a, b) => seats[b].prob_most - seats[a].prob_most || seats[b].mean - seats[a].mean);77}7879/* ============================================================================80 Grammaire des données — helpers d'affichage partagés (QC Signal)81 ========================================================================== */8283/* Indicateur de variation : flèche + valeur, JAMAIS vert/rouge (une hausse d'un84 parti n'est pas « positive » universellement). Couleur du parti ou neutre.85 d = variation (déjà dans l'unité affichée). */86function deltaHTML(d, { unit = " pt", digits = 1, color = null, thresh = 0.05, title = "" } = {}) {87 if (d == null || Number.isNaN(d)) return "";88 const flat = Math.abs(d) < thresh;89 const arr = flat ? "·" : d > 0 ? "▲" : "▼";90 const txt = flat ? "stable" : Math.abs(d).toFixed(digits) + unit;91 const st = color && !flat ? `style="color:${color}"` : "";92 return `<span class="delta" ${st} ${title ? `title="${title}"` : ""}>` +93 `<span class="arr" aria-hidden="true">${arr}</span>${txt}</span>`;94}9596/* Intervalle visuel lo ───●─── hi (dot = valeur centrale, mark = repère). */97function rangeHTML({ lo, hi, val, min = 0, max = 127, color = "#8a93a8", mark = null,98 fmt = v => Math.round(v) } = {}) {99 const pc = v => Math.max(0, Math.min(100, (v - min) / (max - min) * 100));100 return `<div class="range" role="img" aria-label="intervalle ${fmt(lo)} à ${fmt(hi)}, valeur ${fmt(val)}">101 <span>${fmt(lo)}</span>102 <span class="rtrack">103 <span class="rfill" style="left:${pc(lo)}%;right:${100 - pc(hi)}%;background:${color}"></span>104 ${mark != null ? `<span class="rmark" style="left:${pc(mark)}%"></span>` : ""}105 <span class="rdot" style="left:${pc(val)}%;background:${color}"></span>106 </span>107 <span>${fmt(hi)}</span>108 </div>`;109}110111/* État d'erreur propre — jamais de « undefined » à l'écran. */112function emptyStateHTML(msg = "Données temporairement indisponibles.") {113 return `<div class="empty-note">${msg}</div>`;114}115116/* ============================================================================117 Hémicycle SVG — 127 sièges en demi-cercle, composant réutilisable.118 seats : { PARTI: { mean, p50?, p05, p95 } } ; meta : /api/meta.119 Retourne un élément .hemi prêt à insérer (hover : parti / sièges / intervalle).120 ========================================================================== */121function hemicycleSVG(seats, meta, { legend = true } = {}) {122 const total = (meta && meta.election && meta.election.total_seats) || 127;123 const majority = (meta && meta.election && meta.election.majority_seats) || Math.floor(total / 2) + 1;124125 /* -- répartition entière des sièges (p50 sinon moyenne), reste au plus fort -- */126 const order = sortParties(seats).concat(Object.keys(seats).includes("AUT") ? ["AUT"] : []);127 const target = order.map(p => ({ p, x: seats[p].p50 != null ? seats[p].p50 : seats[p].mean }));128 const sumX = target.reduce((a, t) => a + t.x, 0) || 1;129 let alloc = target.map(t => ({ p: t.p, n: Math.floor(t.x / sumX * total),130 frac: (t.x / sumX * total) % 1 }));131 let left = total - alloc.reduce((a, t) => a + t.n, 0);132 alloc.sort((a, b) => b.frac - a.frac);133 for (let i = 0; left > 0; i = (i + 1) % alloc.length, left--) alloc[i].n++;134 const nOf = Object.fromEntries(alloc.map(a => [a.p, a.n]));135136 /* -- géométrie : rangées concentriques, sièges ∝ rayon -- */137 const ROWS = 6, W = 640, R0 = 118, DR = 34, SEAT = 8.6;138 const H = R0 + (ROWS - 1) * DR + 52; // marge haute : ligne + étiquette de majorité139 const CX = W / 2, CY = H - 10;140 const radii = Array.from({ length: ROWS }, (_, i) => R0 + i * DR);141 const wSum = radii.reduce((a, r) => a + r, 0);142 let perRow = radii.map(r => Math.floor(r / wSum * total));143 let rem = total - perRow.reduce((a, b) => a + b, 0);144 for (let i = ROWS - 1; rem > 0; i = (i - 1 + ROWS) % ROWS, rem--) perRow[i]++;145146 const pos = [];147 perRow.forEach((n, row) => {148 const r = radii[row];149 for (let k = 0; k < n; k++) {150 const a = Math.PI - (n === 1 ? Math.PI / 2 : k * Math.PI / (n - 1)); // 180° → 0°151 pos.push({ x: CX + r * Math.cos(a), y: CY - r * Math.sin(a), a });152 }153 });154 pos.sort((u, v) => v.a - u.a); // balayage gauche → droite155156 /* -- attribution des sièges dans l'ordre du balayage -- */157 const seatParty = [];158 for (const p of order) for (let i = 0; i < (nOf[p] || 0); i++) seatParty.push(p);159160 const circles = pos.map((pt, i) => {161 const p = seatParty[i] || "AUT";162 return `<circle class="seat" data-p="${p}" cx="${pt.x.toFixed(1)}" cy="${pt.y.toFixed(1)}"163 r="${SEAT}" fill="${partyColor(p)}"></circle>`;164 }).join("");165166 /* -- marqueur de majorité : à l'angle du 64e siège du balayage -- */167 const mAngle = (pos[majority - 1].a + pos[majority].a) / 2;168 const mx1 = CX + (R0 - 22) * Math.cos(mAngle), my1 = CY - (R0 - 22) * Math.sin(mAngle);169 const mx2 = CX + (radii[ROWS - 1] + 16) * Math.cos(mAngle), my2 = CY - (radii[ROWS - 1] + 16) * Math.sin(mAngle);170171 const el = document.createElement("div");172 el.className = "hemi";173 el.innerHTML = `174 <svg viewBox="0 0 ${W} ${H}" role="img"175 aria-label="Hémicycle : composition attendue des ${total} sièges (majorité à ${majority})">176 ${circles}177 <line x1="${mx1.toFixed(1)}" y1="${my1.toFixed(1)}" x2="${mx2.toFixed(1)}" y2="${my2.toFixed(1)}"178 stroke="currentColor" stroke-width="1.4" stroke-dasharray="3 4" opacity=".55"></line>179 <text x="${mx2.toFixed(1)}" y="${(my2 - 7).toFixed(1)}" text-anchor="middle"180 fill="currentColor" opacity=".75" font-size="12.5" font-weight="650"181 font-family="Inter, sans-serif">majorité ${majority}</text>182 <text x="${CX}" y="${CY - 26}" text-anchor="middle" fill="currentColor" opacity=".55"183 font-size="12" font-family="Inter, sans-serif">${total} sièges</text>184 </svg>185 <div class="hemi-tip" role="status"></div>186 ${legend ? `<div class="hemi-legend">${order.filter(p => nOf[p] > 0).map(p =>187 `<span class="hl-item" data-p="${p}"><span class="sw" style="background:${partyColor(p)}"></span>188 ${p} <b>${nOf[p]}</b> <span class="ci">[${seats[p].p05}–${seats[p].p95}]</span></span>`).join("")}189 </div>` : ""}`;190191 /* -- interactions : hover = met le parti en évidence + info-bulle -- */192 const tip = el.querySelector(".hemi-tip");193 const allSeats = () => el.querySelectorAll(".seat");194 function focus(p) {195 allSeats().forEach(c => c.classList.toggle("dim", p != null && c.dataset.p !== p));196 }197 function showTip(p, ev) {198 const s = seats[p];199 if (!s) return;200 tip.innerHTML = `<b style="color:${partyColor(p)}">${p}</b> — ${nOf[p]} sièges attendus201 <span style="color:var(--muted)">[${s.p05}–${s.p95}]</span>`;202 tip.style.display = "block";203 const r = el.getBoundingClientRect();204 const tw = tip.offsetWidth, th = tip.offsetHeight;205 let x = ev.clientX - r.left + 12, y = ev.clientY - r.top - th - 10;206 x = Math.max(4, Math.min(x, r.width - tw - 4));207 if (y < 0) y = ev.clientY - r.top + 14;208 tip.style.left = x + "px"; tip.style.top = y + "px";209 }210 el.addEventListener("mousemove", ev => {211 const t = ev.target.closest("[data-p]");212 if (t) { focus(t.dataset.p); showTip(t.dataset.p, ev); }213 else { focus(null); tip.style.display = "none"; }214 });215 el.addEventListener("mouseleave", () => { focus(null); tip.style.display = "none"; });216 return el;217}218219/* ============================================================================220 Coquille partagée : header + forecast strip + drawer + footer221 ========================================================================== */222const NAV_LINKS = [223 ["/", "Aperçu"], ["/aujourdhui", "Le point du jour"],224 ["/circonscriptions", "Circonscriptions"], ["/carte", "Carte"],225 ["/sondages", "Sondages"], ["/signaux", "Signaux"], ["/simulateur", "Simulateur"],226 ["/intelligence", "Intelligence"], ["/backtest", "Calibration"],227 ["/methodologie", "Méthodologie"],228];229230/* Fleur-de-lys — icône vectorielle nette (pas d'emoji, pas de pastille). */231const FLEUR_SVG = `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">232 <path d="M12 1.2C9.8 4.2 8.9 6.6 8.9 8.7c0 2.4 1.1 5 3.1 8.7 2-3.7 3.1-6.3 3.1-8.7 0-2.1-.9-4.5-3.1-7.5z"/>233 <path d="M9.9 17.4c-.3-4.2-2.5-7-6.9-7.5-.3 3.6.9 6.1 3.4 7.2 1.1.5 2.3.6 3.5.3z"/>234 <path d="M14.1 17.4c.3-4.2 2.5-7 6.9-7.5.3 3.6-.9 6.1-3.4 7.2-1.1.5-2.3.6-3.5.3z"/>235 <rect x="7.2" y="18.3" width="9.6" height="1.9" rx=".95"/>236 <path d="M12 23c-1-.8-1.6-1.7-1.8-2.6h3.6c-.2.9-.8 1.8-1.8 2.6z"/>237</svg>`;238const SUN_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"239 stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4.4"/>240 <path d="M12 2.5v2.4M12 19.1v2.4M2.5 12h2.4M19.1 12h2.4M5.3 5.3l1.7 1.7M17 17l1.7 1.7M18.7 5.3L17 7M7 17l-1.7 1.7"/></svg>`;241const MOON_SVG = `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">242 <path d="M20.6 14.6A8.7 8.7 0 0 1 9.4 3.4 8.9 8.9 0 1 0 20.6 14.6z"/></svg>`;243244function headerHTML(active) {245 return `<div class="inner">246 <a href="/" class="brand" style="text-decoration:none" aria-label="QC Élection — accueil">247 <span class="fleur">${FLEUR_SVG}</span>QC Élection<span class="b-sub"> Forecast</span></a>248 <nav class="main" aria-label="Navigation principale">${NAV_LINKS.map(([h, t]) =>249 `<a href="${h}" class="${h === active ? "active" : ""}"${h === active ? ' aria-current="page"' : ""}>${t}</a>`).join("")}</nav>250 <div class="updated" id="hdr-updated"></div>251 <button class="theme-toggle" onclick="toggleTheme()"252 aria-label="Basculer vers le thème ${THEME === "dark" ? "clair" : "sombre"}"253 title="Thème ${THEME === "dark" ? "clair" : "sombre"}">${THEME === "dark" ? SUN_SVG : MOON_SVG}</button>254 <button class="nav-burger" id="nav-burger" aria-label="Ouvrir le menu" aria-expanded="false">255 <span></span><span></span><span></span></button>256 </div>257 <div class="fstrip" id="fstrip" hidden></div>`;258}259260/* Forecast strip — fine barre sous la nav, sur toutes les pages.261 P(1er) publiée + variation depuis la veille. Couleur du parti sur le sigle,262 flèche neutre. /api/today, repli /api/forecast/latest. */263async function mountStrip() {264 const host = document.getElementById("fstrip");265 if (!host) return;266 let items = [], right = "";267 try {268 const T = await getJSON(`${API}/today`);269 const order = Object.keys(T.prob_most).filter(p => p !== "AUT")270 .sort((a, b) => T.prob_most[b] - T.prob_most[a]);271 items = order.map(p => {272 const d = T.delta ? T.delta.prob_most[p] * 100 : null;273 const flat = d == null || Math.abs(d) < 0.05;274 return `<a class="fs-item" href="/aujourdhui" title="${partyName(p)} — probabilité d'arriver premier (variation vs veille)">275 <span class="p" style="color:${partyColor(p)}">${p}</span>276 <span class="v">${(T.prob_most[p] * 100).toFixed(0)}</span>277 <span class="dlt" aria-hidden="true">${flat ? "·" : (d > 0 ? "▲" : "▼") + Math.abs(d).toFixed(1)}</span></a>`;278 });279 right = `J−${T.days_to_election}`;280 } catch {281 try {282 const fc = await getJSON(`${API}/forecast/latest`);283 const seats = fc.seats.per_party;284 const blended = (fc.seats.ensemble || {}).blended || {};285 const order = sortParties(seats);286 items = order.map(p => `<a class="fs-item" href="/aujourdhui"287 title="${partyName(p)} — probabilité d'arriver premier">288 <span class="p" style="color:${partyColor(p)}">${p}</span>289 <span class="v">${((blended[p] ?? seats[p].prob_most) * 100).toFixed(0)}</span></a>`);290 } catch { return; } /* pas de forecast : la barre reste masquée */291 }292 host.innerHTML = `<div class="fs-inner">293 <span class="fs-label">P(premier)</span>${items.join("")}294 ${right ? `<span class="fs-right">Scrutin 5 oct. · ${right}</span>` : ""}</div>`;295 host.hidden = false;296}297298/* Tiroir de navigation mobile (hamburger) — verrou de défilement, fermeture par299 fond, lien ou Escape. */300function mountDrawer(active) {301 const drawer = document.createElement("div");302 drawer.className = "drawer";303 drawer.setAttribute("role", "dialog");304 drawer.setAttribute("aria-label", "Menu de navigation");305 drawer.innerHTML = `306 <div class="drawer-head">307 <span class="brand"><span class="fleur">${FLEUR_SVG}</span>QC Élection</span>308 <button class="theme-toggle" onclick="toggleTheme()" aria-label="Basculer le thème">309 ${THEME === "dark" ? SUN_SVG : MOON_SVG}</button>310 </div>311 <nav>${NAV_LINKS.map(([h, t]) =>312 `<a href="${h}" class="${h === active ? "active" : ""}">${t}</a>`).join("")}</nav>313 <div class="drawer-foot"><span id="drawer-updated"></span><span>qc-election.com</span></div>`;314 const backdrop = document.createElement("div");315 backdrop.className = "nav-backdrop";316 document.body.append(backdrop, drawer);317 const toggle = (open) => {318 document.body.classList.toggle("nav-open", open);319 const b = document.getElementById("nav-burger");320 if (b) b.setAttribute("aria-expanded", String(open));321 };322 document.getElementById("nav-burger")?.addEventListener("click",323 () => toggle(!document.body.classList.contains("nav-open")));324 backdrop.addEventListener("click", () => toggle(false));325 drawer.querySelectorAll("nav a").forEach(a => a.addEventListener("click", () => toggle(false)));326 document.addEventListener("keydown", e => { if (e.key === "Escape") toggle(false); });327}328329function footerHTML() {330 return `<div class="wrap">331 Plateforme indépendante et politiquement neutre de prévision électorale.332 Le forecast combine sondages, votes réels des partielles, fondamentaux et333 veille web continue — chaque couche est <a href="/signaux">décomposée et traçable</a>.334 Toutes les valeurs sont des <b>probabilités</b>, jamais des certitudes.335 Les mêmes règles statistiques sont appliquées à tous les partis.336 · <a href="/methodologie">Méthodologie complète</a> · <a href="/api/docs">API</a>337 <div class="credits" style="margin-top:8px">338 Données officielles : <a href="https://www.electionsquebec.qc.ca" target="_blank" rel="noopener">Élections Québec</a> (carte, candidatures, dates du scrutin).339 Photos des chef·fe·s et logos des partis : Wikimedia Commons / Wikipédia (identification seulement).340 Contact : Simon-Pierre Boucher · <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>341 </div>342 </div>`;343}344345async function initShell(active) {346 document.querySelector("header.site").innerHTML = headerHTML(active);347 document.querySelector("footer.site").innerHTML = footerHTML();348 mountDrawer(active);349 mountStrip(); // asynchrone, non bloquant350 const meta = await loadMeta();351 if (meta.last_update) {352 const maj = "Dernière MAJ " + new Date(meta.last_update + "Z").toLocaleTimeString("fr-CA",353 { hour: "2-digit", minute: "2-digit" });354 document.getElementById("hdr-updated").textContent = maj;355 const du = document.getElementById("drawer-updated");356 if (du) du.textContent = maj;357 }358 return meta;359}360361/* ============================================================================362 Thème ECharts « qc-signal » : grille recessive, typo Inter, tooltips premium363 ========================================================================== */364const DARKMODE = THEME === "dark";365const GRID_COLOR = DARKMODE ? "#2b3247" : "#c9d2e2"; // lignes de référence (markLine…)366const SPLIT_COLOR = DARKMODE ? "#1c2231" : "#e6eaf2"; // grille de fond, très légère367const INK2 = DARKMODE ? "#aab2c5" : "#414d68";368const MUTED = DARKMODE ? "#7d8699" : "#67718a";369const CHART_FONT = "Inter, -apple-system, 'Segoe UI', sans-serif";370371function baseChart(el) {372 const c = echarts.init(el, null, { renderer: "canvas" });373 const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;374 c.setOption({ textStyle: { fontFamily: CHART_FONT },375 animationDuration: reduce ? 0 : 420, animationEasing: "cubicOut" });376 if (window.ResizeObserver) new ResizeObserver(() => c.resize()).observe(el);377 else window.addEventListener("resize", () => c.resize());378 return c;379}380381/* Dégradé vertical « signature » sous une courbe (remplace les aplats). */382function areaGrad(color, top = 0.22, bottom = 0.0) {383 return { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [384 { offset: 0, color: hexA(color, top) }, { offset: 1, color: hexA(color, bottom) }]) };385}386/* Trait de série : arrondi + halo discret de sa propre couleur. */387function lineGlow(color, width = 2.4) {388 return { width, cap: "round", join: "round",389 shadowColor: hexA(color, DARKMODE ? 0.3 : 0.18), shadowBlur: 6, shadowOffsetY: 3 };390}391function hexA(hex, a) {392 const n = parseInt(hex.slice(1), 16);393 return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;394}395396const AXIS_STYLE = {397 axisLine: { show: false },398 axisTick: { show: false },399 axisLabel: { color: MUTED, fontSize: 11, fontFamily: CHART_FONT },400 splitLine: { lineStyle: { color: SPLIT_COLOR, width: 1 } },401};402const TOOLTIP = {403 trigger: "axis",404 backgroundColor: DARKMODE ? "rgba(22,27,39,.97)" : "rgba(255,255,255,.98)",405 borderColor: DARKMODE ? "rgba(170,188,218,.22)" : "rgba(38,54,92,.18)",406 borderWidth: 1,407 padding: [9, 13],408 textStyle: { color: DARKMODE ? "#edeff5" : "#161b26", fontSize: 12.5,409 fontFamily: CHART_FONT },410 extraCssText: "border-radius:12px;box-shadow:0 10px 32px -10px rgba(0,0,0,.45);font-variant-numeric:tabular-nums;",411 axisPointer: { type: "cross",412 lineStyle: { color: DARKMODE ? "#39415a" : "#b9c2d4", type: [4, 4] },413 crossStyle: { color: DARKMODE ? "#39415a" : "#b9c2d4" },414 label: { backgroundColor: DARKMODE ? "#2c3346" : "#414d68", borderRadius: 5,415 fontSize: 10.5, padding: [3, 7] } },416};417const LEGEND = { textStyle: { color: INK2, fontSize: 12, fontFamily: CHART_FONT },418 itemWidth: 10, itemHeight: 10, icon: "circle", top: 0, itemGap: 16,419 inactiveColor: DARKMODE ? "#3a4157" : "#c6cedf" };420