spb/qc-election
Public
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