SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
19 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%
17.9 KB · 321 lines html
Raw Blame History
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