import Link from "next/link"; import type { Forecast, Poll, RidingListItem } from "@/lib/api"; import { cls, dateShort, dateTimeFr, delta, int, pct, prob, ratingLabel } from "@/lib/format"; import { PARTY_IDS, party, partyColor, partyLabel, partyName, sortParties } from "@/lib/parties"; import { SeatDistribution } from "./charts"; import { PartyBadge, PartyDot, PartyLogo } from "./party"; import { Badge, Card, Metric, MetricDelta, ProbabilityBar, SectionHeader, SourceBadge } from "./ui"; export function SeatProjectionTable({ f, className }: { f: Forecast; className?: string }) { const rows = sortParties(f.parties, (v) => v.seatsMean); const maj = f.summary.majority; return (
{rows.map(([id, v]) => ( ))}
PartiProjectionIntervalle 80 %Intervalle 95 %Plus de siègesMajorité ({maj})Distribution
{partyLabel(id)} {v.seatsMedian} {v.seats80[0]}–{v.seats80[1]} {v.seats95[0]}–{v.seats95[1]} {prob(v.pMostSeats)} {prob(v.pMajority)} {v.seatDistribution && }

Médiane des {int(f.nSimulations)} simulations ; intervalles = percentiles 10–90 et 2,5–97,5. « Autres » n'est jamais déclaré gagnant dans la version 1 du modèle.

); } export function PopularVoteTable({ f, className }: { f: Forecast; className?: string }) { const rows = sortParties(f.parties, (v) => v.voteMean, true); const ch = f.diff?.changes; return (
{rows.map(([id, v]) => (
{partyLabel(id)}
{v.voteMean.toFixed(1).replace(".", ",")} {ch?.[id] ? : }
))}

Vote populaire projeté au jour du scrutin (%, moyenne ± intervalles 80/95 %). Variation depuis le run précédent.

); } export function MajorityPanel({ f }: { f: Forecast }) { const rows = sortParties(f.parties, (v) => v.pMajority).filter(([, v]) => v.pMostSeats > 0.005 || v.pMajority > 0.005); return (
{rows.map(([id, v]) => (
{partyName(id)}plus de sièges {prob(v.pMostSeats)}
))}
Aucune majorité (gouvernement minoritaire)
); } export function WhatChanged({ f, ridingNames }: { f: Forecast; ridingNames?: Record }) { const d = f.diff; if (!d) return

Premier run publié : pas de comparaison disponible.

; const leader = f.summary.leader; const ch = d.changes[leader]; const biggest = Object.entries(d.changes).sort((a, b) => Math.abs(b[1].p_majority) - Math.abs(a[1].p_majority))[0]; const cause = d.causes[0]; return (
Majorité {partyLabel(leader)}
{prob(f.parties[leader].pMajority - (ch?.p_majority ?? 0))} → {prob(f.parties[leader].pMajority)}
Cause principale
{cause?.kind === "new_polls" ? (
Nouveau{(cause.detail as string[]).length > 1 ? "x" : ""} sondage{(cause.detail as string[]).length > 1 ? "s" : ""} : {(cause.detail as string[]).map((id) => {id})}
) :
{String(cause?.detail ?? "—")}
}
Comparé au run précédent{d.previous_at ? ` (${dateTimeFr(d.previous_at)})` : ""}. Les causes listées sont les seules différences d'entrée entre les deux runs ; QC26 n'infère pas de causalité au-delà.
Variations par parti
{Object.entries(d.changes).filter(([id]) => id !== "aut").sort((a, b) => Math.abs(b[1].seats) - Math.abs(a[1].seats)).map(([id, c]) => ( ))}
PartiVoteSiègesP(majorité)P(1er)
{d.flips.length > 0 && (
Circonscriptions qui changent de favori
    {d.flips.slice(0, 8).map((fl) => (
  • {ridingNames?.[fl.code]?.name ?? fl.code} → {prob(fl.p)}
  • ))}
)} {biggest && biggest[0] !== leader &&
Autre mouvement notable : {partyLabel(biggest[0])} P(majorité) {delta(biggest[1].p_majority * 100, 0, " pt")}.
}
); } export function LatestPolls({ polls, limit = 6, compact = false }: { polls: Poll[]; limit?: number; compact?: boolean }) { const rows = polls.slice(0, limit); return (
{PARTY_IDS.map((id) => )}{!compact && } {rows.map((p) => { const lead = Object.entries(p.results).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1])[0]?.[0]; return ( {PARTY_IDS.map((id) => )} {!compact && } ); })}
FirmeTerrainn{partyLabel(id)}Poids
{p.pollster.name}{p.sponsor && · {p.sponsor}}{!p.validated && index} {p.fieldworkStart ? `${dateShort(p.fieldworkStart)} – ` : ""}{dateShort(p.fieldworkEnd)} {int(p.sampleSize)}{p.results[id]?.toFixed(0) ?? "—"}{p.weight ? p.weight.weight.toFixed(2) : "—"}
); } export function Battlegrounds({ ridings, codes, limit = 10 }: { ridings: RidingListItem[]; codes: number[]; limit?: number }) { const by = new Map(ridings.map((r) => [r.code, r])); const rows = codes.map((c) => by.get(c)).filter((r): r is RidingListItem => !!r && !!r.forecast).slice(0, limit); return (
{rows.map((r) => (
{r.name}
{ratingLabel(r.forecast!.rating)}
{r.regionName}
{Object.entries(r.forecast!.pWin).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1]).slice(0, 3).map(([id, p]) => (
{partyLabel(id)}
{prob(p)}
))}
))}
); } export function RegionalGrid({ regions }: { regions: { id: string; name: string; ridings: number; favorites: Record; voteNow: Record; vote2022: Record }[] }) { return (
{regions.map((g) => { const favs = Object.entries(g.favorites).sort((a, b) => b[1] - a[1]); const top = Object.entries(g.voteNow).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1]).slice(0, 4); return (
{g.name}
{g.ridings} circ.
{favs.map(([id, n]) =>
)}
{favs.map(([id, n]) => {partyLabel(id)} {n})}
{top.map(([id, v]) => (
{partyLabel(id)}
{v.toFixed(0)} %{delta(v - (g.vote2022[id] ?? 0), 0, " pt")}
))}
Vote projeté (moyenne des circonscriptions) · variation vs 2022 transposé
); })}
); } export function HeroForecast({ f, meta }: { f: Forecast; meta: { seatsTotal: number; majority: number; daysToElection: number } }) { const leader = f.summary.leader; const lp = f.parties[leader]; const rows = sortParties(f.parties, (v) => v.seatsMean); const ch = f.diff?.changes?.[leader]; return (
QC26 Forecast · Québec · 5 octobre 2026
Projection · Modèle {f.modelVersion}
Projection actuelle
{partyName(leader)}
{lp.seatsMedian}
sièges sur {meta.seatsTotal} · intervalle 80 % {lp.seats80[0]}–{lp.seats80[1]}
: undefined} sub={`seuil ${meta.majority}`} /> : undefined} /> : undefined} sub={`± ${lp.voteSd.toFixed(1).replace(".", ",")}`} />
Dernière mise à jour {dateTimeFr(f.completedAt)} {f.pollCount} sondages · {int(f.nSimulations)} simulations Dernier terrain {dateShort(f.summary.latest_poll)} Méthodologie
Sièges projetés · médiane et intervalle 80 %
{rows.map(([id, v]) => (
{partyLabel(id)}{party(id)?.officialName} {v.seatsMedian} {v.seats80[0]}–{v.seats80[1]}
))}
0{meta.majority} · majorité{meta.seatsTotal}
{rows.slice(0, 3).map(([id, v]) => (
P(majorité)
{prob(v.pMajority)}
{partyLabel(id)}
))}
); } export { SectionHeader, Card };