SPB Git

spb/qc-election

Public
9.5 KB · 211 lines html
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="stylesheet" href="/assets/style.css">15<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>16</head>17<body>18<header class="site"></header>19<div class="wrap">20  <div class="hero">21    <h1>Simulateur « et si… »</h1>22    <p class="sub">Modifiez le vote national, un choc régional ou la participation — le moteur relance des milliers de simulations complètes.</p>23  </div>2425  <div class="grid" style="grid-template-columns: 380px 1fr">26    <div>27      <div class="card">28        <h3>Chocs nationaux (points de %)</h3>29        <div id="nat-sliders"></div>30        <p class="note">Le total est recomposé : donner +3 à un parti retire proportionnellement aux autres.</p>31      </div>32      <div class="card" style="margin-top:14px">33        <h3>Choc régional</h3>34        <div class="filters" style="margin:8px 0">35          <select id="reg-select"></select>36          <select id="reg-party"></select>37          <input type="number" id="reg-pp" value="0" step="1" min="-15" max="15" style="width:70px"> pp38        </div>39      </div>40      <div class="card" style="margin-top:14px">41        <h3>Participation différentielle</h3>42        <div id="turnout-sliders"></div>43        <p class="note">1,10 = l'électorat de ce parti se mobilise 10 % de plus que la moyenne.</p>44      </div>45      <div class="card" style="margin-top:14px">46        <h3>Scénarios préconfigurés</h3>47        <div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:8px" id="presets"></div>48      </div>49      <div style="display:flex;gap:10px;margin-top:14px">50        <button class="primary" id="run">Recalculer</button>51        <button id="reset">Réinitialiser</button>52        <span id="status" style="color:var(--muted);font-size:.82rem;align-self:center"></span>53      </div>54    </div>5556    <div>57      <div class="grid parties" id="result-cards"></div>58      <div class="card">59        <h3>Sièges — scénario vs forecast de référence</h3>60        <div class="chart" id="seat-compare"></div>61      </div>62      <div class="card" style="margin-top:14px">63        <div class="kv"><span>Probabilité qu'aucun parti n'ait la majorité</span><b id="r-nomaj">—</b></div>64        <div class="kv"><span>Vote national moyen du scénario</span><b id="r-vote">—</b></div>65      </div>66    </div>67  </div>68</div>69<footer class="site"></footer>7071<script src="/assets/app.js"></script>72<script>73(async () => {74  const meta = await initShell("/simulateur");75  const parties = Object.keys(meta.parties).filter(p => p !== "AUT");76  const fc = await getJSON(`${API}/forecast/latest`);77  const baseSeats = fc.seats.per_party;78  const order = sortParties(baseSeats);7980  const districts = await getJSON(`${API}/districts`);81  const regions = [...new Set(districts.districts.map(d => d.region))].sort();82  document.getElementById("reg-select").innerHTML = regions.map(r => `<option>${r}</option>`).join("");83  document.getElementById("reg-party").innerHTML = parties.map(p => `<option>${p}</option>`).join("");8485  document.getElementById("nat-sliders").innerHTML = order.map(p => `86    <div class="slider-row">87      <span><span class="dot" style="background:${partyColor(p)}"></span>${p}</span>88      <input type="range" id="nat-${p}" min="-10" max="10" step="0.5" value="0">89      <span class="val" id="natv-${p}">+0,0</span>90    </div>`).join("");91  document.getElementById("turnout-sliders").innerHTML = order.map(p => `92    <div class="slider-row">93      <span><span class="dot" style="background:${partyColor(p)}"></span>${p}</span>94      <input type="range" id="to-${p}" min="0.8" max="1.2" step="0.01" value="1">95      <span class="val" id="tov-${p}">1,00</span>96    </div>`).join("");9798  const PRESETS = {99    "Les sondages sous-estiment la CAQ de 3 pp": { poll_error_pp: { CAQ: 3 } },100    "Les sondages sous-estiment le PQ de 3 pp": { poll_error_pp: { PQ: 3 } },101    "Vote jeune +10 % (QS, PQ)": { turnout_mult: { QS: 1.1, PQ: 1.05 } },102    "Électorat aîné démobilisé (CAQ −8 %)": { turnout_mult: { CAQ: 0.92 } },103    "PLQ +4 pp à Montréal et Laval": { regional_pp: { "Montréal": { PLQ: 4 }, "Laval": { PLQ: 4 } } },104    "Forte erreur nationale anti-PQ": { poll_error_pp: { PQ: -4, PLQ: 2, CAQ: 2 } },105  };106  document.getElementById("presets").innerHTML = Object.keys(PRESETS)107    .map(k => `<button data-k="${k}" style="font-size:.78rem">${k}</button>`).join("");108109  order.forEach(p => {110    document.getElementById(`nat-${p}`).oninput = e =>111      document.getElementById(`natv-${p}`).textContent =112        (+e.target.value >= 0 ? "+" : "−") + Math.abs(+e.target.value).toFixed(1);113    document.getElementById(`to-${p}`).oninput = e =>114      document.getElementById(`tov-${p}`).textContent = (+e.target.value).toFixed(2).replace(".", ",");115  });116117  const chart = baseChart(document.getElementById("seat-compare"));118  function renderResult(sim) {119    const seats = sim.seats;120    document.getElementById("result-cards").innerHTML = order.map(p => {121      const s = seats[p], b = baseSeats[p];122      const d = s.prob_most - b.prob_most;123      return `<div class="card party-card">124        <div class="bar" style="background:${partyColor(p)}"></div>125        <div class="party-head"><span class="avatars-stack">${avatarHTML(p, 30)}</span>126          <div class="pname">${p}</div></div>127        <div class="bignum">${(s.prob_most * 100).toFixed(0)} <small>% premier</small></div>128        <div class="kv"><span>vs référence</span><b style="color:${d > 0.005 ? "var(--good)" : d < -0.005 ? "var(--plq)" : "var(--muted)"}">${d >= 0 ? "+" : "−"}${Math.abs(d * 100).toFixed(0)} pts</b></div>129        <div class="kv"><span>Majorité</span><b>${(s.prob_majority * 100).toFixed(0)} %</b></div>130        <div class="kv"><span>Sièges</span><b>${s.mean.toFixed(0)} [${s.p05}–${s.p95}]</b></div>131      </div>`;132    }).join("");133    document.getElementById("r-nomaj").textContent = fmtPct(sim.summary.prob_no_majority);134    document.getElementById("r-vote").textContent =135      order.map(p => `${p} ${sim.national_vote[p].toFixed(1)}`).join(" · ");136137    chart.setOption({138      grid: { left: 44, right: 16, top: 34, bottom: 28 },139      legend: { ...LEGEND, data: ["Référence", "Scénario"] },140      tooltip: { ...TOOLTIP, trigger: "axis" },141      xAxis: { type: "category", data: order, ...AXIS_STYLE, splitLine: { show: false },142        axisLabel: { ...AXIS_STYLE.axisLabel, fontWeight: 600 } },143      yAxis: { type: "value", ...AXIS_STYLE, name: "sièges", nameTextStyle: { color: MUTED } },144      series: [145        { name: "Référence", type: "bar", barWidth: 18, itemStyle: { color: "#3a4157", borderRadius: [4, 4, 0, 0] },146          data: order.map(p => baseSeats[p].mean) },147        { name: "Scénario", type: "bar", barWidth: 18,148          itemStyle: { color: pt => partyColor(order[pt.dataIndex]), borderRadius: [4, 4, 0, 0] },149          data: order.map(p => seats[p].mean),150          label: { show: true, position: "top", color: INK2, fontSize: 11,151                   formatter: pt => pt.value.toFixed(0) } },152        { type: "line", markLine: { symbol: "none", silent: true,153          lineStyle: { color: MUTED, type: "dashed" },154          label: { color: MUTED, formatter: "majorité 64", position: "insideEndTop" },155          data: [{ yAxis: 64 }] }, data: [] },156      ],157    });158  }159160  function currentScenario() {161    const sc = { national_pp: {}, regional_pp: {}, turnout_mult: {}, poll_error_pp: {} };162    order.forEach(p => {163      const n = +document.getElementById(`nat-${p}`).value;164      if (n !== 0) sc.national_pp[p] = n;165      const t = +document.getElementById(`to-${p}`).value;166      if (t !== 1) sc.turnout_mult[p] = t;167    });168    const rpp = +document.getElementById("reg-pp").value;169    if (rpp !== 0) sc.regional_pp[document.getElementById("reg-select").value] =170      { [document.getElementById("reg-party").value]: rpp };171    return sc;172  }173174  let pending = false;175  async function run(extra) {176    if (pending) return;177    pending = true;178    document.getElementById("status").textContent = "simulation en cours…";179    try {180      const body = { ...currentScenario(), ...(extra || {}), n_sims: 8000 };181      const sim = await getJSON(`${API}/whatif`, {182        method: "POST", headers: { "Content-Type": "application/json" },183        body: JSON.stringify(body) });184      renderResult(sim);185      document.getElementById("status").textContent =186        `${sim.n_sims.toLocaleString("fr-CA")} simulations`;187    } catch (e) {188      document.getElementById("status").textContent = "erreur : " + e.message;189    } finally { pending = false; }190  }191192  document.getElementById("run").onclick = () => run();193  document.getElementById("reset").onclick = () => {194    order.forEach(p => {195      document.getElementById(`nat-${p}`).value = 0;196      document.getElementById(`natv-${p}`).textContent = "+0,0";197      document.getElementById(`to-${p}`).value = 1;198      document.getElementById(`tov-${p}`).textContent = "1,00";199    });200    document.getElementById("reg-pp").value = 0;201    run();202  };203  document.querySelectorAll("#presets button").forEach(b =>204    b.onclick = () => run(PRESETS[b.dataset.k]));205206  run(); // scénario neutre = référence207})();208</script>209</body>210</html>211
Files · 6780bcf3bce0bb9586d03310336b001f5c628c57
Menu

Theme