/** Graphiques SVG QC26 (rendu serveur, sans dépendance). Marques fines, grille en filigrane, * étiquettes directes sélectives ; les couleurs de partis viennent de variables CSS (thème clair/sombre). */ import type { ReactNode } from "react"; import type { TrendPoint } from "@/lib/api"; import { OTHERS, PARTY_IDS, partyAlpha, partyLabel, partyVar } from "@/lib/parties"; import { cls } from "@/lib/format"; // -------------------------------------------------------------- SeatMeter /** Barre de sièges empilée, seuil de majorité marqué (dérivé des données, jamais codé en dur). */ export function SeatMeter({ seats, total, majority, height = 22, showLabels = true, className, title }: { seats: Record; total: number; majority: number; height?: number; showLabels?: boolean; className?: string; title?: string; }) { const entries = Object.entries(seats).filter(([id]) => id !== "aut").sort((a, b) => b[1] - a[1]); const others = seats["aut"] ?? 0; const majX = (majority / total) * 100; return (
{entries.map(([id, n]) => (
))} {others > 0 &&
}
{majority} · majorité
{showLabels && (
{entries.map(([id, n]) => ( {partyLabel(id)} {n} ))} {others > 0 && Autres {others}}
)}
); } // -------------------------------------------------------------- SeatWaffle /** Les 127 sièges, un point chacun, colorés par la médiane projetée ; le trait marque le seuil de majorité. */ export function SeatWaffle({ seats, total, majority, cols = 16, className, dot = 14, gap = 4 }: { seats: Record; total: number; majority: number; cols?: number; className?: string; dot?: number; gap?: number }) { const entries = Object.entries(seats).filter(([id]) => id !== "aut").sort((a, b) => b[1] - a[1]); const cells: (string | null)[] = []; entries.forEach(([id, n]) => { for (let i = 0; i < n; i++) cells.push(id); }); while (cells.length < total) cells.push(null); const rows = Math.ceil(total / cols); const W = cols * (dot + gap) - gap, H = rows * (dot + gap) - gap; const mi = majority - 1, mr = Math.floor(mi / cols), mc = mi % cols; return ( `${partyLabel(id)} ${n}`).join(", ")} ; majorité à ${majority}`}> {cells.slice(0, total).map((id, i) => { const r = Math.floor(i / cols), c = i % cols; return ; })} ); } // ------------------------------------------------------- SeatDistribution /** Distribution des sièges d'un parti (histogramme Monte Carlo) avec seuil de majorité et intervalles. */ export function SeatDistribution({ dist, color, majority, median, lo80, hi80, lo95, hi95, height = 120, className, total, width = 640 }: { dist: number[]; color: string; majority: number; median: number; lo80: number; hi80: number; lo95: number; hi95: number; height?: number; className?: string; total: number; width?: number; }) { const n = dist.length; const max = Math.max(...dist, 1); const W = width, H = height, pad = 18; const vis = dist.map((v, i) => [i, v] as const).filter(([, v]) => v > 0); const minI = Math.max(0, (vis[0]?.[0] ?? 0) - 2), maxI = Math.min(n - 1, (vis[vis.length - 1]?.[0] ?? n - 1) + 2); const span = Math.max(1, maxI - minI); const xz = (i: number) => pad + ((i - minI) / span) * (W - 2 * pad); const bwz = (W - 2 * pad) / span; return ( {dist.map((v, i) => (i < minI || i > maxI || v === 0) ? null : ( = majority ? 1 : 0.5} rx={1.5} /> ))} {majority >= minI && majority <= maxI && ( <> {majority} · majorité )} {[minI, Math.round((minI + maxI) / 2), maxI].filter((i) => Math.abs(i - median) > Math.max(3, span * 0.06)).map((i) => ( {i} ))} {median} {`Sur ${total} sièges`} ); } // ----------------------------------------------------------------- Trend (statique) export function TrendChart({ trend, polls, from, height = 300, showBand = true, ids = PARTY_IDS, className, markers = [], election2022 }: { trend: TrendPoint[]; polls?: { date: string; values: Record; id?: string }[]; from?: string; height?: number; showBand?: boolean; ids?: string[]; className?: string; markers?: { date: string; label: string }[]; election2022?: Record | null; }) { const pts = from ? trend.filter((t) => t.date >= from) : trend; if (pts.length < 2) return
Pas assez de données.
; const W = 900, H = height, pl = 34, pr = 84, pt = 12, pb = 28; const t0 = new Date(pts[0].date).getTime(), t1 = new Date(pts[pts.length - 1].date).getTime(); const x = (d: string) => pl + ((new Date(d).getTime() - t0) / Math.max(1, t1 - t0)) * (W - pl - pr); const allVals = pts.flatMap((p) => ids.map((id) => p.mean[id] ?? 0)); const pollVals = (polls ?? []).filter((p) => !from || p.date >= from).flatMap((p) => ids.map((id) => p.values[id] ?? 0)); const ymax = Math.min(100, Math.ceil((Math.max(...allVals, ...pollVals, 10) + 4) / 5) * 5); const y = (v: number) => pt + (1 - v / ymax) * (H - pt - pb); const ticks = Array.from({ length: Math.floor(ymax / 10) + 1 }, (_, i) => i * 10); const months: { d: Date; label: string }[] = []; const spanDays = (t1 - t0) / 86400000; const step = spanDays > 900 ? 6 : spanDays > 400 ? 3 : 1; const cur = new Date(t0); cur.setDate(1); cur.setMonth(cur.getMonth() + 1); while (cur.getTime() <= t1) { if (cur.getMonth() % step === 0) months.push({ d: new Date(cur), label: cur.toLocaleDateString("fr-CA", { month: "short", year: spanDays > 200 ? "2-digit" : undefined }) }); cur.setMonth(cur.getMonth() + 1); } const path = (id: string) => pts.map((p, i) => `${i ? "L" : "M"}${x(p.date).toFixed(1)},${y(p.mean[id] ?? 0).toFixed(1)}`).join(" "); const band = (id: string) => { const up = pts.map((p) => `${x(p.date).toFixed(1)},${y((p.mean[id] ?? 0) + 1.28 * (p.sd[id] ?? 0)).toFixed(1)}`); const dn = [...pts].reverse().map((p) => `${x(p.date).toFixed(1)},${y((p.mean[id] ?? 0) - 1.28 * (p.sd[id] ?? 0)).toFixed(1)}`); return `M${up.join(" L")} L${dn.join(" L")} Z`; }; const last = pts[pts.length - 1]; const labels = ids.map((id) => ({ id, v: last.mean[id] ?? 0 })).sort((a, b) => b.v - a.v); let prevY = -Infinity; const labelY = labels.map((l) => { let yy = y(l.v); if (yy - prevY < 13) yy = prevY + 13; prevY = yy; return yy; }); return ( {ticks.map((t) => ( {t} ))} {months.map((m, i) => ( {m.label} ))} {markers.filter((m) => new Date(m.date).getTime() >= t0 && new Date(m.date).getTime() <= t1).map((m, i) => ( {m.label} ))} {polls?.filter((p) => !from || p.date >= from).map((p, i) => ids.map((id) => p.values[id] === undefined ? null : ( )))} {showBand && ids.map((id) => )} {ids.map((id) => )} {ids.map((id) => )} {election2022 && ids.map((id) => election2022[id] !== undefined && ( ))} {labels.map((l, i) => ( ■ {partyLabel(l.id)} {l.v.toFixed(1).replace(".", ",")} ))} ); } // ---------------------------------------------------------------- VoteBars export function VoteBars({ values, ci, max, className, order }: { values: Record; ci?: Record; max?: number; className?: string; order?: string[] }) { const ids = order ?? Object.keys(values).filter((k) => k !== "aut").sort((a, b) => values[b] - values[a]); const m = max ?? Math.max(...ids.map((i) => ci?.[i]?.[1] ?? values[i]), 10) * 1.08; return (
{ids.map((id) => (
{partyLabel(id)}
{ci?.[id] &&
}
{values[id].toFixed(1).replace(".", ",")} %
))}
); } export function Legend({ ids, className }: { ids: string[]; className?: string }) { return (
{ids.map((id) => ( {partyLabel(id)} ))}
); } export function Sparkline({ values, color, width = 120, height = 32, className, fill = false }: { values: number[]; color: string; width?: number; height?: number; className?: string; fill?: boolean }) { if (values.length < 2) return null; const min = Math.min(...values), max = Math.max(...values), span = Math.max(0.001, max - min); const pts = values.map((v, i) => [(i / (values.length - 1)) * width, height - ((v - min) / span) * (height - 6) - 3] as const); const line = pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" "); const last = pts[pts.length - 1]; return ( {fill && } ); } export function Note({ children }: { children: ReactNode }) { return

{children}

; } // -------------------------------------------------------- DotComparison /** Points comparés (ex. P(plus de sièges) : sondages seuls · marchés · ensemble) par parti, sur une échelle 0–max. */ export function DotComparison({ rows, series, className, max = 1, format = (v: number) => `${Math.round(v * 100)} %`, width = 520 }: { rows: { id: string; label: string; values: Record }[]; series: { key: string; label: string; shape: "dot" | "ring" | "diamond" }[]; className?: string; max?: number; format?: (v: number) => string; width?: number; }) { const W = width, rowH = width < 420 ? 44 : 38, pl = 52, pr = 14, H = rows.length * rowH + 16; const x = (v: number) => pl + (Math.max(0, Math.min(max, v)) / max) * (W - pl - pr); return (
{[0, 0.25, 0.5, 0.75, 1].map((t) => )} {rows.map((r, i) => { const cy = i * rowH + rowH / 2 + 4; const vals = series.map((s) => r.values[s.key]).filter((v): v is number => typeof v === "number"); const lo = Math.min(...vals), hi = Math.max(...vals); return ( {r.label} {vals.length > 1 && } {series.map((s) => { const v = r.values[s.key]; if (typeof v !== "number") return null; const cx = x(v); if (s.shape === "ring") return ; if (s.shape === "diamond") return ; return ; })} ); })} {[0, 0.5, 1].map((t) => {format(t * max)})}
{series.map((s) => ( {s.shape === "ring" ? : s.shape === "diamond" ? : } {s.label} ))}
); } // ------------------------------------------------------------ DivergingBars /** Barres divergentes centrées sur zéro (ton médiatique −1…+1, momentum en pp), par parti. */ export function DivergingBars({ values, max, format, className, ids = PARTY_IDS }: { values: Record; max: number; format: (v: number) => string; className?: string; ids?: string[] }) { return (
{ids.map((id) => { const v = values[id]; const has = typeof v === "number"; const w = has ? Math.min(1, Math.abs(v) / max) * 50 : 0; return (
{partyLabel(id)}
{has &&
= 0 ? "50%" : `${50 - w}%`, width: `${w}%`, background: partyVar(id) }} />}
{has ? format(v) : "—"}
); })}
); } // ------------------------------------------------------------- StackedShare /** Part (%) empilée horizontalement par parti (part de voix, part d'attention). */ export function StackedShare({ values, className, height = 12 }: { values: Record; className?: string; height?: number }) { const ids = PARTY_IDS.filter((id) => typeof values[id] === "number" && (values[id] as number) > 0); const tot = ids.reduce((a, id) => a + (values[id] as number), 0) || 1; return (
{ids.map((id) =>
)}
{ids.map((id) => {partyLabel(id)} {Math.round(((values[id] as number) / tot) * 100)} %)}
); }