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>
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'Élections Québec (simplifiée pour le web). Avant le scrutin, aucune circonscription n'est « gagnée » : l'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'enjeu (cliquez pour la source, la page et la citation). Une case vide signifie qu'aucun document officiel ingéré n'aborde le sujet, pas que le parti n'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'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'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'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'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'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'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 | ||