SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
20 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%
16.2 KB · 290 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>Le point du jour — QC Élection Forecast</title>14<meta name="description" content="Le forecast du jour de l'élection québécoise 2026 : probabilités datées, ce qui a changé depuis hier, attention web, nouveaux signaux.">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="Le point du jour — QC Élection Forecast">21<meta property="og:description" content="Le forecast du jour de l'élection québécoise 2026 : probabilités datées, ce qui a changé depuis hier, attention web, nouveaux signaux.">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="Le point du jour — QC Élection Forecast">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<style>33  .att-bar { height: 10px; border-radius: 6px; display: flex; overflow: hidden;34    margin: 8px 0 4px; }35  .mini-spark { height: 34px; }36  .synth-badge { border: 1px dashed var(--muted); border-radius: 8px; padding: 2px 8px;37    font-size: 11px; color: var(--muted); }38  .tick { display: flex; gap: 10px; align-items: baseline; padding: 8px 0;39    border-bottom: 1px solid var(--line); font-size: .88rem; line-height: 1.5; }40  .tick:last-child { border-bottom: 0; }41  .tick .t { color: var(--muted); white-space: nowrap; font-size: 12px; min-width: 86px; }42  #attention-card td { white-space: nowrap; }43  #attention-card td:last-child { min-width: 200px; width: 40%; }44  .day-lede { color: var(--ink-2); font-size: 1.02rem; line-height: 1.65; margin: 0;45    max-width: 860px; font-variant-numeric: tabular-nums; }46</style>47</head>48<body>49<header class="site"></header>50<div class="wrap">51  <div class="hero">52    <div class="day-date" id="day-date">—</div>53    <h1 class="headline" id="headline">Chargement du point du jour…</h1>54    <p class="day-lede sub" id="day-sub"></p>55    <p class="disclaimer">Snapshot daté et archivé — chaque affirmation renvoie au run du modèle56      (<a href="/methodologie">méthodologie</a> · <a href="/signaux">décomposition des couches</a>).</p>57  </div>5859  <h2 class="section">Les chiffres du jour</h2>60  <div class="grid parties" id="day-cards"><div class="loading">Chargement…</div></div>61  <div class="card">62    <div class="kv"><span>Probabilité qu'aucun parti n'obtienne la majorité</span><b id="d-nomaj"></b></div>63    <div class="kv"><span>Ce qui bouge depuis la veille</span><b id="d-move"></b></div>64  </div>6566  <h2 class="section">Ce qui a changé</h2>67  <p class="section-sub">Dernier run vs dernier run de la journée précédente68    (<span id="prev-date">veille</span>). P(1er) = probabilité publiée (ensemble modèle/marchés).</p>69  <div class="grid two">70    <div class="card" style="overflow-x:auto">71      <h3>Variations du forecast</h3>72      <table class="data" id="delta-table"></table>73    </div>74    <div class="card">75      <h3>Le journal des dernières 24 h</h3>76      <div id="ticker"></div>77    </div>78  </div>7980  <h2 class="section">Attention web <span class="chip badge-aux">Wikipédia — recherche d'information</span></h2>81  <p class="section-sub">Pages des partis et des chef·fe·s (fr.wikipédia) : qui les Québécois·es82    recherchent-ils ? La littérature montre que l'attention précède souvent le mouvement — le83    signal ne déplace jamais le forecast, mais un pic global <b>élargit l'incertitude de campagne</b>.</p>84  <div class="card" id="attention-card"><div class="loading">Chargement…</div></div>8586  <h2 class="section">À surveiller</h2>87  <div class="grid two">88    <div class="card">89      <h3>Prochaines échéances</h3>90      <div class="events" id="upcoming"></div>91      <h3 style="margin-top:18px">Les couches du forecast aujourd'hui</h3>92      <div id="layer-state"></div>93    </div>94    <div class="card">95      <h3>Sondage synthétique IA <span class="synth-badge">EXPÉRIMENTAL — poids nul</span></h3>96      <p class="section-sub">« Silicon sampling » : 96 strates démographiques du Québec incarnées97        par un LLM local ancré dans l'actualité réelle de la campagne, poststratifiées. Ce n'est98        <b>pas un sondage</b> (aucune donnée nouvelle) — publié comme objet d'étude, évalué après99        le 5 octobre.</p>100      <div id="synthetic-body"><div class="loading">Chargement…</div></div>101    </div>102  </div>103</div>104<footer class="site"></footer>105106<script src="/assets/app.js"></script>107<script>108(async () => {109  const meta = await initShell("/aujourdhui");110  const parties = Object.keys(meta.parties).filter(p => p !== "AUT");111  const T = await getJSON(`${API}/today`);112113  const dstr = new Date(T.date + "T12:00:00").toLocaleDateString("fr-CA",114    { weekday: "long", year: "numeric", month: "long", day: "numeric" });115  document.getElementById("day-date").textContent =116    `${dstr} · J−${T.days_to_election} avant le scrutin`;117118  const order = [...parties].sort((a, b) => T.prob_most[b] - T.prob_most[a]);119  const lead = order[0];120  document.getElementById("headline").innerHTML =121    `Le <span style="color:${partyColor(lead)}">${partyName(lead)}</span> a122     ${(T.prob_most[lead] * 100).toFixed(0)} % de chances de remporter le plus de sièges`;123  document.getElementById("day-sub").innerHTML =124    `Majorité ${lead} : <b>${(T.seats[lead].prob_majority * 100).toFixed(0)} %</b> ·125     vote projeté ${T.vote[lead].toFixed(1)} % ·126     ${T.seats[lead].mean.toFixed(0)} sièges attendus [${T.seats[lead].p05}–${T.seats[lead].p95}] ·127     run #${T.run_id} (${new Date(T.run_at + "Z").toLocaleTimeString("fr-CA", { hour: "2-digit", minute: "2-digit" })})`;128129  /* variation : flèche + valeur, couleur du parti ou neutre — jamais vert/rouge */130  const dOf = (obj, p) => obj && obj[p] != null ? obj[p] : null;131132  /* ---- cartes du jour (compactes, data-first) ---- */133  document.getElementById("day-cards").innerHTML = order.map(p => {134    const dp = T.delta ? dOf(T.delta.prob_most, p) : null;135    const dv = T.delta ? dOf(T.delta.vote, p) : null;136    return `<div class="card party-card pcard">137      <div class="bar" style="background:${partyColor(p)}"></div>138      <div class="p-top">139        <div class="p-id">140          <span class="avatars-stack">${avatarHTML(p, 30)}</span>141          <div style="min-width:0">142            <div class="p-abbr" title="${partyName(p)}">${p}</div>143            <div class="p-leader">${partyLeader(p)}</div>144          </div>145        </div>146      </div>147      <div class="p-main">148        <span class="v" style="color:${partyColor(p)}">${(T.prob_most[p] * 100).toFixed(0)}<span style="font-size:.42em;font-weight:650;color:var(--ink-2)"> %</span></span>149        <span class="u">chance<br>d'arriver premier ${deltaHTML(dp != null ? dp * 100 : null, { unit: " pt", title: "variation vs la veille" })}</span>150      </div>151      <div class="p-rows">152        <div class="p-row"><span class="l">Vote projeté</span>153          <span class="r">${T.vote[p].toFixed(1)} % ${deltaHTML(dv, { unit: " pp", digits: 2 })}</span></div>154        <div class="p-row"><span class="l">Sièges attendus</span>155          <span class="r">${T.seats[p].mean.toFixed(0)} <span style="color:var(--muted);font-weight:400">[${T.seats[p].p05}–${T.seats[p].p95}]</span></span></div>156      </div>157    </div>`;158  }).join("");159  document.getElementById("d-nomaj").textContent = fmtPct(T.prob_no_majority);160  const movers = T.delta ? order.map(p => [p, T.delta.vote[p]])161    .filter(([, d]) => Math.abs(d) >= 0.05).sort((a, b) => Math.abs(b[1]) - Math.abs(a[1])) : [];162  document.getElementById("d-move").innerHTML = movers.length163    ? movers.slice(0, 3).map(([p, d]) =>164        `<span style="color:${partyColor(p)}">${p}</span> ${d > 0 ? "+" : ""}${d.toFixed(2)} pp`).join(" · ")165    : "journée stable — aucun mouvement notable";166167  /* ---- table des deltas ---- */168  if (T.delta) {169    document.getElementById("prev-date").textContent = fmtDate(T.delta.since);170    document.getElementById("delta-table").innerHTML =171      `<tr><th>Parti</th><th>P(1er)</th><th>Δ</th><th>Vote</th><th>Δ</th><th>Sièges</th><th>Δ</th></tr>` +172      order.map(p => `<tr>173        <td><span class="dot" style="background:${partyColor(p)}"></span>${p}</td>174        <td>${(T.prob_most[p] * 100).toFixed(1)} %</td>175        <td>${deltaHTML((dOf(T.delta.prob_most, p) ?? NaN) * 100, { unit: " pt", color: partyColor(p) })}</td>176        <td>${T.vote[p].toFixed(1)} %</td>177        <td>${deltaHTML(dOf(T.delta.vote, p), { unit: " pp", digits: 2, color: partyColor(p) })}</td>178        <td>${T.seats[p].mean.toFixed(0)}</td>179        <td>${deltaHTML(dOf(T.delta.seats, p), { unit: "", digits: 1, color: partyColor(p) })}</td></tr>`).join("");180  } else {181    document.getElementById("delta-table").innerHTML =182      `<tr><td style="color:var(--muted)">Premier jour de publication — comparaison disponible demain.</td></tr>`;183  }184185  /* ---- journal 24 h ---- */186  const ticks = [];187  for (const p of T.new_polls_24h) {188    const top = Object.entries(p.shares).filter(([k]) => k !== "AUT")189      .sort((a, b) => b[1] - a[1]).slice(0, 2)190      .map(([k, v]) => `${k} ${v.toFixed(0)}`).join(" · ");191    ticks.push([`sondage`, `<b>${p.pollster}</b> intégré (terrain ${fmtDate(p.field_end)}, n=${p.sample_size ?? "?"}) — ${top}`]);192  }193  for (const s of T.new_signals_24h) {194    ticks.push([s.kind === "poll-radar" ? "radar" : s.kind,195      `<a href="${s.url}" target="_blank" rel="noopener">${s.title}</a>${s.pollster ? " · " + s.pollster : ""}`]);196  }197  document.getElementById("ticker").innerHTML = ticks.length198    ? ticks.slice(0, 12).map(([k, h]) =>199        `<div class="tick"><span class="t"><span class="chip badge-aux">${k}</span></span><span>${h}</span></div>`).join("")200    : emptyStateHTML("Aucun nouveau sondage ni signal dans les dernières 24 h.");201202  /* ---- attention web ---- */203  try {204    const A = await getJSON(`${API}/attention`);205    if (!A.available) throw new Error(A.note || "indisponible");206    const ap = A.parties;207    const ordA = parties.filter(p => ap[p]).sort((a, b) => ap[b].share_7j - ap[a].share_7j);208    document.getElementById("attention-card").innerHTML = `209      <div class="att-bar">${ordA.map(p =>210        `<div style="flex:${ap[p].share_7j};background:${partyColor(p)}" title="${p} ${ap[p].share_7j} %"></div>`).join("")}</div>211      <div style="overflow-x:auto"><table class="data">212        <tr><th>Parti</th><th>Part d'attention 7 j</th><th>vs base 180 j</th><th>Pic (z, 3 j)</th><th>28 derniers jours</th></tr>213        ${ordA.map((p, i) => `<tr>214          <td><span class="dot" style="background:${partyColor(p)}"></span>${p}</td>215          <td><b>${ap[p].share_7j.toFixed(1)} %</b> <span style="color:var(--muted);font-size:11px">(${ap[p].views_7j.toLocaleString("fr-CA")} vues)</span></td>216          <td>${deltaHTML(ap[p].delta_share_pp, { unit: " pp", thresh: 0.5, color: partyColor(p) })}</td>217          <td>${ap[p].z_3j >= 2 ? '<span class="chip badge-aux">pic</span> ' : ""}${ap[p].z_3j.toFixed(1)}</td>218          <td><div class="mini-spark" id="spark-${p}"></div></td></tr>`).join("")}219      </table></div>220      <p class="note">${A.note} Turbulence détectée : z max ${A.z_max} → variance de campagne ×${A.drift_multiplier}.221        Données au ${fmtDate(A.as_of)} · Wikimedia REST API.</p>`;222    for (const p of ordA) {223      const c = baseChart(document.getElementById("spark-" + p));224      c.setOption({ grid: { left: 0, right: 0, top: 2, bottom: 2 },225        xAxis: { type: "category", show: false, data: ap[p].sparkline.map((_, i) => i) },226        yAxis: { type: "value", show: false },227        series: [{ type: "line", data: ap[p].sparkline, symbol: "none", smooth: 0.3,228          color: partyColor(p), lineStyle: { width: 1.6, cap: "round" },229          areaStyle: areaGrad(partyColor(p), 0.3) }] });230    }231  } catch (e) {232    document.getElementById("attention-card").innerHTML =233      emptyStateHTML("Signal d'attention en cours de constitution — première fenêtre de 30 jours requise.");234  }235236  /* ---- sondage synthétique ---- */237  try {238    const S = await getJSON(`${API}/synthetic`);239    const ordS = parties.sort((a, b) => (S.shares[b] || 0) - (S.shares[a] || 0));240    document.getElementById("synthetic-body").innerHTML = `241      <table class="data">242        <tr><th>Parti</th><th>Synthétique</th><th>Forecast</th><th>Écart</th></tr>243        ${ordS.map(p => {244          const c = (S.forecast_comparison || {})[p] || {};245          const d = c.synthetique != null && c.forecast != null ? c.synthetique - c.forecast : null;246          return `<tr><td><span class="dot" style="background:${partyColor(p)}"></span>${p}</td>247            <td><b>${S.shares[p] != null ? S.shares[p].toFixed(1) + " %" : "—"}</b></td>248            <td>${c.forecast != null ? c.forecast.toFixed(1) + " %" : "—"}</td>249            <td style="color:var(--muted)">${d != null ? (d >= 0 ? "+" : "") + d.toFixed(1) + " pp" : "—"}</td></tr>`;250        }).join("")}251      </table>252      <p class="note">${S.note} Généré le ${fmtDate(S.as_of)} · modèle <code>${S.model}</code> ·253        ${S.n_strata} strates poststratifiées · ancré dans ${(S.headlines || []).length} manchettes réelles.</p>`;254  } catch (e) {255    document.getElementById("synthetic-body").innerHTML =256      emptyStateHTML("Aucun sondage synthétique généré pour l'instant (LLM local requis).");257  }258259  /* ---- à surveiller + état des couches ---- */260  document.getElementById("upcoming").innerHTML = (T.upcoming_events || []).map(e =>261    `<div class="event"><span class="d">${fmtDate(e.date)}</span><span>${e.title}</span></div>`).join("")262    || emptyStateHTML("Aucun événement à venir enregistré.");263  const B = T.beyond || {};264  const sat = ((B.fundamentals || {}).satisfaction || {});265  const wF = (((B.fundamentals || {}).blend || {}).precision_share || 0) * 100;266  const att = B.web_attention || {};267  const nudgeMax = Math.max(0, ...Object.values((B.media_adjustment || {}).delta_pp || { x: 0 }).map(Math.abs));268  const mkt = B.market_ensemble;269  document.getElementById("layer-state").innerHTML = [270    ["Votes réels", `${B.n_byelections || 0} partielles dans le filtre`],271    ["Fondamentaux", `satisfaction ${sat.value != null ? sat.value.toFixed(0) + " %" : "—"} (${sat.method === "firecrawl" ? "veille live" : "repli"}) · poids ${wF.toFixed(1)} %`],272    ["Attention web", att.available ? `z max ${att.z_max} → variance ×${att.drift_multiplier}` : "en constitution"],273    ["Médias", `nudge ±${nudgeMax.toFixed(2)} pp`],274    ["Marchés", mkt ? `${(mkt.weight_market * 100).toFixed(0)} % dans P(1er)` : "indisponible"],275  ].map(([t, d]) => `<div class="kv"><span>${t}</span><b style="font-weight:500;color:var(--muted)">${d}</b></div>`).join("");276  try {277    const so = await getJSON(`${API}/social?days=7`);278    const tops = Object.entries(so.parties || {}).filter(([, s]) => s.volume > 0)279      .sort((a, b) => b[1].part_de_voix - a[1].part_de_voix).slice(0, 3);280    if (tops.length) document.getElementById("layer-state").insertAdjacentHTML("beforeend",281      `<div class="kv"><span>Pouls social 7 j</span><b style="font-weight:500;color:var(--muted)">${282        tops.map(([p, s]) => `${p} ${s.part_de_voix}%${s.sentiment_pondere != null283          ? " (" + (s.sentiment_pondere > 0 ? "+" : "") + s.sentiment_pondere.toFixed(2) + ")" : ""}`).join(" · ")284      } — <a href="/intelligence">détail</a></b></div>`);285  } catch (e) { /* premier cycle à venir */ }286})();287</script>288</body>289</html>290