spb/qc-election
Public
Python 66.6%
HTML 24.8%
CSS 4.9%
JavaScript 3.6%
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>Carte é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="Carte é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="Carte é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>Carte des 127 circonscriptions</h1>37 <p class="sub">Carte officielle 2026 (Élections Québec) — couleur = parti favori, intensité =38 probabilité de victoire. Cliquez une circonscription pour le détail; molette ou pincement39 pour zoomer (Montréal!).</p>40 </div>4142 <div class="map-tabs">43 <button id="tab-geo" class="on">Carte géographique</button>44 <button id="tab-carto">Cartogramme par région</button>45 </div>4647 <div class="filters">48 <label>Filtrer :</label>49 <select id="f-cat">50 <option value="">Toutes les catégories</option>51 <option>Solide</option><option>Probable</option><option>Favorisé</option>52 <option>Chaudement disputé</option>53 </select>54 <select id="f-party"><option value="">Tous les favoris</option></select>55 <span id="counts" style="color:var(--muted);font-size:.82rem"></span>56 </div>57 <div class="legend-row" id="legend"></div>5859 <div class="map-layout">60 <div>61 <div class="card" id="geo-wrap" style="padding:10px 12px"><div class="geo-map" id="geo-map"></div>62 <p class="chart-note" style="padding:0 8px 4px">Fond de carte : données ouvertes d'Élections Québec (carte électorale 2025, sans plans d'eau).</p></div>63 <div class="card" id="carto-wrap" style="display:none"><div class="carto" id="carto"></div></div>64 </div>65 <div class="card side-panel" id="panel"><div class="empty-note">Sélectionnez une circonscription66 sur la carte pour afficher son forecast détaillé.</div></div>67 </div>6869 <h2 class="section">Sommaire par catégorie</h2>70 <div class="card" style="overflow-x:auto"><table class="data" id="summary"></table></div>71</div>72<footer class="site"></footer>7374<script src="/assets/app.js"></script>75<script>76(async () => {77 const meta = await initShell("/carte");78 const [data, geo] = await Promise.all([79 getJSON(`${API}/districts`), getJSON(`${API}/geo`)]);80 const districts = data.districts;81 const parties = Object.keys(meta.parties).filter(p => p !== "AUT");8283 document.getElementById("f-party").innerHTML +=84 parties.map(p => `<option value="${p}">${partyName(p)}</option>`).join("");85 document.getElementById("legend").innerHTML =86 parties.map(p => `<span><span class="sw" style="background:${partyColor(p)}"></span>${p}</span>`).join("") +87 `<span style="margin-left:16px"><span class="sw" style="border:1px dashed var(--ink-2);background:transparent"></span>chaudement disputé</span>88 <span>intensité = probabilité de victoire</span>`;8990 /* ---- rapprochement noms DGEQ ↔ modèle ---- */91 const norm = s => s.normalize("NFC").replace(/–|—/g, "-").replace(/’/g, "'").toLowerCase().trim();92 const geoByKey = {};93 geo.features.forEach(f => geoByKey[norm(f.properties.name)] = f.properties.name);94 const geoName = d => geoByKey[norm(d)] || d;95 const byGeoName = {};96 districts.forEach(d => byGeoName[geoName(d.district)] = d);9798 /* ---- couleur : teinte du favori, mélangée vers la surface selon la prob ---- */99 const SURFACE = DARKMODE ? [24, 27, 36] : [235, 238, 244];100 function hex2rgb(h) { return [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16)); }101 function mix(hex, t) { // t=1 → pleine couleur, t=0 → surface102 const c = hex2rgb(hex);103 return "rgb(" + c.map((v, i) => Math.round(SURFACE[i] + (v - SURFACE[i]) * t)).join(",") + ")";104 }105 function fillFor(d, dim) {106 if (dim) return mix(partyColor(d.favorite), 0.06);107 const prob = d.win_probs[d.favorite];108 return mix(partyColor(d.favorite), 0.35 + 0.65 * Math.min(1, (prob - 0.3) / 0.65));109 }110111 function filters() {112 return { cat: document.getElementById("f-cat").value,113 fp: document.getElementById("f-party").value };114 }115 const isDim = (d, f) => (f.cat && d.category !== f.cat) || (f.fp && d.favorite !== f.fp);116117 /* ---- carte géographique ---- */118 echarts.registerMap("qc2026", geo);119 const gmap = baseChart(document.getElementById("geo-map"));120 function renderGeo() {121 const f = filters();122 let shown = 0;123 const items = districts.map(d => {124 const dim = isDim(d, f);125 if (!dim) shown++;126 return { name: geoName(d.district), value: d.win_probs[d.favorite],127 itemStyle: { areaColor: fillFor(d, dim) } };128 });129 document.getElementById("counts").textContent = `${shown} / ${districts.length} affichées`;130 gmap.setOption({131 tooltip: { trigger: "item", backgroundColor: TOOLTIP.backgroundColor,132 borderColor: TOOLTIP.borderColor, textStyle: TOOLTIP.textStyle,133 formatter: pt => {134 const d = byGeoName[pt.name];135 if (!d) return pt.name;136 const top = parties.slice().sort((a, b) => d.win_probs[b] - d.win_probs[a]).slice(0, 3);137 return `<b>${d.district}</b> — ${d.region}<br>` + top.map(p =>138 `<span class="dot" style="background:${partyColor(p)}"></span>${p} : ${d.expected[p].toFixed(1)} % · P ${(d.win_probs[p] * 100).toFixed(0)} %`139 ).join("<br>") + `<br><span style="color:${MUTED}">${d.category}</span>`;140 } },141 series: [{142 type: "map", map: "qc2026", nameProperty: "name", roam: true,143 zoom: 1.15, scaleLimit: { min: 0.9, max: 40 },144 itemStyle: { borderColor: DARKMODE ? "#0e1015" : "#ffffff", borderWidth: 0.7 },145 emphasis: { label: { show: false },146 itemStyle: { borderColor: DARKMODE ? "#e8eaf0" : "#1a1e28", borderWidth: 1.6 } },147 select: { disabled: true },148 label: { show: false },149 data: items,150 }],151 });152 }153 gmap.on("click", pt => { if (byGeoName[pt.name]) showPanel(byGeoName[pt.name].district); });154 renderGeo();155156 /* ---- cartogramme ---- */157 const byRegion = {};158 districts.forEach(d => (byRegion[d.region] ??= []).push(d));159 function renderCarto() {160 const f = filters();161 document.getElementById("carto").innerHTML = Object.keys(byRegion).sort().map(reg => {162 const cells = byRegion[reg]163 .sort((a, b) => a.district.localeCompare(b.district))164 .map(d => `<div class="cell ${d.category === "Chaudement disputé" ? "tossup" : ""}"165 style="background:${fillFor(d, isDim(d, f))}"166 data-name="${d.district}" title="${d.district} — ${partyName(d.favorite)} ${(d.win_probs[d.favorite]*100).toFixed(0)} %"></div>`).join("");167 return `<div class="region"><h4>${reg} (${byRegion[reg].length})</h4><div class="cells">${cells}</div></div>`;168 }).join("");169 document.querySelectorAll(".cell").forEach(el =>170 el.onclick = () => showPanel(el.dataset.name));171 }172 renderCarto();173174 /* ---- onglets ---- */175 function setTab(geoOn) {176 document.getElementById("geo-wrap").style.display = geoOn ? "" : "none";177 document.getElementById("carto-wrap").style.display = geoOn ? "none" : "";178 document.getElementById("tab-geo").classList.toggle("on", geoOn);179 document.getElementById("tab-carto").classList.toggle("on", !geoOn);180 if (geoOn) gmap.resize();181 }182 document.getElementById("tab-geo").onclick = () => setTab(true);183 document.getElementById("tab-carto").onclick = () => setTab(false);184185 /* ---- panneau détail ---- */186 async function showPanel(name) {187 const d = districts.find(x => x.district === name);188 const det = await getJSON(`${API}/districts/${encodeURIComponent(name)}`);189 const order = parties.slice().sort((a, b) => d.expected[b] - d.expected[a]);190 const candByParty = {};191 (det.candidates || []).forEach(c => candByParty[c.party] = c);192 document.getElementById("panel").innerHTML = `193 <h3 style="font-size:1rem;color:var(--ink);text-transform:none;letter-spacing:0">${name}</h3>194 <p style="color:var(--muted);font-size:.8rem;margin:2px 0 12px">${d.region}195 ${det.is_new_2026 ? " · nouvelle circonscription 2026" : ""}</p>196 <div style="display:flex;align-items:center;gap:10px">197 <div class="chip" style="background:${partyColor(d.favorite)}22;color:${partyColor(d.favorite)}">198 ${partyName(d.favorite)} — ${d.category}</div>199 ${partyLogo(d.favorite) ? `<img class="plogo" src="${partyLogo(d.favorite)}" alt="" style="margin:0">` : ""}200 </div>201 <table class="data" style="margin-top:12px">202 <tr><th>Parti / candidat·e</th><th>Vote attendu</th><th>IC 95 %</th><th>P(victoire)</th></tr>203 ${order.map(p => `<tr>204 <td><span class="dot" style="background:${partyColor(p)}"></span>${p}205 ${candByParty[p] ? `<div style="display:flex;align-items:center;gap:6px;margin-top:3px">206 ${initialsAvatar(candByParty[p].name, partyColor(p), 24)}207 <span style="color:var(--muted);font-size:.72rem">${candByParty[p].name}${candByParty[p].is_incumbent ? " (sortant·e)" : ""}</span>208 </div>` : ""}</td>209 <td><b>${d.expected[p].toFixed(1)} %</b></td>210 <td style="color:var(--muted)">${d.lo95[p]}–${d.hi95[p]}</td>211 <td>${(d.win_probs[p] * 100).toFixed(0)} %</td></tr>`).join("")}212 </table>213 <h3 style="margin-top:16px">Historique et facteurs</h3>214 <div class="kv"><span>Résultat 2022 (transposé)</span><b>${order.slice(0,3).map(p => `${p} ${(det.baseline_2022[p]||0).toFixed(0)}`).join(" · ")}</b></div>215 <div class="kv"><span>Sortant</span><b>${det.incumbent_party ? partyName(det.incumbent_party) : "—"}216 ${det.incumbent_party && !det.incumbent_running ? " (ne se représente pas)" : ""}</b></div>217 <div class="kv"><span>Participation 2022</span><b>${det.turnout_2022 ? det.turnout_2022.toFixed(1) + " %" : "—"}</b></div>218 <div class="kv"><span>Swing attendu (${order[0]})</span>219 <b>${(d.expected[order[0]] - (det.baseline_2022[order[0]] || 0)).toFixed(1).replace("-", "−")} pp</b></div>220 <p class="note">${det.baseline_source || ""}221 ${det.candidates && det.candidates.length ? "· Candidatures : registre officiel DGEQ." : ""}</p>`;222 }223224 /* ---- sommaire ---- */225 const cats = ["Solide", "Probable", "Favorisé", "Chaudement disputé"];226 const rows = parties.map(p => {227 const cnt = cats.map(c => districts.filter(d => d.favorite === p && d.category === c).length);228 return { p, cnt, tot: cnt.reduce((a, b) => a + b, 0) };229 }).filter(r => r.tot > 0).sort((a, b) => b.tot - a.tot);230 document.getElementById("summary").innerHTML =231 `<tr><th>Parti favori</th>${cats.map(c => `<th>${c}</th>`).join("")}<th>Total</th></tr>` +232 rows.map(r => `<tr><td><span class="dot" style="background:${partyColor(r.p)}"></span>${partyName(r.p)}</td>233 ${r.cnt.map(c => `<td>${c || "—"}</td>`).join("")}<td><b>${r.tot}</b></td></tr>`).join("");234235 document.getElementById("f-cat").onchange = () => { renderGeo(); renderCarto(); };236 document.getElementById("f-party").onchange = () => { renderGeo(); renderCarto(); };237})();238</script>239</body>240</html>241