Python 51.6%
TypeScript 46.7%
CSS 1.7%
1"use client";23import Link from "next/link";4import { useEffect, useMemo, useState } from "react";5import { cls, prob } from "@/lib/format";6import { PARTY_IDS, partyVar, partyLabel, partyName } from "@/lib/parties";7import { PartyLogo } from "./party";8import { Badge, Card, ProbabilityBar, SectionHeader } from "./ui";910export interface CompassData { axes: Record<string, { label: string; left: string; right: string }>; questions: { id: string; axis: string; topic: string; topicLabel: string; text: string; poleAgree: string; poleDisagree: string }[]; coverage: Record<string, { positions: number; total: number }> }11interface MatchResult { results: Record<string, { score: number | null; coverage: number; answered: number; axes: Record<string, number>; details: { question_id: string; user: number; party: number; agreement: number; confidence: number; proposal_ids: number[]; rationale: string | null }[] }>; proposals: Record<string, { proposal: string; quote: string; page: number | null; confidence: number; documentId: number; documentTitle: string | null; documentUrl: string | null }> }1213const 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;14const IMP = [[1, "Pas important"], [2, "Important"], [3, "Très important"]] as const;15const KEY = "qc26-boussole-v1";1617export function Compass({ data }: { data: CompassData }) {18 const [answers, setAnswers] = useState<Record<string, number>>({});19 const [imp, setImp] = useState<Record<string, number>>({});20 const [res, setRes] = useState<MatchResult | null>(null);21 const [busy, setBusy] = useState(false);22 const [why, setWhy] = useState<string | null>(null);23 const [idx, setIdx] = useState(0);24 useEffect(() => { try { const s = JSON.parse(localStorage.getItem(KEY) || "{}"); if (s.answers) setAnswers(s.answers); if (s.imp) setImp(s.imp); } catch { /* ignore */ } }, []);25 useEffect(() => { localStorage.setItem(KEY, JSON.stringify({ answers, imp })); }, [answers, imp]);26 const qs = data.questions;27 const answered = Object.keys(answers).length;28 const total = qs.length;29 const q = qs[idx];30 const byAxis = useMemo(() => { const m: Record<string, number> = {}; qs.forEach((x) => { m[x.axis] = (m[x.axis] ?? 0) + (answers[x.id] !== undefined ? 1 : 0); }); return m; }, [qs, answers]);31 const submit = async () => {32 setBusy(true);33 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); }34 };35 const reset = () => { setAnswers({}); setImp({}); setRes(null); setIdx(0); setWhy(null); };36 const noPositions = Object.values(data.coverage).every((c) => c.positions === 0);3738 if (res) {39 const ranking = PARTY_IDS.map((id) => ({ id, ...res.results[id] })).sort((a, b) => (b.score ?? -1) - (a.score ?? -1));40 return (41 <div className="space-y-8">42 <Card className="card-lg">43 <div className="flex items-center justify-between flex-wrap gap-2"><div className="eyebrow">Votre résultat · {answered} réponses</div><button onClick={reset} className="text-[12.5px] link">Recommencer</button></div>44 <div className="mt-4 space-y-4">45 {ranking.map((r) => (46 <div key={r.id}>47 <div className="flex items-center justify-between gap-3 mb-1"><span className="inline-flex items-center gap-2"><PartyLogo id={r.id} height={18} /><b>{partyName(r.id)}</b><span className="text-[11.5px] text-ink-3">{r.coverage} énoncés documentés</span></span><span className="num font-bold text-[22px]" style={{ color: partyVar(r.id) }}>{r.score === null ? "—" : `${Math.round(r.score)} %`}</span></div>48 <ProbabilityBar p={(r.score ?? 0) / 100} color={partyVar(r.id)} showValue={false} height={12} />49 </div>50 ))}51 </div>52 <p className="text-[12.5px] text-ink-3 mt-4">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.</p>53 </Card>54 <section>55 <SectionHeader eyebrow="Par axe" title="Où vous vous situez" />56 <div className="grid md:grid-cols-2 gap-3">57 {Object.entries(data.axes).map(([axis, a]) => (58 <Card key={axis}><div className="font-semibold text-[14px]">{a.label}</div><div className="flex justify-between text-[11.5px] text-ink-3 mt-1"><span>{a.left}</span><span>{a.right}</span></div>59 <div className="mt-2 space-y-1">{ranking.filter((r) => r.axes[axis] !== undefined).map((r) => (<div key={r.id} className="grid grid-cols-[40px_1fr_44px] items-center gap-2 text-[12.5px]"><b style={{ color: partyVar(r.id) }}>{partyLabel(r.id)}</b><div className="h-[6px] bg-surface-3 rounded-full overflow-hidden"><div className="h-full" style={{ width: `${r.axes[axis]}%`, background: partyVar(r.id) }} /></div><span className="num text-right">{Math.round(r.axes[axis])} %</span></div>))}</div>60 </Card>61 ))}62 </div>63 </section>64 <section>65 <SectionHeader eyebrow="Voir pourquoi" title="Énoncé par énoncé" description="Votre réponse, la position estimée de chaque parti, la source officielle et l'extrait." />66 <div className="space-y-3">67 {qs.filter((x) => answers[x.id] !== undefined).map((x) => (68 <Card key={x.id} pad={false}>69 <button onClick={() => setWhy(why === x.id ? null : x.id)} className="w-full text-left p-4 flex items-start justify-between gap-3"><div><div className="text-[11px] text-ink-3 uppercase tracking-wide">{data.axes[x.axis].label} · {x.topicLabel}</div><div className="font-medium text-[14px] mt-0.5">{x.text}</div><div className="text-[12.5px] text-ink-2 mt-1">Vous : <b>{SCALE.find((s) => s[0] === answers[x.id])?.[1]}</b> · importance {IMP.find((s) => s[0] === (imp[x.id] ?? 2))?.[1]?.toLowerCase()}</div></div><span className="text-ink-3">{why === x.id ? "−" : "+"}</span></button>70 {why === x.id && (71 <div className="border-t border-border p-4 grid md:grid-cols-5 gap-3">72 {PARTY_IDS.map((pid) => { const d = res.results[pid].details.find((dd) => dd.question_id === x.id); return (73 <div key={pid} className="surface-2 rounded-[8px] p-3 text-[12.5px]">74 <div className="flex items-center justify-between"><b style={{ color: partyVar(pid) }}>{partyLabel(pid)}</b>{d ? <span className="num">{Math.round(d.agreement * 100)} %</span> : <Badge>non documenté</Badge>}</div>75 {d ? (<><div className="mt-1">Position : <b>{SCALE.find((s) => s[0] === d.party)?.[1]}</b> <span className="text-ink-3">(confiance {Math.round(d.confidence * 100)} %)</span></div>{d.rationale && <div className="text-ink-2 mt-1 leading-relaxed">{d.rationale}</div>}76 <ul className="mt-2 space-y-1.5">{d.proposal_ids.slice(0, 3).map((pid2) => { const p = res.proposals[String(pid2)]; return p ? (<li key={pid2} className="border-l-2 pl-2" style={{ borderColor: partyVar(pid) }}><div className="italic text-ink-2">« {p.quote.slice(0, 220)}{p.quote.length > 220 ? "…" : ""} »</div><div className="text-[11.5px] text-ink-3 mt-0.5"><Link href={`/documents/${p.documentId}`} className="link">{p.documentTitle ?? "Document"}</Link>{p.page ? ` · p. ${p.page}` : ""} · confiance {Math.round(p.confidence * 100)} %</div></li>) : null; })}</ul></>) : <div className="text-ink-3 mt-1">Aucune proposition officielle ingérée ne permet de situer ce parti sur cet énoncé.</div>}77 </div>); })}78 </div>79 )}80 </Card>81 ))}82 </div>83 </section>84 </div>85 );86 }8788 return (89 <div className="grid lg:grid-cols-[1fr_300px] gap-6 items-start">90 <Card className="card-lg">91 {noPositions && <div className="mb-4 rounded-[8px] bg-[rgba(180,83,9,.08)] text-warn text-[13px] p-3">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.</div>}92 <div className="flex items-center justify-between text-[12.5px] text-ink-3"><span>Énoncé {idx + 1} / {total}</span><span className="num">{answered} réponses</span></div>93 <div className="h-[4px] bg-surface-3 rounded-full mt-2 overflow-hidden"><div className="h-full bg-brand transition-[width]" style={{ width: `${((idx + 1) / total) * 100}%` }} /></div>94 {q && (95 <div className="mt-6 fade-up" key={q.id}>96 <div className="text-[11px] text-ink-3 uppercase tracking-wide">{data.axes[q.axis].label} · {q.topicLabel}</div>97 <h2 className="text-[20px] md:text-[24px] font-semibold tracking-tight leading-snug mt-2">{q.text}</h2>98 <div className="grid grid-cols-1 sm:grid-cols-5 gap-2 mt-6">99 {SCALE.map(([v, l]) => (<button key={v} onClick={() => { setAnswers((a) => ({ ...a, [q.id]: v })); setTimeout(() => setIdx((i) => Math.min(total - 1, i + 1)), 160); }} className={cls("rounded-[8px] border px-3 py-3 text-[13px] font-medium transition-colors", answers[q.id] === v ? "bg-brand text-white border-brand" : "border-border hover:bg-surface-2")}>{l}</button>))}100 </div>101 <div className="mt-5 flex items-center gap-2 flex-wrap text-[12.5px]"><span className="text-ink-3 mr-1">Importance :</span>{IMP.map(([v, l]) => (<button key={v} onClick={() => setImp((m) => ({ ...m, [q.id]: v }))} className={cls("rounded-full border px-3 py-1", (imp[q.id] ?? 2) === v ? "bg-accent-soft text-accent border-transparent font-semibold" : "border-border")}>{l}</button>))}</div>102 <div className="mt-6 flex items-center justify-between"><button onClick={() => setIdx((i) => Math.max(0, i - 1))} disabled={idx === 0} className="text-[13px] text-ink-2 disabled:opacity-30">← Précédent</button><div className="flex gap-2"><button onClick={() => setIdx((i) => Math.min(total - 1, i + 1))} className="text-[13px] text-ink-2">Passer →</button><button onClick={submit} disabled={answered < 5 || busy} className="rounded-[8px] bg-brand text-white px-4 py-2 text-[13.5px] font-semibold disabled:opacity-40">{busy ? "Calcul…" : `Voir mon résultat (${answered})`}</button></div></div>103 </div>104 )}105 </Card>106 <div className="space-y-3">107 <Card><div className="eyebrow mb-2">Progression par axe</div><div className="space-y-2">{Object.entries(data.axes).map(([axis, a]) => { const n = qs.filter((x) => x.axis === axis).length; return (<div key={axis} className="text-[12.5px]"><div className="flex justify-between"><span>{a.label}</span><span className="num text-ink-3">{byAxis[axis] ?? 0}/{n}</span></div><div className="h-[4px] bg-surface-3 rounded-full mt-1 overflow-hidden"><div className="h-full bg-accent" style={{ width: `${((byAxis[axis] ?? 0) / n) * 100}%` }} /></div></div>); })}</div></Card>108 <Card><div className="eyebrow mb-2">Couverture documentaire</div><div className="space-y-1.5 text-[12.5px]">{PARTY_IDS.map((id) => (<div key={id} className="flex items-center justify-between"><span className="inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(id) }} />{partyLabel(id)}</span><span className="num text-ink-2">{data.coverage[id]?.positions ?? 0} / {data.coverage[id]?.total ?? total} positions</span></div>))}</div><p className="text-[11.5px] text-ink-3 mt-2">Positions extraites des documents officiels ; sans document, pas de position. <Link href="/documents" className="link">Bibliothèque →</Link></p></Card>109 <Card><div className="eyebrow mb-1">Grille de réponse</div><div className="grid grid-cols-8 gap-1 mt-2">{qs.map((x, i) => (<button key={x.id} onClick={() => setIdx(i)} aria-label={`Énoncé ${i + 1}`} className={cls("h-6 rounded-[4px] text-[10px] num", answers[x.id] !== undefined ? "bg-brand text-white" : i === idx ? "bg-accent-soft text-accent" : "bg-surface-2 text-ink-3")}>{i + 1}</button>))}</div></Card>110 <div className="text-[11.5px] text-ink-3 px-1">{prob(answered / total)} des énoncés répondus. Minimum 5 pour un résultat.</div>111 </div>112 </div>113 );114}115