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%

Web : refonte mobile-first — cartes à la place des tableaux, graphiques dimensionnés à l'écran, carte avec encarts sous la carte, popovers en feuille basse, logos blancs en thème sombre

- SeatProjectionTable, LatestPolls/PollsTable, WhatChanged, effets maison, marchés : variantes mobiles en cartes/rangées compactes (poll-row)
- TrendChartInteractive : largeur mesurée (ResizeObserver), valeurs sous le graphique sur écran étroit, Dual pour DotComparison/SeatDistribution/AttentionChart
- QuebecMap : encarts Montréal–Laval et Québec–Lévis rendus sous la carte sur mobile, carte plus haute, boutons plus grands, infobulle en bas
- Battlegrounds en rail à défilement magnétique, RegionalGrid compacte, héros empilé, deltas insécables, SourceBadge en feuille basse
- débordements horizontaux corrigés (carte, prévision, statut, sondage, comparateur colonne fixe)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 17 days ago (Sep 7, 2026) parent 83e7931

21 changed files +359 −160

modified web/src/app/carte/page.tsx +2 −2
@@ -12,12 +12,12 @@ export default async function Page() {
12 12 return (
13 13 <div className="space-y-6">
14 14 <div>
15 − <div className="eyebrow">Carte · 127 circonscriptions · limites officielles 2026</div>
15 + <div className="eyebrow eyebrow-accent">Carte · 127 circonscriptions · limites officielles 2026</div>
16 16 <h1 className="display text-[40px] md:text-[56px] mt-3">Carte électorale</h1>
17 17 <p className="text-ink-2 mt-3 max-w-2xl text-[15px] leading-relaxed">Géométrie officielle d&apos;Élections Québec (simplifiée pour le web). Avant le scrutin, aucune circonscription n&apos;est « gagnée » : l&apos;opacité traduit la probabilité. Molette ou pincement pour zoomer ; cliquez pour ouvrir la fiche.</p>
18 18 </div>
19 19 <Card pad={false} className="p-3 md:p-4"><MapSection height={680} live={live} /></Card>
20 − <p className="text-[12px] text-ink-3">Source de la géométrie : donnees.electionsquebec.qc.ca/autres/provincial/circonscriptions_electorales_sans_eau_2026.json. Encarts : Montréal–Laval et Québec–Lévis.</p>
20 + <p className="text-[12px] text-ink-3 break-words">Source de la géométrie : <span className="break-all">donnees.electionsquebec.qc.ca/autres/provincial/circonscriptions_electorales_sans_eau_2026.json</span>. Encarts : Montréal–Laval et Québec–Lévis.</p>
21 21 </div>
22 22 );
23 23 }
modified web/src/app/documents/page.tsx +1 −1
@@ -28,7 +28,7 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ p
28 28 </div>
29 29 <Card pad={false} className="overflow-x-auto">
30 30 <table className="data-table min-w-[820px]"><thead><tr><th>Document</th><th>Parti</th><th>Type</th><th className="r">Pages</th><th className="r">Propositions</th><th className="r">Versions</th><th>Dernière analyse</th></tr></thead>
31 − <tbody>{data.documents.map((d) => (<tr key={d.id}><td><Link href={`/documents/${d.id}`} className="font-medium hover:underline">{d.title}</Link><div className="text-[11.5px] text-ink-3 truncate max-w-[420px]">{d.url}</div></td><td><PartyBadge id={d.party} size="sm" /></td><td><Badge>{d.type.replace("_", " ")}</Badge>{d.isPdf && <span className="ml-1 text-[10.5px] text-ink-3">PDF</span>}</td><td className="r num">{d.pages ?? "—"}</td><td className="r num font-semibold">{d.proposals}</td><td className="r num">{d.versions}</td><td className="num text-ink-2 whitespace-nowrap">{d.analyzedAt ? dateTimeFr(d.analyzedAt) : <span className="text-ink-3">en file</span>}</td></tr>))}
31 + <tbody>{data.documents.map((d) => (<tr key={d.id}><td><Link href={`/documents/${d.id}`} className="font-medium hover:underline">{d.title}</Link><div className="text-[11.5px] text-ink-3 truncate max-w-[60vw] md:max-w-[420px]">{d.url.replace(/^https?:\/\//, "")}</div></td><td><PartyBadge id={d.party} size="sm" /></td><td><Badge>{d.type.replace("_", " ")}</Badge>{d.isPdf && <span className="ml-1 text-[10.5px] text-ink-3">PDF</span>}</td><td className="r num">{d.pages ?? "—"}</td><td className="r num font-semibold">{d.proposals}</td><td className="r num">{d.versions}</td><td className="num text-ink-2 whitespace-nowrap">{d.analyzedAt ? dateTimeFr(d.analyzedAt) : <span className="text-ink-3">en file</span>}</td></tr>))}
32 32 {data.documents.length === 0 && <tr><td colSpan={7} className="text-center text-ink-3 py-8">Aucun document pour ce filtre.</td></tr>}</tbody></table>
33 33 </Card>
34 34 </div>
modified web/src/app/globals.css +20 −0
@@ -226,6 +226,26 @@ input[type="checkbox"] { accent-color: var(--qc26-accent); }
226 226 @keyframes grow-x { from { transform: scaleX(0); } to { transform: scaleX(1); } }
227 227 .grow-x { transform-origin: left; animation: grow-x 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both; }
228 228
229 +/* mobile : rails horizontaux à défilement magnétique */
230 +.rail { display: flex; gap: 12px; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; padding-bottom: 6px; margin-inline: -16px; padding-inline: 16px; scrollbar-width: none; }
231 +.rail::-webkit-scrollbar { display: none; }
232 +.rail > * { scroll-snap-align: start; flex: 0 0 78%; max-width: 320px; }
233 +@media (min-width: 640px) { .rail { display: grid; overflow: visible; margin-inline: 0; padding-inline: 0; } .rail > * { flex: initial; max-width: none; } }
234 +/* mobile : rangée de sondage compacte */
235 +.poll-row { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; padding: 10px 14px; border-bottom: 1px solid var(--border); }
236 +.poll-row:last-child { border-bottom: 0; }
237 +.poll-vals { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; grid-column: 1 / -1; margin-top: 4px; }
238 +.poll-vals > div { text-align: center; border-radius: 6px; padding: 4px 0 3px; background: var(--surface-2); }
239 +.poll-vals .lbl { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.06em; text-transform: uppercase; }
240 +.poll-vals .val { font-family: var(--font-mono); font-size: 14px; font-weight: 600; line-height: 1.1; }
241 +@media (max-width: 767px) {
242 + .data-table { font-size: 13px; }
243 + .data-table th, .data-table td { padding: 8px 10px; }
244 + .figure-hero { font-size: 92px !important; }
245 + .card { border-radius: 12px; }
246 + .section-gap > * + * { margin-top: 44px; }
247 +}
248 +
229 249 /* charts */
230 250 .chart-grid line { stroke: var(--grid); }
231 251 .chart-tip { position: absolute; pointer-events: none; z-index: 20; background: var(--surface); border: 1px solid var(--border-strong); border-radius: 10px; box-shadow: var(--shadow-md); padding: 8px 10px; font-size: 12px; line-height: 1.35; min-width: 150px; }
modified web/src/app/page.tsx +1 −1
@@ -33,7 +33,7 @@ export default async function Home() {
33 33 const comp = f.summary.components;
34 34
35 35 return (
36 − <div className="space-y-16">
36 + <div className="space-y-12 md:space-y-16">
37 37 <HeroForecast f={f} meta={meta} />
38 38
39 39 {comp && (
modified web/src/app/partis/[id]/page.tsx +9 −9
@@ -4,7 +4,7 @@ import { notFound } from "next/navigation";
4 4 import { apiSafe, type Meta, type PartyForecast } from "@/lib/api";
5 5 import { SeatDistribution } from "@/components/charts";
6 6 import { PartyLogo } from "@/components/party";
7 −import { Badge, Card, Empty, KV, SectionHeader } from "@/components/ui";
7 +import { Badge, Card, Dual, Empty, KV, SectionHeader } from "@/components/ui";
8 8 import { dateFr, int, pct, prob } from "@/lib/format";
9 9 import { PARTIES, partyLabel } from "@/lib/parties";
10 10
@@ -34,20 +34,20 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
34 34 <div className="flex items-start justify-between gap-4 flex-wrap">
35 35 <div><Link href="/partis" className="text-[12.5px] text-ink-3 hover:text-ink">← Partis</Link><div className="mt-3"><PartyLogo id={d.id} height={40} /></div><h1 className="display text-[34px] md:text-[48px] mt-3">{d.officialName}</h1><div className="text-[14px] text-ink-2 mt-2">Chef·fe (registre DGEQ) : <b className="text-ink">{d.leader ?? "—"}</b>{d.leaderSince ? ` · depuis le ${dateFr(d.leaderSince)}` : ""}{d.dgeqName && d.dgeqName !== d.officialName ? <span className="text-ink-3"> · nom au registre : {d.dgeqName}</span> : null}</div><a href={d.website} target="_blank" rel="noopener noreferrer" className="link text-[13px] mt-1 inline-block">{d.website.replace(/^https?:\/\//, "")}</a></div>
36 36 {f && (
37 − <div className="grid grid-cols-2 sm:grid-cols-4 gap-x-8 gap-y-3">
38 − <div><div className="eyebrow">Sièges</div><div className="num display text-[44px]" style={{ color: d.color }}>{f.seatsMedian}</div><div className="text-[12px] text-ink-3 num">80 % {f.seats80[0]}–{f.seats80[1]}</div></div>
39 − <div><div className="eyebrow">Vote</div><div className="num display text-[44px]">{f.voteMean.toFixed(1).replace(".", ",")}</div><div className="text-[12px] text-ink-3 num">± {f.voteSd.toFixed(1).replace(".", ",")} · 2022 : {pct(d.vote2022)}</div></div>
40 − <div><div className="eyebrow">Majorité</div><div className="num display text-[44px]">{prob(f.pMajority)}</div><div className="text-[12px] text-ink-3">seuil {meta.majority}</div></div>
41 − <div><div className="eyebrow">Plus de sièges</div><div className="num display text-[44px]">{prob(f.pMostSeats)}</div><div className="text-[12px] text-ink-3 num">2022 : {d.seats2022} sièges</div></div>
37 + <div className="grid grid-cols-2 sm:grid-cols-4 gap-x-6 sm:gap-x-8 gap-y-3 w-full sm:w-auto">
38 + <div><div className="eyebrow">Sièges</div><div className="figure text-[38px] sm:text-[44px]" style={{ color: d.color }}>{f.seatsMedian}</div><div className="text-[12px] text-ink-3 num">80 % {f.seats80[0]}–{f.seats80[1]}</div></div>
39 + <div><div className="eyebrow">Vote</div><div className="figure text-[38px] sm:text-[44px]">{f.voteMean.toFixed(1).replace(".", ",")}</div><div className="text-[12px] text-ink-3 num">± {f.voteSd.toFixed(1).replace(".", ",")} · 2022 : {pct(d.vote2022)}</div></div>
40 + <div><div className="eyebrow">Majorité</div><div className="figure text-[38px] sm:text-[44px]">{prob(f.pMajority)}</div><div className="text-[12px] text-ink-3">seuil {meta.majority}</div></div>
41 + <div><div className="eyebrow">Plus de sièges</div><div className="figure text-[38px] sm:text-[44px]">{prob(f.pMostSeats)}</div><div className="text-[12px] text-ink-3 num">2022 : {d.seats2022} sièges</div></div>
42 42 </div>
43 43 )}
44 44 </div>
45 − {f?.seatDistribution && <div className="mt-6 max-w-[760px]"><SeatDistribution dist={f.seatDistribution} color={d.color} majority={meta.majority} median={f.seatsMedian} lo80={f.seats80[0]} hi80={f.seats80[1]} lo95={f.seats95[0]} hi95={f.seats95[1]} height={90} total={meta.seatsTotal} /></div>}
45 + {f?.seatDistribution && <div className="mt-6 max-w-[760px]"><Dual narrow={<SeatDistribution dist={f.seatDistribution} color={d.color} majority={meta.majority} median={f.seatsMedian} lo80={f.seats80[0]} hi80={f.seats80[1]} lo95={f.seats95[0]} hi95={f.seats95[1]} height={90} total={meta.seatsTotal} width={340} />} wide={<SeatDistribution dist={f.seatDistribution} color={d.color} majority={meta.majority} median={f.seatsMedian} lo80={f.seats80[0]} hi80={f.seats80[1]} lo95={f.seats95[0]} hi95={f.seats95[1]} height={90} total={meta.seatsTotal} />} /></div>}
46 46 </div>
47 47
48 48 <section className="grid lg:grid-cols-3 gap-6">
49 − <div><SectionHeader eyebrow="Carte des forces" title="Bastions" description="Circonscriptions où le parti est favori, par probabilité." /><Card pad={false} className="max-h-[420px] overflow-auto"><table className="data-table"><tbody>{d.strongholds.slice(0, 40).map((r) => <tr key={r.code}><td><Link href={`/circonscription/${r.slug}`} className="hover:underline">{r.name}</Link></td><td className="r num font-semibold">{prob(r.p)}</td></tr>)}{d.strongholds.length === 0 && <tr><td className="text-ink-3 text-center py-6">Aucune circonscription où le parti est favori.</td></tr>}</tbody></table></Card><div className="text-[12px] text-ink-3 mt-1 num">{d.strongholds.length} circonscriptions</div></div>
50 − <div><SectionHeader eyebrow="Cibles" title="Meilleures chances de gain" description="Circonscriptions où le parti est 2e, classées par marge projetée." /><Card pad={false}><table className="data-table"><tbody>{d.targets.map((r) => <tr key={r.code}><td><Link href={`/circonscription/${r.slug}`} className="hover:underline">{r.name}</Link></td><td className="text-ink-3 text-[12px]">derrière {partyLabel(r.favorite)}</td><td className="r num">{r.margin.toFixed(1).replace(".", ",")} pt</td></tr>)}</tbody></table></Card></div>
49 + <div><SectionHeader eyebrow="Carte des forces" title="Bastions" description="Circonscriptions où le parti est favori, par probabilité." /><Card pad={false} className="max-h-[420px] overflow-auto"><table className="data-table"><tbody>{d.strongholds.slice(0, 40).map((r) => <tr key={r.code}><td><Link href={`/circonscription/${r.slug}`} className="hover:underline">{r.name}</Link></td><td className="r num font-semibold whitespace-nowrap">{prob(r.p)}</td></tr>)}{d.strongholds.length === 0 && <tr><td className="text-ink-3 text-center py-6">Aucune circonscription où le parti est favori.</td></tr>}</tbody></table></Card><div className="text-[12px] text-ink-3 mt-1 num">{d.strongholds.length} circonscriptions</div></div>
50 + <div><SectionHeader eyebrow="Cibles" title="Meilleures chances de gain" description="Circonscriptions où le parti est 2e, classées par marge projetée." /><Card pad={false}><table className="data-table"><tbody>{d.targets.map((r) => <tr key={r.code}><td><Link href={`/circonscription/${r.slug}`} className="hover:underline">{r.name}</Link></td><td className="text-ink-3 text-[12px]">derrière {partyLabel(r.favorite)}</td><td className="r num whitespace-nowrap">{r.margin.toFixed(1).replace(".", ",")} pt</td></tr>)}</tbody></table></Card></div>
51 51 <div><SectionHeader eyebrow="Régions" title="Vote projeté par région" description="Moyenne des parts projetées des circonscriptions de la région." /><Card><div className="space-y-1.5">{Object.entries(d.regions).sort((a, b) => b[1] - a[1]).map(([g, v]) => (<div key={g} className="grid grid-cols-[1fr_60px] items-center gap-3 text-[12.5px]"><div><div className="flex justify-between"><span className="capitalize">{g.replace(/-/g, " ")}</span></div><div className="h-[5px] bg-surface-3 rounded-full mt-0.5 overflow-hidden"><div className="h-full" style={{ width: `${v * 1.6}%`, background: d.color }} /></div></div><span className="num text-right font-semibold">{v.toFixed(0)} %</span></div>))}</div></Card></div>
52 52 </section>
53 53
modified web/src/app/partis/comparateur/page.tsx +3 −3
@@ -18,12 +18,12 @@ export default async function Page() {
18 18 <div><div className="eyebrow eyebrow-accent">Policy Matrix · documents officiels</div><h1 className="display text-[40px] md:text-[56px] mt-3">Comparateur des partis</h1><p className="text-ink-2 mt-3 max-w-2xl text-[15px] leading-relaxed">Une cellule = la proposition la mieux documentée du parti sur l&apos;enjeu (cliquez pour la source, la page et la citation). Une case vide signifie qu&apos;aucun document officiel ingéré n&apos;aborde le sujet, pas que le parti n&apos;a pas de position.</p></div>
19 19 {m.topics.length === 0 ? <Empty title="Extraction en cours">Les documents officiels des partis sont en cours d&apos;analyse ; la matrice se remplit au fil des heures.</Empty> : (
20 20 <Card pad={false} className="overflow-x-auto">
21 − <table className="data-table min-w-[900px]">
22 − <thead><tr><th className="w-[160px]">Enjeu</th>{PARTY_IDS.map((id) => <th key={id}><span className="inline-flex items-center gap-2"><PartyLogo id={id} height={14} />{partyLabel(id)}</span></th>)}</tr></thead>
21 + <table className="data-table min-w-[900px] max-md:min-w-[1100px]">
22 + <thead><tr><th className="w-[160px] sticky left-0 z-[2] bg-surface">Enjeu</th>{PARTY_IDS.map((id) => <th key={id}><span className="inline-flex items-center gap-2"><PartyLogo id={id} height={14} />{partyLabel(id)}</span></th>)}</tr></thead>
23 23 <tbody>
24 24 {m.topics.map((t) => (
25 25 <tr key={t.id}>
26 − <td className="font-semibold align-top"><Link href={`/enjeux/${t.id}`} className="hover:underline">{t.label}</Link></td>
26 + <td className="font-semibold align-top sticky left-0 z-[1] bg-surface min-w-[120px] max-md:text-[12.5px]"><Link href={`/enjeux/${t.id}`} className="hover:underline">{t.label}</Link></td>
27 27 {PARTY_IDS.map((id) => { const cell = m.top[`${t.id}:${id}`]; return (
28 28 <td key={id} className="align-top text-[12.5px] leading-snug" style={{ borderLeft: `3px solid ${cell ? partyVar(id) : "transparent"}` }}>
29 29 {cell ? <Link href={`/documents/${cell.documentId}#p${cell.id}`} className="hover:underline">{cell.proposal.length > 150 ? cell.proposal.slice(0, 150) + "…" : cell.proposal}<span className="text-ink-3"> {cell.page ? `· p. ${cell.page}` : ""} · {t.counts[id] ?? 0} prop.</span></Link> : <span className="text-ink-3">—</span>}
modified web/src/app/prevision/page.tsx +5 −5
@@ -6,7 +6,7 @@ import { Legend, SeatDistribution, SeatMeter, SeatWaffle } from "@/components/ch
6 6 import { TrendChartInteractive } from "@/components/trend-chart";
7 7 import { SignalsOverview } from "@/components/signals";
8 8 import { PartyLogo } from "@/components/party";
9 −import { Badge, Card, Empty, KV, PageTitle, SectionHeader } from "@/components/ui";
9 +import { Badge, Card, Dual, Empty, KV, PageTitle, SectionHeader } from "@/components/ui";
10 10 import { ForecastHistory } from "@/components/forecast-history";
11 11 import { dateFr, dateTimeFr, int, prob } from "@/lib/format";
12 12 import { PARTY_IDS, partyLabel, partyVar, sortParties } from "@/lib/parties";
@@ -23,11 +23,11 @@ export default async function Page() {
23 23 const comp = f.summary.components;
24 24 const leader = f.summary.leader;
25 25 return (
26 − <div className="space-y-14">
26 + <div className="space-y-10 md:space-y-14">
27 27 <PageTitle eyebrow={`Prévision · modèle ${f.modelVersion} · run #${f.id}`} title="Prévision QC26"
28 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)}.</>} />
29 29
30 − <section className="grid lg:grid-cols-[1fr_1.2fr] gap-6 items-start">
30 + <section className="grid lg:grid-cols-[1fr_1.2fr] gap-6 items-start [&>*]:min-w-0">
31 31 <Card className="card-lg">
32 32 <div className="eyebrow mb-3">Assemblée nationale · médiane</div>
33 33 <SeatWaffle seats={seats} total={meta.seatsTotal} majority={meta.majority} cols={16} />
@@ -68,8 +68,8 @@ export default async function Page() {
68 68 <div className="grid md:grid-cols-2 gap-4">
69 69 {rows.map(([id, v]) => (
70 70 <Card key={id}>
71 − <div className="flex items-center justify-between mb-2"><span className="inline-flex items-center gap-2"><PartyLogo id={id} height={18} /><b>{partyLabel(id)}</b></span><span className="num 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 && <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} />}
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 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 74 </Card>
75 75 ))}
modified web/src/app/signaux/page.tsx +1 −1
@@ -14,7 +14,7 @@ export default async function Page() {
14 14 if (!sig || !f || !meta) return <Empty title="Signaux indisponibles" />;
15 15 const comp = f.summary.components ?? sig.components;
16 16 return (
17 − <div className="space-y-14">
17 + <div className="space-y-10 md:space-y-14">
18 18 <PageTitle eyebrow="Au-delà des sondages · modèle 2.0" title={<>Ce que disent les marchés, la presse, l&apos;attention et le terrain.</>}
19 19 lede={<>Depuis la version 2.0, QC26 combine les sondages à quatre familles de signaux indépendants. Chacun est archivé avec sa provenance, publié séparément et pesé de façon explicite : cette page montre les données brutes <i>et</i> l&apos;effet exact de chaque composante sur la prévision du {dateTimeFr(f.completedAt)}.</>} />
20 20
modified web/src/app/sondages/[id]/page.tsx +2 −2
@@ -47,7 +47,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
47 47 {p.weight?.impact && (
48 48 <div className="mt-5 pt-4 border-t border-border">
49 49 <div className="eyebrow mb-2">Impact sur QC26</div>
50 − <div className="grid grid-cols-5 gap-2">{PARTY_IDS.map((pid) => <div key={pid} className="surface-2 rounded-[6px] p-2 text-center"><div className="text-[11px] font-semibold" style={{ color: partyVar(pid) }}>{partyLabel(pid)}</div><div className={"num font-bold " + ((p.weight!.impact![pid] ?? 0) > 0 ? "text-ok" : (p.weight!.impact![pid] ?? 0) < 0 ? "text-live" : "text-ink-3")}>{delta(p.weight!.impact![pid], 1)}</div></div>)}</div>
50 + <div className="grid grid-cols-5 gap-1.5 sm:gap-2">{PARTY_IDS.map((pid) => <div key={pid} className="surface-2 rounded-[6px] p-1.5 sm:p-2 text-center"><div className="text-[11px] font-semibold" style={{ color: partyVar(pid) }}>{partyLabel(pid)}</div><div className={"num font-bold " + ((p.weight!.impact![pid] ?? 0) > 0 ? "text-ok" : (p.weight!.impact![pid] ?? 0) < 0 ? "text-live" : "text-ink-3")}>{delta(p.weight!.impact![pid], 1)}</div></div>)}</div>
51 51 <div className="text-[12px] text-ink-3 mt-2">Variation de la moyenne QC26 du jour attribuable à ce sondage (recalcul sans lui). Poids dans le run : {p.weight.weight.toFixed(3)} (récence {p.weight.recency.toFixed(2)} × échantillon {p.weight.sample.toFixed(2)}).</div>
52 52 </div>
53 53 )}
@@ -84,7 +84,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
84 84 </section>
85 85 )}
86 86 {p.audit && p.audit.length > 0 && (
87 − <section><SectionHeader eyebrow="Journal d'audit" title="Historique des modifications" /><Card pad={false}><table className="data-table"><thead><tr><th>Quand</th><th>Qui</th><th>Champ</th><th>Raison</th></tr></thead><tbody>{p.audit.map((a, i) => <tr key={i}><td className="num">{dateTimeFr(a.ts)}</td><td>{a.actor}</td><td>{a.field}</td><td className="text-ink-2">{a.reason}</td></tr>)}</tbody></table></Card></section>
87 + <section><SectionHeader eyebrow="Journal d'audit" title="Historique des modifications" /><Card pad={false} className="overflow-x-auto"><table className="data-table min-w-[560px]"><thead><tr><th>Quand</th><th>Qui</th><th>Champ</th><th>Raison</th></tr></thead><tbody>{p.audit.map((a, i) => <tr key={i}><td className="num whitespace-nowrap">{dateTimeFr(a.ts)}</td><td className="whitespace-nowrap">{a.actor}</td><td>{a.field}</td><td className="text-ink-2">{a.reason}</td></tr>)}</tbody></table></Card></section>
88 88 )}
89 89 <div className="flex gap-2"><PartyBadge id={order[0]} /> <span className="text-[13px] text-ink-2">en tête dans ce sondage (répondants décidés).</span></div>
90 90 </div>
modified web/src/app/sondages/page.tsx +10 −1
@@ -33,7 +33,16 @@ export default async function Page() {
33 33 {ps && (
34 34 <section>
35 35 <SectionHeader eyebrow="Firmes" title="Effets maison" description="Écart moyen de chaque firme à la tendance des autres firmes (points), rétréci de moitié et borné à ±3 ; positif = la firme mesure le parti plus haut que la moyenne." />
36 − <Card pad={false} className="overflow-x-auto">
36 + <div className="md:hidden grid grid-cols-1 gap-2">
37 + {ps.pollsters.sort((a, b) => b.polls - a.polls).map((p) => (
38 + <div key={p.id} className="card p-3">
39 + <div className="flex items-baseline justify-between"><span className="font-semibold text-[14px]">{p.name}</span><span className="text-[12px] text-ink-3 num">{p.polls} sondages · dernier {dateShort(p.lastPoll)}</span></div>
40 + <div className="grid grid-cols-5 gap-1 mt-2">{PARTY_IDS.map((id) => <div key={id} className="inset text-center py-1.5" style={{ borderTop: `3px solid ${partyVar(id)}` }}><div className="mono text-[9.5px] uppercase tracking-[0.06em] text-ink-2">{partyLabel(id)}</div><div className="mono text-[13.5px] font-semibold">{p.houseEffects?.[id] !== undefined ? (p.houseEffects[id] > 0 ? "+" : "") + p.houseEffects[id].toFixed(1).replace(".", ",") : "—"}</div></div>)}</div>
41 + </div>
42 + ))}
43 + <p className="text-[12px] text-ink-3 px-1">— : moins de 2 sondages dans la fenêtre de 400 jours, aucun effet maison estimé.</p>
44 + </div>
45 + <Card pad={false} className="overflow-x-auto hidden md:block">
37 46 <table className="data-table min-w-[640px]"><thead><tr><th>Firme</th><th className="r">Sondages</th><th>Dernier</th>{PARTY_IDS.map((id) => <th key={id} className="r" style={{ color: partyVar(id) }}>{partyLabel(id)}</th>)}</tr></thead>
38 47 <tbody>{ps.pollsters.sort((a, b) => b.polls - a.polls).map((p) => (
39 48 <tr key={p.id}><td className="font-semibold">{p.website ? <a href={p.website} target="_blank" rel="noopener noreferrer" className="hover:underline">{p.name}</a> : p.name}</td><td className="r num">{p.polls}</td><td className="num">{dateShort(p.lastPoll)}</td>{PARTY_IDS.map((id) => <td key={id} className="r num">{p.houseEffects?.[id] !== undefined ? (p.houseEffects[id] > 0 ? "+" : "") + p.houseEffects[id].toFixed(1).replace(".", ",") : <span className="text-ink-3">—</span>}</td>)}</tr>
modified web/src/app/statut/page.tsx +2 −2
@@ -17,9 +17,9 @@ export default async function Page() {
17 17 return (
18 18 <div className="space-y-8">
19 19 <div><div className="eyebrow eyebrow-accent">QC26 Data Status</div><h1 className="display text-[40px] md:text-[56px] mt-3">Statut des données</h1><p className="text-ink-2 mt-2 text-[15px] num">Heure serveur {dateTimeFr(st.serverTime)} · latence base {st.dbLatencyMs} ms · {st.sseClients} clients SSE</p></div>
20 − <div className="grid sm:grid-cols-2 lg:grid-cols-6 gap-3">{Object.entries(st.components).map(([k, v]) => <Card key={k}><div className="text-[13px] font-semibold">{LABELS[k] ?? k}</div><div className="mt-2"><Badge tone={tone(v)}>● {v}</Badge></div></Card>)}</div>
20 + <div className="grid grid-cols-2 lg:grid-cols-6 gap-2 sm:gap-3">{Object.entries(st.components).map(([k, v]) => <Card key={k}><div className="text-[13px] font-semibold">{LABELS[k] ?? k}</div><div className="mt-2"><Badge tone={tone(v)}>● {v}</Badge></div></Card>)}</div>
21 21 <section><SectionHeader eyebrow="Sources" title="Connecteurs et sources" description="Tier 1 = officiel (Élections Québec, partis), 2 = firmes de sondage, 3 = signaux (marchés, presse, attention), 5 = index tiers (amorçage uniquement)." /><Card pad={false} className="overflow-x-auto"><table className="data-table min-w-[720px]"><thead><tr><th>Source</th><th>Tier</th><th>Statut</th><th>Vérifié</th><th>Dernier succès</th><th>Détail</th></tr></thead><tbody>{st.sources.sort((a, b) => a.tier - b.tier).map((s) => <tr key={s.id}><td><a href={s.url} target="_blank" rel="noopener noreferrer" className="font-medium hover:underline">{s.name}</a></td><td className="num">{s.tier}</td><td><Badge tone={tone(s.status)}>{s.status}</Badge></td><td className="num text-ink-2">{s.lastCheckedAt ? relTime(s.lastCheckedAt) : "—"}</td><td className="num text-ink-2">{s.lastOkAt ? relTime(s.lastOkAt) : "—"}</td><td className="text-[12px] text-ink-3">{s.detail}</td></tr>)}</tbody></table></Card></section>
22 − <section><SectionHeader eyebrow="Travaux" title="Derniers travaux d'ingestion" /><Card pad={false}><table className="data-table"><thead><tr><th>Travail</th><th>Statut</th><th>Début</th><th>Fin</th><th className="r">Éléments</th><th>Message</th></tr></thead><tbody>{st.jobs.map((j) => <tr key={j.id}><td className="font-medium">{j.kind}</td><td><Badge tone={j.status === "ok" ? "ok" : j.status === "running" ? "accent" : "warn"}>{j.status}</Badge></td><td className="num">{dateTimeFr(j.startedAt)}</td><td className="num">{dateTimeFr(j.finishedAt)}</td><td className="r num">{j.items}</td><td className="text-[12px] text-ink-3">{j.message}</td></tr>)}</tbody></table></Card></section>
22 + <section><SectionHeader eyebrow="Travaux" title="Derniers travaux d'ingestion" /><Card pad={false} className="overflow-x-auto"><table className="data-table min-w-[720px]"><thead><tr><th>Travail</th><th>Statut</th><th>Début</th><th>Fin</th><th className="r">Éléments</th><th>Message</th></tr></thead><tbody>{st.jobs.map((j) => <tr key={j.id}><td className="font-medium">{j.kind}</td><td><Badge tone={j.status === "ok" ? "ok" : j.status === "running" ? "accent" : "warn"}>{j.status}</Badge></td><td className="num">{dateTimeFr(j.startedAt)}</td><td className="num">{dateTimeFr(j.finishedAt)}</td><td className="r num">{j.items}</td><td className="text-[12px] text-ink-3">{j.message}</td></tr>)}</tbody></table></Card></section>
23 23 </div>
24 24 );
25 25 }
modified web/src/components/charts.tsx +6 −6
@@ -62,12 +62,12 @@ export function SeatWaffle({ seats, total, majority, cols = 16, className, dot =
62 62
63 63 // ------------------------------------------------------- SeatDistribution
64 64 /** Distribution des sièges d'un parti (histogramme Monte Carlo) avec seuil de majorité et intervalles. */
65 −export function SeatDistribution({ dist, color, majority, median, lo80, hi80, lo95, hi95, height = 120, className, total }: {
66 − dist: number[]; color: string; majority: number; median: number; lo80: number; hi80: number; lo95: number; hi95: number; height?: number; className?: string; total: number;
65 +export function SeatDistribution({ dist, color, majority, median, lo80, hi80, lo95, hi95, height = 120, className, total, width = 640 }: {
66 + dist: number[]; color: string; majority: number; median: number; lo80: number; hi80: number; lo95: number; hi95: number; height?: number; className?: string; total: number; width?: number;
67 67 }) {
68 68 const n = dist.length;
69 69 const max = Math.max(...dist, 1);
70 − const W = 640, H = height, pad = 18;
70 + const W = width, H = height, pad = 18;
71 71 const vis = dist.map((v, i) => [i, v] as const).filter(([, v]) => v > 0);
72 72 const minI = Math.max(0, (vis[0]?.[0] ?? 0) - 2), maxI = Math.min(n - 1, (vis[vis.length - 1]?.[0] ?? n - 1) + 2);
73 73 const span = Math.max(1, maxI - minI);
@@ -218,12 +218,12 @@ export function Note({ children }: { children: ReactNode }) {
218 218
219 219 // -------------------------------------------------------- DotComparison
220 220 /** Points comparés (ex. P(plus de sièges) : sondages seuls · marchés · ensemble) par parti, sur une échelle 0–max. */
221 −export function DotComparison({ rows, series, className, max = 1, format = (v: number) => `${Math.round(v * 100)} %` }: {
221 +export function DotComparison({ rows, series, className, max = 1, format = (v: number) => `${Math.round(v * 100)} %`, width = 520 }: {
222 222 rows: { id: string; label: string; values: Record<string, number | null | undefined> }[];
223 223 series: { key: string; label: string; shape: "dot" | "ring" | "diamond" }[];
224 − className?: string; max?: number; format?: (v: number) => string;
224 + className?: string; max?: number; format?: (v: number) => string; width?: number;
225 225 }) {
226 − const W = 520, rowH = 38, pl = 56, pr = 16, H = rows.length * rowH + 16;
226 + const W = width, rowH = width < 420 ? 44 : 38, pl = 52, pr = 14, H = rows.length * rowH + 16;
227 227 const x = (v: number) => pl + (Math.max(0, Math.min(max, v)) / max) * (W - pl - pr);
228 228 return (
229 229 <div className={className}>
modified web/src/components/forecast-blocks.tsx +144 −78
@@ -4,31 +4,51 @@ import { cls, dateShort, dateTimeFr, delta, int, pct, prob, ratingLabel } from "
4 4 import { PARTY_IDS, party, partyAlpha, partyLabel, partyName, partyVar, sortParties } from "@/lib/parties";
5 5 import { DotComparison, SeatDistribution, SeatWaffle } from "./charts";
6 6 import { PartyBadge, PartyDot, PartyLogo } from "./party";
7 −import { Badge, Card, Metric, MetricDelta, ProbabilityBar, SectionHeader, SourceBadge } from "./ui";
7 +import { Badge, Card, Dual, Metric, MetricDelta, ProbabilityBar, SectionHeader, SourceBadge } from "./ui";
8 8
9 +// ------------------------------------------------------- SEAT PROJECTION
9 10 export function SeatProjectionTable({ f, className }: { f: Forecast; className?: string }) {
10 11 const rows = sortParties(f.parties, (v) => v.seatsMean);
11 12 const maj = f.summary.majority;
12 13 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 figure text-[22px]" style={{ color: partyVar(id) }}>{v.seatsMedian}</td>
23 − <td className="r num mono">{v.seats80[0]}–{v.seats80[1]}</td>
24 − <td className="r num mono 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={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>
28 − </tr>
29 − ))}
30 − </tbody>
31 − </table>
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>
32 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>
33 53 </div>
34 54 );
@@ -40,15 +60,15 @@ export function PopularVoteTable({ f, className }: { f: Forecast; className?: st
40 60 return (
41 61 <div className={cls("space-y-2.5", className)}>
42 62 {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] inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(id) }} />{partyLabel(id)}</span>
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>
45 65 <div className="relative h-[14px] bg-surface-3 rounded-[4px] overflow-hidden">
46 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) }} />
47 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) }} />
48 68 <div className="absolute inset-y-0 w-[2px] bg-ink" style={{ left: `${(v.voteMean / 50) * 100}%` }} />
49 69 </div>
50 − <div className="flex items-baseline gap-2 num">
51 − <span className="font-bold text-[16px] w-[58px] text-right mono">{v.voteMean.toFixed(1).replace(".", ",")}</span>
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>
52 72 {ch?.[id] ? <MetricDelta value={ch[id].vote} /> : <span className="w-[54px]" />}
53 73 </div>
54 74 </div>
@@ -64,7 +84,7 @@ export function MajorityPanel({ f }: { f: Forecast }) {
64 84 <div className="space-y-3">
65 85 {rows.map(([id, v]) => (
66 86 <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>
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>
68 88 <ProbabilityBar p={v.pMajority} color={partyVar(id)} label={`majorité ${partyLabel(id)}`} height={12} />
69 89 </div>
70 90 ))}
@@ -76,18 +96,20 @@ export function MajorityPanel({ f }: { f: Forecast }) {
76 96 );
77 97 }
78 98
99 +// ------------------------------------------------------------ WHAT CHANGED
79 100 export function WhatChanged({ f, ridingNames }: { f: Forecast; ridingNames?: Record<number, { name: string; slug: string }> }) {
80 101 const d = f.diff;
81 102 if (!d) return <p className="text-ink-2 text-[14px]">Premier run publié : pas de comparaison disponible.</p>;
82 103 const leader = f.summary.leader;
83 104 const ch = d.changes[leader];
84 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));
85 107 return (
86 − <div className="grid md:grid-cols-[1fr_1.2fr] gap-8">
108 + <div className="grid md:grid-cols-[1fr_1.2fr] gap-6 md:gap-8">
87 109 <div>
88 110 <div className="eyebrow">Majorité {partyLabel(leader)}</div>
89 − <div className="figure text-[44px] mt-2">
90 − <span className="text-ink-3">{prob(f.parties[leader].pMajority - (ch?.p_majority ?? 0))}</span> <span className="text-ink-3 text-[24px] serif">→</span> {prob(f.parties[leader].pMajority)}
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)}
91 113 </div>
92 114 <div className="mt-4 text-[13.5px]">
93 115 <div className="eyebrow mb-1.5">Causes identifiées</div>
@@ -105,9 +127,17 @@ export function WhatChanged({ f, ridingNames }: { f: Forecast; ridingNames?: Rec
105 127 </div>
106 128 <div>
107 129 <div className="eyebrow mb-2">Variations par parti</div>
108 − <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>
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>
109 139 <tbody>
110 − {Object.entries(d.changes).filter(([id]) => id !== "aut").sort((a, b) => Math.abs(b[1].seats) - Math.abs(a[1].seats)).map(([id, c]) => (
140 + {changes.map(([id, c]) => (
111 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>
112 142 ))}
113 143 </tbody></table>
@@ -116,7 +146,7 @@ export function WhatChanged({ f, ridingNames }: { f: Forecast; ridingNames?: Rec
116 146 <div className="eyebrow mb-1.5">Circonscriptions qui changent de favori</div>
117 147 <ul className="space-y-1">
118 148 {d.flips.slice(0, 8).map((fl) => (
119 − <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>
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>
120 150 ))}
121 151 </ul>
122 152 </div>
@@ -127,37 +157,71 @@ export function WhatChanged({ f, ridingNames }: { f: Forecast; ridingNames?: Rec
127 157 );
128 158 }
129 159
160 +// ------------------------------------------------------------- LATEST POLLS
161 +export 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 +}
189 +
130 190 export function LatestPolls({ polls, limit = 6, compact = false }: { polls: Poll[]; limit?: number; compact?: boolean }) {
131 191 const rows = polls.slice(0, limit);
132 192 return (
133 − <div className="overflow-x-auto">
134 − <table className="data-table min-w-[560px]">
135 − <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>
136 − <tbody>
137 − {rows.map((p) => {
138 − const lead = Object.entries(p.results).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1])[0]?.[0];
139 − return (
140 − <tr key={p.id}>
141 − <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>
142 − <td className="num whitespace-nowrap">{p.fieldworkStart ? `${dateShort(p.fieldworkStart)} – ` : ""}{dateShort(p.fieldworkEnd)}</td>
143 − <td className="r num">{int(p.sampleSize)}</td>
144 − {PARTY_IDS.map((id) => <td key={id} className={cls("r num mono", id === lead && "font-bold")}>{p.results[id]?.toFixed(0) ?? "—"}</td>)}
145 − {!compact && <td className="r num text-ink-2 mono">{p.weight ? p.weight.weight.toFixed(2) : "—"}</td>}
146 − <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>
147 − </tr>
148 − );
149 − })}
150 − </tbody>
151 − </table>
152 − </div>
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 + </>
153 216 );
154 217 }
155 218
219 +// ------------------------------------------------------------ BATTLEGROUNDS
156 220 export function Battlegrounds({ ridings, codes, limit = 10 }: { ridings: RidingListItem[]; codes: number[]; limit?: number }) {
157 221 const by = new Map(ridings.map((r) => [r.code, r]));
158 222 const rows = codes.map((c) => by.get(c)).filter((r): r is RidingListItem => !!r && !!r.forecast).slice(0, limit);
159 223 return (
160 − <div className="grid sm:grid-cols-2 lg:grid-cols-5 gap-3">
224 + <div className="rail sm:grid-cols-2 lg:grid-cols-5 sm:gap-3">
161 225 {rows.map((r) => (
162 226 <Link key={r.code} href={`/circonscription/${r.slug}`} className="card card-hover p-4 block" style={{ borderTop: `3px solid ${partyVar(r.forecast!.favorite)}` }}>
163 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>
@@ -173,23 +237,25 @@ export function Battlegrounds({ ridings, codes, limit = 10 }: { ridings: RidingL
173 237 );
174 238 }
175 239
240 +// ------------------------------------------------------------ REGIONAL GRID
176 241 export function RegionalGrid({ regions }: { regions: { id: string; name: string; ridings: number; favorites: Record<string, number>; voteNow: Record<string, number>; vote2022: Record<string, number> }[] }) {
177 242 return (
178 − <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
243 + <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-2 sm:gap-3">
179 244 {regions.map((g) => {
180 245 const favs = Object.entries(g.favorites).sort((a, b) => b[1] - a[1]);
181 246 const top = Object.entries(g.voteNow).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1]).slice(0, 4);
182 247 return (
183 − <div key={g.id} className="card p-4">
184 − <div className="flex items-baseline justify-between"><div className="font-semibold">{g.name}</div><div className="text-[12px] text-ink-3 num mono">{g.ridings} circ.</div></div>
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>
185 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>
186 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>
187 − <div className="mt-3 space-y-1">
252 + <div className="mt-3 space-y-1 hidden sm:block">
188 253 {top.map(([id, v]) => (
189 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>
190 255 ))}
191 256 </div>
192 − <div className="text-[11px] text-ink-3 mt-2">Vote projeté (moyenne des circonscriptions) · variation vs 2022 transposé</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>
193 259 </div>
194 260 );
195 261 })}
@@ -208,31 +274,31 @@ export function HeroForecast({ f, meta }: { f: Forecast; meta: { seatsTotal: num
208 274 const pollsOnly = comp?.pollsOnly?.parties?.[leader];
209 275 const nSources = comp ? [comp.fundamentals?.enabled, comp.momentum?.enabled, comp.markets?.enabled, comp.byelections?.enabled].filter(Boolean).length : 0;
210 276 return (
211 − <section className="relative overflow-hidden rounded-[var(--radius-lg)] border border-border card hero-glow p-6 md:p-10">
212 − <div className="grid lg:grid-cols-[1.05fr_1fr] gap-10 items-start">
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">
213 279 <div className="fade-up">
214 280 <div className="flex items-center gap-2 flex-wrap">
215 281 <span className="eyebrow eyebrow-accent">Prévision QC26</span>
216 282 <span className="text-ink-3 text-[11px] mono">·</span>
217 283 <span className="eyebrow">Modèle {f.modelVersion} · run #{f.id}</span>
218 284 </div>
219 − <h1 className="display text-[36px] md:text-[54px] mt-4 max-w-[14ch]">
285 + <h1 className="display text-[34px] sm:text-[40px] md:text-[54px] mt-4 max-w-[14ch]">
220 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"}.
221 287 </h1>
222 − <div className="mt-6 flex items-end gap-8 flex-wrap">
288 + <div className="mt-5 sm:mt-6 flex flex-col sm:flex-row sm:items-end gap-5 sm:gap-8">
223 289 <div>
224 − <div className="flex items-center gap-2 text-[13px] text-ink-2"><PartyLogo id={leader} height={20} /> {partyName(leader)}</div>
225 − <div className="figure text-[96px] md:text-[136px] mt-1" style={{ color: partyVar(leader) }}>{lp.seatsMedian}</div>
226 − <div className="text-[14px] text-ink-2 -mt-1">sièges sur {meta.seatsTotal} · 80 % : <span className="mono font-semibold text-ink">{lp.seats80[0]}–{lp.seats80[1]}</span></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>
227 293 </div>
228 − <div className="grid grid-cols-2 gap-x-8 gap-y-5 mb-2">
229 − <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}`} />
230 − <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} />
231 − <Metric label="Vote populaire" value={pct(lp.voteMean)} size="md" delta={ch ? <MetricDelta value={ch.vote} /> : undefined} sub={`± ${lp.voteSd.toFixed(1).replace(".", ",")}`} />
232 − <Metric label="Minoritaire" value={prob(f.summary.p_minority)} size="md" sub={`aucun parti à ${meta.majority}`} />
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}`} />
233 299 </div>
234 300 </div>
235 − <div className="mt-8 flex flex-wrap items-center gap-x-5 gap-y-2 text-[12.5px] text-ink-3">
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">
236 302 <span className="num">Mis à jour {dateTimeFr(f.completedAt)}</span>
237 303 <span className="num">{f.pollCount} sondages · {int(f.nSimulations)} simulations{comp?.ess ? ` · ESS ${int(comp.ess)}` : ""}</span>
238 304 {nSources > 0 && <Link href="/signaux" className="link">{nSources} source{nSources > 1 ? "s" : ""} au-delà des sondages →</Link>}
@@ -240,17 +306,17 @@ export function HeroForecast({ f, meta }: { f: Forecast; meta: { seatsTotal: num
240 306 </div>
241 307 </div>
242 308 <div className="fade-up fade-up-2">
243 − <div className="flex items-center justify-between mb-3">
244 − <div className="eyebrow">Assemblée nationale · médiane projetée</div>
245 − <div className="text-[11px] mono text-ink-3">{meta.seatsTotal} sièges · majorité {meta.majority}</div>
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>
246 312 </div>
247 313 <SeatWaffle seats={seats} total={meta.seatsTotal} majority={meta.majority} cols={16} />
248 314 <div className="mt-5 space-y-3">
249 315 {rows.map(([id, v]) => (
250 316 <div key={id}>
251 − <div className="flex items-baseline justify-between text-[13px] mb-1">
252 − <span className="inline-flex items-center gap-2"><span className="w-2.5 h-2.5 rounded-[3px]" style={{ background: partyVar(id) }} /><span className="font-semibold">{partyLabel(id)}</span><span className="text-ink-3 hidden sm:inline">{party(id)?.officialName}</span></span>
253 − <span className="num"><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-2" title="Sondages seuls">(sondages seuls {comp.pollsOnly.parties[id].seats})</span> : null}</span>
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>
254 320 </div>
255 321 <div className="relative h-[10px] rounded-[4px] bg-surface-3 overflow-hidden">
256 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) }} />
@@ -280,9 +346,9 @@ export function ComponentsCompare({ f, compact = false }: { f: Forecast; compact
280 346 <div className={cls("grid gap-6", !compact && "lg:grid-cols-[1.2fr_1fr]")}>
281 347 <div>
282 348 <div className="eyebrow mb-2">Probabilité d&apos;obtenir le plus de sièges</div>
283 − <DotComparison rows={rows} series={series} />
349 + <Dual narrow={<DotComparison rows={rows} series={series} width={340} />} wide={<DotComparison rows={rows} series={series} />} />
284 350 </div>
285 − <div className="grid grid-cols-2 gap-3 content-start">
351 + <div className="grid grid-cols-2 gap-2 sm:gap-3 content-start">
286 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>
287 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>
288 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>
modified web/src/components/map-section.tsx +8 −5
@@ -3,6 +3,7 @@
3 3 import type { FeatureCollection, Geometry } from "geojson";
4 4 import Link from "next/link";
5 5 import { useEffect, useMemo, useState } from "react";
6 +import { useMeasure } from "@/lib/use-measure";
6 7 import { cls } from "@/lib/format";
7 8 import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";
8 9 import { QuebecMap, type MapMode, type MapRiding } from "./map";
@@ -24,6 +25,8 @@ export function MapSection({ height = 560, compact = false, initialMode = "proje
24 25 const [geo, setGeo] = useState<FeatureCollection<Geometry, { code: number; name: string; slug: string }> | null>(null);
25 26 const [data, setData] = useState<RidingsPayload | null>(null);
26 27 const [mode, setMode] = useState<MapMode>(live ? "live" : initialMode);
28 + const { ref, width } = useMeasure<HTMLDivElement>(1000);
29 + const mobile = width < 640;
27 30 useEffect(() => {
28 31 fetch("/api/geo").then((r) => r.json()).then(setGeo).catch(() => {});
29 32 fetch("/api/ridings").then((r) => r.json()).then(setData).catch(() => {});
@@ -44,18 +47,18 @@ export function MapSection({ height = 560, compact = false, initialMode = "proje
44 47 });
45 48 return c;
46 49 }, [ridings, mode]);
47 − if (!geo || !data) return <div className="rounded-[10px] bg-surface-2 animate-pulse" style={{ height }} aria-busy />;
50 + if (!geo || !data) return <div ref={ref} className="rounded-[10px] bg-surface-2 animate-pulse" style={{ height: mobile ? 380 : height }} aria-busy />;
48 51 return (
49 − <div>
52 + <div ref={ref}>
50 53 {showModes && (
51 − <div className="flex flex-wrap items-center gap-1.5 mb-3">
54 + <div className="flex flex-nowrap sm:flex-wrap overflow-x-auto scrollbar-none items-center gap-1.5 mb-3 -mx-1 px-1">
52 55 {(live ? [{ id: "live" as MapMode, label: "2026 Live", hint: "Résultats officiels en cours" }, ...MODES.filter((m) => m.id === "projection" || m.id === "2022")] : MODES).map((m) => (
53 − <button key={m.id} onClick={() => setMode(m.id)} title={m.hint} className={cls("rounded-full px-3 py-1.5 text-[12.5px] font-medium border transition-colors", mode === m.id ? "bg-brand text-white border-brand" : "bg-surface border-border hover:bg-surface-2")}>{m.label}</button>
56 + <button key={m.id} onClick={() => setMode(m.id)} title={m.hint} className={cls("chip !py-1.5 !px-3 !text-[12.5px] shrink-0", mode === m.id && "chip-active")}>{m.label}</button>
54 57 ))}
55 58 <span className="text-[12px] text-ink-3 ml-1 hidden md:inline">{(live ? [{ id: "live", hint: "Résultats officiels en cours" }, ...MODES] : MODES).find((m) => m.id === mode)?.hint}</span>
56 59 </div>
57 60 )}
58 − <QuebecMap geo={geo} ridings={ridings} mode={mode} height={height} compact={compact} interactive />
61 + <QuebecMap geo={geo} ridings={ridings} mode={mode} height={mobile ? 820 : height} compact={compact || mobile} interactive insets={mobile ? "below" : "overlay"} />
59 62 <div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-3 text-[12.5px]">
60 63 {PARTY_IDS.filter((id) => counts[id]).sort((a, b) => (counts[b] ?? 0) - (counts[a] ?? 0)).map((id) => (
61 64 <span key={id} className="inline-flex items-center gap-1.5 num"><span className="w-2.5 h-2.5 rounded-[3px]" style={{ background: partyVar(id) }} /><b>{partyLabel(id)}</b> {counts[id]}</span>
modified web/src/components/map.tsx +25 −7
@@ -26,6 +26,8 @@ interface Props {
26 26 className?: string;
27 27 onSelect?: (code: number) => void;
28 28 compact?: boolean;
29 + /** overlay = encarts superposés (desktop) ; below = encarts rendus sous la carte (mobile) */
30 + insets?: "overlay" | "below" | "none";
29 31 }
30 32
31 33 function fillFor(r: MapRiding | undefined, mode: MapMode): string {
@@ -49,7 +51,7 @@ function fillFor(r: MapRiding | undefined, mode: MapMode): string {
49 51 return partyVar(f.favorite);
50 52 }
51 53
52 −export function QuebecMap({ geo, ridings, mode, height = 560, interactive = true, focus = null, className, onSelect, compact = false }: Props) {
54 +export function QuebecMap({ geo, ridings, mode, height = 560, interactive = true, focus = null, className, onSelect, compact = false, insets = "overlay" }: Props) {
53 55 const router = useRouter();
54 56 const W = 900, H = height;
55 57 const [hover, setHover] = useState<number | null>(null);
@@ -153,14 +155,14 @@ export function QuebecMap({ geo, ridings, mode, height = 560, interactive = true
153 155 <g transform={`translate(${view.x},${view.y}) scale(${view.k})`}>
154 156 {geo.features.map((f) => renderPath(f.properties.code, byCode.get(f.properties.code) ?? ""))}
155 157 </g>
156 − {insetMtl && view.k < 1.8 && (
158 + {insets === "overlay" && insetMtl && view.k < 1.8 && (
157 159 <g transform={`translate(${W - insetMtl.size - 10},${H - insetMtl.size - 10})`}>
158 160 <rect width={insetMtl.size} height={insetMtl.size} rx={8} fill="var(--surface)" stroke="var(--border-strong)" />
159 161 <text x={8} y={14} fontSize={10} fontWeight={700} fill="var(--text-2)" letterSpacing={1}>MONTRÉAL · LAVAL</text>
160 162 <g>{insetMtl.paths.map(([code, d]) => renderPath(code, d, `m${code}`))}</g>
161 163 </g>
162 164 )}
163 − {insetQc && view.k < 1.8 && (
165 + {insets === "overlay" && insetQc && view.k < 1.8 && (
164 166 <g transform={`translate(${W - (insetMtl?.size ?? 0) - insetQc.size - 20},${H - insetQc.size - 10})`}>
165 167 <rect width={insetQc.size} height={insetQc.size} rx={8} fill="var(--surface)" stroke="var(--border-strong)" />
166 168 <text x={8} y={14} fontSize={10} fontWeight={700} fill="var(--text-2)" letterSpacing={1}>QUÉBEC · LÉVIS</text>
@@ -168,15 +170,31 @@ export function QuebecMap({ geo, ridings, mode, height = 560, interactive = true
168 170 </g>
169 171 )}
170 172 </svg>
173 + {insets === "below" && (insetMtl || insetQc) && (
174 + <div className="grid grid-cols-2 gap-2 mt-2">
175 + {insetMtl && (
176 + <div className="card p-2">
177 + <div className="mono text-[10px] font-semibold tracking-[0.08em] text-ink-2 mb-1">MONTRÉAL · LAVAL</div>
178 + <svg viewBox={`0 0 ${insetMtl.size} ${insetMtl.size}`} className="w-full h-auto block" role="img" aria-label="Encart Montréal et Laval">{insetMtl.paths.map(([code, d]) => renderPath(code, d, `mb${code}`))}</svg>
179 + </div>
180 + )}
181 + {insetQc && (
182 + <div className="card p-2">
183 + <div className="mono text-[10px] font-semibold tracking-[0.08em] text-ink-2 mb-1">QUÉBEC · LÉVIS</div>
184 + <svg viewBox={`0 0 ${insetQc.size} ${insetQc.size}`} className="w-full h-auto block" role="img" aria-label="Encart Québec et Lévis">{insetQc.paths.map(([code, d]) => renderPath(code, d, `qb${code}`))}</svg>
185 + </div>
186 + )}
187 + </div>
188 + )}
171 189 {interactive && (
172 190 <div className="absolute top-2 right-2 flex flex-col gap-1">
173 − <button className="w-8 h-8 card text-[16px] font-bold" onClick={() => setView((v) => ({ k: Math.min(12, v.k * 1.4), x: W / 2 - ((W / 2 - v.x) / v.k) * Math.min(12, v.k * 1.4), y: H / 2 - ((H / 2 - v.y) / v.k) * Math.min(12, v.k * 1.4) }))} aria-label="Zoom avant">+</button>
174 − <button className="w-8 h-8 card text-[16px] font-bold" onClick={() => setView((v) => { const k = Math.max(1, v.k / 1.4); return { k, x: W / 2 - ((W / 2 - v.x) / v.k) * k, y: H / 2 - ((H / 2 - v.y) / v.k) * k }; })} aria-label="Zoom arrière">−</button>
175 − <button className="w-8 h-8 card text-[11px] font-bold" onClick={() => setView({ k: 1, x: 0, y: 0 })} aria-label="Réinitialiser">⤢</button>
191 + <button className="w-9 h-9 md:w-8 md:h-8 card text-[16px] font-bold" onClick={() => setView((v) => ({ k: Math.min(12, v.k * 1.4), x: W / 2 - ((W / 2 - v.x) / v.k) * Math.min(12, v.k * 1.4), y: H / 2 - ((H / 2 - v.y) / v.k) * Math.min(12, v.k * 1.4) }))} aria-label="Zoom avant">+</button>
192 + <button className="w-9 h-9 md:w-8 md:h-8 card text-[16px] font-bold" onClick={() => setView((v) => { const k = Math.max(1, v.k / 1.4); return { k, x: W / 2 - ((W / 2 - v.x) / v.k) * k, y: H / 2 - ((H / 2 - v.y) / v.k) * k }; })} aria-label="Zoom arrière">−</button>
193 + <button className="w-9 h-9 md:w-8 md:h-8 card text-[11px] font-bold" onClick={() => setView({ k: 1, x: 0, y: 0 })} aria-label="Réinitialiser">⤢</button>
176 194 </div>
177 195 )}
178 196 {r && tip && (
179 − <div className="pointer-events-none fixed z-50 card p-3 text-[12.5px] shadow-md min-w-[210px]" style={{ left: Math.min(tip.x + 14, (typeof window !== "undefined" ? window.innerWidth : 1000) - 240), top: tip.y + 14 }}>
197 + <div className="pointer-events-none fixed z-50 card p-3 text-[12.5px] shadow-md min-w-[210px] max-md:left-3 max-md:right-3 max-md:bottom-[calc(var(--bottom-nav-h)+env(safe-area-inset-bottom)+12px)] max-md:top-auto" style={typeof window !== "undefined" && window.innerWidth >= 768 ? { left: Math.min(tip.x + 14, window.innerWidth - 240), top: tip.y + 14 } : undefined}>
180 198 <div className="font-semibold text-[13.5px]">{r.name}</div>
181 199 {mode === "live" && r.live ? (
182 200 <div className="mt-1 space-y-0.5">
modified web/src/components/party.tsx +7 −1
@@ -8,7 +8,13 @@ export function PartyLogo({ id, height = 20, className, white = false }: { id: s
8 8 if (!p) return <PartyBadge id={id} className={className} />;
9 9 const width = Math.round(height * p.logoRatio);
10 10 const src = white && p.logoWhite ? p.logoWhite : p.logo;
11 − const img = (
11 + // thème sombre : variante blanche officielle quand elle existe (le marine du PQ disparaît sur fond noir)
12 + const img = p.logoWhite && !white ? (
13 + <>
14 + <Image src={p.logo} alt={`Logo officiel — ${p.officialName}`} width={width} height={height} className="object-contain dark:hidden" style={{ height, width: "auto", maxWidth: width }} unoptimized />
15 + <Image src={p.logoWhite} alt="" aria-hidden width={width} height={height} className="object-contain hidden dark:inline" style={{ height, width: "auto", maxWidth: width }} unoptimized />
16 + </>
17 + ) : (
12 18 <Image src={src} alt={`Logo officiel — ${p.officialName}`} width={width} height={height} className="object-contain" style={{ height, width: "auto", maxWidth: width }} unoptimized />
13 19 );
14 20 if (p.logoBackdrop && !white) {
modified web/src/components/polls-table.tsx +15 −9
@@ -4,26 +4,32 @@ import Link from "next/link";
4 4 import { useMemo, useState } from "react";
5 5 import type { Poll } from "@/lib/api";
6 6 import { cls, dateShort, int, modeLabel } from "@/lib/format";
7 −import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";
7 +import { PARTY_IDS, partyLabel, partyVar } from "@/lib/parties";
8 +import { PollRowMobile } from "./forecast-blocks";
8 9
9 10 export function PollsTable({ polls }: { polls: Poll[] }) {
10 11 const [firm, setFirm] = useState("all");
11 12 const [year, setYear] = useState("all");
12 13 const [onlyPrimary, setOnlyPrimary] = useState(false);
14 + const [shown, setShown] = useState(25);
13 15 const firms = useMemo(() => Array.from(new Map(polls.map((p) => [p.pollster.id, p.pollster.name])).entries()), [polls]);
14 16 const years = useMemo(() => Array.from(new Set(polls.map((p) => p.fieldworkEnd.slice(0, 4)))).sort().reverse(), [polls]);
15 17 const rows = polls.filter((p) => (firm === "all" || p.pollster.id === firm) && (year === "all" || p.fieldworkEnd.startsWith(year)) && (!onlyPrimary || p.sourceTier === "primary"));
16 18 return (
17 19 <div>
18 20 <div className="flex flex-wrap items-center gap-2 p-3 border-b border-border text-[13px]">
19 − <select value={firm} onChange={(e) => setFirm(e.target.value)} className="card px-2.5 py-1.5" aria-label="Firme"><option value="all">Toutes les firmes</option>{firms.map(([id, n]) => <option key={id} value={id}>{n}</option>)}</select>
21 + <select value={firm} onChange={(e) => setFirm(e.target.value)} className="card px-2.5 py-1.5 max-w-[48%]" aria-label="Firme"><option value="all">Toutes les firmes</option>{firms.map(([id, n]) => <option key={id} value={id}>{n}</option>)}</select>
20 22 <select value={year} onChange={(e) => setYear(e.target.value)} className="card px-2.5 py-1.5" aria-label="Année"><option value="all">Toutes les années</option>{years.map((y) => <option key={y} value={y}>{y}</option>)}</select>
21 − <label className="inline-flex items-center gap-2 ml-1"><input type="checkbox" checked={onlyPrimary} onChange={(e) => setOnlyPrimary(e.target.checked)} /> Documents originaux seulement</label>
23 + <label className="inline-flex items-center gap-2 ml-1"><input type="checkbox" checked={onlyPrimary} onChange={(e) => setOnlyPrimary(e.target.checked)} /> <span className="hidden sm:inline">Documents originaux seulement</span><span className="sm:hidden">Originaux</span></label>
22 24 <span className="ml-auto num text-ink-3">{rows.length} sondages</span>
23 25 </div>
24 − <div className="overflow-x-auto max-h-[70vh]">
26 + <div className="md:hidden">
27 + {rows.slice(0, shown).map((p) => <PollRowMobile key={p.id} p={p} />)}
28 + {rows.length > shown && <button onClick={() => setShown((n) => n + 25)} className="w-full py-3 text-[13px] font-semibold text-accent border-t border-border">Afficher 25 sondages de plus ({rows.length - shown} restants)</button>}
29 + </div>
30 + <div className="hidden md:block overflow-x-auto max-h-[70vh]">
25 31 <table className="data-table min-w-[820px]">
26 − <thead><tr><th>Firme</th><th>Terrain</th><th className="r">n</th>{PARTY_IDS.map((id) => <th key={id} className="r" style={{ color: partyVar(id) }}>{partyLabel(id)}</th>)}<th className="r">Autres</th><th className="r">Poids</th><th>Source</th></tr></thead>
32 + <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>)}<th className="r">Autres</th><th className="r">Poids</th><th>Source</th></tr></thead>
27 33 <tbody>
28 34 {rows.map((p) => {
29 35 const lead = Object.entries(p.results).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1])[0]?.[0];
@@ -32,10 +38,10 @@ export function PollsTable({ polls }: { polls: Poll[] }) {
32 38 <td><Link href={`/sondages/${p.id}`} className="font-semibold hover:underline">{p.pollster.name}</Link>{p.sponsor && <div className="text-[11.5px] text-ink-3">{p.sponsor}</div>}</td>
33 39 <td className="num whitespace-nowrap"><Link href={`/sondages/${p.id}`}>{p.fieldworkStart ? `${dateShort(p.fieldworkStart)} – ` : ""}{dateShort(p.fieldworkEnd)}</Link></td>
34 40 <td className="r num">{int(p.sampleSize)}</td>
35 − {PARTY_IDS.map((id) => <td key={id} className={cls("r num", id === lead && "font-bold")} style={id === lead ? { color: partyVar(id) } : undefined}>{p.results[id] !== undefined ? p.results[id].toFixed(p.results[id] % 1 ? 1 : 0).replace(".", ",") : "—"}</td>)}
36 − <td className="r num text-ink-2">{p.results["aut"] !== undefined ? p.results["aut"].toFixed(0) : "—"}</td>
37 − <td className="r num text-ink-2">{p.weight ? p.weight.weight.toFixed(2) : "—"}</td>
38 − <td className="whitespace-nowrap">{p.sourceTier === "primary" ? <span className="text-[10.5px] font-semibold uppercase tracking-wide text-ok">PDF archivé</span> : <span className="text-[10.5px] font-semibold uppercase tracking-wide text-warn" title="Valeur relevée par un index tiers ; document original en cours de récupération">index</span>}{p.excluded && <span className="ml-1 text-[10.5px] uppercase text-live">exclu</span>}</td>
41 + {PARTY_IDS.map((id) => <td key={id} className={cls("r num mono", id === lead && "font-bold")} style={id === lead ? { color: partyVar(id) } : undefined}>{p.results[id] !== undefined ? p.results[id].toFixed(p.results[id] % 1 ? 1 : 0).replace(".", ",") : "—"}</td>)}
42 + <td className="r num text-ink-2 mono">{p.results["aut"] !== undefined ? p.results["aut"].toFixed(0) : "—"}</td>
43 + <td className="r num text-ink-2 mono">{p.weight ? p.weight.weight.toFixed(2) : "—"}</td>
44 + <td className="whitespace-nowrap">{p.sourceTier === "primary" ? <span className="text-[10.5px] font-semibold uppercase tracking-wide text-ok mono">PDF archivé</span> : <span className="text-[10.5px] font-semibold uppercase tracking-wide text-warn mono" title="Valeur relevée par un index tiers ; document original en cours de récupération">index</span>}{p.excluded && <span className="ml-1 text-[10.5px] uppercase text-live mono">exclu</span>}</td>
39 45 </tr>
40 46 );
41 47 })}
modified web/src/components/signals.tsx +18 −6
@@ -4,7 +4,7 @@ import { cls, dateFr, dateShort, delta, int, pct, prob, relTime } from "@/lib/fo
4 4 import { PARTY_IDS, partyAlpha, partyLabel, partyName, partyVar } from "@/lib/parties";
5 5 import { DivergingBars, Sparkline, StackedShare } from "./charts";
6 6 import { PartyBadge, PartyDot } from "./party";
7 −import { Badge, SourceBadge, SourceChip } from "./ui";
7 +import { Badge, Dual, SourceBadge, SourceChip } from "./ui";
8 8
9 9 const fmtPp = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(v).toFixed(2).replace(".", ",")} pt`;
10 10 const fmtTone = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(v).toFixed(2).replace(".", ",")}`;
@@ -65,7 +65,19 @@ export function MarketsPanel({ m, comp, compact = false }: { m: MarketsPayload;
65 65 </div>
66 66 </div>
67 67 {!compact && (
68 − <div className="overflow-x-auto">
68 + <div className="md:hidden divide-y divide-border">
69 + {m.markets.slice(0, 12).map((r) => (
70 + <a key={r.id} href={r.url ?? "#"} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 py-2.5">
71 + {r.party && <PartyBadge id={r.party} size="sm" />}
72 + <div className="min-w-0 flex-1"><div className="text-[13px] leading-snug">{r.kind === "most_seats" ? `Plus de sièges — ${partyLabel(r.party)}` : r.kind === "majority" ? `Majorité ${partyLabel(r.party)}` : r.kind === "seats_band" ? `${partyLabel(r.party)} : ${r.band?.[0] ?? 0}${r.band?.[1] !== null && r.band?.[1] !== undefined ? `–${r.band[1]}` : "+"} sièges` : `${partyLabel(r.party)} ${r.place}e place`}</div><div className="text-[11px] text-ink-3 mono">{int(r.volume)} $ · liq. {int(r.liquidity)} $</div></div>
73 + <div className="mono font-semibold text-[15px] shrink-0">{prob(r.price)}</div>
74 + </a>
75 + ))}
76 + <div className="py-2 text-[12px] text-ink-3">{m.markets.length} marchés suivis · liste complète sur écran large ou sur <a className="link" href="https://polymarket.com/event/quebec-general-election-winner" target="_blank" rel="noopener noreferrer">Polymarket</a>.</div>
77 + </div>
78 + )}
79 + {!compact && (
80 + <div className="hidden md:block overflow-x-auto">
69 81 <table className="data-table min-w-[720px]">
70 82 <thead><tr><th>Marché</th><th>Type</th><th className="r">Oui</th><th className="r">Achat / vente</th><th className="r">Volume</th><th className="r">Liquidité</th><th></th></tr></thead>
71 83 <tbody>
@@ -186,16 +198,16 @@ export function AttentionPanel({ a, compact = false }: { a: AttentionPayload; co
186 198 </div>
187 199 <div className="inset p-4">
188 200 <div className="eyebrow mb-3">Vues quotidiennes · partis + chef·fes</div>
189 − <AttentionChart series={series} />
201 + <Dual narrow={<AttentionChart series={series} width={330} height={180} />} wide={<AttentionChart series={series} />} />
190 202 </div>
191 203 </div>
192 204 </div>
193 205 );
194 206 }
195 207
196 −function AttentionChart({ series }: { series: { date: string; views: Record<string, number> }[] }) {
208 +function AttentionChart({ series, width = 640, height = 200 }: { series: { date: string; views: Record<string, number> }[]; width?: number; height?: number }) {
197 209 if (series.length < 3) return <div className="text-ink-3 text-[13px]">Pas assez de données.</div>;
198 − const W = 640, H = 200, pl = 40, pr = 12, pt = 10, pb = 22;
210 + const W = width, H = height, pl = 40, pr = 12, pt = 10, pb = 22;
199 211 const max = Math.max(...series.flatMap((s) => PARTY_IDS.map((id) => s.views[id] ?? 0)), 10);
200 212 const x = (i: number) => pl + (i / (series.length - 1)) * (W - pl - pr);
201 213 const y = (v: number) => pt + (1 - v / max) * (H - pt - pb);
@@ -245,7 +257,7 @@ export function ByElectionsPanel({ b, comp }: { b: ByElectionsPayload; comp?: Fo
245 257 export function MomentumPanel({ comp }: { comp: ForecastComponents }) {
246 258 const m = comp.momentum;
247 259 return (
248 − <div className="grid md:grid-cols-[1.1fr_1fr] gap-5">
260 + <div className="grid md:grid-cols-[1.1fr_1fr] gap-4 md:gap-5">
249 261 <div className="inset p-4">
250 262 <div className="eyebrow mb-3">Nudge appliqué à la moyenne du vote (pp, somme nulle, borné ± {m.params.maxPp})</div>
251 263 <DivergingBars values={m.shiftPp} max={m.params.maxPp} format={fmtPp} />
modified web/src/components/trend-chart.tsx +35 −16
@@ -4,18 +4,23 @@ import { useMemo, useRef, useState } from "react";
4 4 import type { TrendPoint } from "@/lib/api";
5 5 import { cls, dateFr } from "@/lib/format";
6 6 import { PARTY_IDS, partyLabel, partyVar } from "@/lib/parties";
7 +import { useMeasure } from "@/lib/use-measure";
7 8
8 −/** Tendance interactive : réticule + infobulle (moyenne QC26 ± bande 80 %, sondages du jour). */
9 +/** Tendance interactive, dimensionnée à la largeur réelle (1 unité SVG = 1 px : les textes restent lisibles
10 + * sur téléphone). Réticule + infobulle ; sur écran étroit, les valeurs courantes passent sous le graphique. */
9 11 export function TrendChartInteractive({ trend, polls, from, height = 320, ids = PARTY_IDS, markers = [], election2022, className, showBand = true }: {
10 12 trend: TrendPoint[]; polls?: { date: string; values: Record<string, number>; id?: string; pollster?: string }[]; from?: string; height?: number; ids?: string[];
11 13 markers?: { date: string; label: string }[]; election2022?: Record<string, number> | null; className?: string; showBand?: boolean;
12 14 }) {
15 + const { ref, width } = useMeasure<HTMLDivElement>(900);
13 16 const [range, setRange] = useState<string | undefined>(from);
14 17 const pts = useMemo(() => (range ? trend.filter((t) => t.date >= range) : trend), [trend, range]);
15 18 const [hover, setHover] = useState<number | null>(null);
16 19 const svgRef = useRef<SVGSVGElement>(null);
17 − const W = 900, H = height, pl = 34, pr = 84, pt = 14, pb = 28;
18 20 if (pts.length < 2) return <div className="text-ink-3 text-sm">Pas assez de données.</div>;
21 + const narrow = width < 560;
22 + const W = Math.max(300, width), H = narrow ? Math.min(height, 240) : height;
23 + const pl = 30, pr = narrow ? 10 : 86, pt = 14, pb = 26;
19 24 const t0 = new Date(pts[0].date).getTime(), t1 = new Date(pts[pts.length - 1].date).getTime();
20 25 const x = (d: string) => pl + ((new Date(d).getTime() - t0) / Math.max(1, t1 - t0)) * (W - pl - pr);
21 26 const visPolls = (polls ?? []).filter((p) => p.date >= pts[0].date && p.date <= pts[pts.length - 1].date);
@@ -26,7 +31,7 @@ export function TrendChartInteractive({ trend, polls, from, height = 320, ids =
26 31 const ticks = Array.from({ length: Math.floor(ymax / 10) + 1 }, (_, i) => i * 10);
27 32 const months: { d: Date; label: string }[] = [];
28 33 const spanDays = (t1 - t0) / 86400000;
29 − const step = spanDays > 900 ? 6 : spanDays > 400 ? 3 : 1;
34 + const step = spanDays > 900 ? (narrow ? 12 : 6) : spanDays > 400 ? (narrow ? 6 : 3) : spanDays > 120 ? (narrow ? 2 : 1) : 1;
30 35 const cur = new Date(t0); cur.setDate(1); cur.setMonth(cur.getMonth() + 1);
31 36 while (cur.getTime() <= t1) {
32 37 if (cur.getMonth() % step === 0) months.push({ d: new Date(cur), label: cur.toLocaleDateString("fr-CA", { month: "short", year: spanDays > 200 ? "2-digit" : undefined }) });
@@ -41,28 +46,31 @@ export function TrendChartInteractive({ trend, polls, from, height = 320, ids =
41 46 const last = pts[pts.length - 1];
42 47 const labels = ids.map((id) => ({ id, v: last.mean[id] ?? 0 })).sort((a, b) => b.v - a.v);
43 48 const labelY = labels.reduce<number[]>((acc, l) => { const prev = acc.length ? acc[acc.length - 1] : -Infinity; const yy = y(l.v); acc.push(yy - prev < 13 ? prev + 13 : yy); return acc; }, []);
44 − const onMove = (e: React.PointerEvent) => {
49 + const pick = (clientX: number) => {
45 50 const r = svgRef.current!.getBoundingClientRect();
46 − const px = ((e.clientX - r.left) / r.width) * W;
51 + const px = ((clientX - r.left) / r.width) * W;
47 52 const t = t0 + ((px - pl) / (W - pl - pr)) * (t1 - t0);
48 53 let best = 0, bd = Infinity;
49 54 pts.forEach((p, i) => { const d = Math.abs(new Date(p.date).getTime() - t); if (d < bd) { bd = d; best = i; } });
50 55 setHover(best);
51 56 };
52 57 const hp = hover !== null ? pts[hover] : null;
58 + const shown = hp ?? last;
53 59 const hpolls = hp ? visPolls.filter((p) => Math.abs(new Date(p.date).getTime() - new Date(hp.date).getTime()) <= 2 * 86400000) : [];
54 − const ref = new Date(trend[trend.length - 1].date).getTime(); // date du dernier point (déterministe, pas d'horloge au rendu)
60 + const refT = new Date(trend[trend.length - 1].date).getTime();
55 61 const ranges: { label: string; from?: string }[] = [
56 − { label: "Campagne", from: "2026-08-20" }, { label: "6 mois", from: new Date(ref - 182 * 86400000).toISOString().slice(0, 10) },
57 − { label: "1 an", from: new Date(ref - 365 * 86400000).toISOString().slice(0, 10) }, { label: "Depuis 2022" },
62 + { label: "Campagne", from: "2026-08-20" }, { label: "6 mois", from: new Date(refT - 182 * 86400000).toISOString().slice(0, 10) },
63 + { label: "1 an", from: new Date(refT - 365 * 86400000).toISOString().slice(0, 10) }, { label: "Depuis 2022" },
58 64 ];
65 + const tipLeft = hp ? Math.min(Math.max(8, x(hp.date) + 12), W - 172) : 0;
59 66 return (
60 − <div className={cls("relative", className)}>
67 + <div className={cls("relative", className)} ref={ref}>
61 68 <div className="flex flex-wrap items-center gap-1.5 mb-3">
62 69 {ranges.map((r) => <button key={r.label} onClick={() => { setRange(r.from); setHover(null); }} className={cls("chip !py-1 !px-2.5 !text-[12px]", (range ?? "") === (r.from ?? "") && "chip-active")}>{r.label}</button>)}
63 70 <span className="text-[12px] text-ink-3 ml-auto hidden sm:inline">Survolez pour lire la moyenne QC26 du jour</span>
64 71 </div>
65 − <svg ref={svgRef} viewBox={`0 0 ${W} ${H}`} className="w-full h-auto touch-pan-y" role="img" aria-label="Évolution des intentions de vote (moyenne QC26) et sondages" onPointerMove={onMove} onPointerLeave={() => setHover(null)}>
72 + <svg ref={svgRef} viewBox={`0 0 ${W} ${H}`} width={W} height={H} className="block max-w-full h-auto touch-pan-y" role="img" aria-label="Évolution des intentions de vote (moyenne QC26) et sondages"
73 + onPointerMove={(e) => pick(e.clientX)} onPointerDown={(e) => pick(e.clientX)} onPointerLeave={() => setHover(null)}>
66 74 {ticks.map((t) => (
67 75 <g key={t}>
68 76 <line x1={pl} x2={W - pr} y1={y(t)} y2={y(t)} stroke="var(--grid)" strokeWidth={1} />
@@ -78,11 +86,11 @@ export function TrendChartInteractive({ trend, polls, from, height = 320, ids =
78 86 {markers.filter((m) => new Date(m.date).getTime() >= t0 && new Date(m.date).getTime() <= t1).map((m, i) => (
79 87 <g key={`m${i}`}>
80 88 <line x1={x(m.date)} x2={x(m.date)} y1={pt} y2={H - pb} stroke="var(--text-2)" strokeWidth={1} strokeDasharray="2 3" />
81 − <text x={x(m.date) + 4} y={pt + 9} fontSize={9.5} fill="var(--text-2)" fontFamily="var(--font-mono)">{m.label}</text>
89 + {!narrow && <text x={x(m.date) + 4} y={pt + 9} fontSize={9.5} fill="var(--text-2)" fontFamily="var(--font-mono)">{m.label}</text>}
82 90 </g>
83 91 ))}
84 92 {visPolls.map((p, i) => ids.map((id) => p.values[id] === undefined ? null : (
85 − <circle key={`${i}-${id}`} cx={x(p.date)} cy={y(p.values[id])} r={2.6} fill={partyVar(id)} opacity={0.3} />
93 + <circle key={`${i}-${id}`} cx={x(p.date)} cy={y(p.values[id])} r={narrow ? 2 : 2.6} fill={partyVar(id)} opacity={0.3} />
86 94 )))}
87 95 {showBand && ids.map((id) => <path key={`b${id}`} d={band(id)} fill={partyVar(id)} opacity={0.1} />)}
88 96 {ids.map((id) => <path key={id} d={path(id)} fill="none" stroke={partyVar(id)} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />)}
@@ -95,14 +103,25 @@ export function TrendChartInteractive({ trend, polls, from, height = 320, ids =
95 103 {ids.map((id) => <circle key={`h${id}`} cx={x(hp.date)} cy={y(hp.mean[id] ?? 0)} r={4} fill={partyVar(id)} stroke="var(--surface)" strokeWidth={2} />)}
96 104 </g>
97 105 ) : ids.map((id) => <circle key={`e${id}`} cx={x(last.date)} cy={y(last.mean[id] ?? 0)} r={3.5} fill={partyVar(id)} stroke="var(--surface)" strokeWidth={2} />)}
98 − {labels.map((l, i) => (
106 + {!narrow && labels.map((l, i) => (
99 107 <text key={l.id} x={W - pr + 8} y={labelY[i] + 4} fontSize={12} fontWeight={600} fill="var(--text)" fontFamily="var(--font-sans)">
100 − <tspan fill={partyVar(l.id)}>■</tspan> {partyLabel(l.id)} <tspan fontFamily="var(--font-mono)">{(hp ? hp.mean[l.id] ?? 0 : l.v).toFixed(1).replace(".", ",")}</tspan>
108 + <tspan fill={partyVar(l.id)}>■</tspan> {partyLabel(l.id)} <tspan fontFamily="var(--font-mono)">{(shown.mean[l.id] ?? 0).toFixed(1).replace(".", ",")}</tspan>
101 109 </text>
102 110 ))}
103 111 </svg>
104 − {hp && (
105 − <div className="chart-tip" style={{ left: `min(calc(${((x(hp.date) / W) * 100).toFixed(1)}% + 12px), calc(100% - 200px))`, top: 40 }}>
112 + {narrow && (
113 + <div className="grid grid-cols-5 gap-1.5 mt-2">
114 + {labels.map((l) => (
115 + <div key={l.id} className="inset px-1.5 py-1.5 text-center" style={{ borderTop: `3px solid ${partyVar(l.id)}` }}>
116 + <div className="mono text-[9.5px] uppercase tracking-[0.06em] text-ink-2">{partyLabel(l.id)}</div>
117 + <div className="mono text-[15px] font-semibold leading-tight">{(shown.mean[l.id] ?? 0).toFixed(1).replace(".", ",")}</div>
118 + </div>
119 + ))}
120 + <div className="col-span-5 text-[11px] text-ink-3 text-center">{hp ? `Moyenne QC26 au ${dateFr(hp.date, { day: "numeric", month: "short", year: "numeric" })}` : `Dernier point : ${dateFr(last.date, { day: "numeric", month: "short", year: "numeric" })} · touchez la courbe pour une autre date`}</div>
121 + </div>
122 + )}
123 + {hp && !narrow && (
124 + <div className="chart-tip" style={{ left: tipLeft, top: 40 }}>
106 125 <div className="font-semibold text-ink mb-1">{dateFr(hp.date, { day: "numeric", month: "short", year: "numeric" })}</div>
107 126 {ids.map((id) => ({ id, v: hp.mean[id] ?? 0, sd: hp.sd[id] ?? 0 })).sort((a, b) => b.v - a.v).map((r) => (
108 127 <div key={r.id} className="flex items-center justify-between gap-4"><span className="inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(r.id) }} />{partyLabel(r.id)}</span><span className="mono">{r.v.toFixed(1).replace(".", ",")} <span className="text-ink-3">± {(1.28 * r.sd).toFixed(1).replace(".", ",")}</span></span></div>
modified web/src/components/ui.tsx +25 −5
@@ -40,9 +40,9 @@ export function Metric({ label, value, sub, delta, className, size = "md", color
40 40 <div className="eyebrow">{label}</div>
41 41 <div className={cls(serif ? "figure" : "num font-bold tracking-tight", "mt-1.5", vs)} style={color ? { color } : undefined}>{value}</div>
42 42 {(sub || delta) && (
43 − <div className="flex items-baseline gap-2 mt-1.5 text-[13px] text-ink-2">
43 + <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 mt-1.5 text-[13px] text-ink-2">
44 44 {delta}
45 − {sub && <span>{sub}</span>}
45 + {sub && <span className="whitespace-nowrap">{sub}</span>}
46 46 </div>
47 47 )}
48 48 </div>
@@ -57,7 +57,7 @@ export function MetricDelta({ value, digits = 1, suffix = " pt", invert = false
57 57 const good = invert ? !up : up;
58 58 const txt = zero ? "0" : (up ? "+" : "−") + Math.abs(value).toFixed(digits).replace(".", ",");
59 59 return (
60 − <span className={cls("num inline-flex items-center gap-0.5 font-semibold text-[12px] px-1.5 py-[2px] rounded-[6px] mono", zero ? "text-ink-3 bg-surface-2" : good ? "text-ok bg-ok-soft" : "text-live bg-live-soft")} aria-label={`variation ${txt}${suffix}`}>
60 + <span className={cls("num inline-flex items-center gap-0.5 font-semibold text-[12px] px-1.5 py-[2px] rounded-[6px] mono whitespace-nowrap", zero ? "text-ink-3 bg-surface-2" : good ? "text-ok bg-ok-soft" : "text-live bg-live-soft")} aria-label={`variation ${txt}${suffix}`}>
61 61 {!zero && (up ? "▲" : "▼")} {txt}{suffix}
62 62 </span>
63 63 );
@@ -108,7 +108,7 @@ export function SourceBadge({ source }: { source: { name: string; url?: string |
108 108 <summary className="list-none cursor-pointer inline-flex items-center gap-1.5 text-[10.5px] mono font-semibold uppercase tracking-[0.06em] text-ink-2 border border-border rounded-[6px] px-2 py-[3px] hover:bg-surface-2 select-none">
109 109 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg> Source
110 110 </summary>
111 − <div className="absolute z-30 left-0 mt-2 w-[320px] max-w-[calc(100vw-2rem)] card p-3.5 text-[12.5px] shadow-md text-left leading-relaxed">
111 + <div className="absolute z-30 left-0 mt-2 w-[320px] max-w-[calc(100vw-2rem)] card p-3.5 text-[12.5px] shadow-md text-left leading-relaxed max-md:fixed max-md:left-3 max-md:right-3 max-md:w-auto max-md:max-w-none max-md:bottom-[calc(var(--bottom-nav-h)+env(safe-area-inset-bottom)+12px)] max-md:mt-0 max-md:shadow-lg">
112 112 <div className="font-semibold text-ink">{source.name}</div>
113 113 {source.detail && <div className="text-ink-2 mt-1">{source.detail}</div>}
114 114 <dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-ink-2">
@@ -162,7 +162,7 @@ export function Disclaimer({ text }: { text?: string }) {
162 162
163 163 export function KV({ items, className }: { items: [ReactNode, ReactNode][]; className?: string }) {
164 164 return (
165 − <dl className={cls("grid grid-cols-[minmax(90px,auto)_1fr] gap-x-4 gap-y-1.5 text-[13.5px]", className)}>
165 + <dl className={cls("grid grid-cols-[minmax(80px,auto)_minmax(0,1fr)] gap-x-3 sm:gap-x-4 gap-y-1.5 text-[13.5px]", className)}>
166 166 {items.map(([k, v], i) => (
167 167 <div key={i} className="contents">
168 168 <dt className="text-ink-2">{k}</dt>
@@ -181,3 +181,23 @@ export function SourceChip({ name, at, tone = "neutral" }: { name: string; at?:
181 181 </span>
182 182 );
183 183 }
184 +
185 +/** Rend une variante étroite (mobile) et une variante large (≥ md) : utile pour les SVG à viewBox fixe. */
186 +export function Dual({ narrow, wide, className }: { narrow: ReactNode; wide: ReactNode; className?: string }) {
187 + return (
188 + <>
189 + <div className={cls("md:hidden", className)}>{narrow}</div>
190 + <div className={cls("hidden md:block", className)}>{wide}</div>
191 + </>
192 + );
193 +}
194 +
195 +/** Rangée mobile « clé → valeur » compacte pour remplacer une ligne de tableau. */
196 +export function StatRow({ label, value, sub, className }: { label: ReactNode; value: ReactNode; sub?: ReactNode; className?: string }) {
197 + return (
198 + <div className={cls("flex items-baseline justify-between gap-3 py-2 border-b border-border last:border-b-0", className)}>
199 + <div className="min-w-0"><div className="text-[13.5px] font-medium truncate">{label}</div>{sub && <div className="text-[11.5px] text-ink-3 truncate">{sub}</div>}</div>
200 + <div className="mono text-[13.5px] font-semibold shrink-0">{value}</div>
201 + </div>
202 + );
203 +}
added web/src/lib/use-measure.ts +20 −0
@@ -0,0 +1,20 @@
1 +"use client";
2 +
3 +import { useEffect, useRef, useState } from "react";
4 +
5 +/** Largeur rendue d'un conteneur (ResizeObserver). `fallback` sert au rendu serveur et au premier rendu client. */
6 +export function useMeasure<T extends HTMLElement = HTMLDivElement>(fallback = 640) {
7 + const ref = useRef<T>(null);
8 + const [width, setWidth] = useState(fallback);
9 + useEffect(() => {
10 + const el = ref.current;
11 + if (!el) return;
12 + const ro = new ResizeObserver((entries) => {
13 + const w = Math.round(entries[0]?.contentRect.width ?? 0);
14 + if (w > 0) setWidth(w);
15 + });
16 + ro.observe(el);
17 + return () => ro.disconnect();
18 + }, []);
19 + return { ref, width };
20 +}
21