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, partyAlpha, partyLabel, partyName, partyVar, sortParties } from "@/lib/parties"; import { DotComparison, SeatDistribution, SeatWaffle } from "./charts"; import { PartyBadge, PartyDot, PartyLogo } from "./party"; import { Badge, Card, Dual, Metric, MetricDelta, ProbabilityBar, SectionHeader, SourceBadge } from "./ui"; // ------------------------------------------------------- SEAT PROJECTION export function SeatProjectionTable({ f, className }: { f: Forecast; className?: string }) { const rows = sortParties(f.parties, (v) => v.seatsMean); const maj = f.summary.majority; return (
{/* mobile : une carte par parti */}
{rows.map(([id, v]) => (
{partyLabel(id)}
{v.seatsMedian}{v.seats80[0]}–{v.seats80[1]}
95 %
{v.seats95[0]}–{v.seats95[1]}
Plus de sièges
{prob(v.pMostSeats)}
Majorité
{prob(v.pMajority)}
{v.seatDistribution &&
} ))}
{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 pondérée des {int(f.nSimulations)} simulations ; intervalles = percentiles 10–90 et 2,5–97,5. « Autres » n'est jamais déclaré gagnant.

); } 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)
); } // ------------------------------------------------------------ WHAT CHANGED 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 changes = Object.entries(d.changes).filter(([id]) => id !== "aut").sort((a, b) => Math.abs(b[1].seats) - Math.abs(a[1].seats)); return (
Majorité {partyLabel(leader)}
{prob(f.parties[leader].pMajority - (ch?.p_majority ?? 0))} → {prob(f.parties[leader].pMajority)}
Causes identifiées
    {d.causes.map((cause, i) => (
  • {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})} : cause.kind === "markets" ? Marchés de prédiction : {Object.entries(cause.detail as unknown as Record).map(([p, v]) => `${partyLabel(p)} ${delta(v * 100, 0, " pt")}`).join(" · ")} : {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
{changes.map(([id, c]) => (
votesiègesmaj.
))}
{changes.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")}.
}
); } // ------------------------------------------------------------- LATEST POLLS export function PollRowMobile({ p, showSource = true }: { p: Poll; showSource?: boolean }) { const lead = Object.entries(p.results).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1])[0]?.[0]; return (
{p.pollster.name} {p.sponsor && · {p.sponsor}} {!p.validated && index} {p.excluded && exclu}
{p.fieldworkStart ? `${dateShort(p.fieldworkStart)} – ` : ""}{dateShort(p.fieldworkEnd)}
n = {int(p.sampleSize)}
{PARTY_IDS.map((id) => (
{partyLabel(id)}
{p.results[id] !== undefined ? p.results[id].toFixed(p.results[id] % 1 ? 1 : 0).replace(".", ",") : "—"}
))}
{showSource && (
{p.sourceTier === "primary" ? "PDF archivé" : "index tiers, document en attente"}{p.weight ? ` · poids ${p.weight.weight.toFixed(2)}` : ""}
)}
); } export function LatestPolls({ polls, limit = 6, compact = false }: { polls: Poll[]; limit?: number; compact?: boolean }) { const rows = polls.slice(0, limit); return ( <>
{rows.map((p) => )}
{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) : "—"}
); } // ------------------------------------------------------------ BATTLEGROUNDS 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)}
))}
))}
); } // ------------------------------------------------------------ REGIONAL GRID 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")}
))}
{top.slice(0, 3).map(([id, v]) => `${partyLabel(id)} ${v.toFixed(0)} % (${delta(v - (g.vote2022[id] ?? 0), 0)})`).join(" · ")}
Vote projeté (moyenne des circonscriptions) · variation vs 2022 transposé
); })}
); } // ------------------------------------------------------------------- HERO 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]; const seats = Object.fromEntries(Object.entries(f.parties).map(([k, v]) => [k, v.seatsMedian])); const comp = f.summary.components; const pollsOnly = comp?.pollsOnly?.parties?.[leader]; const nSources = comp ? [comp.fundamentals?.enabled, comp.momentum?.enabled, comp.markets?.enabled, comp.byelections?.enabled].filter(Boolean).length : 0; return (
Prévision QC26 · Modèle {f.modelVersion} · run #{f.id}

Le {partyLabel(leader)} favori, {lp.pMajority >= 0.5 ? "une majorité plus probable qu'improbable" : lp.pMajority >= 0.3 ? "la majorité en jeu" : "un gouvernement minoritaire probable"}.

{partyName(leader)}
{lp.seatsMedian}
sièges sur {meta.seatsTotal}
· 80 % : {lp.seats80[0]}–{lp.seats80[1]}
: undefined} sub={`seuil ${meta.majority}`} /> : undefined} /> : undefined} sub={`± ${lp.voteSd.toFixed(1).replace(".", ",")}`} />
Mis à jour {dateTimeFr(f.completedAt)} {f.pollCount} sondages · {int(f.nSimulations)} simulations{comp?.ess ? ` · ESS ${int(comp.ess)}` : ""} {nSources > 0 && {nSources} source{nSources > 1 ? "s" : ""} au-delà des sondages →} Méthodologie
Assemblée nationale · médiane
{meta.seatsTotal} sièges · maj. {meta.majority}
{rows.map(([id, v]) => (
{partyLabel(id)}{party(id)?.officialName} {v.seatsMedian} {v.seats80[0]}–{v.seats80[1]}{pollsOnly && comp?.pollsOnly?.parties?.[id] && Math.abs(comp.pollsOnly.parties[id].seats - v.seatsMedian) >= 2 ? (sond. {comp.pollsOnly.parties[id].seats}) : null}
))}
0{meta.majority} · majorité{meta.seatsTotal}
); } // ------------------------------------------------------ BEYOND POLLS (accueil / prévision) export function ComponentsCompare({ f, compact = false }: { f: Forecast; compact?: boolean }) { const comp = f.summary.components; if (!comp) return null; const ids = PARTY_IDS.filter((id) => (f.parties[id]?.pMostSeats ?? 0) > 0.005 || (comp.pollsOnly.parties[id]?.p_most_seats ?? 0) > 0.005 || (comp.markets.mostSeatsMarket?.[id] ?? 0) > 0.005); const rows = ids.map((id) => ({ id, label: partyLabel(id), values: { polls: comp.pollsOnly.parties[id]?.p_most_seats, market: comp.markets.enabled ? comp.markets.mostSeatsMarket?.[id] : undefined, ensemble: f.parties[id]?.pMostSeats } })); const series = [{ key: "polls", label: "Sondages seuls", shape: "ring" as const }, ...(comp.markets.enabled ? [{ key: "market", label: "Marchés (Polymarket)", shape: "diamond" as const }] : []), { key: "ensemble", label: "Ensemble QC26", shape: "dot" as const }]; const leader = f.summary.leader; return (
Probabilité d'obtenir le plus de sièges
} wide={} />
Majorité {partyLabel(leader)} · sondages seuls
{prob(comp.pollsOnly.parties[leader]?.p_majority)}
Majorité {partyLabel(leader)} · marchés
{comp.markets.enabled && comp.markets.majorityMarket?.[leader] !== undefined ? prob(comp.markets.majorityMarket[leader]) : "—"}
Majorité {partyLabel(leader)} · ensemble
{prob(f.parties[leader].pMajority)}
Poids des marchés
{comp.markets.enabled ? `${Math.round(comp.markets.weight * 100)} %` : "0 %"}
Fondamentaux {comp.fundamentals.enabled ? `poids ${Math.round(comp.fundamentals.weight * 100)} % (satisfaction ${String(comp.fundamentals.satisfaction).replace(".", ",")} %)` : "inactifs"} · momentum {comp.momentum.enabled ? Object.entries(comp.momentum.shiftPp).filter(([k]) => k !== "aut").map(([k, v]) => `${partyLabel(k)} ${delta(v, 1)}`).join(", ") : "inactif"} · partielles {comp.byelections.enabled ? `${comp.byelections.ridings.length} circonscriptions` : "aucune"}. {!compact && <> Voir tous les signaux →}
); } export { SectionHeader, Card };