SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
20 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%
13.5 KB · 292 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>Simulateur de scénarios — QC Élection Forecast</title>14<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">15<link rel="icon" type="image/png" sizes="64x64" href="/assets/img/favicon-64.png">16<link rel="apple-touch-icon" href="/assets/img/apple-touch-icon.png">17<meta property="og:site_name" content="QC Élection Forecast">18<meta property="og:type" content="website">19<meta property="og:title" content="Simulateur de scénarios — QC Élection Forecast">20<meta property="og:description" content="Prévision probabiliste de l'élection générale québécoise du 5 octobre 2026 : 127 circonscriptions, modèle bayésien calibré, sources traçables.">21<meta property="og:image" content="https://www.qc-election.com/assets/img/og.png">22<meta property="og:image:width" content="1200">23<meta property="og:image:height" content="630">24<meta property="og:locale" content="fr_CA">25<meta name="twitter:card" content="summary_large_image">26<meta name="twitter:title" content="Simulateur de scénarios — QC Élection Forecast">27<meta name="twitter:image" content="https://www.qc-election.com/assets/img/og.png">28<meta name="theme-color" content="#0a0d14">29<link rel="stylesheet" href="/assets/style.css">30<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>31</head>32<body>33<header class="site"></header>34<div class="wrap">35  <div class="hero">36    <h1>Simulateur « et si… »</h1>37    <p class="sub">Modifiez le vote national, un choc régional ou la participation — le moteur relance des milliers de simulations complètes. Chaque scénario est <b>partageable par URL</b>.</p>38    <p class="disclaimer">Un scénario est un résultat <b>conditionnel</b> (« si cette hypothèse était vraie ») — ce n'est jamais le forecast officiel, qui reste celui de l'<a href="/">accueil</a>.</p>39  </div>4041  <div class="grid" style="grid-template-columns: 380px 1fr">42    <div>43      <div class="card">44        <h3>Chocs nationaux (points de %)</h3>45        <div id="nat-sliders"></div>46        <p class="note">Le total est recomposé : donner +3 à un parti retire proportionnellement aux autres.</p>47      </div>48      <div class="card" style="margin-top:14px">49        <h3>Choc régional</h3>50        <div class="filters" style="margin:8px 0">51          <select id="reg-select"></select>52          <select id="reg-party"></select>53          <input type="number" id="reg-pp" value="0" step="1" min="-15" max="15" style="width:70px"> pp54        </div>55      </div>56      <div class="card" style="margin-top:14px">57        <h3>Participation différentielle</h3>58        <div id="turnout-sliders"></div>59        <p class="note">1,10 = l'électorat de ce parti se mobilise 10 % de plus que la moyenne.</p>60      </div>61      <div class="card" style="margin-top:14px">62        <h3>Scénarios préconfigurés</h3>63        <div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:8px" id="presets"></div>64      </div>65      <div style="display:flex;gap:10px;margin-top:14px;flex-wrap:wrap">66        <button class="primary" id="run">Recalculer</button>67        <button id="reset">Réinitialiser</button>68        <button id="share" title="Copier un lien qui rejoue exactement ce scénario">Copier le lien du scénario</button>69        <span id="status" style="color:var(--muted);font-size:.82rem;align-self:center"></span>70      </div>71    </div>7273    <div>74      <div class="grid parties" id="result-cards"></div>75      <div class="card">76        <h3>Sièges — scénario vs forecast de référence</h3>77        <div class="chart" id="seat-compare"></div>78      </div>79      <div class="card" style="margin-top:14px">80        <div class="kv"><span>Probabilité qu'aucun parti n'ait la majorité</span><b id="r-nomaj">—</b></div>81        <div class="kv"><span>Vote national moyen du scénario</span><b id="r-vote">—</b></div>82      </div>83    </div>84  </div>85</div>86<footer class="site"></footer>8788<script src="/assets/app.js"></script>89<script>90(async () => {91  const meta = await initShell("/simulateur");92  const parties = Object.keys(meta.parties).filter(p => p !== "AUT");93  const fc = await getJSON(`${API}/forecast/latest`);94  const baseSeats = fc.seats.per_party;95  const order = sortParties(baseSeats);9697  const districts = await getJSON(`${API}/districts`);98  const regions = [...new Set(districts.districts.map(d => d.region))].sort();99  document.getElementById("reg-select").innerHTML = regions.map(r => `<option>${r}</option>`).join("");100  document.getElementById("reg-party").innerHTML = parties.map(p => `<option>${p}</option>`).join("");101102  document.getElementById("nat-sliders").innerHTML = order.map(p => `103    <div class="slider-row">104      <span><span class="dot" style="background:${partyColor(p)}"></span>${p}</span>105      <input type="range" id="nat-${p}" min="-10" max="10" step="0.5" value="0">106      <span class="val" id="natv-${p}">+0,0</span>107    </div>`).join("");108  document.getElementById("turnout-sliders").innerHTML = order.map(p => `109    <div class="slider-row">110      <span><span class="dot" style="background:${partyColor(p)}"></span>${p}</span>111      <input type="range" id="to-${p}" min="0.8" max="1.2" step="0.01" value="1">112      <span class="val" id="tov-${p}">1,00</span>113    </div>`).join("");114115  const PRESETS = {116    "Les sondages sous-estiment la CAQ de 3 pp": { poll_error_pp: { CAQ: 3 } },117    "Les sondages sous-estiment le PQ de 3 pp": { poll_error_pp: { PQ: 3 } },118    "Vote jeune +10 % (QS, PQ)": { turnout_mult: { QS: 1.1, PQ: 1.05 } },119    "Électorat aîné démobilisé (CAQ −8 %)": { turnout_mult: { CAQ: 0.92 } },120    "PLQ +4 pp à Montréal et Laval": { regional_pp: { "Montréal": { PLQ: 4 }, "Laval": { PLQ: 4 } } },121    "Forte erreur nationale anti-PQ": { poll_error_pp: { PQ: -4, PLQ: 2, CAQ: 2 } },122  };123  document.getElementById("presets").innerHTML = Object.keys(PRESETS)124    .map(k => `<button data-k="${k}" style="font-size:.78rem">${k}</button>`).join("");125126  order.forEach(p => {127    document.getElementById(`nat-${p}`).oninput = e =>128      document.getElementById(`natv-${p}`).textContent =129        (+e.target.value >= 0 ? "+" : "−") + Math.abs(+e.target.value).toFixed(1);130    document.getElementById(`to-${p}`).oninput = e =>131      document.getElementById(`tov-${p}`).textContent = (+e.target.value).toFixed(2).replace(".", ",");132  });133134  const chart = baseChart(document.getElementById("seat-compare"));135  function renderResult(sim) {136    const seats = sim.seats;137    document.getElementById("result-cards").innerHTML = order.map(p => {138      const s = seats[p], b = baseSeats[p];139      const d = s.prob_most - b.prob_most;140      return `<div class="card party-card">141        <div class="bar" style="background:${partyColor(p)}"></div>142        <div class="party-head"><span class="avatars-stack">${avatarHTML(p, 30)}</span>143          <div class="pname">${p}</div></div>144        <div class="bignum" style="color:${partyColor(p)}">${(s.prob_most * 100).toFixed(0)} <small>% premier</small></div>145        <div class="kv"><span>vs référence</span><b>${deltaHTML(d * 100, { unit: " pt", digits: 0, color: partyColor(p), thresh: 0.5 })}</b></div>146        <div class="kv"><span>Majorité</span><b>${(s.prob_majority * 100).toFixed(0)} %</b></div>147        <div class="kv"><span>Sièges</span><b>${s.mean.toFixed(0)} [${s.p05}–${s.p95}]</b></div>148      </div>`;149    }).join("");150    document.getElementById("r-nomaj").textContent = fmtPct(sim.summary.prob_no_majority);151    document.getElementById("r-vote").textContent =152      order.map(p => `${p} ${sim.national_vote[p].toFixed(1)}`).join(" · ");153154    chart.setOption({155      grid: { left: 44, right: 16, top: 34, bottom: 28 },156      legend: { ...LEGEND, data: ["Référence", "Scénario"] },157      tooltip: { ...TOOLTIP, trigger: "axis" },158      xAxis: { type: "category", data: order, ...AXIS_STYLE, splitLine: { show: false },159        axisLabel: { ...AXIS_STYLE.axisLabel, fontWeight: 600 } },160      yAxis: { type: "value", ...AXIS_STYLE, name: "sièges", nameTextStyle: { color: MUTED } },161      series: [162        { name: "Référence", type: "bar", barWidth: 18, itemStyle: { color: "#3a4157", borderRadius: [4, 4, 0, 0] },163          data: order.map(p => baseSeats[p].mean) },164        { name: "Scénario", type: "bar", barWidth: 18,165          itemStyle: { color: pt => partyColor(order[pt.dataIndex]), borderRadius: [4, 4, 0, 0] },166          data: order.map(p => seats[p].mean),167          label: { show: true, position: "top", color: INK2, fontSize: 11,168                   formatter: pt => pt.value.toFixed(0) } },169        { type: "line", markLine: { symbol: "none", silent: true,170          lineStyle: { color: MUTED, type: "dashed" },171          label: { color: MUTED, formatter: "majorité 64", position: "insideEndTop" },172          data: [{ yAxis: 64 }] }, data: [] },173      ],174    });175  }176177  function currentScenario() {178    const sc = { national_pp: {}, regional_pp: {}, turnout_mult: {}, poll_error_pp: {} };179    order.forEach(p => {180      const n = +document.getElementById(`nat-${p}`).value;181      if (n !== 0) sc.national_pp[p] = n;182      const t = +document.getElementById(`to-${p}`).value;183      if (t !== 1) sc.turnout_mult[p] = t;184    });185    const rpp = +document.getElementById("reg-pp").value;186    if (rpp !== 0) sc.regional_pp[document.getElementById("reg-select").value] =187      { [document.getElementById("reg-party").value]: rpp };188    return sc;189  }190191  /* ---- scénarios partageables par URL (?s=base64url) ---- */192  let extraState = {};   // champs sans contrôle direct (poll_error_pp, régions multiples)193  const b64e = o => btoa(unescape(encodeURIComponent(JSON.stringify(o))))194    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");195  const b64d = s => JSON.parse(decodeURIComponent(escape(196    atob(s.replace(/-/g, "+").replace(/_/g, "/")))));197198  function fullScenario() {199    const sc = currentScenario();200    if (extraState.poll_error_pp) sc.poll_error_pp =201      { ...extraState.poll_error_pp, ...sc.poll_error_pp };202    if (extraState.regional_pp) sc.regional_pp =203      { ...extraState.regional_pp, ...sc.regional_pp };204    return sc;205  }206  function syncURL() {207    const sc = fullScenario();208    const empty = !Object.values(sc).some(v => Object.keys(v).length);209    const url = empty ? location.pathname : `${location.pathname}?s=${b64e(sc)}`;210    history.replaceState(null, "", url);211  }212  function applyScenario(sc) {213    order.forEach(p => {214      const n = (sc.national_pp || {})[p] || 0;215      document.getElementById(`nat-${p}`).value = n;216      document.getElementById(`natv-${p}`).textContent =217        (n >= 0 ? "+" : "−") + Math.abs(n).toFixed(1);218      const t = (sc.turnout_mult || {})[p] || 1;219      document.getElementById(`to-${p}`).value = t;220      document.getElementById(`tov-${p}`).textContent = t.toFixed(2).replace(".", ",");221    });222    const regs = Object.entries(sc.regional_pp || {});223    if (regs.length) {224      const [reg, pp] = regs[0];225      const [party, val] = Object.entries(pp)[0] || [];226      if (party) {227        document.getElementById("reg-select").value = reg;228        document.getElementById("reg-party").value = party;229        document.getElementById("reg-pp").value = val;230      }231    }232    extraState = {233      poll_error_pp: sc.poll_error_pp && Object.keys(sc.poll_error_pp).length234        ? sc.poll_error_pp : undefined,235      regional_pp: regs.length > 1236        ? Object.fromEntries(regs.slice(1)) : undefined,237    };238  }239240  let pending = false;241  async function run(extra) {242    if (pending) return;243    pending = true;244    document.getElementById("status").textContent = "simulation en cours…";245    try {246      if (extra) applyScenario({ ...fullScenario(), ...extra });247      const body = { ...fullScenario(), n_sims: 8000 };248      syncURL();249      const sim = await getJSON(`${API}/whatif`, {250        method: "POST", headers: { "Content-Type": "application/json" },251        body: JSON.stringify(body) });252      renderResult(sim);253      const hasShock = Object.values(body).some(v => v && typeof v === "object"254        && Object.keys(v).length);255      document.getElementById("status").textContent =256        `${sim.n_sims.toLocaleString("fr-CA")} simulations` +257        (hasShock ? " · scénario conditionnel" : " · référence");258    } catch (e) {259      document.getElementById("status").textContent = "erreur : " + e.message;260    } finally { pending = false; }261  }262263  document.getElementById("run").onclick = () => run();264  document.getElementById("reset").onclick = () => {265    extraState = {};266    applyScenario({});267    document.getElementById("reg-pp").value = 0;268    run();269  };270  document.getElementById("share").onclick = async () => {271    syncURL();272    try {273      await navigator.clipboard.writeText(location.href);274      document.getElementById("status").textContent = "lien du scénario copié ✓";275    } catch (e) {276      document.getElementById("status").textContent = location.href;277    }278  };279  document.querySelectorAll("#presets button").forEach(b =>280    b.onclick = () => { extraState = {}; run(PRESETS[b.dataset.k]); });281282  /* scénario porté par l'URL au chargement */283  const sp = new URLSearchParams(location.search).get("s");284  if (sp) {285    try { applyScenario(b64d(sp)); } catch (e) { /* lien invalide → référence */ }286  }287  run();288})();289</script>290</body>291</html>292