Python 51.6%
TypeScript 46.7%
CSS 1.7%
1"use client";23import Link from "next/link";4import { useEffect, useMemo, useRef, useState } from "react";5import type { Forecast, LiveState, Meta } from "@/lib/api";6import { cls, dateTimeFr, int, pct, prob, relTime, statusLabel, timeFr } from "@/lib/format";7import { PARTY_IDS, partyVar, partyLabel, partyName } from "@/lib/parties";8import { SeatMeter } from "./charts";9import { MapSection } from "./map-section";10import { PartyBadge, PartyDot, PartyLogo } from "./party";11import { Badge, Card, LiveBadge, ProbabilityBar, ProjectionBadge, SectionHeader } from "./ui";1213interface LiveEvent { id: number; ts: string; kind: string; message: string; ridingCode: number | null; riding?: string | null; party: string | null }1415/** Hook : état live + événements, rafraîchi par SSE (repli : polling 20 s). */16export function useLive(initial?: LiveState | null) {17 const [state, setState] = useState<LiveState | null>(initial ?? null);18 const [events, setEvents] = useState<LiveEvent[]>([]);19 const [connected, setConnected] = useState(false);20 const [lastMsg, setLastMsg] = useState<string | null>(null);21 const lastId = useRef(0);22 const refresh = async () => {23 try {24 const [st, ev] = await Promise.all([fetch("/api/live/state", { cache: "no-store" }).then((r) => r.json()), fetch(`/api/live/events?since=${lastId.current}&limit=60`, { cache: "no-store" }).then((r) => r.json())]);25 setState(st);26 if (ev.events?.length) {27 setEvents((prev) => { const merged = [...ev.events, ...prev]; const seen = new Set<number>(); return merged.filter((e) => !seen.has(e.id) && seen.add(e.id)).slice(0, 200); });28 lastId.current = Math.max(lastId.current, ...ev.events.map((e: LiveEvent) => e.id));29 }30 setLastMsg(new Date().toISOString());31 } catch { /* garder le dernier état valide */ }32 };33 useEffect(() => {34 refresh();35 let es: EventSource | null = null;36 let poll: ReturnType<typeof setInterval> | null = null;37 try {38 es = new EventSource("/api/live/stream");39 es.onopen = () => setConnected(true);40 es.onerror = () => setConnected(false);41 es.addEventListener("snapshot", () => refresh());42 es.addEventListener("nowcast", () => refresh());43 es.addEventListener("status", () => refresh());44 } catch { setConnected(false); }45 poll = setInterval(refresh, 20000);46 return () => { es?.close(); if (poll) clearInterval(poll); };47 }, []);48 return { state, events, connected, lastMsg };49}5051export function LiveHome({ meta, forecast, initial }: { meta: Meta; forecast: Forecast; initial?: LiveState | null }) {52 const { state, events, connected } = useLive(initial);53 const seatsNow = state?.seatsNow ?? {};54 const totalNow = (id: string) => (seatsNow[id]?.elected ?? 0) + (seatsNow[id]?.leading ?? 0);55 const leaderNow = PARTY_IDS.slice().sort((a, b) => totalNow(b) - totalNow(a))[0];56 const nc = state?.nowcast;57 const reporting = state?.snapshot?.reporting ?? 0;58 const archive = meta.mode === "archive";59 return (60 <div className="space-y-12">61 <section className="card card-lg p-6 md:p-8 grid lg:grid-cols-[1fr_1fr] gap-8">62 <div>63 <div className="flex items-center gap-3 flex-wrap">{archive ? <Badge tone="ink">Archive · Résultats</Badge> : <LiveBadge />}{state?.simulation && <Badge tone="warn">Simulation (données synthétiques 2022)</Badge>}{state?.quarantine && <Badge tone="warn">Flux en quarantaine</Badge>}</div>64 <h1 className="display text-[40px] md:text-[56px] mt-4">Québec 2026<br /><span className="text-ink-2">{archive ? "Résultats officiels" : "Résultats en direct"}</span></h1>65 <div className="mt-6 flex items-end gap-8 flex-wrap">66 <div><div className="num display text-[72px] md:text-[96px]">{reporting}<span className="text-ink-3 text-[32px]"> / {meta.seatsTotal}</span></div><div className="text-[13.5px] text-ink-2 -mt-1">circonscriptions rapportent</div></div>67 <div><div className="num display text-[56px]">{meta.majority}</div><div className="text-[13.5px] text-ink-2">majorité</div></div>68 </div>69 <FeedStatus state={state} connected={connected} />70 </div>71 <div>72 <div className="eyebrow mb-3">{archive ? "Résultat final" : "Résultat live"} · élus + en avance</div>73 <SeatMeter seats={Object.fromEntries(PARTY_IDS.map((id) => [id, totalNow(id)]))} total={meta.seatsTotal} majority={meta.majority} showLabels={false} title="Sièges en avance ou élus" />74 <div className="mt-4 space-y-2.5">75 {PARTY_IDS.slice().sort((a, b) => totalNow(b) - totalNow(a)).map((id) => (76 <div key={id} className="grid grid-cols-[86px_1fr_auto] items-center gap-3 text-[13.5px]">77 <PartyLogo id={id} height={16} />78 <div className="text-ink-2 num"><b className="text-ink">{seatsNow[id]?.elected ?? 0}</b> élus · <b className="text-ink">{seatsNow[id]?.leading ?? 0}</b> en avance</div>79 <div className="num font-bold text-[22px]" style={{ color: partyVar(id) }}>{totalNow(id)}</div>80 </div>81 ))}82 </div>83 {nc && (84 <div className="mt-5 pt-4 border-t border-border">85 <div className="flex items-baseline justify-between"><div className="eyebrow">Probabilité de majorité {partyLabel(leaderNow)} · Nowcast QC26</div><ProjectionBadge>Nowcast</ProjectionBadge></div>86 <div className="num display text-[44px] mt-1" style={{ color: partyVar(leaderNow) }}>{prob(nc.parties[leaderNow]?.p_majority)}</div>87 <ProbabilityBar p={nc.parties[leaderNow]?.p_majority ?? 0} color={partyVar(leaderNow)} height={14} showValue={false} />88 <div className="text-[12px] text-ink-3 mt-1 num">Minoritaire {prob(nc.p_minority)} · couverture {pct(nc.coverage * 100, 0)} · {relTime(nc.ts)}</div>89 </div>90 )}91 </div>92 </section>9394 <section className="grid lg:grid-cols-[1.3fr_1fr] gap-6">95 <div>96 <SectionHeader eyebrow="Forecast vs Live" title="Prévision QC26 contre les résultats" description="La colonne Forecast est la dernière projection publiée avant 20 h. La colonne Live ne contient que des données officielles." action={<Link href="/live/model-vs-reality" className="link text-[13.5px]">Modèle vs réalité →</Link>} />97 <Card pad={false}>98 <table className="data-table"><thead><tr><th>Parti</th><th className="r">Forecast</th><th className="r">Élus</th><th className="r">En avance</th><th className="r">Live</th><th className="r">Nowcast</th><th className="r">P(maj)</th></tr></thead>99 <tbody>100 {PARTY_IDS.slice().sort((a, b) => totalNow(b) - totalNow(a) || forecast.parties[b].seatsMedian - forecast.parties[a].seatsMedian).map((id) => (101 <tr key={id}>102 <td><span className="inline-flex items-center gap-2"><PartyDot id={id} /><b>{partyLabel(id)}</b></span></td>103 <td className="r num text-ink-2">{forecast.parties[id].seatsMedian} <span className="text-ink-3 text-[11px]">({forecast.parties[id].seats80[0]}–{forecast.parties[id].seats80[1]})</span></td>104 <td className="r num">{seatsNow[id]?.elected ?? 0}</td>105 <td className="r num">{seatsNow[id]?.leading ?? 0}</td>106 <td className="r num font-bold text-[16px]" style={{ color: partyVar(id) }}>{totalNow(id)}</td>107 <td className="r num">{nc ? `${nc.parties[id]?.seats_median} ` : "—"}{nc && <span className="text-ink-3 text-[11px]">({nc.parties[id]?.seats80[0]}–{nc.parties[id]?.seats80[1]})</span>}</td>108 <td className="r num">{nc ? prob(nc.parties[id]?.p_majority) : "—"}</td>109 </tr>110 ))}111 </tbody></table>112 </Card>113 <div className="mt-6"><SectionHeader eyebrow="Carte live" title="Le Québec, bureau par bureau" description="Gris : aucun résultat. Couleur pâle : dépouillement en cours. Couleur pleine : élu·e, final ou projeté QC26." /><Card pad={false} className="p-3 md:p-4"><MapSection height={520} compact live liveState={state?.ridings as never} /></Card></div>114 </div>115 <div className="space-y-6">116 <div><SectionHeader eyebrow="Fil des résultats" title="Ticker" description="Événements construits uniquement à partir des données officielles." /><Ticker events={events} /></div>117 <MajorityTimeline />118 </div>119 </section>120 <section>121 <SectionHeader eyebrow="Battlegrounds live" title="Courses les plus serrées" description="Circonscriptions rapportées où la marge est la plus faible." />122 <CloseRaces state={state} />123 </section>124 </div>125 );126}127128export function FeedStatus({ state, connected }: { state: LiveState | null; connected: boolean }) {129 const lv = state?.lastValid;130 return (131 <div className="mt-6 text-[12.5px] text-ink-2 space-y-1">132 <div className="flex items-center gap-2"><span className={cls("w-2 h-2 rounded-full", connected ? "bg-ok" : "bg-warn")} />Flux Élections Québec · {state?.quarantine ? <span className="text-warn font-semibold">actualisation temporairement retardée (anomalie détectée)</span> : lv ? <span>dernière donnée officielle reçue <b className="num">{timeFr(lv.at)}</b>{lv.source_updated_at ? ` (horodatage DGEQ ${lv.source_updated_at.slice(11, 19)})` : ""}</span> : <span>en attente des premiers résultats</span>}</div>133 <div className="text-ink-3">Source : donnees.electionsquebec.qc.ca (JSON officiel, mis à jour toutes les 2 à 5 min après 20 h). QC26 conserve toujours la dernière donnée valide ; l'absence de données n'est jamais affichée comme un zéro.</div>134 </div>135 );136}137138export function Ticker({ events, limit = 30 }: { events: LiveEvent[]; limit?: number }) {139 return (140 <Card pad={false} className="max-h-[560px] overflow-auto">141 {events.length === 0 && <div className="p-6 text-center text-ink-3 text-[13.5px]">Aucun événement pour l'instant.</div>}142 <ul>143 {events.slice(0, limit).map((e) => (144 <li key={e.id} className="ticker-in flex gap-3 px-4 py-2.5 border-b border-border last:border-0 text-[13px]">145 <span className="num text-ink-3 shrink-0 w-[60px]">{timeFr(e.ts).slice(0, 5)}</span>146 <span className={cls("shrink-0 w-2 h-2 rounded-full mt-1.5", e.kind === "lead_change" ? "bg-warn" : e.kind === "riding_complete" ? "bg-ok" : e.kind === "quarantine" ? "bg-live" : "bg-ink-3")} style={e.party ? { background: partyVar(e.party) } : undefined} />147 <span className="min-w-0">{e.ridingCode ? <Link href={`/circonscription/${e.ridingCode}`} className="hover:underline">{e.message}</Link> : e.message}</span>148 </li>149 ))}150 </ul>151 </Card>152 );153}154155export function MajorityTimeline() {156 const [hist, setHist] = useState<{ ts: string; reporting: number; parties: Record<string, { pMajority: number }>; pMinority: number }[]>([]);157 useEffect(() => {158 const load = () => fetch("/api/live/nowcast/history", { cache: "no-store" }).then((r) => r.json()).then((d) => setHist(d.history ?? [])).catch(() => {});159 load();160 const t = setInterval(load, 30000);161 return () => clearInterval(t);162 }, []);163 if (hist.length < 2) return null;164 const W = 420, H = 160, pl = 30, pb = 22;165 const x = (i: number) => pl + (i / (hist.length - 1)) * (W - pl - 8);166 const y = (p: number) => 8 + (1 - p) * (H - pb - 8);167 return (168 <div>169 <SectionHeader eyebrow="Nowcast" title="Probabilité de majorité dans le temps" />170 <Card>171 <svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto" role="img" aria-label="Évolution de la probabilité de majorité par parti">172 {[0, 0.5, 1].map((p) => (<g key={p}><line x1={pl} x2={W - 8} y1={y(p)} y2={y(p)} stroke="var(--border)" /><text x={pl - 4} y={y(p) + 3} fontSize={9.5} textAnchor="end" fill="var(--text-3)">{Math.round(p * 100)}</text></g>))}173 {PARTY_IDS.map((id) => (<polyline key={id} fill="none" stroke={partyVar(id)} strokeWidth={2} points={hist.map((h, i) => `${x(i)},${y(h.parties[id]?.pMajority ?? 0)}`).join(" ")} />))}174 <polyline fill="none" stroke="var(--text)" strokeWidth={1.5} strokeDasharray="3 3" points={hist.map((h, i) => `${x(i)},${y(h.pMinority ?? 0)}`).join(" ")} />175 {[0, Math.floor(hist.length / 2), hist.length - 1].map((i) => (<text key={i} x={x(i)} y={H - 6} fontSize={9.5} textAnchor="middle" fill="var(--text-3)" className="num">{timeFr(hist[i].ts).slice(0, 5)}</text>))}176 </svg>177 <div className="flex flex-wrap gap-x-3 text-[11.5px] mt-1">{PARTY_IDS.map((id) => <span key={id} className="inline-flex items-center gap-1"><PartyDot id={id} />{partyLabel(id)}</span>)}<span className="inline-flex items-center gap-1"><span className="w-3 border-t border-dashed border-ink" />Minoritaire</span></div>178 </Card>179 </div>180 );181}182183export function CloseRaces({ state, limit = 10 }: { state: LiveState | null; limit?: number }) {184 const [names, setNames] = useState<Record<string, { name: string; slug: string }>>({});185 useEffect(() => { fetch("/api/ridings").then((r) => r.json()).then((d) => setNames(Object.fromEntries(d.ridings.map((r: { code: number; name: string; slug: string }) => [String(r.code), { name: r.name, slug: r.slug }])))).catch(() => {}); }, []);186 const rows = useMemo(() => Object.entries(state?.ridings ?? {}).filter(([, r]) => r.bureaux[0] > 0 && r.marginPct !== null && !r.final).sort((a, b) => (a[1].marginPct ?? 99) - (b[1].marginPct ?? 99)).slice(0, limit), [state, limit]);187 if (!rows.length) return <Card><div className="text-ink-3 text-[13.5px] text-center py-4">Aucune circonscription en dépouillement.</div></Card>;188 return (189 <div className="grid sm:grid-cols-2 lg:grid-cols-5 gap-3">190 {rows.map(([code, r]) => (191 <Link key={code} href={`/circonscription/${names[code]?.slug ?? code}`} className="card p-3.5 block hover:shadow-md">192 <div className="font-semibold text-[14px] leading-tight">{names[code]?.name ?? code}</div>193 <div className="text-[11.5px] text-ink-3 mt-0.5 num">{statusLabel(r.status)} · {r.bureaux[0]}/{r.bureaux[1]} bureaux</div>194 <div className="mt-2 space-y-1">{(r.candidates ?? []).slice(0, 3).map((c) => (<div key={c.name} className="flex justify-between text-[12.5px]"><span className="inline-flex items-center gap-1.5 truncate"><PartyDot id={c.party_id} />{c.name}</span><span className="num">{int(c.votes)}</span></div>))}</div>195 <div className="text-[12px] mt-2 num">Marge <b>{pct(r.marginPct)}</b> · {int(r.marginVotes)} votes</div>196 </Link>197 ))}198 </div>199 );200}201202export { PartyBadge, partyName, dateTimeFr };203