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>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