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>Intelligence électorale — QC Élection Forecast</title>14<link rel="stylesheet" href="/assets/style.css">15<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>16</head>17<body>18<header class="site"></header>19<div class="wrap">20 <div class="hero">21 <h1>Intelligence électorale</h1>22 <p class="sub">Sentiment médiatique, momentum et nouvelles regroupées.23 <span class="chip badge-aux">signal auxiliaire — n'entre pas dans le forecast</span></p>24 </div>2526 <h2 class="section">Interroger le modèle</h2>27 <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>28 <div class="card">29 <div class="ask-box">30 <input type="text" id="q" placeholder="Ex. : pourquoi la probabilité du PQ a-t-elle changé cette semaine ?" maxlength="500">31 <button class="primary" id="ask">Demander</button>32 </div>33 <div class="answer" id="answer"></div>34 </div>3536 <h2 class="section">Momentum <span class="chip badge-aux">indicateur maison</span></h2>37 <div class="grid parties" id="momentum"></div>3839 <h2 class="section">Marchés prédictifs <span class="chip badge-aux">signal auxiliaire</span></h2>40 <p class="section-sub">Probabilités implicites des parieurs (Polymarket) comparées au modèle — deux visions indépendantes de la même question : « qui remportera le plus de sièges ? »</p>41 <div class="card" id="markets-card"><div class="loading">Chargement des marchés…</div></div>4243 <h2 class="section">Sentiment médiatique par parti</h2>44 <div class="grid parties" id="sent-cards"><div class="loading">Chargement…</div></div>45 <div class="card">46 <h3>Sentiment quotidien (moyenne mobile des scores)</h3>47 <div class="chart" id="sent-chart"></div>48 <p class="chart-note" id="sent-note"></p>49 </div>5051 <h2 class="section">Nouvelles (regroupées par grappe)</h2>52 <div class="card" style="overflow-x:auto"><table class="data" id="news"></table></div>53</div>54<footer class="site"></footer>5556<script src="/assets/app.js"></script>57<script>58(async () => {59 const meta = await initShell("/intelligence");60 const parties = Object.keys(meta.parties).filter(p => p !== "AUT");6162 document.getElementById("ask").onclick = async () => {63 const q = document.getElementById("q").value.trim();64 if (q.length < 3) return;65 const el = document.getElementById("answer");66 el.style.display = "block";67 el.textContent = "Analyse des données du modèle…";68 try {69 const r = await getJSON(`${API}/ask`, { method: "POST",70 headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question: q }) });71 el.textContent = r.answer;72 } catch (e) { el.textContent = "Erreur : " + e.message; }73 };74 document.getElementById("q").addEventListener("keydown", e => {75 if (e.key === "Enter") document.getElementById("ask").click(); });7677 try {78 const mom = await getJSON(`${API}/momentum`);79 document.getElementById("momentum").innerHTML = parties80 .filter(p => mom.momentum[p])81 .sort((a, b) => mom.momentum[b].score - mom.momentum[a].score)82 .map(p => {83 const m = mom.momentum[p];84 const up = m.score > 3, down = m.score < -3;85 return `<div class="card party-card">86 <div class="bar" style="background:${partyColor(p)}"></div>87 <div class="party-head"><span class="avatars-stack">${avatarHTML(p, 34)}</span>88 <div class="pname">${p}</div></div>89 <div class="bignum" style="color:${up ? "var(--good)" : down ? "var(--plq)" : "var(--ink)"}">90 ${m.score > 0 ? "▲" : m.score < 0 ? "▼" : "—"} ${Math.abs(m.score).toFixed(0)}</div>91 <div class="kv"><span>Δ vote 14 j</span><b>${m.d_vote_14j_pp >= 0 ? "+" : ""}${m.d_vote_14j_pp} pp</b></div>92 <div class="kv"><span>Sentiment 7 j</span><b>${m.sentiment_7j != null ? m.sentiment_7j : "—"}</b></div>93 <div class="kv"><span>Mentions 7 j</span><b>${m.volume_7j}</b></div>94 </div>`;95 }).join("");96 } catch (e) { document.getElementById("momentum").innerHTML = ""; }9798 try {99 const mk = await getJSON(`${API}/markets`);100 const rows = Object.entries(mk.implied_prob_most_seats)101 .sort((a, b) => b[1] - a[1]);102 document.getElementById("markets-card").innerHTML = `103 <table class="data">104 <tr><th>Parti</th><th>Marché (Polymarket)</th><th>Modèle QC Forecast</th><th>Écart</th></tr>105 ${rows.map(([p, v]) => {106 const m = (mk.model_prob_most_seats || {})[p];107 const d = m != null ? m - v : null;108 return `<tr><td><span class="dot" style="background:${partyColor(p)}"></span>${partyName(p)}</td>109 <td>${(v * 100).toFixed(1)} %</td>110 <td>${m != null ? (m * 100).toFixed(1) + " %" : "—"}</td>111 <td style="color:var(--muted)">${d != null ? (d >= 0 ? "+" : "−") + Math.abs(d * 100).toFixed(1) + " pts" : "—"}</td></tr>`;112 }).join("")}113 </table>114 <p class="note">${mk.note} Volume : ${mk.volume_usd.toLocaleString("fr-CA")} $ US ·115 <a href="${mk.url}" target="_blank" rel="noopener">${mk.event}</a> ·116 consulté ${new Date(mk.fetched_at).toLocaleString("fr-CA")}</p>`;117 } catch (e) {118 document.getElementById("markets-card").innerHTML =119 '<p class="note">Marchés prédictifs indisponibles pour le moment.</p>';120 }121122 const sent = await getJSON(`${API}/sentiment?days=60`);123 document.getElementById("sent-note").textContent = sent.note +124 ` (${sent.documents} documents analysés — presse québécoise via RSS)`;125 const sp = sent.parties || {};126 document.getElementById("sent-cards").innerHTML = parties.filter(p => sp[p]).map(p => {127 const s = sp[p];128 return `<div class="card party-card">129 <div class="bar" style="background:${partyColor(p)}"></div>130 <div class="pname">${p}</div>131 <div class="bignum">${s.sentiment_moyen > 0 ? "+" : ""}${s.sentiment_moyen.toFixed(2)}</div>132 <div class="kv"><span>Volume total</span><b>${s.volume}</b></div>133 <div class="kv"><span>24 h</span><b>${s.h24.volume} mentions ${s.h24.moyenne != null ? "(" + s.h24.moyenne + ")" : ""}</b></div>134 <div class="kv"><span>Positions</span><b>${s.stances.pro} pro · ${s.stances.anti} anti · ${s.stances.neutre} neutres</b></div>135 </div>`;136 }).join("") || '<div class="card"><p class="note">Aucun document de sentiment encore ingéré — le pipeline RSS alimentera cette section automatiquement.</p></div>';137138 const withTl = parties.filter(p => sp[p] && sp[p].timeline.length > 1);139 if (withTl.length) {140 const c = baseChart(document.getElementById("sent-chart"));141 c.setOption({142 grid: { left: 44, right: 20, top: 34, bottom: 28 },143 legend: { ...LEGEND, data: withTl.map(partyName) },144 tooltip: { ...TOOLTIP },145 xAxis: { type: "time", ...AXIS_STYLE, splitLine: { show: false } },146 yAxis: { type: "value", min: -1, max: 1, ...AXIS_STYLE },147 series: withTl.map(p => ({ name: partyName(p), type: "line", color: partyColor(p),148 symbol: "circle", symbolSize: 6, lineStyle: { width: 2 },149 data: sp[p].timeline.map(t => [t.date, t.sentiment]) })),150 });151 } else {152 document.getElementById("sent-chart").innerHTML =153 '<p class="loading">Pas encore assez de données quotidiennes.</p>';154 }155156 const news = await getJSON(`${API}/news`);157 document.getElementById("news").innerHTML =158 `<tr><th>Nouvelle</th><th>Source</th><th>Date</th><th>Articles</th><th>Partis (stance)</th></tr>` +159 (news.length ? news.map(n => `<tr>160 <td><a href="${n.url}" target="_blank" rel="noopener">${n.title}</a></td>161 <td>${n.source}</td><td>${fmtDate(n.published.slice(0, 10))}</td>162 <td>${n.n_articles}</td>163 <td>${Object.entries(n.parties).map(([p, s]) =>164 `<span class="chip" style="background:${partyColor(p)}22;color:${partyColor(p)}">${p} ${s.stance}</span>`).join(" ")}</td>165 </tr>`).join("") : `<tr><td colspan="5" style="color:var(--muted)">Aucune nouvelle ingérée pour l'instant.</td></tr>`);166})();167</script>168</body>169</html>170