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%
12.3 KB · 241 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>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