"use client"; import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; import { cls, prob } from "@/lib/format"; import { PARTY_IDS, partyVar, partyLabel, partyName } from "@/lib/parties"; import { PartyLogo } from "./party"; import { Badge, Card, ProbabilityBar, SectionHeader } from "./ui"; export interface CompassData { axes: Record; questions: { id: string; axis: string; topic: string; topicLabel: string; text: string; poleAgree: string; poleDisagree: string }[]; coverage: Record } interface MatchResult { results: Record; details: { question_id: string; user: number; party: number; agreement: number; confidence: number; proposal_ids: number[]; rationale: string | null }[] }>; proposals: Record } const SCALE = [[-2, "Tout à fait en désaccord"], [-1, "Plutôt en désaccord"], [0, "Neutre"], [1, "Plutôt d'accord"], [2, "Tout à fait d'accord"]] as const; const IMP = [[1, "Pas important"], [2, "Important"], [3, "Très important"]] as const; const KEY = "qc26-boussole-v1"; export function Compass({ data }: { data: CompassData }) { const [answers, setAnswers] = useState>({}); const [imp, setImp] = useState>({}); const [res, setRes] = useState(null); const [busy, setBusy] = useState(false); const [why, setWhy] = useState(null); const [idx, setIdx] = useState(0); useEffect(() => { try { const s = JSON.parse(localStorage.getItem(KEY) || "{}"); if (s.answers) setAnswers(s.answers); if (s.imp) setImp(s.imp); } catch { /* ignore */ } }, []); useEffect(() => { localStorage.setItem(KEY, JSON.stringify({ answers, imp })); }, [answers, imp]); const qs = data.questions; const answered = Object.keys(answers).length; const total = qs.length; const q = qs[idx]; const byAxis = useMemo(() => { const m: Record = {}; qs.forEach((x) => { m[x.axis] = (m[x.axis] ?? 0) + (answers[x.id] !== undefined ? 1 : 0); }); return m; }, [qs, answers]); const submit = async () => { setBusy(true); try { const r = await fetch("/api/compass/match", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ answers, importance: imp }) }); setRes(await r.json()); window.scrollTo({ top: 0, behavior: "smooth" }); } finally { setBusy(false); } }; const reset = () => { setAnswers({}); setImp({}); setRes(null); setIdx(0); setWhy(null); }; const noPositions = Object.values(data.coverage).every((c) => c.positions === 0); if (res) { const ranking = PARTY_IDS.map((id) => ({ id, ...res.results[id] })).sort((a, b) => (b.score ?? -1) - (a.score ?? -1)); return (
Votre résultat · {answered} réponses
{ranking.map((r) => (
{partyName(r.id)}{r.coverage} énoncés documentés{r.score === null ? "—" : `${Math.round(r.score)} %`}
))}

Concordance = 1 − |votre réponse − position du parti| / 4, pondérée par l'importance. Un parti sans position documentée sur un énoncé n'est ni pénalisé ni avantagé sur cet énoncé : regardez la couverture. Ce résultat n'est pas une recommandation de vote.

{Object.entries(data.axes).map(([axis, a]) => (
{a.label}
{a.left}{a.right}
{ranking.filter((r) => r.axes[axis] !== undefined).map((r) => (
{partyLabel(r.id)}
{Math.round(r.axes[axis])} %
))}
))}
{qs.filter((x) => answers[x.id] !== undefined).map((x) => ( {why === x.id && (
{PARTY_IDS.map((pid) => { const d = res.results[pid].details.find((dd) => dd.question_id === x.id); return (
{partyLabel(pid)}{d ? {Math.round(d.agreement * 100)} % : non documenté}
{d ? (<>
Position : {SCALE.find((s) => s[0] === d.party)?.[1]} (confiance {Math.round(d.confidence * 100)} %)
{d.rationale &&
{d.rationale}
}
    {d.proposal_ids.slice(0, 3).map((pid2) => { const p = res.proposals[String(pid2)]; return p ? (
  • « {p.quote.slice(0, 220)}{p.quote.length > 220 ? "…" : ""} »
    {p.documentTitle ?? "Document"}{p.page ? ` · p. ${p.page}` : ""} · confiance {Math.round(p.confidence * 100)} %
  • ) : null; })}
) :
Aucune proposition officielle ingérée ne permet de situer ce parti sur cet énoncé.
}
); })}
)}
))}
); } return (
{noPositions &&
Les positions des partis sont en cours d'extraction depuis les documents officiels. Vous pouvez répondre dès maintenant ; le résultat s'affinera à mesure que les documents sont analysés.
}
Énoncé {idx + 1} / {total}{answered} réponses
{q && (
{data.axes[q.axis].label} · {q.topicLabel}

{q.text}

{SCALE.map(([v, l]) => ())}
Importance :{IMP.map(([v, l]) => ())}
)}
Progression par axe
{Object.entries(data.axes).map(([axis, a]) => { const n = qs.filter((x) => x.axis === axis).length; return (
{a.label}{byAxis[axis] ?? 0}/{n}
); })}
Couverture documentaire
{PARTY_IDS.map((id) => (
{partyLabel(id)}{data.coverage[id]?.positions ?? 0} / {data.coverage[id]?.total ?? total} positions
))}

Positions extraites des documents officiels ; sans document, pas de position. Bibliothèque →

Grille de réponse
{qs.map((x, i) => ())}
{prob(answered / total)} des énoncés répondus. Minimum 5 pour un résultat.
); }