SPB Git

Repository Public

index.html 15.3 KB 281 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>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
Files e6325299f4fddaa71fa341f4d44ed851fc90f9ec
Menu

Theme