SPB Git forge

spb/qc26

Public
10commits 1branches 0releases
2.8 MBsize
maindefault branch
17 days agolast push
Python 51.6% TypeScript 46.7% CSS 1.7%
14.5 KB · 203 lines tsx
Raw Blame History
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&apos;absence de données n&apos;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&apos;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