spb/qc-election
Public
Python 66.6%
HTML 24.8%
CSS 4.9%
JavaScript 3.6%
1<!DOCTYPE html>2<!--3 QC Élection Forecast — Plateforme de prévision électorale du Québec 20264 Auteur : Simon-Pierre Boucher5 Contact : contact@spboucher.ai6 https://www.qc-election.com7-->8<html lang="fr">9<head>10<script>document.documentElement.dataset.theme=localStorage.getItem("qce-theme")||"dark";</script>11<meta charset="utf-8">12<meta name="viewport" content="width=device-width, initial-scale=1">13<title>Forecast par circonscription — QC Élection Forecast</title>14<meta name="description" content="Le forecast des 127 circonscriptions du Québec : probabilités de victoire, circonscriptions pivots de la majorité, champs de bataille.">15<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">16<link rel="icon" type="image/png" sizes="64x64" href="/assets/img/favicon-64.png">17<link rel="apple-touch-icon" href="/assets/img/apple-touch-icon.png">18<meta property="og:site_name" content="QC Élection Forecast">19<meta property="og:type" content="website">20<meta property="og:title" content="Forecast par circonscription — QC Élection Forecast">21<meta property="og:description" content="Le forecast des 127 circonscriptions du Québec : probabilités de victoire, circonscriptions pivots de la majorité, champs de bataille.">22<meta property="og:image" content="https://www.qc-election.com/assets/img/og.png">23<meta property="og:image:width" content="1200">24<meta property="og:image:height" content="630">25<meta property="og:locale" content="fr_CA">26<meta name="twitter:card" content="summary_large_image">27<meta name="twitter:title" content="Forecast par circonscription — QC Élection Forecast">28<meta name="twitter:image" content="https://www.qc-election.com/assets/img/og.png">29<meta name="theme-color" content="#0a0d14">30<link rel="stylesheet" href="/assets/style.css">31<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>32<style>33 .prob-bar { display: flex; height: 12px; border-radius: 6px; overflow: hidden;34 gap: 1.5px; min-width: 130px; }35 .prob-bar div { min-width: 0; }36 .riding-row { cursor: pointer; }37 .riding-row.open td { background: color-mix(in srgb, var(--accent) 10%, transparent); }38 .detail-cell { padding: 0 !important; }39 .detail-inner { padding: 14px 12px 18px; display: grid;40 grid-template-columns: 1fr 1fr; gap: 18px; }41 @media (max-width: 760px) { .detail-inner { grid-template-columns: 1fr; } }42 .cat-chip { font-size: .7rem; }43 .tip-num { font-variant-numeric: tabular-nums; font-weight: 700; }44 #search { min-width: 220px; }45</style>46</head>47<body>48<header class="site"></header>49<div class="wrap">50 <div class="hero">51 <h1>Le forecast, comté par comté</h1>52 <p class="sub">Les <b>127 circonscriptions</b> : probabilités de victoire issues des simulations53 Monte Carlo (erreurs nationale, régionales et locales corrélées), circonscriptions54 <b>pivots de la majorité</b> et champs de bataille.</p>55 <p class="disclaimer">Cliquer une circonscription pour le détail complet (parts attendues,56 IC 95 %, sortant·e, candidatures, référence 2022).</p>57 </div>5859 <div class="grid three" id="kpis"></div>6061 <h2 class="section">Circonscriptions pivots de la majorité <span class="chip badge-aux">méthode des simulations</span></h2>62 <p class="section-sub">Dans chaque simulation où un parti atteint 64 sièges, la 64ᵉ circonscription63 la plus sûre de ce parti est <b>celle qui fait basculer la majorité</b>. P(pivot) = fréquence sur64 les simulations.</p>65 <div class="card" style="overflow-x:auto"><table class="data" id="tips-table"></table></div>6667 <h2 class="section">La course à 64</h2>68 <p class="section-sub">Pour chaque parti, ses circonscriptions classées de la plus sûre à la69 plus décisive : la <b>64ᵉ</b> est son « siège de la majorité ». On montre l'échelle autour70 du seuil (rangs 56 à 72) — marge attendue = écart projeté entre les deux premiers partis71 dans la circonscription (le swing minimal pour la faire basculer).</p>72 <div class="filters" id="p64-tabs" role="tablist"></div>73 <div class="card" style="padding:10px 12px">74 <div class="kv" style="padding:4px 2px 10px"><span id="p64-summary"></span><b id="p64-seat64"></b></div>75 <div class="table-scroll"><table class="data" id="p64-table"></table></div>76 </div>7778 <h2 class="section">Les 127 circonscriptions</h2>79 <div class="filters">80 <input type="text" id="search" placeholder="Rechercher une circonscription…">81 <select id="f-region"><option value="">Toutes les régions</option></select>82 <select id="f-cat">83 <option value="">Toutes les catégories</option>84 <option>Chaudement disputé</option><option>Favorisé</option>85 <option>Probable</option><option>Solide</option>86 </select>87 <select id="f-sort">88 <option value="contested">Tri : plus disputées d'abord</option>89 <option value="name">Tri : alphabétique</option>90 <option value="region">Tri : par région</option>91 </select>92 <span style="color:var(--muted);font-size:.8rem" id="count"></span>93 </div>94 <div class="card" style="padding:10px 12px"><div class="table-scroll"><table class="data" id="ridings"></table></div></div>95</div>96<footer class="site"></footer>9798<script src="/assets/app.js"></script>99<script>100(async () => {101 const meta = await initShell("/circonscriptions");102 const parties = Object.keys(meta.parties).filter(p => p !== "AUT");103104 const [dd, bg] = await Promise.all([105 getJSON(`${API}/districts`), getJSON(`${API}/battlegrounds`).catch(() => null)]);106 const ridings = dd.districts;107108 /* ---- KPIs ---- */109 const byCat = {};110 for (const r of ridings) byCat[r.category] = (byCat[r.category] || 0) + 1;111 const flips = ridings.filter(r => r.incumbent_party && r.favorite !== r.incumbent_party).length;112 document.getElementById("kpis").innerHTML = [113 ["Chaudement disputées", byCat["Chaudement disputé"] || 0,114 "aucun parti au-dessus de 60 % de chances"],115 ["Sièges qui changeraient de main", flips, "favori ≠ parti sortant (médiane des simulations)"],116 ["Solides", byCat["Solide"] || 0, "favori à ≥ 95 % de chances"],117 ].map(([t, v, d]) => `<div class="card"><h3>${t}</h3>118 <div class="bignum">${v}</div><p class="note">${d}</p></div>`).join("");119120 /* ---- pivots ---- */121 const tips = (bg && bg.tipping && bg.tipping.majority_tipping) || [];122 const rByName = Object.fromEntries(ridings.map(r => [r.district, r]));123 document.getElementById("tips-table").innerHTML = tips.length124 ? `<tr><th>#</th><th>Circonscription</th><th>P(pivot de la majorité)</th><th>Favori</th><th>Région</th><th>Course</th></tr>` +125 tips.slice(0, 12).map((t, i) => {126 const r = rByName[t.district] || {};127 return `<tr><td style="color:var(--muted)">${i + 1}</td>128 <td><b>${t.district}</b></td>129 <td class="tip-num">${(t.prob_majority_tipping * 100).toFixed(1)} %</td>130 <td>${r.favorite ? `<span class="dot" style="background:${partyColor(r.favorite)}"></span>${r.favorite}` : "—"}</td>131 <td style="color:var(--muted)">${r.region || "—"}</td>132 <td>${probBar(r)}</td></tr>`;133 }).join("")134 : `<tr><td style="color:var(--muted)">Pivots disponibles au prochain run du pipeline.</td></tr>`;135136 /* ---- course à 64 ---- */137 const paths = (bg && bg.paths_to_64) || null;138 if (paths) {139 const tabs = document.getElementById("p64-tabs");140 const active = () => tabs.querySelector("button.on")?.dataset.p ||141 Object.keys(paths).sort((a, b) => paths[b].seats_ge_50 - paths[a].seats_ge_50)[0];142 tabs.innerHTML = Object.keys(paths)143 .sort((a, b) => paths[b].seats_ge_50 - paths[a].seats_ge_50)144 .map((p, i) => `<button data-p="${p}" class="${i === 0 ? "on" : ""}"145 style="${i === 0 ? `border-color:${partyColor(p)}` : ""}" role="tab"146 aria-selected="${i === 0}"><span class="dot" style="background:${partyColor(p)}"></span>${p}147 <span style="color:var(--muted);font-weight:400">· ${paths[p].seats_ge_50} sièges ≥ 50 %</span></button>`).join("");148 function renderP64() {149 const p = active();150 const d = paths[p];151 document.getElementById("p64-summary").textContent =152 `${partyName(p)} — ${d.seats_ge_50} circonscriptions où il est favori (≥ 50 %)`;153 document.getElementById("p64-seat64").textContent = d.majority_seat154 ? `Siège de la majorité (64ᵉ) : ${d.majority_seat.district} — P(victoire) ${(d.majority_seat.win_prob * 100).toFixed(0)} %`155 : "—";156 const window_ = d.ladder.filter(r => r.rank >= 56 && r.rank <= 72);157 document.getElementById("p64-table").innerHTML =158 `<tr><th>Rang</th><th>Circonscription</th><th>P(victoire ${p})</th><th>Marge attendue</th><th>Favori actuel</th></tr>` +159 window_.map(r => `<tr style="${r.is_majority_seat160 ? `background:color-mix(in srgb, ${partyColor(p)} 12%, transparent);font-weight:650` : ""}">161 <td class="tabular" style="color:var(--muted)">${r.rank}${r.is_majority_seat ? " ★" : ""}</td>162 <td><b>${r.district}</b>${r.is_majority_seat ? ' <span class="chip badge-aux">majorité 64</span>' : ""}</td>163 <td class="tip-num">${(r.win_prob * 100).toFixed(0)} %</td>164 <td class="tabular">${r.margin_pp != null ? r.margin_pp.toFixed(1) + " pp" : "—"}</td>165 <td><span class="dot" style="background:${partyColor(r.favorite)}"></span>${r.favorite}</td>166 </tr>`).join("");167 }168 tabs.addEventListener("click", e => {169 const b = e.target.closest("button[data-p]");170 if (!b) return;171 tabs.querySelectorAll("button").forEach(x => { x.classList.remove("on");172 x.style.borderColor = ""; x.setAttribute("aria-selected", "false"); });173 b.classList.add("on"); b.style.borderColor = partyColor(b.dataset.p);174 b.setAttribute("aria-selected", "true");175 renderP64();176 });177 renderP64();178 } else {179 document.getElementById("p64-table").innerHTML =180 `<tr><td style="color:var(--muted)">Course à 64 disponible au prochain run.</td></tr>`;181 }182183 /* ---- explorateur ---- */184 const regions = [...new Set(ridings.map(r => r.region))].sort();185 document.getElementById("f-region").innerHTML +=186 regions.map(r => `<option>${r}</option>`).join("");187188 function probBar(r) {189 if (!r.win_probs) return "";190 const entries = parties.map(p => [p, r.win_probs[p] || 0]).filter(([, v]) => v > 0.01);191 return `<div class="prob-bar">${entries.sort((a, b) => b[1] - a[1]).map(([p, v]) =>192 `<div style="flex:${v};background:${partyColor(p)}" title="${p} ${(v * 100).toFixed(0)} %"></div>`).join("")}</div>`;193 }194 const catColor = c => c === "Chaudement disputé" ? "var(--danger)" :195 c === "Favorisé" ? "var(--warn)" : c === "Probable" ? "var(--accent)" : "var(--good)";196197 let open = null;198 function render() {199 const q = document.getElementById("search").value.trim().toLowerCase();200 const fr = document.getElementById("f-region").value;201 const fcat = document.getElementById("f-cat").value;202 const sort = document.getElementById("f-sort").value;203 let rows = ridings.filter(r =>204 (!q || r.district.toLowerCase().includes(q)) &&205 (!fr || r.region === fr) && (!fcat || r.category === fcat));206 if (sort === "name") rows.sort((a, b) => a.district.localeCompare(b.district, "fr"));207 else if (sort === "region") rows.sort((a, b) =>208 a.region.localeCompare(b.region, "fr") || a.district.localeCompare(b.district, "fr"));209 else rows.sort((a, b) =>210 Math.max(...Object.values(a.win_probs)) - Math.max(...Object.values(b.win_probs)));211 document.getElementById("count").textContent = `${rows.length} / ${ridings.length}`;212 document.getElementById("ridings").innerHTML =213 `<tr><th>Circonscription</th><th>Région</th><th>Favori</th><th>P(victoire)</th><th>Course</th><th>Catégorie</th><th>Sortant</th></tr>` +214 rows.map(r => `<tr class="riding-row" data-d="${r.district}">215 <td><b>${r.district}</b>${r.is_new_2026 ? ' <span class="chip badge-aux">nouvelle</span>' : ""}</td>216 <td style="color:var(--muted)">${r.region}</td>217 <td><span class="dot" style="background:${partyColor(r.favorite)}"></span>${r.favorite}</td>218 <td class="tip-num">${(Math.max(...Object.values(r.win_probs)) * 100).toFixed(0)} %</td>219 <td>${probBar(r)}</td>220 <td><span class="chip cat-chip" style="background:color-mix(in srgb, ${catColor(r.category)} 15%, transparent);color:${catColor(r.category)}">${r.category}</span></td>221 <td style="color:var(--muted)">${r.incumbent_party || "—"}${r.incumbent_running === false ? " · retrait" : ""}</td>222 </tr>`).join("");223 document.querySelectorAll(".riding-row").forEach(tr =>224 tr.addEventListener("click", () => toggleDetail(tr)));225 }226227 async function toggleDetail(tr) {228 const name = tr.dataset.d;229 document.querySelectorAll(".detail-row").forEach(e => e.remove());230 document.querySelectorAll(".riding-row.open").forEach(e => e.classList.remove("open"));231 if (open === name) { open = null; return; }232 open = name;233 tr.classList.add("open");234 const det = await getJSON(`${API}/districts/${encodeURIComponent(name)}`);235 const f = det.forecast || {};236 const row = document.createElement("tr");237 row.className = "detail-row";238 const exp = f.expected || {};239 const ordD = parties.filter(p => exp[p] != null).sort((a, b) => exp[b] - exp[a]);240 row.innerHTML = `<td colspan="7" class="detail-cell"><div class="detail-inner">241 <div>242 <h3>Parts attendues — ${name}</h3>243 <table class="data">244 <tr><th>Parti</th><th>Attendu</th><th>IC 95 %</th><th>P(victoire)</th><th>2022</th></tr>245 ${ordD.map(p => `<tr>246 <td><span class="dot" style="background:${partyColor(p)}"></span>${p}</td>247 <td><b>${exp[p].toFixed(1)} %</b></td>248 <td style="color:var(--muted)">${f.lo95 ? f.lo95[p].toFixed(0) + "–" + f.hi95[p].toFixed(0) + " %" : "—"}</td>249 <td>${((f.win_probs || {})[p] * 100 || 0).toFixed(1)} %</td>250 <td style="color:var(--muted)">${det.baseline_2022 && det.baseline_2022[p] != null ? det.baseline_2022[p].toFixed(1) + " %" : "—"}</td>251 </tr>`).join("")}252 </table>253 <p class="note">${det.baseline_source || ""} · participation 2022 : ${det.turnout_2022 != null ? det.turnout_2022.toFixed(0) + " %" : "—"}</p>254 ${(() => {255 if (ordD.length < 2) return "";256 const [p1, p2] = ordD;257 const margin = exp[p1] - exp[p2];258 const swing = margin / 2;259 const wp1 = ((f.win_probs || {})[p1] || 0) * 100;260 // sensibilité : IC 95 % du meneur — plus il est large vs la marge,261 // plus le siège est instable262 const width = f.hi95 && f.lo95 ? f.hi95[p1] - f.lo95[p1] : null;263 const instab = width ? Math.min(3, width / Math.max(margin, 0.1)) : null;264 return `<h3 style="margin-top:14px">Sensibilité au swing</h3>265 <div class="kv"><span>Marge attendue</span>266 <b><span class="dot" style="background:${partyColor(p1)}"></span>${p1}267 +${margin.toFixed(1)} pp sur ${p2}</b></div>268 <div class="kv"><span>Chemin de bascule minimal</span>269 <b>swing d'environ ${Math.max(0.1, swing).toFixed(1)} pp vers270 <span class="dot" style="background:${partyColor(p2)}"></span>${p2}</b></div>271 <div class="kv"><span>Stabilité</span>272 <b>${wp1 >= 95 ? "verrouillée" : wp1 >= 80 ? "solide" : wp1 >= 60 ? "favorisée"273 : "réellement disputée"} — P(${p1}) ${wp1.toFixed(0)} %${274 instab != null ? ` · IC ${width.toFixed(0)} pp vs marge ${margin.toFixed(1)} pp` : ""}</b></div>275 <p class="note">Le « chemin de bascule » suppose un transfert direct entre les deux276 meneurs; un mouvement national se propage ici en swing proportionnel (log-ratio),277 donc l'ampleur réelle requise varie selon la structure locale.</p>`;278 })()}279 </div>280 <div>281 <div id="demo-${cssId(name)}"></div>282 <h3>Candidatures (DGEQ)</h3>283 ${det.candidates && det.candidates.length ? `<table class="data">284 <tr><th>Candidat·e</th><th>Parti</th><th></th></tr>285 ${det.candidates.map(c => `<tr><td>${c.name}</td>286 <td><span class="dot" style="background:${partyColor(c.party)}"></span>${c.party}</td>287 <td style="color:var(--muted)">${c.is_incumbent ? "sortant·e" : ""}</td></tr>`).join("")}288 </table>` : '<p class="note">Candidatures officielles à venir (registre DGEQ).</p>'}289 </div>290 </div></td>`;291 tr.after(row);292 /* démographie officielle (Recensement 2021, limites 2026) — best-effort */293 try {294 const dg = await getJSON(`${API}/demographics/${encodeURIComponent(name)}`);295 const kv = (l, v, u = " %") => v != null296 ? `<div class="kv"><span>${l}</span><b>${typeof v === "number" ? v.toLocaleString("fr-CA") : v}${u}</b></div>` : "";297 document.getElementById(`demo-${cssId(name)}`).innerHTML = `298 <h3>Démographie <span class="chip badge-aux">Recensement 2021 · limites 2026</span></h3>299 ${kv("Population", dg.population, "")}300 ${kv("65 ans et plus", dg.pct_60_plus)}301 ${kv("Français à la maison", dg.pct_francais)}302 ${kv("Universitaire (bacc+)", dg.pct_universitaire)}303 ${kv("Locataires", dg.pct_locataires)}304 ${kv("Immigrant·e·s", dg.pct_immigrants)}305 ${kv("Ménages 100 k$ +", dg.pct_revenu_100k_plus)}306 ${dg.similar && dg.similar.length ? `<div class="kv"><span>Semblables</span>307 <b style="font-weight:500">${dg.similar.slice(0, 4).map(s => s.district).join(" · ")}</b></div>` : ""}308 <p class="note"><a href="${dg.source_url}" target="_blank" rel="noopener">${dg.source}</a></p>309 <div style="height:14px"></div>`;310 } catch (e) { /* démographie absente : la fiche reste complète */ }311 }312 function cssId(s) { return s.replace(/[^a-zA-Z0-9]/g, "_"); }313314 ["search", "f-region", "f-cat", "f-sort"].forEach(id =>315 document.getElementById(id).addEventListener(id === "search" ? "input" : "change", render));316 render();317})();318</script>319</body>320</html>321