SPB Git forge

spb/qc26

Public
10commits 1branches 0releases
2.8 MBsize
maindefault branch
17 days agolast push
Python 51.6% TypeScript 46.7% CSS 1.7%
3.6 KB · 50 lines tsx
Raw Blame History
1"use client";23import { useEffect, useMemo, useState } from "react";4import { dateFr, dateTimeFr, prob } from "@/lib/format";5import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";6import { SeatMeter } from "./charts";78interface Run { id: number; date: string; completedAt: string | null; modelVersion: string; pollCount: number; parties: Record<string, { vote: number; seats: number; seats80: [number, number]; pMajority: number; pMostSeats: number }>; pMinority: number }910export function ForecastHistory({ majority, total }: { majority: number; total: number }) {11  const [runs, setRuns] = useState<Run[]>([]);12  const [i, setI] = useState(0);13  useEffect(() => { fetch("/api/forecast/history").then((r) => r.json()).then((d) => { setRuns(d.runs ?? []); setI(Math.max(0, (d.runs?.length ?? 1) - 1)); }).catch(() => {}); }, []);14  const run = runs[i];15  const W = 640, H = 120;16  const path = useMemo(() => {17    if (runs.length < 2) return null;18    const x = (k: number) => 8 + (k / (runs.length - 1)) * (W - 16);19    const y = (p: number) => 6 + (1 - p) * (H - 12);20    return PARTY_IDS.map((id) => ({ id, d: runs.map((r, k) => `${k ? "L" : "M"}${x(k)},${y(r.parties[id]?.pMajority ?? 0)}`).join(" ") }));21  }, [runs]);22  if (!run) return <div className="text-ink-3 text-[13.5px]">Historique en cours de constitution : un point par jour de recalcul.</div>;23  return (24    <div>25      <div className="flex items-center gap-4 flex-wrap">26        <input type="range" min={0} max={Math.max(0, runs.length - 1)} value={i} onChange={(e) => setI(Number(e.target.value))} className="flex-1 min-w-[200px]" aria-label="Date du run" />27        <div className="num text-[13.5px]"><b>{dateFr(run.date)}</b> <span className="text-ink-3">run #{run.id} · {run.pollCount} sondages · modèle {run.modelVersion}</span></div>28      </div>29      <div className="flex justify-between text-[11px] text-ink-3 num mt-1"><span>{dateFr(runs[0].date, { day: "numeric", month: "short" })}</span><span>{dateFr(runs[runs.length - 1].date, { day: "numeric", month: "short" })}</span></div>30      <div className="mt-4"><SeatMeter seats={Object.fromEntries(PARTY_IDS.map((id) => [id, run.parties[id]?.seats ?? 0]))} total={total} majority={majority} /></div>31      <div className="grid grid-cols-2 md:grid-cols-5 gap-2 mt-4">32        {PARTY_IDS.slice().sort((a, b) => (run.parties[b]?.seats ?? 0) - (run.parties[a]?.seats ?? 0)).map((id) => (33          <div key={id} className="surface-2 rounded-[8px] p-2.5 text-[12.5px]"><b style={{ color: partyVar(id) }}>{partyLabel(id)}</b><div className="num mt-1"><b className="text-[18px]">{run.parties[id]?.seats}</b> <span className="text-ink-3">{run.parties[id]?.seats80?.join("–")}</span></div><div className="num text-ink-2">{run.parties[id]?.vote?.toFixed(1)} % · maj. {prob(run.parties[id]?.pMajority)}</div></div>34        ))}35      </div>36      {path && (37        <div className="mt-5">38          <div className="eyebrow mb-1">P(majorité) par run</div>39          <svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto" role="img" aria-label="Probabilité de majorité par run">40            {[0, 0.5, 1].map((p) => <line key={p} x1={8} x2={W - 8} y1={6 + (1 - p) * (H - 12)} y2={6 + (1 - p) * (H - 12)} stroke="var(--border)" />)}41            {path.map((p) => <path key={p.id} d={p.d} fill="none" stroke={partyVar(p.id)} strokeWidth={2} />)}42            <line x1={8 + (i / Math.max(1, runs.length - 1)) * (W - 16)} x2={8 + (i / Math.max(1, runs.length - 1)) * (W - 16)} y1={0} y2={H} stroke="var(--text)" strokeDasharray="3 3" />43          </svg>44        </div>45      )}46      <div className="text-[11.5px] text-ink-3 mt-2">Run terminé {dateTimeFr(run.completedAt)}.</div>47    </div>48  );49}50