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%
10.4 KB · 113 lines tsx
Raw Blame History
1import type { Metadata } from "next";2import Link from "next/link";3import { apiSafe, type Forecast, type Meta, type RidingListItem } from "@/lib/api";4import { ComponentsCompare, MajorityPanel, PopularVoteTable, SeatProjectionTable, WhatChanged } from "@/components/forecast-blocks";5import { Legend, SeatDistribution, SeatMeter, SeatWaffle } from "@/components/charts";6import { TrendChartInteractive } from "@/components/trend-chart";7import { SignalsOverview } from "@/components/signals";8import { PartyLogo } from "@/components/party";9import { Badge, Card, Dual, Empty, KV, PageTitle, SectionHeader } from "@/components/ui";10import { ForecastHistory } from "@/components/forecast-history";11import { dateFr, dateTimeFr, int, prob } from "@/lib/format";12import { PARTY_IDS, partyLabel, partyVar, sortParties } from "@/lib/parties";1314export const metadata: Metadata = { title: "Prévision — sièges, vote populaire et probabilités", description: "Projection QC26 de l'élection québécoise 2026 : sièges par parti, intervalles, probabilité de majorité, composantes (sondages, marchés, fondamentaux, momentum), historique du modèle." };15export const revalidate = 60;1617export default async function Page() {18  const [meta, f, ridings] = await Promise.all([apiSafe<Meta>("/api/meta"), apiSafe<Forecast>("/api/forecast/latest"), apiSafe<{ ridings: RidingListItem[] }>("/api/ridings")]);19  if (!meta || !f) return <Empty title="Prévision indisponible" />;20  const seats = Object.fromEntries(Object.entries(f.parties).map(([k, v]) => [k, v.seatsMedian]));21  const ridingNames = Object.fromEntries((ridings?.ridings ?? []).map((r) => [r.code, { name: r.name, slug: r.slug }]));22  const rows = sortParties(f.parties, (v) => v.seatsMean);23  const comp = f.summary.components;24  const leader = f.summary.leader;25  return (26    <div className="space-y-10 md:space-y-14">27      <PageTitle eyebrow={`Prévision · modèle ${f.modelVersion} · run #${f.id}`} title="Prévision QC26"28        lede={<>Projection probabiliste de l&apos;Assemblée nationale au 5 octobre 2026, recalculée à chaque nouveau sondage et à chaque mise à jour des signaux. Données arrêtées au {dateFr(f.dataCutoff)} · {f.pollCount} sondages · {int(f.nSimulations)} simulations · mise à jour {dateTimeFr(f.completedAt)}.</>} />2930      <section className="grid lg:grid-cols-[1fr_1.2fr] gap-6 items-start [&>*]:min-w-0">31        <Card className="card-lg">32          <div className="eyebrow mb-3">Assemblée nationale · médiane</div>33          <SeatWaffle seats={seats} total={meta.seatsTotal} majority={meta.majority} cols={16} />34          <div className="mt-4 grid grid-cols-3 gap-3 text-center">35            {rows.slice(0, 3).map(([id, v]) => (<div key={id} className="inset p-2.5"><div className="text-[11px] text-ink-3">P(majorité)</div><div className="figure text-[24px]" style={{ color: partyVar(id) }}>{prob(v.pMajority)}</div><div className="text-[11px] font-semibold">{partyLabel(id)}</div></div>))}36          </div>37        </Card>38        <Card><SeatMeter seats={seats} total={meta.seatsTotal} majority={meta.majority} className="mb-6" /><SeatProjectionTable f={f} /></Card>39      </section>4041      {comp && (42        <section>43          <SectionHeader eyebrow="Composantes" title="Sondages seuls, marchés, ensemble" description="Le modèle 2.0 publie chaque composante. Les points creux sont la vue « sondages seuls » (v1) ; les losanges, les marchés de prédiction ; les points pleins, l'ensemble QC26." action={<Link href="/signaux" className="link text-[13.5px]">Détail des signaux →</Link>} />44          <SignalsOverview comp={comp} className="mb-4" />45          <Card><ComponentsCompare f={f} /></Card>46          <div className="grid md:grid-cols-3 gap-3 mt-4">47            {(["pollsOnly", "modelNoMarkets"] as const).map((k) => {48              const c = comp[k];49              return (50                <Card key={k}>51                  <div className="eyebrow mb-2">{k === "pollsOnly" ? "Sondages seuls" : "Modèle sans marchés"}</div>52                  <div className="space-y-1.5 text-[13px]">{PARTY_IDS.map((id) => <div key={id} className="flex items-center justify-between"><span className="font-semibold inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(id) }} />{partyLabel(id)}</span><span className="mono"><b>{c.parties[id]?.seats}</b> <span className="text-ink-3">{c.parties[id]?.seats80?.join("–")}</span> · {prob(c.parties[id]?.p_majority)}</span></div>)}</div>53                  <div className="text-[11.5px] text-ink-3 mt-2">minoritaire {prob(c.p_minority)}</div>54                </Card>55              );56            })}57            <Card>58              <div className="eyebrow mb-2">Ensemble QC26</div>59              <div className="space-y-1.5 text-[13px]">{PARTY_IDS.map((id) => <div key={id} className="flex items-center justify-between"><span className="font-semibold inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(id) }} />{partyLabel(id)}</span><span className="mono"><b>{f.parties[id]?.seatsMedian}</b> <span className="text-ink-3">{f.parties[id]?.seats80.join("–")}</span> · {prob(f.parties[id]?.pMajority)}</span></div>)}</div>60              <div className="text-[11.5px] text-ink-3 mt-2">minoritaire {prob(f.summary.p_minority)} · ESS {int(comp.ess)} / {int(comp.nSimulations)}</div>61            </Card>62          </div>63        </section>64      )}6566      <section>67        <SectionHeader eyebrow="Distributions" title="Incertitude sur les sièges" description={`Histogrammes pondérés des simulations. La ligne marque ${meta.majority} sièges ; les bandes, les intervalles 80 % et 95 %.`} />68        <div className="grid md:grid-cols-2 gap-4">69          {rows.map(([id, v]) => (70            <Card key={id}>71              <div className="flex items-center justify-between mb-2 flex-wrap gap-1"><span className="inline-flex items-center gap-2"><PartyLogo id={id} height={18} /><b>{partyLabel(id)}</b></span><span className="num text-[12px] sm:text-[13px] text-ink-2">médiane <b className="text-ink mono">{v.seatsMedian}</b> · 80 % <span className="mono">{v.seats80[0]}–{v.seats80[1]}</span> · 95 % <span className="mono">{v.seats95[0]}–{v.seats95[1]}</span></span></div>72              {v.seatDistribution && <Dual narrow={<SeatDistribution dist={v.seatDistribution} color={partyVar(id)} majority={meta.majority} median={v.seatsMedian} lo80={v.seats80[0]} hi80={v.seats80[1]} lo95={v.seats95[0]} hi95={v.seats95[1]} height={100} total={meta.seatsTotal} width={340} />} wide={<SeatDistribution dist={v.seatDistribution} color={partyVar(id)} majority={meta.majority} median={v.seatsMedian} lo80={v.seats80[0]} hi80={v.seats80[1]} lo95={v.seats95[0]} hi95={v.seats95[1]} height={110} total={meta.seatsTotal} />} />}73              <div className="grid grid-cols-3 gap-2 mt-2 text-center text-[12.5px]"><div className="inset p-2"><div className="text-ink-3">Majorité</div><b className="num">{prob(v.pMajority)}</b></div><div className="inset p-2"><div className="text-ink-3">Pluralité</div><b className="num">{prob(v.pMostSeats)}</b></div><div className="inset p-2"><div className="text-ink-3">Vote</div><b className="num">{v.voteMean.toFixed(1).replace(".", ",")} % ± {v.voteSd.toFixed(1).replace(".", ",")}</b></div></div>74            </Card>75          ))}76        </div>77      </section>78      <section className="grid lg:grid-cols-2 gap-6">79        <div><SectionHeader eyebrow="Vote populaire" title="Intentions de vote au jour du scrutin" /><Card><PopularVoteTable f={f} /></Card></div>80        <div><SectionHeader eyebrow="Majorité" title="Probabilités" /><Card><MajorityPanel f={f} /></Card></div>81      </section>82      <section>83        <SectionHeader eyebrow="Tendance" title="État latent du vote depuis 2022" description="Moyenne QC26 (filtre de Kalman, effets maison corrigés) avec bande à 80 %. Les traits à gauche marquent le résultat de 2022. Survolez pour lire une date." />84        <Card>{f.trend && <TrendChartInteractive trend={f.trend} height={320} election2022={{ caq: 40.98, plq: 14.37, qs: 15.43, pq: 14.61, pcq: 12.91 }} markers={[{ date: meta.campaignStart, label: "Déclenchement" }]} />}<Legend ids={PARTY_IDS} className="mt-2" /></Card>85      </section>86      <section>87        <SectionHeader eyebrow="Historique" title="Revoir QC26 à n'importe quelle date" description="Chaque run publié est conservé avec ses paramètres. Déplacez le curseur pour revoir la projection d'un jour donné." />88        <Card><ForecastHistory majority={meta.majority} total={meta.seatsTotal} /></Card>89      </section>90      <section>91        <SectionHeader eyebrow="Qu'est-ce qui a changé ?" title="Depuis le run précédent" />92        <Card><WhatChanged f={f} ridingNames={ridingNames} /></Card>93      </section>94      <section className="grid md:grid-cols-2 gap-6">95        <Card>96          <div className="eyebrow mb-3">Fiche du run</div>97          <KV items={[["Run", `#${f.id}`], ["Modèle", f.modelVersion], ["Déclencheur", f.trigger ?? "—"], ["Données arrêtées", dateFr(f.dataCutoff)], ["Terminé", dateTimeFr(f.completedAt)], ["Sondages", String(f.pollCount)], ["Simulations", int(f.nSimulations)], ["ESS (repondération)", comp ? int(comp.ess) : "—"], ["Graine", String(f.seed)], ["Commit", f.codeCommit ?? "—"]]} />98        </Card>99        <Card>100          <div className="eyebrow mb-3">Lire la prévision</div>101          <ul className="text-[13.5px] text-ink-2 space-y-2 leading-relaxed list-disc pl-4">102            <li>Une projection n&apos;est pas une prédiction : un parti à 70 % de chances de majorité perd cette majorité dans 3 simulations sur 10.</li>103            <li>Les intervalles 80 % couvrent le résultat 8 fois sur 10 si le modèle est bien calibré ; les erreurs des sondages sont corrélées et c&apos;est intégré (σ industrie ≈ 2,2 pt).</li>104            <li>L&apos;ensemble QC26 ({partyLabel(leader)} favori) tempère les sondages par les marchés, les fondamentaux et le momentum ; la vue « sondages seuls » reste publiée ci-dessus pour comparaison.</li>105            <li>Chaque chiffre est audité : <Link href="/sondages" className="link">sondages</Link>, <Link href="/signaux" className="link">signaux</Link>, <Link href="/methodologie" className="link">méthodologie</Link>, <Link href="/methodologie/changelog" className="link">changelog</Link>.</li>106          </ul>107          <div className="mt-3"><Badge tone="accent">Projection QC26</Badge></div>108        </Card>109      </section>110    </div>111  );112}113