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%
14.0 KB · 257 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>Intelligence électorale — 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="Intelligence électorale — 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="Intelligence électorale — 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>Intelligence électorale</h1>37    <p class="sub">Sentiment médiatique, momentum et nouvelles regroupées.38      <span class="chip badge-aux">agit sur le forecast via un ajustement borné (±0,35 pp max) — <a href="/signaux">détail</a></span></p>39  </div>4041  <h2 class="section">Interroger le modèle</h2>42  <p class="section-sub">Les réponses sont générées à partir des données réelles du modèle (sondages, runs, événements) — chaque affirmation est traçable.</p>43  <div class="card">44    <div class="ask-box">45      <input type="text" id="q" placeholder="Ex. : pourquoi la probabilité du PQ a-t-elle changé cette semaine ?" maxlength="500">46      <button class="primary" id="ask">Demander</button>47    </div>48    <div class="answer" id="answer"></div>49  </div>5051  <h2 class="section">Momentum <span class="chip badge-aux">indicateur maison</span></h2>52  <div class="grid parties" id="momentum"></div>5354  <h2 class="section">Pouls social <span class="chip badge-aux">poids nul — actor Apify maison + sources ouvertes</span></h2>55  <p class="section-sub">Ce que les Québécois·es écrivent (commentaires YouTube publics, Reddit r/Quebec,56    Mastodon, Lemmy — sentiment pondéré par l'engagement) et ce qu'ils font (engagement des comptes57    officiels des partis, via notre acteur <code>qc-social-pulse</code>, proxy résidentiel CA).</p>58  <div class="grid parties" id="social-cards"><div class="loading">Chargement du pouls social…</div></div>59  <div class="grid two">60    <div class="card">61      <h3>Sentiment social quotidien (pondéré engagement)</h3>62      <div class="chart" id="social-chart"></div>63      <p class="chart-note" id="social-note"></p>64    </div>65    <div class="card">66      <h3>Verbatims les plus engagés (7-14 j)</h3>67      <div id="social-verbatims" style="display:flex;flex-direction:column;gap:10px;overflow-wrap:anywhere"></div>68    </div>69  </div>7071  <h2 class="section">Marchés prédictifs <span class="chip badge-aux">12 % de l'ensemble publié</span></h2>72  <p class="section-sub">Probabilités implicites des parieurs (Polymarket) comparées au modèle — depuis la v2, la probabilité « plus de sièges » publiée est un ensemble modèle/marché (88/12). <a href="/signaux">Voir la décomposition.</a></p>73  <div class="card" id="markets-card"><div class="loading">Chargement des marchés…</div></div>7475  <h2 class="section">Sentiment médiatique par parti</h2>76  <div class="grid parties" id="sent-cards"><div class="loading">Chargement…</div></div>77  <div class="card">78    <h3>Sentiment quotidien (moyenne mobile des scores)</h3>79    <div class="chart" id="sent-chart"></div>80    <p class="chart-note" id="sent-note"></p>81  </div>8283  <h2 class="section">Nouvelles (regroupées par grappe)</h2>84  <div class="card" style="overflow-x:auto"><table class="data" id="news"></table></div>85</div>86<footer class="site"></footer>8788<script src="/assets/app.js"></script>89<script>90(async () => {91  const meta = await initShell("/intelligence");92  const parties = Object.keys(meta.parties).filter(p => p !== "AUT");9394  document.getElementById("ask").onclick = async () => {95    const q = document.getElementById("q").value.trim();96    if (q.length < 3) return;97    const el = document.getElementById("answer");98    el.style.display = "block";99    el.textContent = "Analyse des données du modèle…";100    try {101      const r = await getJSON(`${API}/ask`, { method: "POST",102        headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question: q }) });103      el.textContent = r.answer;104    } catch (e) { el.textContent = "Erreur : " + e.message; }105  };106  document.getElementById("q").addEventListener("keydown", e => {107    if (e.key === "Enter") document.getElementById("ask").click(); });108109  try {110    const mom = await getJSON(`${API}/momentum`);111    document.getElementById("momentum").innerHTML = parties112      .filter(p => mom.momentum[p])113      .sort((a, b) => mom.momentum[b].score - mom.momentum[a].score)114      .map(p => {115        const m = mom.momentum[p];116        return `<div class="card party-card">117          <div class="bar" style="background:${partyColor(p)}"></div>118          <div class="party-head"><span class="avatars-stack">${avatarHTML(p, 34)}</span>119            <div class="pname">${p}</div></div>120          <div class="bignum" style="color:${partyColor(p)}">121            <span style="font-size:.6em" aria-hidden="true">${m.score > 0 ? "▲" : m.score < 0 ? "▼" : "·"}</span> ${Math.abs(m.score).toFixed(0)}</div>122          <div class="kv"><span>Δ vote 14 j</span><b>${m.d_vote_14j_pp >= 0 ? "+" : ""}${m.d_vote_14j_pp} pp</b></div>123          <div class="kv"><span>Sentiment 7 j</span><b>${m.sentiment_7j != null ? m.sentiment_7j : "—"}</b></div>124          <div class="kv"><span>Mentions 7 j</span><b>${m.volume_7j}</b></div>125        </div>`;126      }).join("");127  } catch (e) { document.getElementById("momentum").innerHTML = ""; }128129  /* ---- pouls social ---- */130  try {131    const so = await getJSON(`${API}/social`);132    const sp2 = so.parties || {};133    const ordS = parties.filter(p => sp2[p] && sp2[p].volume > 0)134      .sort((a, b) => sp2[b].part_de_voix - sp2[a].part_de_voix);135    document.getElementById("social-cards").innerHTML = ordS.map(p => {136      const s = sp2[p], sent = s.sentiment_pondere;137      const eng = (so.enthusiasm || {})[p];138      return `<div class="card party-card">139        <div class="bar" style="background:${partyColor(p)}"></div>140        <div class="party-head"><span class="avatars-stack">${avatarHTML(p, 34)}</span>141          <div class="pname">${p}</div></div>142        <div class="bignum" style="color:${partyColor(p)}">143          ${sent != null ? (sent > 0 ? "+" : "") + sent.toFixed(2) : "—"}</div>144        <div class="kv"><span>Part de voix</span><b>${s.part_de_voix} %</b></div>145        <div class="kv"><span>Mentions ${so.days} j</span><b>${s.volume}</b></div>146        <div class="kv"><span>Positions</span><b>${(s.stances.pro || 0)} pro · ${(s.stances.anti || 0)} anti</b></div>147        ${eng ? `<div class="kv"><span>Engagement/post (officiel)</span><b>${Math.round(eng.avg_post_engagement).toLocaleString("fr-CA")}</b></div>` : ""}148      </div>`;149    }).join("") || '<div class="card"><p class="note">Le premier cycle du pouls social alimentera cette section.</p></div>';150    document.getElementById("social-note").textContent =151      `${so.note} ${so.posts_total} publications analysées (` +152      Object.entries(so.platforms || {}).map(([k, v]) => `${k} ${v}`).join(" · ") + ").";153    const withTl2 = ordS.filter(p => (sp2[p].timeline || []).length > 1);154    if (withTl2.length) {155      const sc2 = baseChart(document.getElementById("social-chart"));156      sc2.setOption({157        grid: { left: 44, right: 20, top: 34, bottom: 28 },158        legend: { ...LEGEND, data: withTl2.map(partyName) },159        tooltip: { ...TOOLTIP },160        xAxis: { type: "time", ...AXIS_STYLE, splitLine: { show: false } },161        yAxis: { type: "value", min: -1, max: 1, ...AXIS_STYLE },162        series: withTl2.map(p => ({ name: partyName(p), type: "line",163          color: partyColor(p), symbol: "circle", symbolSize: 6,164          lineStyle: lineGlow(partyColor(p), 2),165          data: sp2[p].timeline.map(t => [t.date, t.sentiment]) })),166      });167    } else {168      document.getElementById("social-chart").innerHTML =169        '<p class="loading">Pas encore assez de jours de données sociales.</p>';170    }171    const verbs = ordS.flatMap(p => (sp2[p].verbatims || []).map(v => ({ ...v, party: p })))172      .sort((a, b) => b.engagement - a.engagement).slice(0, 6);173    document.getElementById("social-verbatims").innerHTML = verbs.map(v =>174      `<div style="border:1px solid var(--border);border-radius:12px;padding:10px 12px">175        <div style="font-size:.85rem;line-height:1.5">« ${v.text.replace(/</g, "&lt;")} »</div>176        <div style="margin-top:6px;font-size:.74rem;color:var(--muted)">177          <span class="chip" style="background:${partyColor(v.party)}22;color:${partyColor(v.party)}">${v.party} ${v.stance || ""}</span>178          ${v.platform} · ${Math.round(v.engagement)} ♥ · <a href="${v.url}" target="_blank" rel="noopener">source</a></div>179      </div>`).join("") || '<p class="note">Aucun verbatim pour l\'instant.</p>';180  } catch (e) {181    document.getElementById("social-cards").innerHTML =182      '<div class="card"><p class="note">Pouls social indisponible pour le moment.</p></div>';183  }184185  try {186    const mk = await getJSON(`${API}/markets`);187    const rows = Object.entries(mk.implied_prob_most_seats)188      .sort((a, b) => b[1] - a[1]);189    document.getElementById("markets-card").innerHTML = `190      <table class="data">191        <tr><th>Parti</th><th>Marché (Polymarket)</th><th>Modèle QC Forecast</th><th>Écart</th></tr>192        ${rows.map(([p, v]) => {193          const m = (mk.model_prob_most_seats || {})[p];194          const d = m != null ? m - v : null;195          return `<tr><td><span class="dot" style="background:${partyColor(p)}"></span>${partyName(p)}</td>196            <td>${(v * 100).toFixed(1)} %</td>197            <td>${m != null ? (m * 100).toFixed(1) + " %" : "—"}</td>198            <td style="color:var(--muted)">${d != null ? (d >= 0 ? "+" : "−") + Math.abs(d * 100).toFixed(1) + " pts" : "—"}</td></tr>`;199        }).join("")}200      </table>201      <p class="note">${mk.note} Volume : ${mk.volume_usd.toLocaleString("fr-CA")} $ US ·202        <a href="${mk.url}" target="_blank" rel="noopener">${mk.event}</a> ·203        consulté ${new Date(mk.fetched_at).toLocaleString("fr-CA")}</p>`;204  } catch (e) {205    document.getElementById("markets-card").innerHTML =206      '<p class="note">Marchés prédictifs indisponibles pour le moment.</p>';207  }208209  const sent = await getJSON(`${API}/sentiment?days=60`);210  document.getElementById("sent-note").textContent = sent.note +211    ` (${sent.documents} documents analysés — presse québécoise via RSS)`;212  const sp = sent.parties || {};213  document.getElementById("sent-cards").innerHTML = parties.filter(p => sp[p]).map(p => {214    const s = sp[p];215    return `<div class="card party-card">216      <div class="bar" style="background:${partyColor(p)}"></div>217      <div class="pname">${p}</div>218      <div class="bignum">${s.sentiment_moyen > 0 ? "+" : ""}${s.sentiment_moyen.toFixed(2)}</div>219      <div class="kv"><span>Volume total</span><b>${s.volume}</b></div>220      <div class="kv"><span>24 h</span><b>${s.h24.volume} mentions ${s.h24.moyenne != null ? "(" + s.h24.moyenne + ")" : ""}</b></div>221      <div class="kv"><span>Positions</span><b>${s.stances.pro} pro · ${s.stances.anti} anti · ${s.stances.neutre} neutres</b></div>222    </div>`;223  }).join("") || '<div class="card"><p class="note">Aucun document de sentiment encore ingéré — le pipeline RSS alimentera cette section automatiquement.</p></div>';224225  const withTl = parties.filter(p => sp[p] && sp[p].timeline.length > 1);226  if (withTl.length) {227    const c = baseChart(document.getElementById("sent-chart"));228    c.setOption({229      grid: { left: 44, right: 20, top: 34, bottom: 28 },230      legend: { ...LEGEND, data: withTl.map(partyName) },231      tooltip: { ...TOOLTIP },232      xAxis: { type: "time", ...AXIS_STYLE, splitLine: { show: false } },233      yAxis: { type: "value", min: -1, max: 1, ...AXIS_STYLE },234      series: withTl.map(p => ({ name: partyName(p), type: "line", color: partyColor(p),235        symbol: "circle", symbolSize: 6, lineStyle: { width: 2 },236        data: sp[p].timeline.map(t => [t.date, t.sentiment]) })),237    });238  } else {239    document.getElementById("sent-chart").innerHTML =240      '<p class="loading">Pas encore assez de données quotidiennes.</p>';241  }242243  const news = await getJSON(`${API}/news`);244  document.getElementById("news").innerHTML =245    `<tr><th>Nouvelle</th><th>Source</th><th>Date</th><th>Articles</th><th>Partis (stance)</th></tr>` +246    (news.length ? news.map(n => `<tr>247      <td><a href="${n.url}" target="_blank" rel="noopener">${n.title}</a></td>248      <td>${n.source}</td><td>${fmtDate(n.published.slice(0, 10))}</td>249      <td>${n.n_articles}</td>250      <td>${Object.entries(n.parties).map(([p, s]) =>251        `<span class="chip" style="background:${partyColor(p)}22;color:${partyColor(p)}">${p} ${s.stance}</span>`).join(" ")}</td>252    </tr>`).join("") : `<tr><td colspan="5" style="color:var(--muted)">Aucune nouvelle ingérée pour l'instant.</td></tr>`);253})();254</script>255</body>256</html>257