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 && (
)}
{!compact && (
Marché Type Oui Achat / vente Volume Liquidité
{m.markets.slice(0, 40).map((r) => (
{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 ? 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
Parti Chef Attention Médias
{PARTY_IDS.map((id) => {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 };