1import Link from "next/link";2import type { Forecast, Poll, RidingListItem } from "@/lib/api";3import { cls, dateShort, dateTimeFr, delta, int, pct, prob, ratingLabel } from "@/lib/format";4import { PARTY_IDS, party, partyColor, partyLabel, partyName, sortParties } from "@/lib/parties";5import { SeatDistribution } from "./charts";6import { PartyBadge, PartyDot, PartyLogo } from "./party";7import { Badge, Card, Metric, MetricDelta, ProbabilityBar, SectionHeader, SourceBadge } from "./ui";89export function SeatProjectionTable({ f, className }: { f: Forecast; className?: string }) {10 const rows = sortParties(f.parties, (v) => v.seatsMean);11 const maj = f.summary.majority;12 return (13 <div className={cls("overflow-x-auto", className)}>14 <table className="data-table min-w-[640px]">15 <thead>16 <tr><th>Parti</th><th className="r">Projection</th><th className="r">Intervalle 80 %</th><th className="r">Intervalle 95 %</th><th className="r">Plus de sièges</th><th className="r">Majorité ({maj})</th><th className="w-[220px]">Distribution</th></tr>17 </thead>18 <tbody>19 {rows.map(([id, v]) => (20 <tr key={id}>21 <td><Link href={`/partis/${id}`} className="inline-flex items-center gap-2.5"><PartyLogo id={id} height={18} /><span className="font-semibold">{partyLabel(id)}</span></Link></td>22 <td className="r num text-[18px] font-bold" style={{ color: partyColor(id) }}>{v.seatsMedian}</td>23 <td className="r num">{v.seats80[0]}–{v.seats80[1]}</td>24 <td className="r num text-ink-2">{v.seats95[0]}–{v.seats95[1]}</td>25 <td className="r num font-semibold">{prob(v.pMostSeats)}</td>26 <td className="r num font-semibold">{prob(v.pMajority)}</td>27 <td>{v.seatDistribution && <SeatDistribution dist={v.seatDistribution} color={partyColor(id)} majority={maj} median={v.seatsMedian} lo80={v.seats80[0]} hi80={v.seats80[1]} lo95={v.seats95[0]} hi95={v.seats95[1]} height={44} total={f.summary.seats_total} />}</td>28 </tr>29 ))}30 </tbody>31 </table>32 <p className="text-[12px] text-ink-3 mt-2">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.</p>33 </div>34 );35}3637export function PopularVoteTable({ f, className }: { f: Forecast; className?: string }) {38 const rows = sortParties(f.parties, (v) => v.voteMean, true);39 const ch = f.diff?.changes;40 return (41 <div className={cls("space-y-2.5", className)}>42 {rows.map(([id, v]) => (43 <div key={id} className="grid grid-cols-[52px_1fr_auto] items-center gap-3">44 <span className="font-semibold num text-[13.5px]" style={{ color: partyColor(id) }}>{partyLabel(id)}</span>45 <div className="relative h-[16px] bg-surface-3 rounded-[4px] overflow-hidden">46 <div className="absolute inset-y-0 rounded-[4px]" style={{ left: `${(v.vote95[0] / 50) * 100}%`, width: `${((v.vote95[1] - v.vote95[0]) / 50) * 100}%`, background: partyColor(id), opacity: 0.15 }} />47 <div className="absolute inset-y-0 rounded-[4px]" style={{ left: `${(v.vote80[0] / 50) * 100}%`, width: `${((v.vote80[1] - v.vote80[0]) / 50) * 100}%`, background: partyColor(id), opacity: 0.3 }} />48 <div className="absolute inset-y-0 w-[2px] bg-ink" style={{ left: `${(v.voteMean / 50) * 100}%` }} />49 </div>50 <div className="flex items-baseline gap-2 num">51 <span className="font-bold text-[16px] w-[58px] text-right">{v.voteMean.toFixed(1).replace(".", ",")}</span>52 {ch?.[id] ? <MetricDelta value={ch[id].vote} /> : <span className="w-[54px]" />}53 </div>54 </div>55 ))}56 <p className="text-[12px] text-ink-3">Vote populaire projeté au jour du scrutin (%, moyenne ± intervalles 80/95 %). Variation depuis le run précédent.</p>57 </div>58 );59}6061export function MajorityPanel({ f }: { f: Forecast }) {62 const rows = sortParties(f.parties, (v) => v.pMajority).filter(([, v]) => v.pMostSeats > 0.005 || v.pMajority > 0.005);63 return (64 <div className="space-y-3">65 {rows.map(([id, v]) => (66 <div key={id}>67 <div className="flex items-baseline justify-between mb-1 text-[13px]"><span className="font-semibold inline-flex items-center gap-1.5"><PartyDot id={id} />{partyName(id)}</span><span className="text-ink-2">plus de sièges {prob(v.pMostSeats)}</span></div>68 <ProbabilityBar p={v.pMajority} color={partyColor(id)} label={`majorité ${partyLabel(id)}`} height={12} />69 </div>70 ))}71 <div>72 <div className="flex items-baseline justify-between mb-1 text-[13px]"><span className="font-semibold">Aucune majorité (gouvernement minoritaire)</span></div>73 <ProbabilityBar p={f.summary.p_minority} color="#111827" label="minoritaire" height={12} />74 </div>75 </div>76 );77}7879export function WhatChanged({ f, ridingNames }: { f: Forecast; ridingNames?: Record<number, { name: string; slug: string }> }) {80 const d = f.diff;81 if (!d) return <p className="text-ink-2 text-[14px]">Premier run publié : pas de comparaison disponible.</p>;82 const leader = f.summary.leader;83 const ch = d.changes[leader];84 const biggest = Object.entries(d.changes).sort((a, b) => Math.abs(b[1].p_majority) - Math.abs(a[1].p_majority))[0];85 const cause = d.causes[0];86 return (87 <div className="grid md:grid-cols-[1fr_1.2fr] gap-6">88 <div>89 <div className="eyebrow">Majorité {partyLabel(leader)}</div>90 <div className="num display text-[40px] mt-1">91 <span className="text-ink-3">{prob(f.parties[leader].pMajority - (ch?.p_majority ?? 0))}</span> <span className="text-ink-3 text-[24px]">→</span> {prob(f.parties[leader].pMajority)}92 </div>93 <div className="mt-3 text-[13.5px]">94 <div className="eyebrow mb-1">Cause principale</div>95 {cause?.kind === "new_polls" ? (96 <div>Nouveau{(cause.detail as string[]).length > 1 ? "x" : ""} sondage{(cause.detail as string[]).length > 1 ? "s" : ""} : {(cause.detail as string[]).map((id) => <Link key={id} href={`/sondages/${id}`} className="link mr-2">{id}</Link>)}</div>97 ) : <div className="text-ink-2">{String(cause?.detail ?? "—")}</div>}98 </div>99 <div className="text-[12px] text-ink-3 mt-3">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à.</div>100 </div>101 <div>102 <div className="eyebrow mb-2">Variations par parti</div>103 <table className="data-table"><thead><tr><th>Parti</th><th className="r">Vote</th><th className="r">Sièges</th><th className="r">P(majorité)</th><th className="r">P(1er)</th></tr></thead>104 <tbody>105 {Object.entries(d.changes).filter(([id]) => id !== "aut").sort((a, b) => Math.abs(b[1].seats) - Math.abs(a[1].seats)).map(([id, c]) => (106 <tr key={id}><td><PartyBadge id={id} size="sm" /></td><td className="r"><MetricDelta value={c.vote} /></td><td className="r"><MetricDelta value={c.seats} digits={0} suffix="" /></td><td className="r"><MetricDelta value={c.p_majority * 100} digits={0} suffix=" pt" /></td><td className="r"><MetricDelta value={c.p_most_seats * 100} digits={0} suffix=" pt" /></td></tr>107 ))}108 </tbody></table>109 {d.flips.length > 0 && (110 <div className="mt-3 text-[13px]">111 <div className="eyebrow mb-1">Circonscriptions qui changent de favori</div>112 <ul className="space-y-1">113 {d.flips.slice(0, 8).map((fl) => (114 <li key={fl.code} className="flex items-center gap-2"><Link href={`/circonscription/${ridingNames?.[fl.code]?.slug ?? fl.code}`} className="link">{ridingNames?.[fl.code]?.name ?? fl.code}</Link><PartyBadge id={fl.from} size="sm" /> → <PartyBadge id={fl.to} size="sm" /> <span className="num text-ink-3">{prob(fl.p)}</span></li>115 ))}116 </ul>117 </div>118 )}119 {biggest && biggest[0] !== leader && <div className="text-[12.5px] text-ink-2 mt-2">Autre mouvement notable : {partyLabel(biggest[0])} P(majorité) {delta(biggest[1].p_majority * 100, 0, " pt")}.</div>}120 </div>121 </div>122 );123}124125export function LatestPolls({ polls, limit = 6, compact = false }: { polls: Poll[]; limit?: number; compact?: boolean }) {126 const rows = polls.slice(0, limit);127 return (128 <div className="overflow-x-auto">129 <table className="data-table min-w-[560px]">130 <thead><tr><th>Firme</th><th>Terrain</th><th className="r">n</th>{PARTY_IDS.map((id) => <th key={id} className="r" style={{ color: partyColor(id) }}>{partyLabel(id)}</th>)}{!compact && <th className="r">Poids</th>}<th></th></tr></thead>131 <tbody>132 {rows.map((p) => {133 const lead = Object.entries(p.results).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1])[0]?.[0];134 return (135 <tr key={p.id}>136 <td><Link href={`/sondages/${p.id}`} className="font-semibold hover:underline">{p.pollster.name}</Link>{p.sponsor && <span className="text-ink-3 text-[12px]"> · {p.sponsor}</span>}{!p.validated && <span className="ml-1.5 text-[10px] uppercase tracking-wide text-warn font-semibold" title="Document original en cours de vérification">index</span>}</td>137 <td className="num whitespace-nowrap">{p.fieldworkStart ? `${dateShort(p.fieldworkStart)} – ` : ""}{dateShort(p.fieldworkEnd)}</td>138 <td className="r num">{int(p.sampleSize)}</td>139 {PARTY_IDS.map((id) => <td key={id} className={cls("r num", id === lead && "font-bold")}>{p.results[id]?.toFixed(0) ?? "—"}</td>)}140 {!compact && <td className="r num text-ink-2">{p.weight ? p.weight.weight.toFixed(2) : "—"}</td>}141 <td className="r"><SourceBadge source={{ name: p.document ? "Document original (PDF de la firme)" : "Index tiers en attente du document original", url: p.sourceUrl, archiveUrl: p.document?.archiveUrl, retrievedAt: p.document?.downloadedAt, page: p.resultsPage, tier: p.sourceTier === "primary" ? "Primaire (firme)" : "Secondaire (index)", detail: p.methodology ?? undefined }} /></td>142 </tr>143 );144 })}145 </tbody>146 </table>147 </div>148 );149}150151export function Battlegrounds({ ridings, codes, limit = 10 }: { ridings: RidingListItem[]; codes: number[]; limit?: number }) {152 const by = new Map(ridings.map((r) => [r.code, r]));153 const rows = codes.map((c) => by.get(c)).filter((r): r is RidingListItem => !!r && !!r.forecast).slice(0, limit);154 return (155 <div className="grid sm:grid-cols-2 lg:grid-cols-5 gap-3">156 {rows.map((r) => (157 <Link key={r.code} href={`/circonscription/${r.slug}`} className="card p-3.5 hover:shadow-md transition-shadow block">158 <div className="flex items-start justify-between gap-2"><div className="font-semibold text-[14px] leading-tight">{r.name}</div><Badge tone={r.forecast!.rating === "tossup" ? "warn" : "neutral"}>{ratingLabel(r.forecast!.rating)}</Badge></div>159 <div className="text-[11.5px] text-ink-3 mt-0.5">{r.regionName}</div>160 <div className="mt-3 space-y-1">161 {Object.entries(r.forecast!.pWin).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1]).slice(0, 3).map(([id, p]) => (162 <div key={id} className="flex items-center gap-2 text-[12.5px]"><span className="w-8 font-semibold num" style={{ color: partyColor(id) }}>{partyLabel(id)}</span><div className="flex-1 h-[6px] rounded-full bg-surface-3 overflow-hidden"><div className="h-full" style={{ width: `${p * 100}%`, background: partyColor(id) }} /></div><span className="num w-10 text-right">{prob(p)}</span></div>163 ))}164 </div>165 </Link>166 ))}167 </div>168 );169}170171export function RegionalGrid({ regions }: { regions: { id: string; name: string; ridings: number; favorites: Record<string, number>; voteNow: Record<string, number>; vote2022: Record<string, number> }[] }) {172 return (173 <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">174 {regions.map((g) => {175 const favs = Object.entries(g.favorites).sort((a, b) => b[1] - a[1]);176 const top = Object.entries(g.voteNow).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1]).slice(0, 4);177 return (178 <div key={g.id} className="card p-4">179 <div className="flex items-baseline justify-between"><div className="font-semibold">{g.name}</div><div className="text-[12px] text-ink-3 num">{g.ridings} circ.</div></div>180 <div className="flex h-[8px] rounded-full overflow-hidden mt-2 bg-surface-3">{favs.map(([id, n]) => <div key={id} style={{ width: `${(n / g.ridings) * 100}%`, background: partyColor(id) }} title={`${partyLabel(id)} ${n}`} />)}</div>181 <div className="flex flex-wrap gap-x-3 mt-1.5 text-[12px] num">{favs.map(([id, n]) => <span key={id}><b style={{ color: partyColor(id) }}>{partyLabel(id)}</b> {n}</span>)}</div>182 <div className="mt-3 space-y-1">183 {top.map(([id, v]) => (184 <div key={id} className="grid grid-cols-[34px_1fr_44px_54px] items-center gap-2 text-[12px]"><span className="font-semibold num" style={{ color: partyColor(id) }}>{partyLabel(id)}</span><div className="h-[5px] rounded-full bg-surface-3 overflow-hidden"><div className="h-full" style={{ width: `${v * 2}%`, background: partyColor(id) }} /></div><span className="num text-right font-semibold">{v.toFixed(0)} %</span><span className="num text-right text-ink-3">{delta(v - (g.vote2022[id] ?? 0), 0, " pt")}</span></div>185 ))}186 </div>187 <div className="text-[11px] text-ink-3 mt-2">Vote projeté (moyenne des circonscriptions) · variation vs 2022 transposé</div>188 </div>189 );190 })}191 </div>192 );193}194195export function HeroForecast({ f, meta }: { f: Forecast; meta: { seatsTotal: number; majority: number; daysToElection: number } }) {196 const leader = f.summary.leader;197 const lp = f.parties[leader];198 const rows = sortParties(f.parties, (v) => v.seatsMean);199 const ch = f.diff?.changes?.[leader];200 return (201 <section className="grid lg:grid-cols-[1.1fr_1fr] gap-6 lg:gap-10 items-stretch">202 <div className="card card-lg p-6 md:p-8 flex flex-col">203 <div className="flex items-center justify-between flex-wrap gap-2">204 <div className="eyebrow">QC26 Forecast · Québec · 5 octobre 2026</div>205 <Badge tone="accent">Projection · Modèle {f.modelVersion}</Badge>206 </div>207 <div className="mt-6 flex items-end gap-6 flex-wrap">208 <div>209 <div className="text-[13px] text-ink-2 mb-1">Projection actuelle</div>210 <div className="flex items-center gap-3"><PartyLogo id={leader} height={26} /><span className="text-[15px] font-semibold">{partyName(leader)}</span></div>211 <div className="num display text-[84px] md:text-[112px] mt-1" style={{ color: partyColor(leader) }}>{lp.seatsMedian}</div>212 <div className="text-[14px] text-ink-2 -mt-2">sièges sur {meta.seatsTotal} · intervalle 80 % <span className="num font-semibold text-ink">{lp.seats80[0]}–{lp.seats80[1]}</span></div>213 </div>214 <div className="ml-auto grid grid-cols-2 gap-x-8 gap-y-4">215 <Metric label="Majorité" value={prob(lp.pMajority)} size="lg" delta={ch ? <MetricDelta value={ch.p_majority * 100} digits={0} suffix=" pt" /> : undefined} sub={`seuil ${meta.majority}`} />216 <Metric label="Plus de sièges" value={prob(lp.pMostSeats)} size="lg" delta={ch ? <MetricDelta value={ch.p_most_seats * 100} digits={0} suffix=" pt" /> : undefined} />217 <Metric label="Vote populaire" value={pct(lp.voteMean)} size="md" delta={ch ? <MetricDelta value={ch.vote} /> : undefined} sub={`± ${lp.voteSd.toFixed(1).replace(".", ",")}`} />218 <Metric label="Minoritaire" value={prob(f.summary.p_minority)} size="md" sub="aucun parti à 64" />219 </div>220 </div>221 <div className="mt-auto pt-6 flex flex-wrap items-center gap-x-5 gap-y-2 text-[12.5px] text-ink-3">222 <span className="num">Dernière mise à jour {dateTimeFr(f.completedAt)}</span>223 <span className="num">{f.pollCount} sondages · {int(f.nSimulations)} simulations</span>224 <span className="num">Dernier terrain {dateShort(f.summary.latest_poll)}</span>225 <Link href="/methodologie" className="link">Méthodologie</Link>226 </div>227 </div>228 <div className="card card-lg p-6 md:p-8">229 <div className="eyebrow mb-4">Sièges projetés · médiane et intervalle 80 %</div>230 <div className="space-y-4">231 {rows.map(([id, v]) => (232 <div key={id}>233 <div className="flex items-baseline justify-between text-[13.5px] mb-1">234 <span className="inline-flex items-center gap-2"><PartyLogo id={id} height={16} /><span className="font-semibold">{partyLabel(id)}</span><span className="text-ink-3 hidden sm:inline">{party(id)?.officialName}</span></span>235 <span className="num"><b className="text-[18px]">{v.seatsMedian}</b> <span className="text-ink-3">{v.seats80[0]}–{v.seats80[1]}</span></span>236 </div>237 <div className="relative h-[14px] rounded-[4px] bg-surface-3 overflow-hidden">238 <div className="absolute inset-y-0 rounded-[4px]" style={{ left: `${(v.seats95[0] / meta.seatsTotal) * 100}%`, width: `${((v.seats95[1] - v.seats95[0]) / meta.seatsTotal) * 100}%`, background: partyColor(id), opacity: 0.16 }} />239 <div className="absolute inset-y-0 rounded-[4px]" style={{ left: `${(v.seats80[0] / meta.seatsTotal) * 100}%`, width: `${((v.seats80[1] - v.seats80[0]) / meta.seatsTotal) * 100}%`, background: partyColor(id), opacity: 0.45 }} />240 <div className="absolute inset-y-0 w-[3px] rounded-sm" style={{ left: `calc(${(v.seatsMedian / meta.seatsTotal) * 100}% - 1px)`, background: partyColor(id) }} />241 <div className="absolute inset-y-0 w-[1.5px] bg-ink" style={{ left: `${(meta.majority / meta.seatsTotal) * 100}%` }} />242 </div>243 </div>244 ))}245 </div>246 <div className="flex justify-between text-[11px] text-ink-3 num mt-2"><span>0</span><span className="font-bold text-ink">{meta.majority} · majorité</span><span>{meta.seatsTotal}</span></div>247 <div className="mt-5 grid grid-cols-3 gap-3 text-center">248 {rows.slice(0, 3).map(([id, v]) => (249 <div key={id} className="rounded-[8px] surface-2 p-2.5"><div className="text-[11px] text-ink-3">P(majorité)</div><div className="num font-bold text-[18px]" style={{ color: partyColor(id) }}>{prob(v.pMajority)}</div><div className="text-[11px] font-semibold">{partyLabel(id)}</div></div>250 ))}251 </div>252 </div>253 </section>254 );255}256257export { SectionHeader, Card };258