Python 51.6%
TypeScript 46.7%
CSS 1.7%
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'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'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'est intégré (σ industrie ≈ 2,2 pt).</li>104 <li>L'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