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%
20.7 KB · 306 lines tsx
Raw Blame History
1/** Graphiques SVG QC26 (rendu serveur, sans dépendance). Marques fines, grille en filigrane,2 * étiquettes directes sélectives ; les couleurs de partis viennent de variables CSS (thème clair/sombre). */3import type { ReactNode } from "react";4import type { TrendPoint } from "@/lib/api";5import { OTHERS, PARTY_IDS, partyAlpha, partyLabel, partyVar } from "@/lib/parties";6import { cls } from "@/lib/format";78// -------------------------------------------------------------- SeatMeter9/** Barre de sièges empilée, seuil de majorité marqué (dérivé des données, jamais codé en dur). */10export function SeatMeter({ seats, total, majority, height = 22, showLabels = true, className, title }: {11  seats: Record<string, number>; total: number; majority: number; height?: number; showLabels?: boolean; className?: string; title?: string;12}) {13  const entries = Object.entries(seats).filter(([id]) => id !== "aut").sort((a, b) => b[1] - a[1]);14  const others = seats["aut"] ?? 0;15  const majX = (majority / total) * 100;16  return (17    <div className={cls("w-full", className)}>18      <div className="relative" style={{ height: height + 24 }} role="img" aria-label={title ?? `Répartition des sièges sur ${total}, majorité à ${majority}`}>19        <div className="absolute inset-x-0 top-[16px] rounded-[7px] overflow-hidden bg-surface-3 flex gap-[2px]" style={{ height }}>20          {entries.map(([id, n]) => (21            <div key={id} className="h-full relative" style={{ width: `calc(${(n / total) * 100}% - 2px)`, background: partyVar(id) }} title={`${partyLabel(id)} : ${n}`} />22          ))}23          {others > 0 && <div className="h-full" style={{ width: `${(others / total) * 100}%`, background: OTHERS.color }} title={`Autres : ${others}`} />}24        </div>25        <div className="absolute top-[10px] bottom-0 w-[2px] bg-ink" style={{ left: `calc(${majX}% - 1px)` }} aria-hidden />26        <div className="absolute top-0 mono text-[10px] font-semibold uppercase tracking-[0.08em] translate-x-[-50%] bg-background px-1 text-ink" style={{ left: `${majX}%` }}>27          {majority} · majorité28        </div>29      </div>30      {showLabels && (31        <div className="flex flex-wrap gap-x-4 gap-y-1 mt-1.5 text-[12.5px]">32          {entries.map(([id, n]) => (33            <span key={id} className="inline-flex items-center gap-1.5 num"><span className="w-2.5 h-2.5 rounded-[3px] inline-block" style={{ background: partyVar(id) }} /><span className="font-semibold">{partyLabel(id)}</span> {n}</span>34          ))}35          {others > 0 && <span className="inline-flex items-center gap-1.5 num"><span className="w-2.5 h-2.5 rounded-[3px] inline-block" style={{ background: OTHERS.color }} />Autres {others}</span>}36        </div>37      )}38    </div>39  );40}4142// -------------------------------------------------------------- SeatWaffle43/** Les 127 sièges, un point chacun, colorés par la médiane projetée ; le trait marque le seuil de majorité. */44export function SeatWaffle({ seats, total, majority, cols = 16, className, dot = 14, gap = 4 }: { seats: Record<string, number>; total: number; majority: number; cols?: number; className?: string; dot?: number; gap?: number }) {45  const entries = Object.entries(seats).filter(([id]) => id !== "aut").sort((a, b) => b[1] - a[1]);46  const cells: (string | null)[] = [];47  entries.forEach(([id, n]) => { for (let i = 0; i < n; i++) cells.push(id); });48  while (cells.length < total) cells.push(null);49  const rows = Math.ceil(total / cols);50  const W = cols * (dot + gap) - gap, H = rows * (dot + gap) - gap;51  const mi = majority - 1, mr = Math.floor(mi / cols), mc = mi % cols;52  return (53    <svg viewBox={`0 0 ${W} ${H}`} className={cls("w-full h-auto", className)} role="img" aria-label={`${total} sièges ; ${entries.map(([id, n]) => `${partyLabel(id)} ${n}`).join(", ")} ; majorité à ${majority}`}>54      {cells.slice(0, total).map((id, i) => {55        const r = Math.floor(i / cols), c = i % cols;56        return <rect key={i} className="waffle-dot" x={c * (dot + gap)} y={r * (dot + gap)} width={dot} height={dot} rx={dot * 0.32} fill={id ? partyVar(id) : "var(--surface-3)"} />;57      })}58      <line x1={(mc + 1) * (dot + gap) - gap / 2} x2={(mc + 1) * (dot + gap) - gap / 2} y1={mr * (dot + gap) - 3} y2={(mr + 1) * (dot + gap) - gap + 3} stroke="var(--text)" strokeWidth={2.5} strokeLinecap="round" />59    </svg>60  );61}6263// ------------------------------------------------------- SeatDistribution64/** Distribution des sièges d'un parti (histogramme Monte Carlo) avec seuil de majorité et intervalles. */65export function SeatDistribution({ dist, color, majority, median, lo80, hi80, lo95, hi95, height = 120, className, total, width = 640 }: {66  dist: number[]; color: string; majority: number; median: number; lo80: number; hi80: number; lo95: number; hi95: number; height?: number; className?: string; total: number; width?: number;67}) {68  const n = dist.length;69  const max = Math.max(...dist, 1);70  const W = width, H = height, pad = 18;71  const vis = dist.map((v, i) => [i, v] as const).filter(([, v]) => v > 0);72  const minI = Math.max(0, (vis[0]?.[0] ?? 0) - 2), maxI = Math.min(n - 1, (vis[vis.length - 1]?.[0] ?? n - 1) + 2);73  const span = Math.max(1, maxI - minI);74  const xz = (i: number) => pad + ((i - minI) / span) * (W - 2 * pad);75  const bwz = (W - 2 * pad) / span;76  return (77    <svg viewBox={`0 0 ${W} ${H + 26}`} className={cls("w-full h-auto", className)} role="img" aria-label={`Distribution des sièges, médiane ${median}, intervalle 80 % ${lo80}–${hi80}, 95 % ${lo95}–${hi95}, majorité à ${majority}`}>78      <rect x={xz(lo95)} y={0} width={Math.max(1, xz(hi95) - xz(lo95) + bwz)} height={H} fill={color} opacity={0.06} />79      <rect x={xz(lo80)} y={0} width={Math.max(1, xz(hi80) - xz(lo80) + bwz)} height={H} fill={color} opacity={0.09} />80      {dist.map((v, i) => (i < minI || i > maxI || v === 0) ? null : (81        <rect key={i} x={xz(i) + 0.5} y={H - (v / max) * (H - 8)} width={Math.max(1, bwz - 1)} height={(v / max) * (H - 8)} fill={color} opacity={i >= majority ? 1 : 0.5} rx={1.5} />82      ))}83      {majority >= minI && majority <= maxI && (84        <>85          <line x1={xz(majority)} x2={xz(majority)} y1={0} y2={H + 4} stroke="var(--text)" strokeWidth={1.5} />86          <text x={xz(majority) + 4} y={11} fontSize={10.5} fontWeight={600} fill="var(--text)" fontFamily="var(--font-mono)">{majority} · majorité</text>87        </>88      )}89      <line x1={xz(median) + bwz / 2} x2={xz(median) + bwz / 2} y1={0} y2={H} stroke="var(--text)" strokeWidth={1} strokeOpacity={0.6} />90      {[minI, Math.round((minI + maxI) / 2), maxI].filter((i) => Math.abs(i - median) > Math.max(3, span * 0.06)).map((i) => (91        <text key={i} x={xz(i) + bwz / 2} y={H + 20} fontSize={10.5} textAnchor="middle" fill="var(--text-3)" fontFamily="var(--font-mono)">{i}</text>92      ))}93      <text x={xz(median) + bwz / 2} y={H + 20} fontSize={10.5} textAnchor="middle" fontWeight={700} fill="var(--text)" fontFamily="var(--font-mono)">{median}</text>94      <title>{`Sur ${total} sièges`}</title>95    </svg>96  );97}9899// ----------------------------------------------------------------- Trend (statique)100export function TrendChart({ trend, polls, from, height = 300, showBand = true, ids = PARTY_IDS, className, markers = [], election2022 }: {101  trend: TrendPoint[]; polls?: { date: string; values: Record<string, number>; id?: string }[]; from?: string; height?: number; showBand?: boolean; ids?: string[]; className?: string;102  markers?: { date: string; label: string }[]; election2022?: Record<string, number> | null;103}) {104  const pts = from ? trend.filter((t) => t.date >= from) : trend;105  if (pts.length < 2) return <div className="text-ink-3 text-sm">Pas assez de données.</div>;106  const W = 900, H = height, pl = 34, pr = 84, pt = 12, pb = 28;107  const t0 = new Date(pts[0].date).getTime(), t1 = new Date(pts[pts.length - 1].date).getTime();108  const x = (d: string) => pl + ((new Date(d).getTime() - t0) / Math.max(1, t1 - t0)) * (W - pl - pr);109  const allVals = pts.flatMap((p) => ids.map((id) => p.mean[id] ?? 0));110  const pollVals = (polls ?? []).filter((p) => !from || p.date >= from).flatMap((p) => ids.map((id) => p.values[id] ?? 0));111  const ymax = Math.min(100, Math.ceil((Math.max(...allVals, ...pollVals, 10) + 4) / 5) * 5);112  const y = (v: number) => pt + (1 - v / ymax) * (H - pt - pb);113  const ticks = Array.from({ length: Math.floor(ymax / 10) + 1 }, (_, i) => i * 10);114  const months: { d: Date; label: string }[] = [];115  const spanDays = (t1 - t0) / 86400000;116  const step = spanDays > 900 ? 6 : spanDays > 400 ? 3 : 1;117  const cur = new Date(t0); cur.setDate(1); cur.setMonth(cur.getMonth() + 1);118  while (cur.getTime() <= t1) {119    if (cur.getMonth() % step === 0) months.push({ d: new Date(cur), label: cur.toLocaleDateString("fr-CA", { month: "short", year: spanDays > 200 ? "2-digit" : undefined }) });120    cur.setMonth(cur.getMonth() + 1);121  }122  const path = (id: string) => pts.map((p, i) => `${i ? "L" : "M"}${x(p.date).toFixed(1)},${y(p.mean[id] ?? 0).toFixed(1)}`).join(" ");123  const band = (id: string) => {124    const up = pts.map((p) => `${x(p.date).toFixed(1)},${y((p.mean[id] ?? 0) + 1.28 * (p.sd[id] ?? 0)).toFixed(1)}`);125    const dn = [...pts].reverse().map((p) => `${x(p.date).toFixed(1)},${y((p.mean[id] ?? 0) - 1.28 * (p.sd[id] ?? 0)).toFixed(1)}`);126    return `M${up.join(" L")} L${dn.join(" L")} Z`;127  };128  const last = pts[pts.length - 1];129  const labels = ids.map((id) => ({ id, v: last.mean[id] ?? 0 })).sort((a, b) => b.v - a.v);130  let prevY = -Infinity;131  const labelY = labels.map((l) => { let yy = y(l.v); if (yy - prevY < 13) yy = prevY + 13; prevY = yy; return yy; });132  return (133    <svg viewBox={`0 0 ${W} ${H}`} className={cls("w-full h-auto", className)} role="img" aria-label="Évolution des intentions de vote (moyenne QC26) et sondages">134      {ticks.map((t) => (135        <g key={t}>136          <line x1={pl} x2={W - pr} y1={y(t)} y2={y(t)} stroke="var(--grid)" strokeWidth={1} />137          <text x={pl - 6} y={y(t) + 3.5} fontSize={10.5} textAnchor="end" fill="var(--text-3)" fontFamily="var(--font-mono)">{t}</text>138        </g>139      ))}140      {months.map((m, i) => (141        <g key={i}>142          <line x1={x(m.d.toISOString())} x2={x(m.d.toISOString())} y1={pt} y2={H - pb + 4} stroke="var(--grid)" strokeWidth={1} />143          <text x={x(m.d.toISOString())} y={H - 8} fontSize={10.5} textAnchor="middle" fill="var(--text-3)" fontFamily="var(--font-mono)">{m.label}</text>144        </g>145      ))}146      {markers.filter((m) => new Date(m.date).getTime() >= t0 && new Date(m.date).getTime() <= t1).map((m, i) => (147        <g key={`m${i}`}>148          <line x1={x(m.date)} x2={x(m.date)} y1={pt} y2={H - pb} stroke="var(--text-2)" strokeWidth={1} strokeDasharray="2 3" />149          <text x={x(m.date) + 4} y={pt + 9} fontSize={9.5} fill="var(--text-2)" fontFamily="var(--font-mono)">{m.label}</text>150        </g>151      ))}152      {polls?.filter((p) => !from || p.date >= from).map((p, i) => ids.map((id) => p.values[id] === undefined ? null : (153        <circle key={`${i}-${id}`} cx={x(p.date)} cy={y(p.values[id])} r={2.6} fill={partyVar(id)} opacity={0.3} />154      )))}155      {showBand && ids.map((id) => <path key={`b${id}`} d={band(id)} fill={partyVar(id)} opacity={0.1} />)}156      {ids.map((id) => <path key={id} d={path(id)} fill="none" stroke={partyVar(id)} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />)}157      {ids.map((id) => <circle key={`e${id}`} cx={x(last.date)} cy={y(last.mean[id] ?? 0)} r={3.5} fill={partyVar(id)} stroke="var(--surface)" strokeWidth={2} />)}158      {election2022 && ids.map((id) => election2022[id] !== undefined && (159        <g key={`e22${id}`}><rect x={pl - 2} y={y(election2022[id]) - 1.5} width={5} height={3} fill={partyVar(id)} /></g>160      ))}161      {labels.map((l, i) => (162        <text key={l.id} x={W - pr + 8} y={labelY[i] + 4} fontSize={12} fontWeight={600} fill="var(--text)" fontFamily="var(--font-sans)">163          <tspan fill={partyVar(l.id)}>■</tspan> {partyLabel(l.id)} <tspan fontFamily="var(--font-mono)">{l.v.toFixed(1).replace(".", ",")}</tspan>164        </text>165      ))}166    </svg>167  );168}169170// ---------------------------------------------------------------- VoteBars171export function VoteBars({ values, ci, max, className, order }: { values: Record<string, number>; ci?: Record<string, [number, number]>; max?: number; className?: string; order?: string[] }) {172  const ids = order ?? Object.keys(values).filter((k) => k !== "aut").sort((a, b) => values[b] - values[a]);173  const m = max ?? Math.max(...ids.map((i) => ci?.[i]?.[1] ?? values[i]), 10) * 1.08;174  return (175    <div className={cls("space-y-2", className)}>176      {ids.map((id) => (177        <div key={id} className="grid grid-cols-[44px_1fr_58px] items-center gap-3 text-[13.5px]">178          <span className="font-semibold num inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(id) }} />{partyLabel(id)}</span>179          <div className="relative h-[12px] bg-surface-3 rounded-[4px] overflow-hidden">180            {ci?.[id] && <div className="absolute top-0 bottom-0 rounded-[4px]" style={{ left: `${(ci[id][0] / m) * 100}%`, width: `${((ci[id][1] - ci[id][0]) / m) * 100}%`, background: partyAlpha(id, 0.25) }} />}181            <div className="absolute top-0 bottom-0 left-0 rounded-[4px]" style={{ width: `${(values[id] / m) * 100}%`, background: partyVar(id) }} />182          </div>183          <span className="num font-semibold text-right mono">{values[id].toFixed(1).replace(".", ",")} %</span>184        </div>185      ))}186    </div>187  );188}189190export function Legend({ ids, className }: { ids: string[]; className?: string }) {191  return (192    <div className={cls("flex flex-wrap gap-x-4 gap-y-1 text-[12.5px]", className)}>193      {ids.map((id) => (194        <span key={id} className="inline-flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full inline-block" style={{ background: partyVar(id) }} />{partyLabel(id)}</span>195      ))}196    </div>197  );198}199200export function Sparkline({ values, color, width = 120, height = 32, className, fill = false }: { values: number[]; color: string; width?: number; height?: number; className?: string; fill?: boolean }) {201  if (values.length < 2) return null;202  const min = Math.min(...values), max = Math.max(...values), span = Math.max(0.001, max - min);203  const pts = values.map((v, i) => [(i / (values.length - 1)) * width, height - ((v - min) / span) * (height - 6) - 3] as const);204  const line = pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ");205  const last = pts[pts.length - 1];206  return (207    <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={className} aria-hidden>208      {fill && <polygon points={`0,${height} ${line} ${width},${height}`} fill={color} opacity={0.12} />}209      <polyline points={line} fill="none" stroke={color} strokeWidth={1.8} strokeLinejoin="round" strokeLinecap="round" />210      <circle cx={last[0]} cy={last[1]} r={2.5} fill={color} />211    </svg>212  );213}214215export function Note({ children }: { children: ReactNode }) {216  return <p className="text-[12px] text-ink-3 leading-relaxed mt-2">{children}</p>;217}218219// -------------------------------------------------------- DotComparison220/** Points comparés (ex. P(plus de sièges) : sondages seuls · marchés · ensemble) par parti, sur une échelle 0–max. */221export function DotComparison({ rows, series, className, max = 1, format = (v: number) => `${Math.round(v * 100)} %`, width = 520 }: {222  rows: { id: string; label: string; values: Record<string, number | null | undefined> }[];223  series: { key: string; label: string; shape: "dot" | "ring" | "diamond" }[];224  className?: string; max?: number; format?: (v: number) => string; width?: number;225}) {226  const W = width, rowH = width < 420 ? 44 : 38, pl = 52, pr = 14, H = rows.length * rowH + 16;227  const x = (v: number) => pl + (Math.max(0, Math.min(max, v)) / max) * (W - pl - pr);228  return (229    <div className={className}>230      <svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto" role="img" aria-label="Comparaison des probabilités par source">231        {[0, 0.25, 0.5, 0.75, 1].map((t) => <line key={t} x1={x(t * max)} x2={x(t * max)} y1={4} y2={H - 12} stroke="var(--grid)" />)}232        {rows.map((r, i) => {233          const cy = i * rowH + rowH / 2 + 4;234          const vals = series.map((s) => r.values[s.key]).filter((v): v is number => typeof v === "number");235          const lo = Math.min(...vals), hi = Math.max(...vals);236          return (237            <g key={r.id}>238              <text x={pl - 10} y={cy + 4.5} textAnchor="end" fontSize={13} fontWeight={600} fill="var(--text)" fontFamily="var(--font-sans)">{r.label}</text>239              <line x1={pl} x2={W - pr} y1={cy} y2={cy} stroke="var(--grid)" />240              {vals.length > 1 && <line x1={x(lo)} x2={x(hi)} y1={cy} y2={cy} stroke={partyVar(r.id)} strokeWidth={2} opacity={0.5} />}241              {series.map((s) => {242                const v = r.values[s.key];243                if (typeof v !== "number") return null;244                const cx = x(v);245                if (s.shape === "ring") return <circle key={s.key} cx={cx} cy={cy} r={6} fill="var(--surface)" stroke={partyVar(r.id)} strokeWidth={2.2} />;246                if (s.shape === "diamond") return <rect key={s.key} x={cx - 5} y={cy - 5} width={10} height={10} transform={`rotate(45 ${cx} ${cy})`} fill={partyVar(r.id)} stroke="var(--surface)" strokeWidth={1.5} />;247                return <circle key={s.key} cx={cx} cy={cy} r={6.5} fill={partyVar(r.id)} stroke="var(--surface)" strokeWidth={2} />;248              })}249            </g>250          );251        })}252        {[0, 0.5, 1].map((t) => <text key={t} x={x(t * max)} y={H - 1} textAnchor="middle" fontSize={11} fill="var(--text-3)" fontFamily="var(--font-mono)">{format(t * max)}</text>)}253      </svg>254      <div className="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-[12px] text-ink-2">255        {series.map((s) => (256          <span key={s.key} className="inline-flex items-center gap-1.5">257            {s.shape === "ring" ? <span className="w-3 h-3 rounded-full border-2 border-ink-2 inline-block" /> : s.shape === "diamond" ? <span className="w-2.5 h-2.5 bg-ink-2 inline-block rotate-45" /> : <span className="w-3 h-3 rounded-full bg-ink-2 inline-block" />}258            {s.label}259          </span>260        ))}261      </div>262    </div>263  );264}265266// ------------------------------------------------------------ DivergingBars267/** Barres divergentes centrées sur zéro (ton médiatique −1…+1, momentum en pp), par parti. */268export function DivergingBars({ values, max, format, className, ids = PARTY_IDS }: { values: Record<string, number | null | undefined>; max: number; format: (v: number) => string; className?: string; ids?: string[] }) {269  return (270    <div className={cls("space-y-1.5", className)}>271      {ids.map((id) => {272        const v = values[id];273        const has = typeof v === "number";274        const w = has ? Math.min(1, Math.abs(v) / max) * 50 : 0;275        return (276          <div key={id} className="grid grid-cols-[40px_1fr_64px] items-center gap-3 text-[12.5px]">277            <span className="font-semibold num inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(id) }} />{partyLabel(id)}</span>278            <div className="relative h-[10px] bg-surface-3 rounded-[4px] overflow-hidden">279              <div className="absolute inset-y-0 left-1/2 w-px bg-ink-3 opacity-60" />280              {has && <div className="absolute inset-y-0 rounded-[3px]" style={{ left: v >= 0 ? "50%" : `${50 - w}%`, width: `${w}%`, background: partyVar(id) }} />}281            </div>282            <span className={cls("num text-right mono", has ? "font-semibold" : "text-ink-3")}>{has ? format(v) : "—"}</span>283          </div>284        );285      })}286    </div>287  );288}289290// ------------------------------------------------------------- StackedShare291/** Part (%) empilée horizontalement par parti (part de voix, part d'attention). */292export function StackedShare({ values, className, height = 12 }: { values: Record<string, number | null | undefined>; className?: string; height?: number }) {293  const ids = PARTY_IDS.filter((id) => typeof values[id] === "number" && (values[id] as number) > 0);294  const tot = ids.reduce((a, id) => a + (values[id] as number), 0) || 1;295  return (296    <div className={className}>297      <div className="flex gap-[2px] rounded-[6px] overflow-hidden" style={{ height }}>298        {ids.map((id) => <div key={id} style={{ width: `${((values[id] as number) / tot) * 100}%`, background: partyVar(id) }} title={`${partyLabel(id)} ${(values[id] as number).toFixed(0)} %`} />)}299      </div>300      <div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1.5 text-[12px] num">301        {ids.map((id) => <span key={id} className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(id) }} /><b>{partyLabel(id)}</b> {Math.round(((values[id] as number) / tot) * 100)} %</span>)}302      </div>303    </div>304  );305}306