SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
19 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%
23.8 KB · 428 lines html
Raw Blame History
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="icon" type="image/svg+xml" href="/assets/img/favicon.svg">16<link rel="icon" type="image/png" sizes="64x64" href="/assets/img/favicon-64.png">17<link rel="apple-touch-icon" href="/assets/img/apple-touch-icon.png">18<meta property="og:site_name" content="QC Élection Forecast">19<meta property="og:type" content="website">20<meta property="og:title" content="QC Élection Forecast — Prévision de l'élection québécoise 2026">21<meta property="og: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.">22<meta property="og:image" content="https://www.qc-election.com/assets/img/og.png">23<meta property="og:image:width" content="1200">24<meta property="og:image:height" content="630">25<meta property="og:locale" content="fr_CA">26<meta name="twitter:card" content="summary_large_image">27<meta name="twitter:title" content="QC Élection Forecast — Prévision de l'élection québécoise 2026">28<meta name="twitter:image" content="https://www.qc-election.com/assets/img/og.png">29<meta name="theme-color" content="#0a0d14">30<link rel="stylesheet" href="/assets/style.css">31<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>32</head>33<body>34<header class="site"></header>35<div class="wrap">36  <div class="hero">37    <p class="kicker"><span>Élection générale du Québec</span><span class="sep">·</span>38      <span>Scrutin le <b id="edate" style="color:var(--ink-2)"></b></span><span class="sep">·</span>39      <span class="countdown" id="countdown"></span><span class="sep">·</span>40      <span><span id="npolls"></span> observations</span><span class="sep">·</span>41      <span><span id="nsims"></span> simulations</span></p>42    <h1 id="verdict-line">Chargement du forecast…</h1>43    <p class="disclaimer">Un forecast est une distribution de probabilités, pas une prédiction44      certaine. Un parti à 70 % perd 3 fois sur 10.</p>45  </div>4647  <!-- ==================== MAIN OUTCOME ==================== -->48  <div class="card outcome" id="outcome" style="display:none">49    <div class="o-left">50      <h3 style="margin:0">Chance d'arriver premier</h3>51      <div class="o-verdict"><span class="metric-xl" id="o-prob">—</span>52        <span id="o-delta" style="vertical-align:baseline"></span></div>53      <div id="o-name" style="color:var(--ink-2);font-size:.95rem"></div>54      <div class="o-facts">55        <div class="o-fact"><span class="f-l">Sièges attendus</span>56          <span class="f-v" id="o-seats"></span></div>57        <div class="o-fact"><span class="f-l">Vote projeté</span>58          <span class="f-v" id="o-vote"></span></div>59        <div class="o-fact"><span class="f-l">Majorité (≥ 64)</span>60          <span class="f-v" id="o-maj"></span></div>61      </div>62    </div>63    <div class="o-right">64      <div id="hemicycle"></div>65    </div>66  </div>6768  <!-- ==================== ISSUES DU SCRUTIN ==================== -->69  <div class="card" id="outcomes-card" style="display:none">70    <h3>Les issues possibles du scrutin</h3>71    <div class="outcome-bar" id="outcome-bar" style="margin-top:10px"></div>72    <p class="note" id="outcome-note"></p>73  </div>7475  <!-- ==================== CARTES PARTIS ==================== -->76  <h2 class="section">Les cinq partis</h2>77  <p class="section-sub">Probabilité publiée (ensemble modèle/marchés), variation depuis la78    veille, sièges attendus avec intervalle à 90 %, vote projeté.</p>79  <div class="grid parties" id="party-cards"><div class="loading">Chargement du forecast…</div></div>8081  <!-- ==================== MOUVEMENT + BATTLEGROUNDS ==================== -->82  <div class="grid two" id="pulse-row" style="display:none">83    <div class="card">84      <h3>Mouvement récent</h3>85      <div id="movement"></div>86      <p class="chart-note"><a href="/aujourdhui">Le point du jour — analyse complète →</a></p>87    </div>88    <div class="card">89      <h3>Champs de bataille — pivots de la majorité</h3>90      <div id="battlegrounds"></div>91      <p class="chart-note"><a href="/circonscriptions">Les 127 circonscriptions →</a> ·92        <a href="/carte">Carte interactive →</a></p>93    </div>94  </div>9596  <div class="card" id="beyond-card" style="display:none">97    <h3>D'où vient le forecast <span class="chip badge-aux">v2 — au-delà des sondages</span></h3>98    <p class="section-sub" style="margin:4px 0 10px">Cinq couches d'inférence combinées, chacune à poids explicite et borné :</p>99    <div id="beyond-strip" style="display:flex;gap:10px;flex-wrap:wrap"></div>100    <p class="chart-note" style="margin-top:10px"><a href="/signaux">Décomposition complète, couche par couche →</a></p>101  </div>102103  <h2 class="section">Intentions de vote</h2>104  <p class="section-sub">Points = sondages individuels (taille ∝ poids dans le modèle) · courbes = état latent estimé · bandes = intervalle crédible à 95 %</p>105  <div class="card">106    <div class="filters">107      <label>Fenêtre :</label>108      <select id="trend-window">109        <option value="120">Campagne + 4 mois</option>110        <option value="365">12 mois</option>111        <option value="0" selected>Tout (depuis 2022)</option>112      </select>113      <label style="margin-left:12px"><input type="checkbox" id="show-band" checked> bandes d'incertitude</label>114    </div>115    <div class="chart tall" id="trend-chart"></div>116    <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>117  </div>118119  <div class="grid two">120    <div class="card">121      <h3>Distribution des sièges (Monte Carlo)</h3>122      <div class="chart" id="seat-hist"></div>123      <p class="chart-note" id="seat-note"></p>124    </div>125    <div class="card">126      <h3>Évolution de la chance d'arriver premier</h3>127      <div class="chart" id="prob-hist"></div>128      <p class="chart-note">Chaque point correspond à une mise à jour du modèle (snapshots consultables via l'API <code>/api/forecast/history</code>).</p>129    </div>130  </div>131132  <div class="grid two">133    <div class="card">134      <h3>Nowcast vs Forecast</h3>135      <p class="section-sub">Nowcast : « si l'élection avait lieu aujourd'hui ». Forecast : jour du vote, incertitude d'évolution de l'opinion incluse.</p>136      <table class="data" id="nowfc"></table>137    </div>138    <div class="card">139      <h3>Événements de campagne</h3>140      <div class="events" id="events"></div>141    </div>142  </div>143144  <h2 class="section">Derniers sondages</h2>145  <div class="card" style="overflow-x:auto">146    <table class="data" id="latest-polls"></table>147    <p class="chart-note"><a href="/sondages">Explorateur complet des sondages →</a></p>148  </div>149</div>150<footer class="site"></footer>151152<script src="/assets/app.js"></script>153<script>154(async () => {155  const meta = await initShell("/");156  document.getElementById("edate").textContent = fmtDate(meta.election.date);157  document.getElementById("countdown").textContent = `J−${meta.election.days_remaining}`;158159  const fc = await getJSON(`${API}/forecast/latest`);160  const today = await getJSON(`${API}/today`).catch(() => null);161  document.getElementById("npolls").textContent = fc.n_polls_used;162  document.getElementById("nsims").textContent = fc.n_simulations.toLocaleString("fr-CA");163  const seats = fc.seats.per_party, order = sortParties(seats);164  const MAJ = fc.seats.summary.majority_threshold || 64;165  // Probabilité « plus de sièges » PUBLIÉE = ensemble modèle/marchés (v2);166  // repli sur la probabilité du modèle si le marché était indisponible.167  const probFirst = p => ((fc.seats.ensemble || {}).blended || {})[p] ?? seats[p].prob_most;168  const deltaFirst = p => today && today.delta ? today.delta.prob_most[p] * 100 : null;169170  /* ==================== MAIN OUTCOME ==================== */171  const lead = order[0];172  const lf = fc.national.forecast[lead], ls = seats[lead];173  document.getElementById("verdict-line").innerHTML =174    `Le <span style="color:${partyColor(lead)}">${partyName(lead)}</span> est favori175     pour remporter le plus de sièges`;176  document.getElementById("o-prob").innerHTML =177    `${(probFirst(lead) * 100).toFixed(0)}<span style="font-size:.45em;font-weight:650;color:var(--ink-2)"> %</span>`;178  document.getElementById("o-prob").style.color = partyColor(lead);179  document.getElementById("o-delta").innerHTML =180    deltaHTML(deltaFirst(lead), { unit: " pt", color: partyColor(lead), title: "variation depuis la veille" });181  document.getElementById("o-name").innerHTML =182    `<b>${partyName(lead)}</b> · ${partyLeader(lead)}`;183  document.getElementById("o-seats").innerHTML =184    `<b>${ls.mean.toFixed(0)}</b>&nbsp;&nbsp;` + rangeHTML({ lo: ls.p05, hi: ls.p95,185      val: ls.mean, min: 0, max: meta.election.total_seats, mark: MAJ, color: partyColor(lead) });186  document.getElementById("o-vote").innerHTML =187    `${lf.mean.toFixed(1)} % <span style="color:var(--muted);font-weight:500">[${lf.lo95.toFixed(1)}–${lf.hi95.toFixed(1)}]</span>`;188  document.getElementById("o-maj").textContent = fmtPct(ls.prob_majority);189  document.getElementById("outcome").style.display = "";190191  /* hémicycle des 127 sièges */192  document.getElementById("hemicycle").appendChild(hemicycleSVG(seats, meta));193194  /* ==================== ISSUES DU SCRUTIN ==================== */195  // Trois issues exhaustives côté modèle : majorité du favori, favori premier mais196  // minoritaire, un autre parti premier. Aucune valeur inventée :197  // prob_majority + prob_minority_win + (1 − prob_most_modèle) = 1.198  if (ls.prob_minority_win != null) {199    const pMaj = ls.prob_majority, pMin = ls.prob_minority_win;200    const pOther = Math.max(0, 1 - pMaj - pMin);201    const segs = [202      [`Majorité ${lead}`, pMaj, partyColor(lead), "#fff"],203      [`${lead} premier, minoritaire`, pMin, hexA(partyColor(lead), 0.45), "var(--ink)"],204      [`Un autre parti premier`, pOther, "color-mix(in srgb, var(--ink) 14%, transparent)", "var(--ink)"],205    ];206    document.getElementById("outcome-bar").innerHTML = segs207      .filter(([, v]) => v >= 0.005)208      .map(([t, v, bg, fg]) => `<div style="flex:${v};background:${bg};color:${fg}"209        title="${t} : ${(v * 100).toFixed(0)} %">210        <span class="ob-t">${t}</span><span class="ob-v">${(v * 100).toFixed(0)} %</span></div>`).join("");211    document.getElementById("outcome-note").textContent =212      `Probabilités du modèle (simulations Monte Carlo). Aucun parti n'obtient la majorité dans ` +213      `${fmtPct(fc.seats.summary.prob_no_majority)} des simulations.`;214    document.getElementById("outcomes-card").style.display = "";215  }216217  /* ==================== CARTES PARTIS (compactes, data-first) ==================== */218  const hist = await getJSON(`${API}/forecast/history`).catch(() => []);219  function sparkline(p) {220    if (!hist || hist.length < 2) return "";221    const vals = hist.map(h => h.prob_most[p] * 100);222    const mn = Math.min(...vals), mx = Math.max(...vals), span = (mx - mn) || 1;223    const W = 120, H = 26, pad = 3;224    const pts = vals.map((v, i) => `${(pad + i * (W - 2 * pad) / (vals.length - 1)).toFixed(1)},${225      (H - pad - (v - mn) / span * (H - 2 * pad)).toFixed(1)}`).join(" ");226    return `<svg class="p-spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"227      style="width:100%" aria-hidden="true">228      <polyline points="${pts}" fill="none" stroke="${partyColor(p)}" stroke-width="2"229        stroke-linecap="round" stroke-linejoin="round" opacity=".85"/></svg>`;230  }231  document.getElementById("party-cards").innerHTML = order.map(p => {232    const s = seats[p], v = fc.national.forecast[p];233    return `<div class="card party-card pcard">234      <div class="bar" style="background:${partyColor(p)}"></div>235      <div class="p-top">236        <div class="p-id">237          <span class="avatars-stack">${avatarHTML(p, 32)}</span>238          <div style="min-width:0">239            <div class="p-abbr" title="${partyName(p)}">${p}</div>240            <div class="p-leader">${partyLeader(p)}</div>241          </div>242        </div>243        ${partyLogo(p) ? `<img class="plogo" src="${partyLogo(p)}" alt="Logo ${partyName(p)}" style="margin:0" loading="lazy">` : ""}244      </div>245      <div class="p-main">246        <span class="v" style="color:${partyColor(p)}">${(probFirst(p) * 100).toFixed(0)}<span style="font-size:.42em;font-weight:650;color:var(--ink-2)"> %</span></span>247        <span class="u">chance<br>d'arriver premier ${deltaHTML(deltaFirst(p), { unit: " pt", title: "variation vs veille" })}</span>248      </div>249      ${sparkline(p)}250      <div class="p-rows">251        <div class="p-row"><span class="l">Sièges attendus</span><span class="r">${s.mean.toFixed(0)}</span></div>252        <div style="margin:1px 0 3px">${rangeHTML({ lo: s.p05, hi: s.p95, val: s.mean, min: 0,253            max: meta.election.total_seats, mark: MAJ, color: partyColor(p),254            fmt: x => Math.round(x) })}</div>255        <div class="p-row"><span class="l">Vote projeté</span><span class="r">${v.mean.toFixed(1)} %</span></div>256        <div class="p-row"><span class="l">Majorité</span><span class="r">${(s.prob_majority * 100).toFixed(0)} %</span></div>257      </div>258    </div>`;259  }).join("");260261  /* ==================== MOUVEMENT + BATTLEGROUNDS ==================== */262  try {263    const bg = await getJSON(`${API}/battlegrounds`).catch(() => null);264    const movers = today && today.delta265      ? order.map(p => [p, today.delta.vote[p]])266          .filter(([, d]) => Math.abs(d) >= 0.05)267          .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1])).slice(0, 4)268      : [];269    document.getElementById("movement").innerHTML = movers.length270      ? movers.map(([p, d]) => `<div class="kv">271          <span><span class="dot" style="background:${partyColor(p)}"></span>${partyName(p)}</span>272          <b>${deltaHTML(d, { unit: " pp", digits: 2, color: partyColor(p) })}</b></div>`).join("") +273        `<p class="note" style="margin-top:6px">Variation du vote projeté vs le run de la veille (${fmtDate(today.delta.since)}).</p>`274      : emptyStateHTML("Journée stable — aucun mouvement notable depuis la veille.");275    const tips = (bg && bg.tipping && bg.tipping.majority_tipping || []).slice(0, 5);276    document.getElementById("battlegrounds").innerHTML = tips.length277      ? tips.map((t, i) => `<div class="kv">278          <span><span style="color:var(--muted)">${i + 1}.</span>279            <a href="/circonscriptions">${t.district}</a></span>280          <b>${(t.prob_majority_tipping * 100).toFixed(1)} %</b></div>`).join("") +281        `<p class="note" style="margin-top:6px">P(circonscription pivot) : fréquence à laquelle282          ce comté fait basculer la majorité dans les simulations.</p>`283      : emptyStateHTML("Pivots disponibles au prochain run du pipeline.");284    document.getElementById("pulse-row").style.display = "";285  } catch (e) { /* rangée facultative */ }286287  /* ==================== d'où vient le forecast (couches v2) ==================== */288  try {289    const bd = await getJSON(`${API}/beyond`);290    const B = bd.beyond || {};291    const wFund = (((B.fundamentals || {}).blend || {}).precision_share || 0) * 100;292    const sat = ((B.fundamentals || {}).satisfaction || {}).value;293    const nBye = (B.byelections_used || []).length;294    const mkt = B.market_ensemble;295    const nudge = Math.max(0, ...Object.values((B.media_adjustment || {}).delta_pp || { x: 0 }).map(Math.abs));296    const chips = [297      ["Sondages", `${fc.n_polls_used - nBye} pondérés (Kalman + effets maison)`],298      ["Votes réels", `${nBye} partielles dans le filtre`],299      ["Fondamentaux", sat != null ? `satisfaction ${sat.toFixed(0)} % · poids ${wFund.toFixed(1)} %` : "—"],300      ["Médias", `nudge ±${nudge.toFixed(2)} pp (max 0,35)`],301      ["Marchés", mkt ? `${(mkt.weight_market * 100).toFixed(0)} % dans P(1er)` : "indisponible"],302    ];303    document.getElementById("beyond-strip").innerHTML = chips.map(([t, d]) =>304      `<div style="flex:1;min-width:150px;padding:10px 12px;border:1px solid var(--line);border-radius:10px">305        <div style="font-weight:650;font-size:13px">${t}</div>306        <div style="color:var(--muted);font-size:12px;margin-top:2px">${d}</div></div>`).join("");307    document.getElementById("beyond-card").style.display = "";308  } catch (e) { /* run v1 encore en base — la carte restera masquée */ }309310  /* ==================== tendance ==================== */311  const polls = await getJSON(`${API}/polls?limit=800`);312  const series = fc.national.trend_series;313  const trendChart = baseChart(document.getElementById("trend-chart"));314  function renderTrend() {315    const windowDays = +document.getElementById("trend-window").value;316    const showBand = document.getElementById("show-band").checked;317    const minDate = windowDays ? new Date(Date.now() - windowDays * 864e5).toISOString().slice(0, 10) : "2000-01-01";318    const idx = series.dates.map((d, i) => d >= minDate ? i : -1).filter(i => i >= 0);319    const dates = idx.map(i => series.dates[i]);320    const s = [];321    for (const p of order) {322      if (showBand) {323        s.push({ name: p + "·lo", type: "line", data: dates.map((d, k) => [d, series.lo[p][idx[k]]]),324          lineStyle: { width: 0 }, stack: "b" + p, symbol: "none", silent: true, showInLegend: false,325          tooltip: { show: false }, color: "transparent" });326        s.push({ name: p + "·band", type: "line",327          data: dates.map((d, k) => [d, +(series.hi[p][idx[k]] - series.lo[p][idx[k]]).toFixed(2)]),328          lineStyle: { width: 0 }, stack: "b" + p, symbol: "none", silent: true,329          areaStyle: { color: partyColor(p), opacity: 0.10 }, tooltip: { show: false }, color: "transparent" });330      }331      s.push({ name: partyName(p), type: "line", color: partyColor(p),332        data: dates.map((d, k) => [d, series.mean[p][idx[k]]]),333        symbol: "none", lineStyle: lineGlow(partyColor(p), 2.4), z: 5,334        endLabel: { show: true, formatter: () => ` ${p} ${series.mean[p][idx[idx.length-1]].toFixed(1)}`,335          color: "#fff", fontSize: 10.5, fontWeight: 700,336          backgroundColor: partyColor(p), borderRadius: 7, padding: [2.5, 7],337          shadowColor: "rgba(0,0,0,.3)", shadowBlur: 5 } });338      s.push({ name: partyName(p), type: "scatter", color: partyColor(p), z: 4,339        data: polls.filter(x => !x.excluded && x.field_end >= minDate && x.adjusted[p] != null)340          .map(x => ({ value: [x.field_end, x.adjusted[p]], symbolSize: 4 + x.weight * 9,341                       poll: `${x.pollster} · n=${x.sample_size ?? "?"} · poids ${(x.weight*100).toFixed(0)}%` })),342        itemStyle: { opacity: 0.45 }, tooltip: { formatter: pt => `${pt.data.poll}<br>${p}: ${pt.value[1].toFixed(1)} %` } });343    }344    trendChart.setOption({345      grid: { left: 40, right: 74, top: 34, bottom: 28 },346      legend: { ...LEGEND, data: order.map(partyName) },347      tooltip: { ...TOOLTIP, valueFormatter: v => (typeof v === "number" ? v.toFixed(1) + " %" : v) },348      xAxis: { type: "time", ...AXIS_STYLE, splitLine: { show: false } },349      yAxis: { type: "value", ...AXIS_STYLE, axisLabel: { ...AXIS_STYLE.axisLabel, formatter: "{value} %" },350               min: 0, max: v => Math.ceil(v.max / 5) * 5 },351      series: s,352    }, true);353  }354  renderTrend();355  document.getElementById("trend-window").onchange = renderTrend;356  document.getElementById("show-band").onchange = renderTrend;357358  /* ==================== histogramme des sièges ==================== */359  const hc = baseChart(document.getElementById("seat-hist"));360  const histSeries = order.slice(0, 4).map(p => {361    const h = seats[p].hist, xs = Object.keys(h).map(Number).sort((a, b) => a - b);362    const tot = Object.values(h).reduce((a, b) => a + b, 0);363    return { name: partyName(p), type: "line", color: partyColor(p), symbol: "none",364      smooth: 0.25, lineStyle: lineGlow(partyColor(p), 2.2),365      areaStyle: areaGrad(partyColor(p), 0.26),366      data: xs.map(x => [x, +(100 * h[x] / tot).toFixed(2)]) };367  });368  hc.setOption({369    grid: { left: 44, right: 16, top: 34, bottom: 42 },370    legend: { ...LEGEND, data: order.slice(0, 4).map(partyName) },371    tooltip: { ...TOOLTIP, valueFormatter: v => v.toFixed(2) + " % des simulations" },372    xAxis: { type: "value", name: "sièges", nameLocation: "middle", nameGap: 26,373      nameTextStyle: { color: MUTED }, ...AXIS_STYLE, max: 127, splitLine: { show: false } },374    yAxis: { type: "value", ...AXIS_STYLE, axisLabel: { ...AXIS_STYLE.axisLabel, formatter: "{value} %" } },375    series: [...histSeries,376      { type: "line", markLine: { symbol: "none", silent: true,377        lineStyle: { color: MUTED, type: "dashed" },378        label: { color: MUTED, formatter: "majorité 64" }, data: [{ xAxis: 64 }] }, data: [] }],379  });380  document.getElementById("seat-note").textContent =381    `Distribution du nombre de sièges sur ${fc.n_simulations.toLocaleString("fr-CA")} simulations (erreurs nationale, régionales et locales corrélées).`;382383  /* ==================== historique des probabilités ==================== */384  const pc = baseChart(document.getElementById("prob-hist"));385  pc.setOption({386    grid: { left: 44, right: 60, top: 34, bottom: 28 },387    legend: { ...LEGEND, data: order.map(partyName) },388    tooltip: { ...TOOLTIP, valueFormatter: v => v.toFixed(0) + " %" },389    xAxis: { type: "time", ...AXIS_STYLE, splitLine: { show: false } },390    yAxis: { type: "value", min: 0, max: 100, ...AXIS_STYLE,391      axisLabel: { ...AXIS_STYLE.axisLabel, formatter: "{value} %" } },392    series: order.map(p => ({ name: partyName(p), type: "line", color: partyColor(p),393      symbol: "circle", symbolSize: 7,394      itemStyle: { borderColor: DARKMODE ? "#131824" : "#fff", borderWidth: 2 },395      lineStyle: lineGlow(partyColor(p), 2.2), areaStyle: areaGrad(partyColor(p), 0.10),396      data: hist.map(h => [h.as_of, +(h.prob_most[p] * 100).toFixed(1)]) })),397  });398399  /* ==================== nowcast vs forecast ==================== */400  document.getElementById("nowfc").innerHTML =401    `<tr><th>Parti</th><th>Nowcast</th><th>Forecast (5 oct.)</th><th>IC 95 % forecast</th></tr>` +402    order.map(p => {403      const n = fc.national.nowcast[p], f = fc.national.forecast[p];404      return `<tr><td><span class="dot" style="background:${partyColor(p)}"></span>${p}</td>405        <td>${n.mean.toFixed(1)} %</td><td><b>${f.mean.toFixed(1)} %</b></td>406        <td style="color:var(--muted)">${f.lo95.toFixed(1)} – ${f.hi95.toFixed(1)} %</td></tr>`;407    }).join("");408409  /* ==================== événements ==================== */410  const evs = await getJSON(`${API}/events`).catch(() => []);411  document.getElementById("events").innerHTML = evs.slice(-8).reverse().map(e =>412    `<div class="event"><span class="d">${fmtDate(e.date)}</span>413     <span>${e.title}${e.kind === "anomalie-volume" ? ' <span class="chip badge-aux">signal auto</span>' : ""}</span></div>`414  ).join("") || emptyStateHTML("Aucun événement détecté.");415416  /* ==================== derniers sondages ==================== */417  const latest = polls.slice(0, 8);418  document.getElementById("latest-polls").innerHTML =419    `<tr><th>Maison</th><th>Fin de terrain</th><th>n</th>${order.map(p => `<th>${p}</th>`).join("")}<th>Poids</th></tr>` +420    latest.map(x => `<tr><td>${x.pollster}</td><td>${fmtDate(x.field_end)}</td>421      <td>${x.sample_size ?? "—"}</td>422      ${order.map(p => `<td>${x.raw[p] != null ? x.raw[p].toFixed(0) + " %" : "—"}</td>`).join("")}423      <td style="color:var(--muted)">${(x.weight * 100).toFixed(0)} %</td></tr>`).join("");424})();425</script>426</body>427</html>428