Repository 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>QC Élection Forecast — Prévision de l'élection québécoise 2026</title>14<meta name="description" content="Prévision probabiliste de l'élection générale québécoise 2026 : modèle bayésien, simulations Monte Carlo, sondages pondérés.">15<link rel="stylesheet" href="/assets/style.css">16<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>17</head>18<body>19<header class="site"></header>20<div class="wrap">21 <div class="hero">22 <h1>Élection générale du Québec 2026</h1>23 <p class="sub">Scrutin le <b id="edate"></b> · <span class="countdown" id="countdown"></span>24 · <span id="npolls"></span> observations intégrées (sondages + votes réels)25 · <span id="nsims"></span> simulations Monte Carlo</p>26 <p class="disclaimer">Un forecast est une distribution de probabilités, pas une prédiction certaine.27 Un parti à 70 % perd 3 fois sur 10.</p>28 </div>2930 <div class="grid parties" id="party-cards"><div class="loading">Chargement du forecast…</div></div>3132 <div class="card" id="beyond-card" style="display:none">33 <h3>D'où vient le forecast <span class="chip badge-aux">v2 — au-delà des sondages</span></h3>34 <p class="section-sub" style="margin:4px 0 10px">Cinq couches d'inférence combinées, chacune à poids explicite et borné :</p>35 <div id="beyond-strip" style="display:flex;gap:10px;flex-wrap:wrap"></div>36 <p class="chart-note" style="margin-top:10px"><a href="/signaux">Décomposition complète, couche par couche →</a></p>37 </div>3839 <h2 class="section">Baromètre de la majorité</h2>40 <p class="section-sub">Sièges médians par parti (127 sièges — majorité à 64)</p>41 <div class="card">42 <div class="meter" id="meter"></div>43 <div class="meter-axis"><span class="maj">▲ majorité (64)</span></div>44 <div class="kv" style="margin-top:10px"><span>Probabilité qu'aucun parti n'obtienne la majorité (gouvernement minoritaire)</span><b id="p-nomaj"></b></div>45 </div>4647 <h2 class="section">Intentions de vote</h2>48 <p class="section-sub">Points = sondages individuels (taille ∝ poids dans le modèle) · courbes = état latent estimé · bandes = intervalle crédible à 95 %</p>49 <div class="card">50 <div class="filters">51 <label>Fenêtre :</label>52 <select id="trend-window">53 <option value="120">Campagne + 4 mois</option>54 <option value="365">12 mois</option>55 <option value="0" selected>Tout (depuis 2022)</option>56 </select>57 <label style="margin-left:12px"><input type="checkbox" id="show-band" checked> bandes d'incertitude</label>58 </div>59 <div class="chart tall" id="trend-chart"></div>60 <p class="chart-note">Sondages corrigés des effets maison et renormalisés (les répartitions publiées peuvent différer). Source des sondages : agrégation Wikipédia, maisons individuelles.</p>61 </div>6263 <div class="grid two">64 <div class="card">65 <h3>Distribution des sièges (Monte Carlo)</h3>66 <div class="chart" id="seat-hist"></div>67 <p class="chart-note" id="seat-note"></p>68 </div>69 <div class="card">70 <h3>Évolution de la probabilité de finir premier</h3>71 <div class="chart" id="prob-hist"></div>72 <p class="chart-note">Chaque point correspond à une mise à jour du modèle (snapshots consultables via l'API <code>/api/forecast/history</code>).</p>73 </div>74 </div>7576 <div class="grid two">77 <div class="card">78 <h3>Nowcast vs Forecast</h3>79 <p class="section-sub">Nowcast : « si l'élection avait lieu aujourd'hui ». Forecast : jour du vote, incertitude d'évolution de l'opinion incluse.</p>80 <table class="data" id="nowfc"></table>81 </div>82 <div class="card">83 <h3>Événements de campagne</h3>84 <div class="events" id="events"></div>85 </div>86 </div>8788 <h2 class="section">Derniers sondages</h2>89 <div class="card" style="overflow-x:auto">90 <table class="data" id="latest-polls"></table>91 <p class="chart-note"><a href="/sondages">Explorateur complet des sondages →</a></p>92 </div>93</div>94<footer class="site"></footer>9596<script src="/assets/app.js"></script>97<script>98(async () => {99 const meta = await initShell("/");100 document.getElementById("edate").textContent = fmtDate(meta.election.date);101 document.getElementById("countdown").textContent = `J−${meta.election.days_remaining}`;102103 const fc = await getJSON(`${API}/forecast/latest`);104 document.getElementById("npolls").textContent = fc.n_polls_used;105 document.getElementById("nsims").textContent = fc.n_simulations.toLocaleString("fr-CA");106 const seats = fc.seats.per_party, order = sortParties(seats);107 // Probabilité « plus de sièges » PUBLIÉE = ensemble modèle/marchés (v2);108 // repli sur la probabilité du modèle si le marché était indisponible.109 const probFirst = p => ((fc.seats.ensemble || {}).blended || {})[p] ?? seats[p].prob_most;110111 /* ---- cartes partis ---- */112 document.getElementById("party-cards").innerHTML = order.map(p => {113 const s = seats[p], v = fc.national.forecast[p];114 return `<div class="card party-card">115 <div class="bar" style="background:${partyColor(p)}"></div>116 <div class="party-head">117 <span class="avatars-stack">${avatarHTML(p, 44)}</span>118 <div class="who">119 <div class="pname">${partyName(p)}</div>120 <div class="leader" style="margin:0">${partyLeader(p)}</div>121 ${partyLogo(p) ? `<img class="plogo" src="${partyLogo(p)}" alt="Logo ${p}" loading="lazy">` : ""}122 </div>123 </div>124 <div class="bignum">${(probFirst(p)*100).toFixed(0)} <small>% premier</small></div>125 <div style="height:8px"></div>126 <div class="kv"><span>Majorité</span><b>${(s.prob_majority*100).toFixed(0)} %</b></div>127 <div class="kv"><span>Sièges attendus</span><b>${s.mean.toFixed(0)} <span style="color:var(--muted);font-weight:400">[${s.p05}–${s.p95}]</span></b></div>128 <div class="kv"><span>Vote</span><b>${v.mean.toFixed(1)} % <span style="color:var(--muted);font-weight:400">[${v.lo95.toFixed(1)}–${v.hi95.toFixed(1)}]</span></b></div>129 </div>`;130 }).join("");131132 /* ---- d'où vient le forecast (couches v2) ---- */133 try {134 const bd = await getJSON(`${API}/beyond`);135 const B = bd.beyond || {};136 const wFund = (((B.fundamentals || {}).blend || {}).precision_share || 0) * 100;137 const sat = ((B.fundamentals || {}).satisfaction || {}).value;138 const nBye = (B.byelections_used || []).length;139 const mkt = B.market_ensemble;140 const nudge = Math.max(0, ...Object.values((B.media_adjustment || {}).delta_pp || { x: 0 }).map(Math.abs));141 const chips = [142 ["🗳️ Sondages", `${fc.n_polls_used - nBye} pondérés (Kalman + effets maison)`],143 ["✅ Votes réels", `${nBye} partielles dans le filtre`],144 ["🏛️ Fondamentaux", sat != null ? `satisfaction ${sat.toFixed(0)} % · poids ${wFund.toFixed(1)} %` : "—"],145 ["📰 Médias", `nudge ±${nudge.toFixed(2)} pp (max 0,35)`],146 ["📈 Marchés", mkt ? `${(mkt.weight_market * 100).toFixed(0)} % dans P(1er)` : "indisponible"],147 ];148 document.getElementById("beyond-strip").innerHTML = chips.map(([t, d]) =>149 `<div style="flex:1;min-width:150px;padding:10px 12px;border:1px solid var(--line,#2a2f42);border-radius:10px">150 <div style="font-weight:600;font-size:13px">${t}</div>151 <div style="color:var(--muted);font-size:12px;margin-top:2px">${d}</div></div>`).join("");152 document.getElementById("beyond-card").style.display = "";153 } catch (e) { /* run v1 encore en base — la carte restera masquée */ }154155 /* ---- baromètre ---- */156 const total = meta.election.total_seats;157 document.getElementById("meter").innerHTML = order.concat(["AUT"])158 .filter(p => seats[p] && seats[p].p50 > 0)159 .map(p => `<div style="background:${partyColor(p)};flex:${seats[p].p50}" title="${partyName(p)}: ${seats[p].p50} sièges médians">${seats[p].p50 >= 8 ? p + " " + seats[p].p50 : ""}</div>`).join("");160 document.getElementById("p-nomaj").textContent = fmtPct(fc.seats.summary.prob_no_majority);161162 /* ---- tendance ---- */163 const polls = await getJSON(`${API}/polls?limit=800`);164 const series = fc.national.trend_series;165 const trendChart = baseChart(document.getElementById("trend-chart"));166 function renderTrend() {167 const windowDays = +document.getElementById("trend-window").value;168 const showBand = document.getElementById("show-band").checked;169 const minDate = windowDays ? new Date(Date.now() - windowDays * 864e5).toISOString().slice(0, 10) : "2000-01-01";170 const idx = series.dates.map((d, i) => d >= minDate ? i : -1).filter(i => i >= 0);171 const dates = idx.map(i => series.dates[i]);172 const s = [];173 for (const p of order) {174 if (showBand) {175 s.push({ name: p + "·lo", type: "line", data: dates.map((d, k) => [d, series.lo[p][idx[k]]]),176 lineStyle: { width: 0 }, stack: "b" + p, symbol: "none", silent: true, showInLegend: false,177 tooltip: { show: false }, color: "transparent" });178 s.push({ name: p + "·band", type: "line",179 data: dates.map((d, k) => [d, +(series.hi[p][idx[k]] - series.lo[p][idx[k]]).toFixed(2)]),180 lineStyle: { width: 0 }, stack: "b" + p, symbol: "none", silent: true,181 areaStyle: { color: partyColor(p), opacity: 0.10 }, tooltip: { show: false }, color: "transparent" });182 }183 s.push({ name: partyName(p), type: "line", color: partyColor(p),184 data: dates.map((d, k) => [d, series.mean[p][idx[k]]]),185 symbol: "none", lineStyle: lineGlow(partyColor(p), 2.4), z: 5,186 endLabel: { show: true, formatter: () => ` ${p} ${series.mean[p][idx[idx.length-1]].toFixed(1)}`,187 color: "#fff", fontSize: 10.5, fontWeight: 700,188 backgroundColor: partyColor(p), borderRadius: 7, padding: [2.5, 7],189 shadowColor: "rgba(0,0,0,.3)", shadowBlur: 5 } });190 s.push({ name: partyName(p), type: "scatter", color: partyColor(p), z: 4,191 data: polls.filter(x => !x.excluded && x.field_end >= minDate && x.adjusted[p] != null)192 .map(x => ({ value: [x.field_end, x.adjusted[p]], symbolSize: 4 + x.weight * 9,193 poll: `${x.pollster} · n=${x.sample_size ?? "?"} · poids ${(x.weight*100).toFixed(0)}%` })),194 itemStyle: { opacity: 0.45 }, tooltip: { formatter: pt => `${pt.data.poll}<br>${p}: ${pt.value[1].toFixed(1)} %` } });195 }196 trendChart.setOption({197 grid: { left: 40, right: 74, top: 34, bottom: 28 },198 legend: { ...LEGEND, data: order.map(partyName) },199 tooltip: { ...TOOLTIP, valueFormatter: v => (typeof v === "number" ? v.toFixed(1) + " %" : v) },200 xAxis: { type: "time", ...AXIS_STYLE, splitLine: { show: false } },201 yAxis: { type: "value", ...AXIS_STYLE, axisLabel: { ...AXIS_STYLE.axisLabel, formatter: "{value} %" },202 min: 0, max: v => Math.ceil(v.max / 5) * 5 },203 series: s,204 }, true);205 }206 renderTrend();207 document.getElementById("trend-window").onchange = renderTrend;208 document.getElementById("show-band").onchange = renderTrend;209210 /* ---- histogramme des sièges ---- */211 const hc = baseChart(document.getElementById("seat-hist"));212 const histSeries = order.slice(0, 4).map(p => {213 const h = seats[p].hist, xs = Object.keys(h).map(Number).sort((a, b) => a - b);214 const tot = Object.values(h).reduce((a, b) => a + b, 0);215 return { name: partyName(p), type: "line", color: partyColor(p), symbol: "none",216 smooth: 0.25, lineStyle: lineGlow(partyColor(p), 2.2),217 areaStyle: areaGrad(partyColor(p), 0.26),218 data: xs.map(x => [x, +(100 * h[x] / tot).toFixed(2)]) };219 });220 hc.setOption({221 grid: { left: 44, right: 16, top: 34, bottom: 42 },222 legend: { ...LEGEND, data: order.slice(0, 4).map(partyName) },223 tooltip: { ...TOOLTIP, valueFormatter: v => v.toFixed(2) + " % des simulations" },224 xAxis: { type: "value", name: "sièges", nameLocation: "middle", nameGap: 26,225 nameTextStyle: { color: MUTED }, ...AXIS_STYLE, max: 127, splitLine: { show: false } },226 yAxis: { type: "value", ...AXIS_STYLE, axisLabel: { ...AXIS_STYLE.axisLabel, formatter: "{value} %" } },227 series: [...histSeries,228 { type: "line", markLine: { symbol: "none", silent: true,229 lineStyle: { color: MUTED, type: "dashed" },230 label: { color: MUTED, formatter: "majorité 64" }, data: [{ xAxis: 64 }] }, data: [] }],231 });232 document.getElementById("seat-note").textContent =233 `Distribution du nombre de sièges sur ${fc.n_simulations.toLocaleString("fr-CA")} simulations (erreurs nationale, régionales et locales corrélées).`;234235 /* ---- historique des probabilités ---- */236 const hist = await getJSON(`${API}/forecast/history`);237 const pc = baseChart(document.getElementById("prob-hist"));238 pc.setOption({239 grid: { left: 44, right: 60, top: 34, bottom: 28 },240 legend: { ...LEGEND, data: order.map(partyName) },241 tooltip: { ...TOOLTIP, valueFormatter: v => v.toFixed(0) + " %" },242 xAxis: { type: "time", ...AXIS_STYLE, splitLine: { show: false } },243 yAxis: { type: "value", min: 0, max: 100, ...AXIS_STYLE,244 axisLabel: { ...AXIS_STYLE.axisLabel, formatter: "{value} %" } },245 series: order.map(p => ({ name: partyName(p), type: "line", color: partyColor(p),246 symbol: "circle", symbolSize: 7,247 itemStyle: { borderColor: DARKMODE ? "#151924" : "#fff", borderWidth: 2 },248 lineStyle: lineGlow(partyColor(p), 2.2), areaStyle: areaGrad(partyColor(p), 0.10),249 data: hist.map(h => [h.as_of, +(h.prob_most[p] * 100).toFixed(1)]) })),250 });251252 /* ---- nowcast vs forecast ---- */253 document.getElementById("nowfc").innerHTML =254 `<tr><th>Parti</th><th>Nowcast</th><th>Forecast (5 oct.)</th><th>IC 95 % forecast</th></tr>` +255 order.map(p => {256 const n = fc.national.nowcast[p], f = fc.national.forecast[p];257 return `<tr><td><span class="dot" style="background:${partyColor(p)}"></span>${p}</td>258 <td>${n.mean.toFixed(1)} %</td><td><b>${f.mean.toFixed(1)} %</b></td>259 <td style="color:var(--muted)">${f.lo95.toFixed(1)} – ${f.hi95.toFixed(1)} %</td></tr>`;260 }).join("");261262 /* ---- événements ---- */263 const evs = await getJSON(`${API}/events`);264 document.getElementById("events").innerHTML = evs.slice(-8).reverse().map(e =>265 `<div class="event"><span class="d">${fmtDate(e.date)}</span>266 <span>${e.title}${e.kind === "anomalie-volume" ? ' <span class="chip badge-aux">signal auto</span>' : ""}</span></div>`267 ).join("") || '<span style="color:var(--muted)">Aucun événement détecté.</span>';268269 /* ---- derniers sondages ---- */270 const latest = polls.slice(0, 8);271 document.getElementById("latest-polls").innerHTML =272 `<tr><th>Maison</th><th>Fin de terrain</th><th>n</th>${order.map(p => `<th>${p}</th>`).join("")}<th>Poids</th></tr>` +273 latest.map(x => `<tr><td>${x.pollster}</td><td>${fmtDate(x.field_end)}</td>274 <td>${x.sample_size ?? "—"}</td>275 ${order.map(p => `<td>${x.raw[p] != null ? x.raw[p].toFixed(0) + " %" : "—"}</td>`).join("")}276 <td style="color:var(--muted)">${(x.weight * 100).toFixed(0)} %</td></tr>`).join("");277})();278</script>279</body>280</html>281