import Link from "next/link"; import type { AttentionPayload, ByElectionsPayload, ForecastComponents, FundamentalsPayload, MarketsPayload, MediaPayload } from "@/lib/api"; import { cls, dateFr, dateShort, delta, int, pct, prob, relTime } from "@/lib/format"; import { PARTY_IDS, partyAlpha, partyLabel, partyName, partyVar } from "@/lib/parties"; import { DivergingBars, Sparkline, StackedShare } from "./charts"; import { PartyBadge, PartyDot } from "./party"; import { Badge, Dual, SourceBadge, SourceChip } from "./ui"; const fmtPp = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(v).toFixed(2).replace(".", ",")} pt`; const fmtTone = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(v).toFixed(2).replace(".", ",")}`; // ------------------------------------------------------------- MARCHÉS export function MarketsPanel({ m, comp, compact = false }: { m: MarketsPayload; comp?: ForecastComponents | null; compact?: boolean }) { const most = Object.entries(m.mostSeats).sort((a, b) => b[1] - a[1]); const hist = m.history ?? {}; const asOf = m.asOf; return (
volume cumulé {int(m.volume)} $ US · {m.markets.length} marchés suivis
Plus de sièges · probabilité implicite
{most.filter(([, p]) => p >= 0.003).map(([id, p]) => { const series = (hist[`most_seats:${id}`] ?? []).map((x) => x.p); return (
{partyLabel(id)}
{prob(p)}
); })}
Étincelles : 60 derniers jours du prix « Oui ».
Majorité & classement
{Object.entries(m.majority).map(([id, p]) => (
Majorité {partyLabel(id)}{prob(p)}
))} {[2, 3].map((pl) => { 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); if (!rows.length) return null; return
{pl}e place
{rows.map(([id, p]) => {partyLabel(id)} {prob(p)})}
; })}
Tranches de sièges · marché vs QC26
{Object.entries(m.seatBands).map(([id, sb]) => (
{partyLabel(id)}moy. implicite {String(sb.impliedMean).replace(".", ",")}
{sb.bands.map((b, i) =>
)}
{sb.bands[0]?.lo === 0 ? `< ${(sb.bands[0]?.hi ?? 0) + 1}` : sb.bands[0]?.lo}{sb.bands[sb.bands.length - 1]?.lo}+
))} {comp?.markets?.enabled &&
Tempérage QC26 : poids {Math.round(comp.markets.weight * 100)} % (tranches {Math.round((comp.markets.bandWeight ?? 0) * 100)} %), ESS {int(comp.markets.ess)}.
}
{!compact && (
{m.markets.slice(0, 12).map((r) => ( {r.party && }
{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`}
{int(r.volume)} $ · liq. {int(r.liquidity)} $
{prob(r.price)}
))}
{m.markets.length} marchés suivis · liste complète sur écran large ou sur Polymarket.
)} {!compact && (
{m.markets.slice(0, 40).map((r) => ( ))}
MarchéTypeOuiAchat / venteVolumeLiquidité
{r.party && }{r.question}{r.kind === "most_seats" ? "plus de sièges" : r.kind === "majority" ? "majorité" : r.kind === "seats_band" ? "tranche" : `${r.place}e place`}{prob(r.price)}{r.bid !== null && r.ask !== null ? `${Math.round(r.bid * 100)} / ${Math.round(r.ask * 100)}` : "—"}{int(r.volume)} ${int(r.liquidity)} ${r.url && ouvrir}
)}
); } // ------------------------------------------------------------ FONDAMENTAUX export function FundamentalsPanel({ f, comp }: { f: FundamentalsPayload; comp?: ForecastComponents | null }) { const satSeries = (f.series?.satisfaction ?? []).filter((x) => x.party === f.incumbent); return (
{f.satisfactionPolls} sondages avec question de satisfaction (150 j) · pondération récence (demi-vie 30 j) × √n
Satisfaction · gouvernement {partyLabel(f.incumbent)}
{f.satisfaction !== null ? pct(f.satisfaction, 0) : "—"}
net {f.satisfactionNet !== null ? delta(f.satisfactionNet, 0, " pt") : "—"}{f.satisfactionBilan !== null ? ` · bilan des huit années : ${pct(f.satisfactionBilan, 0)}` : ""}
{satSeries.length > 1 &&
x.value)} color={partyVar(f.incumbent)} width={220} height={36} fill />
{dateShort(satSeries[0].date)} → {dateShort(satSeries[satSeries.length - 1].date)}
} {comp?.fundamentals?.enabled &&
Prior fondamentaux : {partyLabel(f.incumbent)} ≈ {pct(comp.fundamentals.incumbentPrior)} ({String(comp.fundamentals.params?.alpha).replace(".", ",")} + {String(comp.fundamentals.params?.beta).replace(".", ",")} × satisfaction), poids {Math.round(comp.fundamentals.weight * 100)} % à 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(", ")}.
}
Meilleur·e premier·ère ministre · part des répondants décidés
{PARTY_IDS.map((id) => ({ id, v: f.bestPmShare[id] })).sort((a, b) => (b.v ?? 0) - (a.v ?? 0)).map(({ id, v }) => (
{partyLabel(id)}
{v !== null &&
}
{v !== null ? pct(v, 0) : "—"}
))}
Écart « meilleur PM − intention de vote » = prime au chef (κ {comp?.momentum?.params?.leaderKappa ?? 0.15}) dans le momentum.
Favorabilité nette des chef·fes
delta(v, 0, " pt")} />
Souveraineté (Oui)
{f.sovereigntyYes !== null ? pct(f.sovereigntyYes, 0) : "—"}
« Représente le changement »
{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(" · ") || "—"}
{Object.values(f.secondChoice).some((v) => v !== null) &&
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(" · ")}
}
); } // ---------------------------------------------------------------- MÉDIAS export function MediaPanel({ m, compact = false }: { m: MediaPayload; compact?: boolean }) { const topics = Object.entries(m.topics ?? {}).slice(0, 8); return (
{m.articles} titres classés sur {m.window} jours · ton par parti attribué par LLM, validé (parti reconnu, ton borné)
Part de voix · {m.window} j
Part des titres mentionnant chaque parti ({Object.values(m.counts).reduce((a, b) => a + b, 0)} mentions).
Ton moyen · −1 défavorable … +1 favorable
Moyenne des tons attribués aux titres ; n faible = signal fragile ({PARTY_IDS.map((id) => `${partyLabel(id)} ${m.counts[id] ?? 0}`).join(", ")}).
Sujets de la couverture
{topics.map(([t, n]) => {t} {n})}
{Object.values(m.gdelt).some((g) => g.volume28 !== null) && (
GDELT · volume 7 j vs 28 j (‰ des articles surveillés)
{PARTY_IDS.map((id) => { const g = m.gdelt[id]; return g?.volume28 !== null ?
{partyLabel(id)}{g.volume7?.toFixed(3)} / {g.volume28?.toFixed(3)}{g.tone7 !== null ? ` · ton ${g.tone7}` : ""}
: null; })}
)}
{!compact && (
Revue de presse · derniers titres classés
)}
); } // ------------------------------------------------------------- ATTENTION export function AttentionPanel({ a, compact = false }: { a: AttentionPayload; compact?: boolean }) { if (!a.enabled) return
Signal d'attention indisponible.
; const series = a.series ?? []; return (
vues humaines des pages des partis et de leurs chef·fes (API Wikimedia), part du total des cinq partis `${p.toUpperCase()} : ${t.join(" + ")}`).join(" · ") }} />
Part d'attention · 7 derniers jours
Variation 7 j vs 28 j (pt de part)
delta(v, 1, " pt")} />
Vues quotidiennes · partis + chef·fes
} wide={} />
); } function AttentionChart({ series, width = 640, height = 200 }: { series: { date: string; views: Record }[]; width?: number; height?: number }) { if (series.length < 3) return
Pas assez de données.
; const W = width, H = height, pl = 40, pr = 12, pt = 10, pb = 22; const max = Math.max(...series.flatMap((s) => PARTY_IDS.map((id) => s.views[id] ?? 0)), 10); const x = (i: number) => pl + (i / (series.length - 1)) * (W - pl - pr); const y = (v: number) => pt + (1 - v / max) * (H - pt - pb); const path = (id: string) => series.map((s, i) => `${i ? "L" : "M"}${x(i).toFixed(1)},${y(s.views[id] ?? 0).toFixed(1)}`).join(" "); const ticks = [0, Math.round(max / 2), Math.round(max)]; return ( {ticks.map((t) => {int(t)})} {PARTY_IDS.map((id) => )} {[0, Math.floor(series.length / 2), series.length - 1].map((i) => {dateShort(series[i].date)})} ); } // ------------------------------------------------------------ PARTIELLES export function ByElectionsPanel({ b, comp }: { b: ByElectionsPayload; comp?: ForecastComponents | null }) { if (!b.enabled) return
Aucune partielle archivée.
; const adj = new Map((comp?.byelections?.ridings ?? []).map((r) => [r.riding, r])); return (
{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
{b.byElections.slice().reverse().map((e) => { const a = adj.get(e.riding ?? ""); return (
{e.riding}
{dateFr(e.date)}{e.turnout !== null ? ` · participation ${pct(e.turnout, 0)}` : ""}
{e.results.filter((r) => r.party_id).slice(0, 5).map((r) => (
{partyLabel(r.party_id)}
{r.share.toFixed(0)} % 0 ? "text-ok" : "text-live")}>{e.swing[r.party_id ?? ""] !== undefined ? delta(e.swing[r.party_id ?? ""], 0, " pt") : ""}
))}
{a ? `ajustement QC26 ${partyLabel(e.winner)} ${(a.adjLogit[e.winner ?? ""] ?? 0) >= 0 ? "+" : ""}${(a.adjLogit[e.winner ?? ""] ?? 0).toFixed(2)} logit` : "hors carte 2026"}DGEQ
); })}
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}).
); } // ------------------------------------------------------------- MOMENTUM export function MomentumPanel({ comp }: { comp: ForecastComponents }) { const m = comp.momentum; return (
Nudge appliqué à la moyenne du vote (pp, somme nulle, borné ± {m.params.maxPp})
Décomposition avant bornage
{PARTY_IDS.map((id) => )}
PartiChefAttentionMédias
{partyLabel(id)}{m.parts.leader[id] !== undefined ? fmtPp(m.parts.leader[id]) : "—"}{m.parts.attention[id] !== undefined ? fmtPp(m.parts.attention[id]) : "—"}{m.parts.media[id] !== undefined ? fmtPp(m.parts.media[id]) : "—"}
κ 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.
); } export function SignalsOverview({ comp, className }: { comp: ForecastComponents; className?: string }) { const items = [ { k: "Sondages", v: `${comp.nSimulations ? int(comp.nSimulations) : ""} simulations`, on: true, href: "/sondages", sub: "filtre de Kalman, effets maison" }, { 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" }, { 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" }, { 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" }, { 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" }, ]; return (
{items.map((it) => (
{it.k}
{it.v}
{it.sub}
))}
); } export { partyName };