/*
* QC Élection Forecast — Plateforme de prévision électorale du Québec 2026
* Auteur : Simon-Pierre Boucher
* Contact : contact@spboucher.ai
* https://www.qc-election.com
*/
/* Utilitaires partagés — design system « QC Signal » */
const API = "/api";
/* ---- thème (sombre par défaut) ---- */
const THEME = localStorage.getItem("qce-theme") || "dark";
document.documentElement.dataset.theme = THEME;
function toggleTheme() {
localStorage.setItem("qce-theme", THEME === "dark" ? "light" : "dark");
location.reload(); // recharge : les graphiques reprennent les couleurs du thème
}
const PARTY_ORDER = ["PQ", "PLQ", "CAQ", "PCQ", "QS", "AUT"]; // jamais utilisé pour l'affichage : tri dynamique par résultat
const FALLBACK_COLORS = { CAQ: "#0097cf", PLQ: "#e5484d", PQ: "#3f7ad6", QS: "#e06c1f", PCQ: "#7069d6", AUT: "#ad8226" };
let META = null;
/* ---- visuels des partis (logos + chefs) — fichiers locaux, source Wikimedia ---- */
const PARTY_IMG = {
CAQ: { logo: "/assets/img/logo_caq.png", leaders: ["/assets/img/leader_caq.jpg"] },
PLQ: { logo: "/assets/img/logo_plq.png", leaders: ["/assets/img/leader_plq.jpg"] },
PQ: { logo: "/assets/img/logo_pq.png", leaders: ["/assets/img/leader_pq.jpg"] },
QS: { logo: "/assets/img/logo_qs.png", leaders: ["/assets/img/leader_qs1.jpg", "/assets/img/leader_qs2.jpg"] },
PCQ: { logo: "/assets/img/logo_pcq.png", leaders: ["/assets/img/leader_pcq.jpg"] },
};
function partyLogo(p) { return (PARTY_IMG[p] || {}).logo || null; }
function leaderPhotos(p) { return (PARTY_IMG[p] || {}).leaders || []; }
function avatarHTML(p, size = 40) {
const photos = leaderPhotos(p);
if (!photos.length) return "";
return photos.map(src =>
` `).join("");
}
function initialsAvatar(name, color, size = 30) {
const init = (name || "?").split(/\s+/).map(w => w[0]).slice(0, 2).join("").toUpperCase();
return `${init} `;
}
async function getJSON(url, opts) {
const r = await fetch(url, opts);
if (!r.ok) throw new Error(`${url}: ${r.status}`);
return r.json();
}
async function loadMeta() {
if (!META) META = await getJSON(`${API}/meta`);
return META;
}
function partyColor(p) {
return (META && META.parties[p] && META.parties[p].color) || FALLBACK_COLORS[p] || "#888";
}
function partyName(p) {
return (META && META.parties[p] && META.parties[p].name) || p;
}
function partyLeader(p) {
return (META && META.parties[p] && META.parties[p].leader) || "";
}
function fmtPct(x, d = 0) { return (x * 100).toFixed(d) + " %"; }
function fmtDate(iso) {
return new Date(iso + (iso.length === 10 ? "T12:00:00" : "")).toLocaleDateString("fr-CA",
{ year: "numeric", month: "short", day: "numeric" });
}
/* Tri neutre : par probabilité décroissante dans le run courant (aucun parti favorisé
par un ordre arbitraire fixe). */
function sortParties(seats) {
return Object.keys(seats).filter(p => p !== "AUT")
.sort((a, b) => seats[b].prob_most - seats[a].prob_most || seats[b].mean - seats[a].mean);
}
/* ============================================================================
Grammaire des données — helpers d'affichage partagés (QC Signal)
========================================================================== */
/* Indicateur de variation : flèche + valeur, JAMAIS vert/rouge (une hausse d'un
parti n'est pas « positive » universellement). Couleur du parti ou neutre.
d = variation (déjà dans l'unité affichée). */
function deltaHTML(d, { unit = " pt", digits = 1, color = null, thresh = 0.05, title = "" } = {}) {
if (d == null || Number.isNaN(d)) return "";
const flat = Math.abs(d) < thresh;
const arr = flat ? "·" : d > 0 ? "▲" : "▼";
const txt = flat ? "stable" : Math.abs(d).toFixed(digits) + unit;
const st = color && !flat ? `style="color:${color}"` : "";
return `` +
`${arr} ${txt} `;
}
/* Intervalle visuel lo ───●─── hi (dot = valeur centrale, mark = repère). */
function rangeHTML({ lo, hi, val, min = 0, max = 127, color = "#8a93a8", mark = null,
fmt = v => Math.round(v) } = {}) {
const pc = v => Math.max(0, Math.min(100, (v - min) / (max - min) * 100));
return `
${fmt(lo)}
${mark != null ? ` ` : ""}
${fmt(hi)}
`;
}
/* État d'erreur propre — jamais de « undefined » à l'écran. */
function emptyStateHTML(msg = "Données temporairement indisponibles.") {
return `${msg}
`;
}
/* ============================================================================
Hémicycle SVG — 127 sièges en demi-cercle, composant réutilisable.
seats : { PARTI: { mean, p50?, p05, p95 } } ; meta : /api/meta.
Retourne un élément .hemi prêt à insérer (hover : parti / sièges / intervalle).
========================================================================== */
function hemicycleSVG(seats, meta, { legend = true } = {}) {
const total = (meta && meta.election && meta.election.total_seats) || 127;
const majority = (meta && meta.election && meta.election.majority_seats) || Math.floor(total / 2) + 1;
/* -- répartition entière des sièges (p50 sinon moyenne), reste au plus fort -- */
const order = sortParties(seats).concat(Object.keys(seats).includes("AUT") ? ["AUT"] : []);
const target = order.map(p => ({ p, x: seats[p].p50 != null ? seats[p].p50 : seats[p].mean }));
const sumX = target.reduce((a, t) => a + t.x, 0) || 1;
let alloc = target.map(t => ({ p: t.p, n: Math.floor(t.x / sumX * total),
frac: (t.x / sumX * total) % 1 }));
let left = total - alloc.reduce((a, t) => a + t.n, 0);
alloc.sort((a, b) => b.frac - a.frac);
for (let i = 0; left > 0; i = (i + 1) % alloc.length, left--) alloc[i].n++;
const nOf = Object.fromEntries(alloc.map(a => [a.p, a.n]));
/* -- géométrie : rangées concentriques, sièges ∝ rayon -- */
const ROWS = 6, W = 640, R0 = 118, DR = 34, SEAT = 8.6;
const H = R0 + (ROWS - 1) * DR + 52; // marge haute : ligne + étiquette de majorité
const CX = W / 2, CY = H - 10;
const radii = Array.from({ length: ROWS }, (_, i) => R0 + i * DR);
const wSum = radii.reduce((a, r) => a + r, 0);
let perRow = radii.map(r => Math.floor(r / wSum * total));
let rem = total - perRow.reduce((a, b) => a + b, 0);
for (let i = ROWS - 1; rem > 0; i = (i - 1 + ROWS) % ROWS, rem--) perRow[i]++;
const pos = [];
perRow.forEach((n, row) => {
const r = radii[row];
for (let k = 0; k < n; k++) {
const a = Math.PI - (n === 1 ? Math.PI / 2 : k * Math.PI / (n - 1)); // 180° → 0°
pos.push({ x: CX + r * Math.cos(a), y: CY - r * Math.sin(a), a });
}
});
pos.sort((u, v) => v.a - u.a); // balayage gauche → droite
/* -- attribution des sièges dans l'ordre du balayage -- */
const seatParty = [];
for (const p of order) for (let i = 0; i < (nOf[p] || 0); i++) seatParty.push(p);
const circles = pos.map((pt, i) => {
const p = seatParty[i] || "AUT";
return ` `;
}).join("");
/* -- marqueur de majorité : à l'angle du 64e siège du balayage -- */
const mAngle = (pos[majority - 1].a + pos[majority].a) / 2;
const mx1 = CX + (R0 - 22) * Math.cos(mAngle), my1 = CY - (R0 - 22) * Math.sin(mAngle);
const mx2 = CX + (radii[ROWS - 1] + 16) * Math.cos(mAngle), my2 = CY - (radii[ROWS - 1] + 16) * Math.sin(mAngle);
const el = document.createElement("div");
el.className = "hemi";
el.innerHTML = `
${circles}
majorité ${majority}
${total} sièges
${legend ? `${order.filter(p => nOf[p] > 0).map(p =>
`
${p} ${nOf[p]} [${seats[p].p05}–${seats[p].p95}] `).join("")}
` : ""}`;
/* -- interactions : hover = met le parti en évidence + info-bulle -- */
const tip = el.querySelector(".hemi-tip");
const allSeats = () => el.querySelectorAll(".seat");
function focus(p) {
allSeats().forEach(c => c.classList.toggle("dim", p != null && c.dataset.p !== p));
}
function showTip(p, ev) {
const s = seats[p];
if (!s) return;
tip.innerHTML = `${p} — ${nOf[p]} sièges attendus
[${s.p05}–${s.p95}] `;
tip.style.display = "block";
const r = el.getBoundingClientRect();
const tw = tip.offsetWidth, th = tip.offsetHeight;
let x = ev.clientX - r.left + 12, y = ev.clientY - r.top - th - 10;
x = Math.max(4, Math.min(x, r.width - tw - 4));
if (y < 0) y = ev.clientY - r.top + 14;
tip.style.left = x + "px"; tip.style.top = y + "px";
}
el.addEventListener("mousemove", ev => {
const t = ev.target.closest("[data-p]");
if (t) { focus(t.dataset.p); showTip(t.dataset.p, ev); }
else { focus(null); tip.style.display = "none"; }
});
el.addEventListener("mouseleave", () => { focus(null); tip.style.display = "none"; });
return el;
}
/* ============================================================================
Coquille partagée : header + forecast strip + drawer + footer
========================================================================== */
const NAV_LINKS = [
["/", "Aperçu"], ["/aujourdhui", "Le point du jour"],
["/circonscriptions", "Circonscriptions"], ["/carte", "Carte"],
["/sondages", "Sondages"], ["/signaux", "Signaux"], ["/simulateur", "Simulateur"],
["/intelligence", "Intelligence"], ["/backtest", "Calibration"],
["/methodologie", "Méthodologie"],
];
/* Fleur-de-lys — icône vectorielle nette (pas d'emoji, pas de pastille). */
const FLEUR_SVG = `
`;
const SUN_SVG = `
`;
const MOON_SVG = `
`;
function headerHTML(active) {
return `
`;
}
/* Forecast strip — fine barre sous la nav, sur toutes les pages.
P(1er) publiée + variation depuis la veille. Couleur du parti sur le sigle,
flèche neutre. /api/today, repli /api/forecast/latest. */
async function mountStrip() {
const host = document.getElementById("fstrip");
if (!host) return;
let items = [], right = "";
try {
const T = await getJSON(`${API}/today`);
const order = Object.keys(T.prob_most).filter(p => p !== "AUT")
.sort((a, b) => T.prob_most[b] - T.prob_most[a]);
items = order.map(p => {
const d = T.delta ? T.delta.prob_most[p] * 100 : null;
const flat = d == null || Math.abs(d) < 0.05;
return `
${p}
${(T.prob_most[p] * 100).toFixed(0)}
${flat ? "·" : (d > 0 ? "▲" : "▼") + Math.abs(d).toFixed(1)} `;
});
right = `J−${T.days_to_election}`;
} catch {
try {
const fc = await getJSON(`${API}/forecast/latest`);
const seats = fc.seats.per_party;
const blended = (fc.seats.ensemble || {}).blended || {};
const order = sortParties(seats);
items = order.map(p => `
${p}
${((blended[p] ?? seats[p].prob_most) * 100).toFixed(0)} `);
} catch { return; } /* pas de forecast : la barre reste masquée */
}
host.innerHTML = `
P(premier) ${items.join("")}
${right ? `Scrutin 5 oct. · ${right} ` : ""}
`;
host.hidden = false;
}
/* Tiroir de navigation mobile (hamburger) — verrou de défilement, fermeture par
fond, lien ou Escape. */
function mountDrawer(active) {
const drawer = document.createElement("div");
drawer.className = "drawer";
drawer.setAttribute("role", "dialog");
drawer.setAttribute("aria-label", "Menu de navigation");
drawer.innerHTML = `
${FLEUR_SVG} QC Élection
${THEME === "dark" ? SUN_SVG : MOON_SVG}
${NAV_LINKS.map(([h, t]) =>
`${t} `).join("")}
`;
const backdrop = document.createElement("div");
backdrop.className = "nav-backdrop";
document.body.append(backdrop, drawer);
const toggle = (open) => {
document.body.classList.toggle("nav-open", open);
const b = document.getElementById("nav-burger");
if (b) b.setAttribute("aria-expanded", String(open));
};
document.getElementById("nav-burger")?.addEventListener("click",
() => toggle(!document.body.classList.contains("nav-open")));
backdrop.addEventListener("click", () => toggle(false));
drawer.querySelectorAll("nav a").forEach(a => a.addEventListener("click", () => toggle(false)));
document.addEventListener("keydown", e => { if (e.key === "Escape") toggle(false); });
}
function footerHTML() {
return `
Plateforme indépendante et politiquement neutre de prévision électorale.
Le forecast combine sondages, votes réels des partielles, fondamentaux et
veille web continue — chaque couche est
décomposée et traçable .
Toutes les valeurs sont des
probabilités , jamais des certitudes.
Les mêmes règles statistiques sont appliquées à tous les partis.
·
Méthodologie complète ·
API
Données officielles :
Élections Québec (carte, candidatures, dates du scrutin).
Photos des chef·fe·s et logos des partis : Wikimedia Commons / Wikipédia (identification seulement).
Contact : Simon-Pierre Boucher ·
contact@spboucher.ai
`;
}
async function initShell(active) {
document.querySelector("header.site").innerHTML = headerHTML(active);
document.querySelector("footer.site").innerHTML = footerHTML();
mountDrawer(active);
mountStrip(); // asynchrone, non bloquant
const meta = await loadMeta();
if (meta.last_update) {
const maj = "Dernière MAJ " + new Date(meta.last_update + "Z").toLocaleTimeString("fr-CA",
{ hour: "2-digit", minute: "2-digit" });
document.getElementById("hdr-updated").textContent = maj;
const du = document.getElementById("drawer-updated");
if (du) du.textContent = maj;
}
return meta;
}
/* ============================================================================
Thème ECharts « qc-signal » : grille recessive, typo Inter, tooltips premium
========================================================================== */
const DARKMODE = THEME === "dark";
const GRID_COLOR = DARKMODE ? "#2b3247" : "#c9d2e2"; // lignes de référence (markLine…)
const SPLIT_COLOR = DARKMODE ? "#1c2231" : "#e6eaf2"; // grille de fond, très légère
const INK2 = DARKMODE ? "#aab2c5" : "#414d68";
const MUTED = DARKMODE ? "#7d8699" : "#67718a";
const CHART_FONT = "Inter, -apple-system, 'Segoe UI', sans-serif";
function baseChart(el) {
const c = echarts.init(el, null, { renderer: "canvas" });
const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
c.setOption({ textStyle: { fontFamily: CHART_FONT },
animationDuration: reduce ? 0 : 420, animationEasing: "cubicOut" });
if (window.ResizeObserver) new ResizeObserver(() => c.resize()).observe(el);
else window.addEventListener("resize", () => c.resize());
return c;
}
/* Dégradé vertical « signature » sous une courbe (remplace les aplats). */
function areaGrad(color, top = 0.22, bottom = 0.0) {
return { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: hexA(color, top) }, { offset: 1, color: hexA(color, bottom) }]) };
}
/* Trait de série : arrondi + halo discret de sa propre couleur. */
function lineGlow(color, width = 2.4) {
return { width, cap: "round", join: "round",
shadowColor: hexA(color, DARKMODE ? 0.3 : 0.18), shadowBlur: 6, shadowOffsetY: 3 };
}
function hexA(hex, a) {
const n = parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
}
const AXIS_STYLE = {
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: MUTED, fontSize: 11, fontFamily: CHART_FONT },
splitLine: { lineStyle: { color: SPLIT_COLOR, width: 1 } },
};
const TOOLTIP = {
trigger: "axis",
backgroundColor: DARKMODE ? "rgba(22,27,39,.97)" : "rgba(255,255,255,.98)",
borderColor: DARKMODE ? "rgba(170,188,218,.22)" : "rgba(38,54,92,.18)",
borderWidth: 1,
padding: [9, 13],
textStyle: { color: DARKMODE ? "#edeff5" : "#161b26", fontSize: 12.5,
fontFamily: CHART_FONT },
extraCssText: "border-radius:12px;box-shadow:0 10px 32px -10px rgba(0,0,0,.45);font-variant-numeric:tabular-nums;",
axisPointer: { type: "cross",
lineStyle: { color: DARKMODE ? "#39415a" : "#b9c2d4", type: [4, 4] },
crossStyle: { color: DARKMODE ? "#39415a" : "#b9c2d4" },
label: { backgroundColor: DARKMODE ? "#2c3346" : "#414d68", borderRadius: 5,
fontSize: 10.5, padding: [3, 7] } },
};
const LEGEND = { textStyle: { color: INK2, fontSize: 12, fontFamily: CHART_FONT },
itemWidth: 10, itemHeight: 10, icon: "circle", top: 0, itemGap: 16,
inactiveColor: DARKMODE ? "#3a4157" : "#c6cedf" };