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]) => (
{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 &&
}
))}
Parti Projection Intervalle 80 % Intervalle 95 % Plus de sièges Majorité ({maj}) Distribution
{rows.map(([id, v]) => (
{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]) => (
))}
Parti Vote Sièges P(majorité) P(1er)
{changes.map(([id, c]) => (
))}
{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 (
<>
Firme Terrain n {PARTY_IDS.map((id) => {partyLabel(id)} )}{!compact && Poids }
{rows.map((p) => {
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.fieldworkStart ? `${dateShort(p.fieldworkStart)} – ` : ""}{dateShort(p.fieldworkEnd)}
{int(p.sampleSize)}
{PARTY_IDS.map((id) => {p.results[id]?.toFixed(0) ?? "—"} )}
{!compact && {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"}.
{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 };