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%
29.3 KB · 366 lines tsx
Raw Blame History
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, partyAlpha, partyLabel, partyName, partyVar, sortParties } from "@/lib/parties";5import { DotComparison, SeatDistribution, SeatWaffle } from "./charts";6import { PartyBadge, PartyDot, PartyLogo } from "./party";7import { Badge, Card, Dual, Metric, MetricDelta, ProbabilityBar, SectionHeader, SourceBadge } from "./ui";89// ------------------------------------------------------- SEAT PROJECTION10export function SeatProjectionTable({ f, className }: { f: Forecast; className?: string }) {11  const rows = sortParties(f.parties, (v) => v.seatsMean);12  const maj = f.summary.majority;13  return (14    <div className={className}>15      {/* mobile : une carte par parti */}16      <div className="md:hidden space-y-2">17        {rows.map(([id, v]) => (18          <Link key={id} href={`/partis/${id}`} className="block inset p-3" style={{ borderLeft: `4px solid ${partyVar(id)}` }}>19            <div className="flex items-center justify-between gap-3">20              <div className="flex items-center gap-2 min-w-0"><PartyLogo id={id} height={16} /><span className="font-semibold text-[14px]">{partyLabel(id)}</span></div>21              <div className="flex items-baseline gap-2"><span className="figure text-[30px]" style={{ color: partyVar(id) }}>{v.seatsMedian}</span><span className="mono text-[12px] text-ink-2">{v.seats80[0]}–{v.seats80[1]}</span></div>22            </div>23            <div className="grid grid-cols-3 gap-2 mt-2 text-[12px]">24              <div><div className="text-ink-3 text-[10.5px] mono uppercase tracking-[0.06em]">95 %</div><div className="mono font-semibold">{v.seats95[0]}–{v.seats95[1]}</div></div>25              <div><div className="text-ink-3 text-[10.5px] mono uppercase tracking-[0.06em]">Plus de sièges</div><div className="mono font-semibold">{prob(v.pMostSeats)}</div></div>26              <div><div className="text-ink-3 text-[10.5px] mono uppercase tracking-[0.06em]">Majorité</div><div className="mono font-semibold">{prob(v.pMajority)}</div></div>27            </div>28            {v.seatDistribution && <div className="mt-2 -mb-1"><SeatDistribution dist={v.seatDistribution} color={partyVar(id)} majority={maj} median={v.seatsMedian} lo80={v.seats80[0]} hi80={v.seats80[1]} lo95={v.seats95[0]} hi95={v.seats95[1]} height={56} total={f.summary.seats_total} width={320} /></div>}29          </Link>30        ))}31      </div>32      <div className="hidden md:block overflow-x-auto">33        <table className="data-table min-w-[640px]">34          <thead>35            <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>36          </thead>37          <tbody>38            {rows.map(([id, v]) => (39              <tr key={id}>40                <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>41                <td className="r figure text-[22px]" style={{ color: partyVar(id) }}>{v.seatsMedian}</td>42                <td className="r num mono">{v.seats80[0]}–{v.seats80[1]}</td>43                <td className="r num mono text-ink-2">{v.seats95[0]}–{v.seats95[1]}</td>44                <td className="r num font-semibold">{prob(v.pMostSeats)}</td>45                <td className="r num font-semibold">{prob(v.pMajority)}</td>46                <td>{v.seatDistribution && <SeatDistribution dist={v.seatDistribution} color={partyVar(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>47              </tr>48            ))}49          </tbody>50        </table>51      </div>52      <p className="text-[12px] text-ink-3 mt-2">Médiane pondérée des {int(f.nSimulations)} simulations ; intervalles = percentiles 10–90 et 2,5–97,5. « Autres » n&apos;est jamais déclaré gagnant.</p>53    </div>54  );55}5657export function PopularVoteTable({ f, className }: { f: Forecast; className?: string }) {58  const rows = sortParties(f.parties, (v) => v.voteMean, true);59  const ch = f.diff?.changes;60  return (61    <div className={cls("space-y-2.5", className)}>62      {rows.map(([id, v]) => (63        <div key={id} className="grid grid-cols-[44px_1fr_auto] sm:grid-cols-[52px_1fr_auto] items-center gap-2 sm:gap-3">64          <span className="font-semibold num text-[13px] inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(id) }} />{partyLabel(id)}</span>65          <div className="relative h-[14px] bg-surface-3 rounded-[4px] overflow-hidden">66            <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: partyAlpha(id, 0.15) }} />67            <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: partyAlpha(id, 0.35) }} />68            <div className="absolute inset-y-0 w-[2px] bg-ink" style={{ left: `${(v.voteMean / 50) * 100}%` }} />69          </div>70          <div className="flex items-baseline gap-1.5 num">71            <span className="font-bold text-[15px] w-[46px] text-right mono">{v.voteMean.toFixed(1).replace(".", ",")}</span>72            {ch?.[id] ? <MetricDelta value={ch[id].vote} /> : <span className="w-[54px]" />}73          </div>74        </div>75      ))}76      <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>77    </div>78  );79}8081export function MajorityPanel({ f }: { f: Forecast }) {82  const rows = sortParties(f.parties, (v) => v.pMajority).filter(([, v]) => v.pMostSeats > 0.005 || v.pMajority > 0.005);83  return (84    <div className="space-y-3">85      {rows.map(([id, v]) => (86        <div key={id}>87          <div className="flex items-baseline justify-between mb-1 text-[13px] gap-2"><span className="font-semibold inline-flex items-center gap-1.5 min-w-0"><PartyDot id={id} /><span className="truncate">{partyName(id)}</span></span><span className="text-ink-2 shrink-0 text-[12.5px]">plus de sièges {prob(v.pMostSeats)}</span></div>88          <ProbabilityBar p={v.pMajority} color={partyVar(id)} label={`majorité ${partyLabel(id)}`} height={12} />89        </div>90      ))}91      <div>92        <div className="flex items-baseline justify-between mb-1 text-[13px]"><span className="font-semibold">Aucune majorité (gouvernement minoritaire)</span></div>93        <ProbabilityBar p={f.summary.p_minority} color="var(--text)" label="minoritaire" height={12} />94      </div>95    </div>96  );97}9899// ------------------------------------------------------------ WHAT CHANGED100export function WhatChanged({ f, ridingNames }: { f: Forecast; ridingNames?: Record<number, { name: string; slug: string }> }) {101  const d = f.diff;102  if (!d) return <p className="text-ink-2 text-[14px]">Premier run publié : pas de comparaison disponible.</p>;103  const leader = f.summary.leader;104  const ch = d.changes[leader];105  const biggest = Object.entries(d.changes).sort((a, b) => Math.abs(b[1].p_majority) - Math.abs(a[1].p_majority))[0];106  const changes = Object.entries(d.changes).filter(([id]) => id !== "aut").sort((a, b) => Math.abs(b[1].seats) - Math.abs(a[1].seats));107  return (108    <div className="grid md:grid-cols-[1fr_1.2fr] gap-6 md:gap-8">109      <div>110        <div className="eyebrow">Majorité {partyLabel(leader)}</div>111        <div className="figure text-[38px] md:text-[44px] mt-2">112          <span className="text-ink-3">{prob(f.parties[leader].pMajority - (ch?.p_majority ?? 0))}</span> <span className="text-ink-3 text-[22px] serif">→</span> {prob(f.parties[leader].pMajority)}113        </div>114        <div className="mt-4 text-[13.5px]">115          <div className="eyebrow mb-1.5">Causes identifiées</div>116          <ul className="space-y-1">117            {d.causes.map((cause, i) => (118              <li key={i}>119                {cause.kind === "new_polls" ? <span>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>)}</span>120                  : cause.kind === "markets" ? <span>Marchés de prédiction : {Object.entries(cause.detail as unknown as Record<string, number>).map(([p, v]) => `${partyLabel(p)} ${delta(v * 100, 0, " pt")}`).join(" · ")}</span>121                  : <span className="text-ink-2">{String(cause.detail ?? "—")}</span>}122              </li>123            ))}124          </ul>125        </div>126        <div className="text-[12px] text-ink-3 mt-4">Comparé au run précédent{d.previous_at ? ` (${dateTimeFr(d.previous_at)})` : ""}. Les causes listées sont les seules différences d&apos;entrée entre les deux runs ; QC26 n&apos;infère pas de causalité au-delà.</div>127      </div>128      <div>129        <div className="eyebrow mb-2">Variations par parti</div>130        <div className="md:hidden divide-y divide-border">131          {changes.map(([id, c]) => (132            <div key={id} className="py-2 flex items-center justify-between gap-2">133              <PartyBadge id={id} size="sm" />134              <div className="flex flex-wrap justify-end gap-1 text-[11px]"><span className="text-ink-3 mr-0.5">vote</span><MetricDelta value={c.vote} /><span className="text-ink-3 ml-1 mr-0.5">sièges</span><MetricDelta value={c.seats} digits={0} suffix="" /><span className="text-ink-3 ml-1 mr-0.5">maj.</span><MetricDelta value={c.p_majority * 100} digits={0} suffix=" pt" /></div>135            </div>136          ))}137        </div>138        <table className="data-table hidden md: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>139          <tbody>140            {changes.map(([id, c]) => (141              <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>142            ))}143          </tbody></table>144        {d.flips.length > 0 && (145          <div className="mt-4 text-[13px]">146            <div className="eyebrow mb-1.5">Circonscriptions qui changent de favori</div>147            <ul className="space-y-1">148              {d.flips.slice(0, 8).map((fl) => (149                <li key={fl.code} className="flex items-center gap-2 flex-wrap"><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>150              ))}151            </ul>152          </div>153        )}154        {biggest && biggest[0] !== leader && <div className="text-[12.5px] text-ink-2 mt-3">Autre mouvement notable : {partyLabel(biggest[0])} P(majorité) {delta(biggest[1].p_majority * 100, 0, " pt")}.</div>}155      </div>156    </div>157  );158}159160// ------------------------------------------------------------- LATEST POLLS161export function PollRowMobile({ p, showSource = true }: { p: Poll; showSource?: boolean }) {162  const lead = Object.entries(p.results).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1])[0]?.[0];163  return (164    <div className="poll-row">165      <div className="min-w-0">166        <Link href={`/sondages/${p.id}`} className="font-semibold text-[14px] hover:underline">{p.pollster.name}</Link>167        {p.sponsor && <span className="text-ink-3 text-[12px]"> · {p.sponsor}</span>}168        {!p.validated && <span className="ml-1.5 text-[10px] uppercase tracking-wide text-warn font-semibold mono" title="Document original en cours de vérification">index</span>}169        {p.excluded && <span className="ml-1.5 text-[10px] uppercase tracking-wide text-live font-semibold mono">exclu</span>}170      </div>171      <div className="text-right text-[12px] text-ink-2 num whitespace-nowrap">{p.fieldworkStart ? `${dateShort(p.fieldworkStart)} – ` : ""}{dateShort(p.fieldworkEnd)}<div className="text-ink-3">n = {int(p.sampleSize)}</div></div>172      <div className="poll-vals">173        {PARTY_IDS.map((id) => (174          <div key={id} style={{ borderTop: `3px solid ${partyVar(id)}` }}>175            <div className="lbl text-ink-2">{partyLabel(id)}</div>176            <div className={cls("val", id === lead ? "text-ink" : "text-ink-2")}>{p.results[id] !== undefined ? p.results[id].toFixed(p.results[id] % 1 ? 1 : 0).replace(".", ",") : "—"}</div>177          </div>178        ))}179      </div>180      {showSource && (181        <div className="col-span-2 flex items-center justify-between text-[11px] text-ink-3 mt-1">182          <span>{p.sourceTier === "primary" ? "PDF archivé" : "index tiers, document en attente"}{p.weight ? ` · poids ${p.weight.weight.toFixed(2)}` : ""}</span>183          <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 }} />184        </div>185      )}186    </div>187  );188}189190export function LatestPolls({ polls, limit = 6, compact = false }: { polls: Poll[]; limit?: number; compact?: boolean }) {191  const rows = polls.slice(0, limit);192  return (193    <>194      <div className="md:hidden">{rows.map((p) => <PollRowMobile key={p.id} p={p} showSource={false} />)}</div>195      <div className="hidden md:block overflow-x-auto">196        <table className="data-table min-w-[560px]">197          <thead><tr><th>Firme</th><th>Terrain</th><th className="r">n</th>{PARTY_IDS.map((id) => <th key={id} className="r"><span className="inline-flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full" style={{ background: partyVar(id) }} />{partyLabel(id)}</span></th>)}{!compact && <th className="r">Poids</th>}<th></th></tr></thead>198          <tbody>199            {rows.map((p) => {200              const lead = Object.entries(p.results).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1])[0]?.[0];201              return (202                <tr key={p.id}>203                  <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 mono" title="Document original en cours de vérification">index</span>}</td>204                  <td className="num whitespace-nowrap">{p.fieldworkStart ? `${dateShort(p.fieldworkStart)} – ` : ""}{dateShort(p.fieldworkEnd)}</td>205                  <td className="r num">{int(p.sampleSize)}</td>206                  {PARTY_IDS.map((id) => <td key={id} className={cls("r num mono", id === lead && "font-bold")}>{p.results[id]?.toFixed(0) ?? "—"}</td>)}207                  {!compact && <td className="r num text-ink-2 mono">{p.weight ? p.weight.weight.toFixed(2) : "—"}</td>}208                  <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>209                </tr>210              );211            })}212          </tbody>213        </table>214      </div>215    </>216  );217}218219// ------------------------------------------------------------ BATTLEGROUNDS220export function Battlegrounds({ ridings, codes, limit = 10 }: { ridings: RidingListItem[]; codes: number[]; limit?: number }) {221  const by = new Map(ridings.map((r) => [r.code, r]));222  const rows = codes.map((c) => by.get(c)).filter((r): r is RidingListItem => !!r && !!r.forecast).slice(0, limit);223  return (224    <div className="rail sm:grid-cols-2 lg:grid-cols-5 sm:gap-3">225      {rows.map((r) => (226        <Link key={r.code} href={`/circonscription/${r.slug}`} className="card card-hover p-4 block" style={{ borderTop: `3px solid ${partyVar(r.forecast!.favorite)}` }}>227          <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>228          <div className="text-[11.5px] text-ink-3 mt-0.5">{r.regionName}</div>229          <div className="mt-3 space-y-1">230            {Object.entries(r.forecast!.pWin).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1]).slice(0, 3).map(([id, p]) => (231              <div key={id} className="flex items-center gap-2 text-[12.5px]"><span className="w-8 font-semibold num" style={{ color: partyVar(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: partyVar(id) }} /></div><span className="num w-10 text-right mono">{prob(p)}</span></div>232            ))}233          </div>234        </Link>235      ))}236    </div>237  );238}239240// ------------------------------------------------------------ REGIONAL GRID241export function RegionalGrid({ regions }: { regions: { id: string; name: string; ridings: number; favorites: Record<string, number>; voteNow: Record<string, number>; vote2022: Record<string, number> }[] }) {242  return (243    <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-2 sm:gap-3">244      {regions.map((g) => {245        const favs = Object.entries(g.favorites).sort((a, b) => b[1] - a[1]);246        const top = Object.entries(g.voteNow).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1]).slice(0, 4);247        return (248          <div key={g.id} className="card p-3.5 sm:p-4">249            <div className="flex items-baseline justify-between"><div className="font-semibold text-[14px]">{g.name}</div><div className="text-[12px] text-ink-3 num mono">{g.ridings} circ.</div></div>250            <div className="flex gap-[2px] 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: partyVar(id) }} title={`${partyLabel(id)} ${n}`} />)}</div>251            <div className="flex flex-wrap gap-x-3 mt-1.5 text-[12px] num">{favs.map(([id, n]) => <span key={id}><b style={{ color: partyVar(id) }}>{partyLabel(id)}</b> {n}</span>)}</div>252            <div className="mt-3 space-y-1 hidden sm:block">253              {top.map(([id, v]) => (254                <div key={id} className="grid grid-cols-[34px_1fr_44px_54px] items-center gap-2 text-[12px]"><span className="font-semibold num" style={{ color: partyVar(id) }}>{partyLabel(id)}</span><div className="h-[5px] rounded-full bg-surface-3 overflow-hidden"><div className="h-full" style={{ width: `${v * 2}%`, background: partyVar(id) }} /></div><span className="num text-right font-semibold mono">{v.toFixed(0)} %</span><span className="num text-right text-ink-3 mono">{delta(v - (g.vote2022[id] ?? 0), 0, " pt")}</span></div>255              ))}256            </div>257            <div className="mt-2 text-[12px] text-ink-2 sm:hidden">{top.slice(0, 3).map(([id, v]) => `${partyLabel(id)} ${v.toFixed(0)} % (${delta(v - (g.vote2022[id] ?? 0), 0)})`).join(" · ")}</div>258            <div className="text-[11px] text-ink-3 mt-2 hidden sm:block">Vote projeté (moyenne des circonscriptions) · variation vs 2022 transposé</div>259          </div>260        );261      })}262    </div>263  );264}265266// ------------------------------------------------------------------- HERO267export function HeroForecast({ f, meta }: { f: Forecast; meta: { seatsTotal: number; majority: number; daysToElection: number } }) {268  const leader = f.summary.leader;269  const lp = f.parties[leader];270  const rows = sortParties(f.parties, (v) => v.seatsMean);271  const ch = f.diff?.changes?.[leader];272  const seats = Object.fromEntries(Object.entries(f.parties).map(([k, v]) => [k, v.seatsMedian]));273  const comp = f.summary.components;274  const pollsOnly = comp?.pollsOnly?.parties?.[leader];275  const nSources = comp ? [comp.fundamentals?.enabled, comp.momentum?.enabled, comp.markets?.enabled, comp.byelections?.enabled].filter(Boolean).length : 0;276  return (277    <section className="relative overflow-hidden rounded-[var(--radius-lg)] border border-border card hero-glow p-5 md:p-10">278      <div className="grid lg:grid-cols-[1.05fr_1fr] gap-8 lg:gap-10 items-start">279        <div className="fade-up">280          <div className="flex items-center gap-2 flex-wrap">281            <span className="eyebrow eyebrow-accent">Prévision QC26</span>282            <span className="text-ink-3 text-[11px] mono">·</span>283            <span className="eyebrow">Modèle {f.modelVersion} · run #{f.id}</span>284          </div>285          <h1 className="display text-[34px] sm:text-[40px] md:text-[54px] mt-4 max-w-[14ch]">286            Le <span style={{ color: partyVar(leader) }}>{partyLabel(leader)}</span> favori, {lp.pMajority >= 0.5 ? "une majorité plus probable qu'improbable" : lp.pMajority >= 0.3 ? "la majorité en jeu" : "un gouvernement minoritaire probable"}.287          </h1>288          <div className="mt-5 sm:mt-6 flex flex-col sm:flex-row sm:items-end gap-5 sm:gap-8">289            <div>290              <div className="flex items-center gap-2 text-[13px] text-ink-2"><PartyLogo id={leader} height={18} /> <span className="hidden sm:inline">{partyName(leader)}</span></div>291              <div className="figure text-[84px] sm:text-[110px] md:text-[136px] mt-1" style={{ color: partyVar(leader) }}>{lp.seatsMedian}</div>292              <div className="text-[13px] sm:text-[14px] text-ink-2 -mt-1">sièges sur {meta.seatsTotal}<br className="sm:hidden" /> <span className="hidden sm:inline">· </span>80 % : <span className="mono font-semibold text-ink">{lp.seats80[0]}–{lp.seats80[1]}</span></div>293            </div>294            <div className="grid grid-cols-2 gap-x-6 sm:gap-x-8 gap-y-4 sm:gap-y-5 mb-2 sm:self-end">295              <Metric label="Majorité" value={prob(lp.pMajority)} size="md" delta={ch ? <MetricDelta value={ch.p_majority * 100} digits={0} suffix=" pt" /> : undefined} sub={`seuil ${meta.majority}`} />296              <Metric label="Plus de sièges" value={prob(lp.pMostSeats)} size="md" delta={ch ? <MetricDelta value={ch.p_most_seats * 100} digits={0} suffix=" pt" /> : undefined} />297              <Metric label="Vote populaire" value={pct(lp.voteMean)} size="sm" delta={ch ? <MetricDelta value={ch.vote} /> : undefined} sub={`± ${lp.voteSd.toFixed(1).replace(".", ",")}`} />298              <Metric label="Minoritaire" value={prob(f.summary.p_minority)} size="sm" sub={`aucun à ${meta.majority}`} />299            </div>300          </div>301          <div className="mt-6 md:mt-8 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] md:text-[12.5px] text-ink-3">302            <span className="num">Mis à jour {dateTimeFr(f.completedAt)}</span>303            <span className="num">{f.pollCount} sondages · {int(f.nSimulations)} simulations{comp?.ess ? ` · ESS ${int(comp.ess)}` : ""}</span>304            {nSources > 0 && <Link href="/signaux" className="link">{nSources} source{nSources > 1 ? "s" : ""} au-delà des sondages →</Link>}305            <Link href="/methodologie" className="link">Méthodologie</Link>306          </div>307        </div>308        <div className="fade-up fade-up-2">309          <div className="flex items-center justify-between mb-3 gap-2">310            <div className="eyebrow">Assemblée nationale · médiane</div>311            <div className="text-[11px] mono text-ink-3 whitespace-nowrap">{meta.seatsTotal} sièges · maj. {meta.majority}</div>312          </div>313          <SeatWaffle seats={seats} total={meta.seatsTotal} majority={meta.majority} cols={16} />314          <div className="mt-5 space-y-3">315            {rows.map(([id, v]) => (316              <div key={id}>317                <div className="flex items-baseline justify-between text-[13px] mb-1 gap-2">318                  <span className="inline-flex items-center gap-2 min-w-0"><span className="w-2.5 h-2.5 rounded-[3px] shrink-0" style={{ background: partyVar(id) }} /><span className="font-semibold">{partyLabel(id)}</span><span className="text-ink-3 hidden sm:inline truncate">{party(id)?.officialName}</span></span>319                  <span className="num whitespace-nowrap"><b className="mono text-[16px]">{v.seatsMedian}</b> <span className="text-ink-3 mono text-[12px]">{v.seats80[0]}–{v.seats80[1]}</span>{pollsOnly && comp?.pollsOnly?.parties?.[id] && Math.abs(comp.pollsOnly.parties[id].seats - v.seatsMedian) >= 2 ? <span className="text-ink-3 text-[11px] ml-1.5" title="Sondages seuls">(sond. {comp.pollsOnly.parties[id].seats})</span> : null}</span>320                </div>321                <div className="relative h-[10px] rounded-[4px] bg-surface-3 overflow-hidden">322                  <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: partyAlpha(id, 0.18) }} />323                  <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: partyAlpha(id, 0.5) }} />324                  <div className="absolute inset-y-0 w-[3px] rounded-sm" style={{ left: `calc(${(v.seatsMedian / meta.seatsTotal) * 100}% - 1px)`, background: partyVar(id) }} />325                  <div className="absolute inset-y-0 w-[1.5px] bg-ink" style={{ left: `${(meta.majority / meta.seatsTotal) * 100}%` }} />326                </div>327              </div>328            ))}329          </div>330          <div className="flex justify-between text-[11px] text-ink-3 mono mt-2"><span>0</span><span className="font-bold text-ink">{meta.majority} · majorité</span><span>{meta.seatsTotal}</span></div>331        </div>332      </div>333    </section>334  );335}336337// ------------------------------------------------------ BEYOND POLLS (accueil / prévision)338export function ComponentsCompare({ f, compact = false }: { f: Forecast; compact?: boolean }) {339  const comp = f.summary.components;340  if (!comp) return null;341  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);342  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 } }));343  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 }];344  const leader = f.summary.leader;345  return (346    <div className={cls("grid gap-6", !compact && "lg:grid-cols-[1.2fr_1fr]")}>347      <div>348        <div className="eyebrow mb-2">Probabilité d&apos;obtenir le plus de sièges</div>349        <Dual narrow={<DotComparison rows={rows} series={series} width={340} />} wide={<DotComparison rows={rows} series={series} />} />350      </div>351      <div className="grid grid-cols-2 gap-2 sm:gap-3 content-start">352        <div className="inset p-3"><div className="text-[11px] text-ink-3">Majorité {partyLabel(leader)} · sondages seuls</div><div className="figure text-[26px] mt-1">{prob(comp.pollsOnly.parties[leader]?.p_majority)}</div></div>353        <div className="inset p-3"><div className="text-[11px] text-ink-3">Majorité {partyLabel(leader)} · marchés</div><div className="figure text-[26px] mt-1">{comp.markets.enabled && comp.markets.majorityMarket?.[leader] !== undefined ? prob(comp.markets.majorityMarket[leader]) : "—"}</div></div>354        <div className="inset p-3"><div className="text-[11px] text-ink-3">Majorité {partyLabel(leader)} · ensemble</div><div className="figure text-[26px] mt-1" style={{ color: partyVar(leader) }}>{prob(f.parties[leader].pMajority)}</div></div>355        <div className="inset p-3"><div className="text-[11px] text-ink-3">Poids des marchés</div><div className="figure text-[26px] mt-1">{comp.markets.enabled ? `${Math.round(comp.markets.weight * 100)} %` : "0 %"}</div></div>356        <div className="col-span-2 text-[12px] text-ink-3 leading-relaxed">357          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"}.358          {!compact && <> <Link href="/signaux" className="link">Voir tous les signaux →</Link></>}359        </div>360      </div>361    </div>362  );363}364365export { SectionHeader, Card };366