Python 51.6%
TypeScript 46.7%
CSS 1.7%
1import Link from "next/link";2import type { AttentionPayload, ByElectionsPayload, ForecastComponents, FundamentalsPayload, MarketsPayload, MediaPayload } from "@/lib/api";3import { cls, dateFr, dateShort, delta, int, pct, prob, relTime } from "@/lib/format";4import { PARTY_IDS, partyAlpha, partyLabel, partyName, partyVar } from "@/lib/parties";5import { DivergingBars, Sparkline, StackedShare } from "./charts";6import { PartyBadge, PartyDot } from "./party";7import { Badge, Dual, SourceBadge, SourceChip } from "./ui";89const fmtPp = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(v).toFixed(2).replace(".", ",")} pt`;10const fmtTone = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(v).toFixed(2).replace(".", ",")}`;1112// ------------------------------------------------------------- MARCHÉS13export function MarketsPanel({ m, comp, compact = false }: { m: MarketsPayload; comp?: ForecastComponents | null; compact?: boolean }) {14 const most = Object.entries(m.mostSeats).sort((a, b) => b[1] - a[1]);15 const hist = m.history ?? {};16 const asOf = m.asOf;17 return (18 <div className="space-y-5">19 <div className="flex flex-wrap items-center gap-2">20 <SourceChip name="Polymarket" at={asOf} tone={asOf ? "ok" : "warn"} />21 <span className="text-[12px] text-ink-3 num">volume cumulé {int(m.volume)} $ US · {m.markets.length} marchés suivis</span>22 <span className="ml-auto"><SourceBadge source={{ name: "Polymarket — Quebec General Election (API Gamma publique, prix « Oui »)", url: "https://polymarket.com/event/quebec-general-election-winner", retrievedAt: asOf, tier: "Tier 3 · marché de prédiction (signal)", detail: "Prix renormalisés entre partis pour « plus de sièges » ; historique quotidien archivé." }} /></span>23 </div>24 <div className={cls("grid gap-4", compact ? "md:grid-cols-2" : "lg:grid-cols-3")}>25 <div className="inset p-4">26 <div className="eyebrow mb-3">Plus de sièges · probabilité implicite</div>27 <div className="space-y-2.5">28 {most.filter(([, p]) => p >= 0.003).map(([id, p]) => {29 const series = (hist[`most_seats:${id}`] ?? []).map((x) => x.p);30 return (31 <div key={id} className="grid grid-cols-[38px_1fr_52px_84px] items-center gap-3 text-[13px]">32 <span className="font-semibold inline-flex items-center gap-1.5"><PartyDot id={id} />{partyLabel(id)}</span>33 <div className="h-[8px] rounded-full bg-surface-3 overflow-hidden"><div className="h-full rounded-full" style={{ width: `${p * 100}%`, background: partyVar(id) }} /></div>34 <span className="mono font-semibold text-right">{prob(p)}</span>35 <Sparkline values={series.slice(-60)} color={partyVar(id)} width={84} height={22} />36 </div>37 );38 })}39 </div>40 <div className="text-[11px] text-ink-3 mt-3">Étincelles : 60 derniers jours du prix « Oui ».</div>41 </div>42 <div className="inset p-4">43 <div className="eyebrow mb-3">Majorité & classement</div>44 {Object.entries(m.majority).map(([id, p]) => (45 <div key={id} className="flex items-center justify-between text-[13px] mb-2"><span className="inline-flex items-center gap-1.5"><PartyDot id={id} />Majorité {partyLabel(id)}</span><span className="mono font-semibold">{prob(p)}</span></div>46 ))}47 {[2, 3].map((pl) => {48 const rows = Object.entries(m.place?.[String(pl)] ?? m.place?.[pl as unknown as string] ?? {}).sort((a, b) => b[1] - a[1]).slice(0, 3);49 if (!rows.length) return null;50 return <div key={pl} className="mt-3"><div className="text-[11px] text-ink-3 mb-1">{pl}e place</div><div className="flex flex-wrap gap-1.5">{rows.map(([id, p]) => <span key={id} className="inline-flex items-center gap-1.5 text-[12px] rounded-[6px] border border-border px-2 py-[3px]"><PartyDot id={id} />{partyLabel(id)} <b className="mono">{prob(p)}</b></span>)}</div></div>;51 })}52 </div>53 <div className="inset p-4">54 <div className="eyebrow mb-3">Tranches de sièges · marché vs QC26</div>55 {Object.entries(m.seatBands).map(([id, sb]) => (56 <div key={id} className="mb-3">57 <div className="flex items-baseline justify-between text-[12.5px]"><span className="font-semibold inline-flex items-center gap-1.5"><PartyDot id={id} />{partyLabel(id)}</span><span className="text-ink-3 mono">moy. implicite {String(sb.impliedMean).replace(".", ",")}</span></div>58 <div className="flex gap-[2px] mt-1 h-[16px]">59 {sb.bands.map((b, i) => <div key={i} className="relative rounded-[3px] overflow-hidden bg-surface-3" style={{ flex: 1 }} title={`${b.lo}${b.hi !== null ? `–${b.hi}` : "+"} : ${prob(b.p)}`}><div className="absolute bottom-0 inset-x-0" style={{ height: `${Math.min(100, b.p * 100 * 1.6)}%`, background: partyAlpha(id, 0.8) }} /></div>)}60 </div>61 <div className="flex justify-between text-[10px] mono text-ink-3 mt-0.5"><span>{sb.bands[0]?.lo === 0 ? `< ${(sb.bands[0]?.hi ?? 0) + 1}` : sb.bands[0]?.lo}</span><span>{sb.bands[sb.bands.length - 1]?.lo}+</span></div>62 </div>63 ))}64 {comp?.markets?.enabled && <div className="text-[11px] text-ink-3">Tempérage QC26 : poids {Math.round(comp.markets.weight * 100)} % (tranches {Math.round((comp.markets.bandWeight ?? 0) * 100)} %), ESS {int(comp.markets.ess)}.</div>}65 </div>66 </div>67 {!compact && (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">81 <table className="data-table min-w-[720px]">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>83 <tbody>84 {m.markets.slice(0, 40).map((r) => (85 <tr key={r.id}><td className="max-w-[420px]">{r.party && <PartyBadge id={r.party} size="sm" className="mr-2" />}{r.question}</td><td><Badge>{r.kind === "most_seats" ? "plus de sièges" : r.kind === "majority" ? "majorité" : r.kind === "seats_band" ? "tranche" : `${r.place}e place`}</Badge></td><td className="r mono font-semibold">{prob(r.price)}</td><td className="r mono text-ink-2">{r.bid !== null && r.ask !== null ? `${Math.round(r.bid * 100)} / ${Math.round(r.ask * 100)}` : "—"}</td><td className="r mono">{int(r.volume)} $</td><td className="r mono text-ink-2">{int(r.liquidity)} $</td><td className="r">{r.url && <a href={r.url} target="_blank" rel="noopener noreferrer" className="link text-[12px]">ouvrir</a>}</td></tr>86 ))}87 </tbody>88 </table>89 </div>90 )}91 </div>92 );93}9495// ------------------------------------------------------------ FONDAMENTAUX96export function FundamentalsPanel({ f, comp }: { f: FundamentalsPayload; comp?: ForecastComponents | null }) {97 const satSeries = (f.series?.satisfaction ?? []).filter((x) => x.party === f.incumbent);98 return (99 <div className="space-y-5">100 <div className="flex flex-wrap items-center gap-2">101 <SourceChip name="Rapports de sondage · questions de leadership" at={f.asOf} tone="ok" />102 <span className="text-[12px] text-ink-3">{f.satisfactionPolls} sondages avec question de satisfaction (150 j) · pondération récence (demi-vie 30 j) × √n</span>103 </div>104 <div className="grid md:grid-cols-3 gap-4">105 <div className="inset p-4">106 <div className="eyebrow">Satisfaction · gouvernement {partyLabel(f.incumbent)}</div>107 <div className="figure text-[52px] mt-2">{f.satisfaction !== null ? pct(f.satisfaction, 0) : "—"}</div>108 <div className="text-[12.5px] text-ink-2">net {f.satisfactionNet !== null ? delta(f.satisfactionNet, 0, " pt") : "—"}{f.satisfactionBilan !== null ? ` · bilan des huit années : ${pct(f.satisfactionBilan, 0)}` : ""}</div>109 {satSeries.length > 1 && <div className="mt-3"><Sparkline values={satSeries.map((x) => x.value)} color={partyVar(f.incumbent)} width={220} height={36} fill /><div className="text-[10.5px] mono text-ink-3 mt-1">{dateShort(satSeries[0].date)} → {dateShort(satSeries[satSeries.length - 1].date)}</div></div>}110 {comp?.fundamentals?.enabled && <div className="mt-3 text-[12px] text-ink-2 leading-relaxed">Prior fondamentaux : {partyLabel(f.incumbent)} ≈ <b className="mono">{pct(comp.fundamentals.incumbentPrior)}</b> ({String(comp.fundamentals.params?.alpha).replace(".", ",")} + {String(comp.fundamentals.params?.beta).replace(".", ",")} × satisfaction), poids <b className="mono">{Math.round(comp.fundamentals.weight * 100)} %</b> à J−{comp.fundamentals.daysLeft}. Effet sur la moyenne : {Object.entries(comp.fundamentals.shiftPp ?? {}).filter(([k]) => k !== "aut").map(([k, v]) => `${partyLabel(k)} ${fmtPp(v)}`).join(", ")}.</div>}111 </div>112 <div className="inset p-4">113 <div className="eyebrow mb-3">Meilleur·e premier·ère ministre · part des répondants décidés</div>114 <div className="space-y-2">115 {PARTY_IDS.map((id) => ({ id, v: f.bestPmShare[id] })).sort((a, b) => (b.v ?? 0) - (a.v ?? 0)).map(({ id, v }) => (116 <div key={id} className="grid grid-cols-[38px_1fr_52px] items-center gap-3 text-[13px]"><span className="font-semibold inline-flex items-center gap-1.5"><PartyDot id={id} />{partyLabel(id)}</span><div className="h-[8px] rounded-full bg-surface-3 overflow-hidden">{v !== null && <div className="h-full rounded-full" style={{ width: `${v}%`, background: partyVar(id) }} />}</div><span className="mono font-semibold text-right">{v !== null ? pct(v, 0) : "—"}</span></div>117 ))}118 </div>119 <div className="text-[11px] text-ink-3 mt-3">Écart « meilleur PM − intention de vote » = prime au chef (κ {comp?.momentum?.params?.leaderKappa ?? 0.15}) dans le momentum.</div>120 </div>121 <div className="inset p-4">122 <div className="eyebrow mb-3">Favorabilité nette des chef·fes</div>123 <DivergingBars values={f.leaderNetFavorable} max={50} format={(v) => delta(v, 0, " pt")} />124 <div className="grid grid-cols-2 gap-3 mt-4 text-[12.5px]">125 <div><div className="text-[11px] text-ink-3">Souveraineté (Oui)</div><div className="figure text-[24px]">{f.sovereigntyYes !== null ? pct(f.sovereigntyYes, 0) : "—"}</div></div>126 <div><div className="text-[11px] text-ink-3">« Représente le changement »</div><div className="text-[13px] mt-1">{PARTY_IDS.map((id) => ({ id, v: f.changeLeader[id] })).filter((x) => x.v !== null).sort((a, b) => (b.v ?? 0) - (a.v ?? 0)).slice(0, 3).map((x) => `${partyLabel(x.id)} ${pct(x.v, 0)}`).join(" · ") || "—"}</div></div>127 </div>128 {Object.values(f.secondChoice).some((v) => v !== null) && <div className="mt-3 text-[12px] text-ink-2">Deuxième choix : {PARTY_IDS.map((id) => ({ id, v: f.secondChoice[id] })).filter((x) => x.v !== null).sort((a, b) => (b.v ?? 0) - (a.v ?? 0)).map((x) => `${partyLabel(x.id)} ${pct(x.v, 0)}`).join(" · ")}</div>}129 </div>130 </div>131 </div>132 );133}134135// ---------------------------------------------------------------- MÉDIAS136export function MediaPanel({ m, compact = false }: { m: MediaPayload; compact?: boolean }) {137 const topics = Object.entries(m.topics ?? {}).slice(0, 8);138 return (139 <div className="space-y-5">140 <div className="flex flex-wrap items-center gap-2">141 <SourceChip name="Presse québécoise · Google Actualités" at={m.asOf} tone={m.enabled ? "ok" : "warn"} />142 <span className="text-[12px] text-ink-3">{m.articles} titres classés sur {m.window} jours · ton par parti attribué par LLM, validé (parti reconnu, ton borné)</span>143 </div>144 <div className={cls("grid gap-4", compact ? "md:grid-cols-2" : "lg:grid-cols-3")}>145 <div className="inset p-4">146 <div className="eyebrow mb-3">Part de voix · {m.window} j</div>147 <StackedShare values={m.shareOfVoice} height={14} />148 <div className="text-[11px] text-ink-3 mt-3">Part des titres mentionnant chaque parti ({Object.values(m.counts).reduce((a, b) => a + b, 0)} mentions).</div>149 </div>150 <div className="inset p-4">151 <div className="eyebrow mb-3">Ton moyen · −1 défavorable … +1 favorable</div>152 <DivergingBars values={m.tone} max={0.6} format={fmtTone} />153 <div className="text-[11px] text-ink-3 mt-3">Moyenne des tons attribués aux titres ; n faible = signal fragile ({PARTY_IDS.map((id) => `${partyLabel(id)} ${m.counts[id] ?? 0}`).join(", ")}).</div>154 </div>155 <div className="inset p-4">156 <div className="eyebrow mb-3">Sujets de la couverture</div>157 <div className="flex flex-wrap gap-1.5">{topics.map(([t, n]) => <span key={t} className="chip !py-1 !px-2.5 !text-[12px]">{t} <b className="mono">{n}</b></span>)}</div>158 {Object.values(m.gdelt).some((g) => g.volume28 !== null) && (159 <div className="mt-4 text-[12px]"><div className="text-[11px] text-ink-3 mb-1">GDELT · volume 7 j vs 28 j (‰ des articles surveillés)</div>{PARTY_IDS.map((id) => { const g = m.gdelt[id]; return g?.volume28 !== null ? <div key={id} className="flex justify-between"><span>{partyLabel(id)}</span><span className="mono">{g.volume7?.toFixed(3)} / {g.volume28?.toFixed(3)}{g.tone7 !== null ? ` · ton ${g.tone7}` : ""}</span></div> : null; })}</div>160 )}161 </div>162 </div>163 {!compact && (164 <div>165 <div className="eyebrow mb-2">Revue de presse · derniers titres classés</div>166 <div className="grid md:grid-cols-2 gap-2">167 {m.latest.slice(0, 24).map((it) => (168 <a key={it.id} href={it.url} target="_blank" rel="noopener noreferrer" className="card card-hover p-3.5 block">169 <div className="flex items-center gap-2 text-[11px] text-ink-3 mono"><span className="uppercase tracking-[0.06em]">{it.domain}</span><span>·</span><span>{relTime(it.publishedAt)}</span>{it.topic && <span className="ml-auto chip !py-0 !px-1.5 !text-[10.5px]">{it.topic}</span>}</div>170 <div className="font-semibold text-[14px] mt-1.5 leading-snug">{it.title}</div>171 {it.summary && <div className="text-[12.5px] text-ink-2 mt-1">{it.summary}</div>}172 {it.parties && <div className="flex flex-wrap gap-1.5 mt-2">{Object.entries(it.parties).map(([p, t]) => <span key={p} className={cls("inline-flex items-center gap-1 text-[11px] mono rounded-[5px] px-1.5 py-[2px]", t > 0.15 ? "bg-ok-soft text-ok" : t < -0.15 ? "bg-live-soft text-live" : "bg-surface-2 text-ink-2")}><span className="w-1.5 h-1.5 rounded-full" style={{ background: partyVar(p) }} />{partyLabel(p)} {fmtTone(t)}</span>)}</div>}173 </a>174 ))}175 </div>176 </div>177 )}178 </div>179 );180}181182// ------------------------------------------------------------- ATTENTION183export function AttentionPanel({ a, compact = false }: { a: AttentionPayload; compact?: boolean }) {184 if (!a.enabled) return <div className="text-ink-3 text-[13.5px]">Signal d'attention indisponible.</div>;185 const series = a.series ?? [];186 return (187 <div className="space-y-5">188 <div className="flex flex-wrap items-center gap-2">189 <SourceChip name="Wikipédia FR · pages vues" at={a.asOf} tone="ok" />190 <span className="text-[12px] text-ink-3">vues humaines des pages des partis et de leurs chef·fes (API Wikimedia), part du total des cinq partis</span>191 <span className="ml-auto"><SourceBadge source={{ name: "Wikimedia REST API — pageviews per-article (fr.wikipedia, all-access, user)", url: "https://wikimedia.org/api/rest_v1/", tier: "Tier 3 · attention publique (signal)", detail: Object.entries(a.pages ?? {}).map(([p, t]) => `${p.toUpperCase()} : ${t.join(" + ")}`).join(" · ") }} /></span>192 </div>193 <div className={cls("grid gap-4", compact ? "md:grid-cols-2" : "lg:grid-cols-[1fr_1.4fr]")}>194 <div className="inset p-4">195 <div className="eyebrow mb-3">Part d'attention · 7 derniers jours</div>196 <StackedShare values={a.share7 ?? {}} height={14} />197 <div className="mt-4"><div className="text-[11px] text-ink-3 mb-1.5">Variation 7 j vs 28 j (pt de part)</div><DivergingBars values={a.delta ?? {}} max={8} format={(v) => delta(v, 1, " pt")} /></div>198 </div>199 <div className="inset p-4">200 <div className="eyebrow mb-3">Vues quotidiennes · partis + chef·fes</div>201 <Dual narrow={<AttentionChart series={series} width={330} height={180} />} wide={<AttentionChart series={series} />} />202 </div>203 </div>204 </div>205 );206}207208function AttentionChart({ series, width = 640, height = 200 }: { series: { date: string; views: Record<string, number> }[]; width?: number; height?: number }) {209 if (series.length < 3) return <div className="text-ink-3 text-[13px]">Pas assez de données.</div>;210 const W = width, H = height, pl = 40, pr = 12, pt = 10, pb = 22;211 const max = Math.max(...series.flatMap((s) => PARTY_IDS.map((id) => s.views[id] ?? 0)), 10);212 const x = (i: number) => pl + (i / (series.length - 1)) * (W - pl - pr);213 const y = (v: number) => pt + (1 - v / max) * (H - pt - pb);214 const path = (id: string) => series.map((s, i) => `${i ? "L" : "M"}${x(i).toFixed(1)},${y(s.views[id] ?? 0).toFixed(1)}`).join(" ");215 const ticks = [0, Math.round(max / 2), Math.round(max)];216 return (217 <svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto" role="img" aria-label="Pages vues quotidiennes par parti">218 {ticks.map((t) => <g key={t}><line x1={pl} x2={W - pr} y1={y(t)} y2={y(t)} stroke="var(--grid)" /><text x={pl - 6} y={y(t) + 3.5} fontSize={10} textAnchor="end" fill="var(--text-3)" fontFamily="var(--font-mono)">{int(t)}</text></g>)}219 {PARTY_IDS.map((id) => <path key={id} d={path(id)} fill="none" stroke={partyVar(id)} strokeWidth={1.8} strokeLinejoin="round" />)}220 {[0, Math.floor(series.length / 2), series.length - 1].map((i) => <text key={i} x={x(i)} y={H - 6} fontSize={10} textAnchor={i === 0 ? "start" : i === series.length - 1 ? "end" : "middle"} fill="var(--text-3)" fontFamily="var(--font-mono)">{dateShort(series[i].date)}</text>)}221 </svg>222 );223}224225// ------------------------------------------------------------ PARTIELLES226export function ByElectionsPanel({ b, comp }: { b: ByElectionsPayload; comp?: ForecastComponents | null }) {227 if (!b.enabled) return <div className="text-ink-3 text-[13.5px]">Aucune partielle archivée.</div>;228 const adj = new Map((comp?.byelections?.ridings ?? []).map((r) => [r.riding, r]));229 return (230 <div className="space-y-4">231 <div className="flex flex-wrap items-center gap-2">232 <SourceChip name="Élections Québec · résultats officiels" tone="ok" />233 <span className="text-[12px] text-ink-3">{b.n} partielles depuis le 3 octobre 2022 · swing = résultat de la partielle − résultat général 2022 dans la même circonscription</span>234 </div>235 <div className="grid md:grid-cols-2 xl:grid-cols-3 gap-3">236 {b.byElections.slice().reverse().map((e) => {237 const a = adj.get(e.riding ?? "");238 return (239 <div key={e.id} className="card p-4" style={{ borderTop: `3px solid ${partyVar(e.winner)}` }}>240 <div className="flex items-start justify-between gap-2"><div><div className="font-semibold text-[15px]">{e.riding}</div><div className="text-[12px] text-ink-3 num">{dateFr(e.date)}{e.turnout !== null ? ` · participation ${pct(e.turnout, 0)}` : ""}</div></div><PartyBadge id={e.winner} /></div>241 <div className="mt-3 space-y-1.5">242 {e.results.filter((r) => r.party_id).slice(0, 5).map((r) => (243 <div key={r.candidate} className="grid grid-cols-[36px_1fr_48px_56px] items-center gap-2 text-[12.5px]"><span className="font-semibold" style={{ color: partyVar(r.party_id) }}>{partyLabel(r.party_id)}</span><div className="h-[6px] rounded-full bg-surface-3 overflow-hidden"><div className="h-full" style={{ width: `${r.share}%`, background: partyVar(r.party_id) }} /></div><span className="mono text-right">{r.share.toFixed(0)} %</span><span className={cls("mono text-right", (e.swing[r.party_id ?? ""] ?? 0) > 0 ? "text-ok" : "text-live")}>{e.swing[r.party_id ?? ""] !== undefined ? delta(e.swing[r.party_id ?? ""], 0, " pt") : ""}</span></div>244 ))}245 </div>246 <div className="mt-3 flex items-center justify-between text-[11.5px] text-ink-3"><span>{a ? `ajustement QC26 ${partyLabel(e.winner)} ${(a.adjLogit[e.winner ?? ""] ?? 0) >= 0 ? "+" : ""}${(a.adjLogit[e.winner ?? ""] ?? 0).toFixed(2)} logit` : "hors carte 2026"}</span><a href={e.url} target="_blank" rel="noopener noreferrer" className="link">DGEQ</a></div>247 </div>248 );249 })}250 </div>251 <div className="text-[12.5px] text-ink-2">Swing moyen sur {b.n} partielles : {PARTY_IDS.map((id) => `${partyLabel(id)} ${b.meanSwing[id] !== null ? delta(b.meanSwing[id], 0, " pt") : "—"}`).join(" · ")}. Les partielles ont une participation faible et des dynamiques locales : le modèle n'en retient que l'excès local net du swing national, rétréci (κ {comp?.byelections?.ridings?.[0]?.kappa ?? 0.35}).</div>252 </div>253 );254}255256// ------------------------------------------------------------- MOMENTUM257export function MomentumPanel({ comp }: { comp: ForecastComponents }) {258 const m = comp.momentum;259 return (260 <div className="grid md:grid-cols-[1.1fr_1fr] gap-4 md:gap-5">261 <div className="inset p-4">262 <div className="eyebrow mb-3">Nudge appliqué à la moyenne du vote (pp, somme nulle, borné ± {m.params.maxPp})</div>263 <DivergingBars values={m.shiftPp} max={m.params.maxPp} format={fmtPp} />264 </div>265 <div className="inset p-4 text-[12.5px]">266 <div className="eyebrow mb-3">Décomposition avant bornage</div>267 <table className="w-full"><thead><tr className="text-[11px] text-ink-3 mono uppercase"><th className="text-left font-medium pb-1">Parti</th><th className="text-right font-medium pb-1">Chef</th><th className="text-right font-medium pb-1">Attention</th><th className="text-right font-medium pb-1">Médias</th></tr></thead>268 <tbody>{PARTY_IDS.map((id) => <tr key={id} className="border-t border-border"><td className="py-1 font-semibold inline-flex items-center gap-1.5"><PartyDot id={id} />{partyLabel(id)}</td><td className="text-right mono">{m.parts.leader[id] !== undefined ? fmtPp(m.parts.leader[id]) : "—"}</td><td className="text-right mono">{m.parts.attention[id] !== undefined ? fmtPp(m.parts.attention[id]) : "—"}</td><td className="text-right mono">{m.parts.media[id] !== undefined ? fmtPp(m.parts.media[id]) : "—"}</td></tr>)}</tbody></table>269 <div className="text-[11px] text-ink-3 mt-3">κ chef {m.params.leaderKappa} · κ attention {m.params.attentionKappa} · κ médias {m.params.mediaKappa}. Le total est écrêté puis recentré : le momentum déplace du vote entre partis, il n'en crée pas.</div>270 </div>271 </div>272 );273}274275export function SignalsOverview({ comp, className }: { comp: ForecastComponents; className?: string }) {276 const items = [277 { k: "Sondages", v: `${comp.nSimulations ? int(comp.nSimulations) : ""} simulations`, on: true, href: "/sondages", sub: "filtre de Kalman, effets maison" },278 { k: "Marchés", v: comp.markets.enabled ? `poids ${Math.round(comp.markets.weight * 100)} %` : "inactif", on: comp.markets.enabled, href: "/signaux#marches", sub: comp.markets.marketVolume ? `${int(comp.markets.marketVolume)} $ de volume` : "Polymarket" },279 { k: "Fondamentaux", v: comp.fundamentals.enabled ? `poids ${Math.round(comp.fundamentals.weight * 100)} %` : "inactif", on: comp.fundamentals.enabled, href: "/signaux#fondamentaux", sub: comp.fundamentals.satisfaction !== undefined ? `satisfaction ${String(comp.fundamentals.satisfaction).replace(".", ",")} %` : "satisfaction du gouvernement" },280 { k: "Momentum", v: comp.momentum.enabled ? `± ${Math.max(...Object.values(comp.momentum.shiftPp).map(Math.abs)).toFixed(1).replace(".", ",")} pt max` : "inactif", on: comp.momentum.enabled, href: "/signaux#momentum", sub: "chef · attention · presse" },281 { k: "Partielles", v: comp.byelections.enabled ? `${comp.byelections.ridings.length} circonscriptions` : "aucune", on: comp.byelections.enabled, href: "/signaux#partielles", sub: "excès de swing local" },282 ];283 return (284 <div className={cls("grid grid-cols-2 md:grid-cols-5 gap-3", className)}>285 {items.map((it) => (286 <Link key={it.k} href={it.href} className="card card-hover p-3.5 block">287 <div className="flex items-center gap-1.5 text-[11px] mono uppercase tracking-[0.06em] text-ink-2"><span className={cls("w-1.5 h-1.5 rounded-full", it.on ? "bg-ok" : "bg-ink-3")} />{it.k}</div>288 <div className="font-semibold text-[15px] mt-1.5">{it.v}</div>289 <div className="text-[12px] text-ink-3">{it.sub}</div>290 </Link>291 ))}292 </div>293 );294}295296export { partyName };297