SPB Git

spb/qc26

Public
12.5 KB · 193 lines tsx
1/** Graphiques SVG QC26 (rendu serveur possible, sans dépendance). */2import type { ReactNode } from "react";3import type { TrendPoint } from "@/lib/api";4import { OTHERS, PARTY_IDS, partyColor, partyLabel } from "@/lib/parties";5import { cls } from "@/lib/format";67// -------------------------------------------------------------- SeatMeter8/** Barre de sièges empilée, seuil de majorité marqué (dérivé des données, jamais codé en dur). */9export function SeatMeter({ seats, total, majority, height = 22, showLabels = true, className, title }: {10  seats: Record<string, number>; total: number; majority: number; height?: number; showLabels?: boolean; className?: string; title?: string;11}) {12  const entries = Object.entries(seats).filter(([id]) => id !== "aut").sort((a, b) => b[1] - a[1]);13  const others = seats["aut"] ?? 0;14  let x = 0;15  const majX = (majority / total) * 100;16  return (17    <div className={cls("w-full", className)}>18      <div className="relative" style={{ height: height + 22 }} role="img" aria-label={title ?? `Répartition des sièges sur ${total}, majorité à ${majority}`}>19        <div className="absolute inset-x-0 top-[14px] rounded-[6px] overflow-hidden bg-surface-3 flex" style={{ height }}>20          {entries.map(([id, n]) => {21            const w = (n / total) * 100;22            const el = <div key={id} className="h-full relative" style={{ width: `${w}%`, background: partyColor(id) }} title={`${partyLabel(id)} : ${n}`} />;23            x += w;24            return el;25          })}26          {others > 0 && <div className="h-full" style={{ width: `${(others / total) * 100}%`, background: OTHERS.color }} title={`Autres : ${others}`} />}27        </div>28        <div className="absolute top-0 bottom-0 w-[2px] bg-ink" style={{ left: `calc(${majX}% - 1px)` }} aria-hidden />29        <div className="absolute -top-[2px] text-[10.5px] font-bold uppercase tracking-wider num translate-x-[-50%] bg-background px-1" style={{ left: `${majX}%` }}>30          {majority} · Majorité31        </div>32      </div>33      {showLabels && (34        <div className="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-[12.5px]">35          {entries.map(([id, n]) => (36            <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: partyColor(id) }} /><span className="font-semibold">{partyLabel(id)}</span> {n}</span>37          ))}38          {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>}39        </div>40      )}41    </div>42  );43}4445// ------------------------------------------------------- SeatDistribution46/** Distribution des sièges d'un parti (histogramme Monte Carlo) avec seuil de majorité et intervalles. */47export function SeatDistribution({ dist, color, majority, median, lo80, hi80, lo95, hi95, height = 120, className, total }: {48  dist: number[]; color: string; majority: number; median: number; lo80: number; hi80: number; lo95: number; hi95: number; height?: number; className?: string; total: number;49}) {50  const n = dist.length;51  const max = Math.max(...dist, 1);52  const W = 640, H = height, pad = 18;53  const bw = (W - 2 * pad) / n;54  const xs = (i: number) => pad + i * bw;55  const vis = dist.map((v, i) => [i, v] as const).filter(([, v]) => v > 0);56  const minI = Math.max(0, (vis[0]?.[0] ?? 0) - 2), maxI = Math.min(n - 1, (vis[vis.length - 1]?.[0] ?? n - 1) + 2);57  const span = Math.max(1, maxI - minI);58  const xz = (i: number) => pad + ((i - minI) / span) * (W - 2 * pad);59  const bwz = (W - 2 * pad) / span;60  return (61    <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}`}>62      <rect x={xz(lo95)} y={0} width={Math.max(1, xz(hi95) - xz(lo95) + bwz)} height={H} fill={color} opacity={0.06} />63      <rect x={xz(lo80)} y={0} width={Math.max(1, xz(hi80) - xz(lo80) + bwz)} height={H} fill={color} opacity={0.08} />64      {dist.map((v, i) => (i < minI || i > maxI || v === 0) ? null : (65        <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.55} rx={1} />66      ))}67      {majority >= minI && majority <= maxI && (68        <>69          <line x1={xz(majority)} x2={xz(majority)} y1={0} y2={H + 4} stroke="var(--text)" strokeWidth={1.5} strokeDasharray="3 3" />70          <text x={xz(majority) + 4} y={11} fontSize={10.5} fontWeight={700} fill="var(--text)" className="num">{majority} · majorité</text>71        </>72      )}73      <line x1={xz(median) + bwz / 2} x2={xz(median) + bwz / 2} y1={0} y2={H} stroke={color} strokeWidth={2} />74      {[minI, Math.round((minI + maxI) / 2), maxI].map((i) => (75        <text key={i} x={xz(i) + bwz / 2} y={H + 20} fontSize={10.5} textAnchor="middle" fill="var(--text-2)" className="num">{i}</text>76      ))}77      <text x={xz(median) + bwz / 2} y={H + 20} fontSize={10.5} textAnchor="middle" fontWeight={700} fill={color} className="num">{median}</text>78      <title>{`Sur ${total} sièges`}</title>79    </svg>80  );81}8283// ----------------------------------------------------------------- Trend84export function TrendChart({ trend, polls, from, height = 300, showBand = true, ids = PARTY_IDS, className, markers = [], election2022 }: {85  trend: TrendPoint[]; polls?: { date: string; values: Record<string, number>; id?: string }[]; from?: string; height?: number; showBand?: boolean; ids?: string[]; className?: string;86  markers?: { date: string; label: string }[]; election2022?: Record<string, number> | null;87}) {88  const pts = from ? trend.filter((t) => t.date >= from) : trend;89  if (pts.length < 2) return <div className="text-ink-3 text-sm">Pas assez de données.</div>;90  const W = 900, H = height, pl = 34, pr = 78, pt = 12, pb = 28;91  const t0 = new Date(pts[0].date).getTime(), t1 = new Date(pts[pts.length - 1].date).getTime();92  const x = (d: string) => pl + ((new Date(d).getTime() - t0) / Math.max(1, t1 - t0)) * (W - pl - pr);93  const allVals = pts.flatMap((p) => ids.map((id) => p.mean[id] ?? 0));94  const pollVals = (polls ?? []).filter((p) => !from || p.date >= from).flatMap((p) => ids.map((id) => p.values[id] ?? 0));95  const ymax = Math.min(100, Math.ceil((Math.max(...allVals, ...pollVals, 10) + 4) / 5) * 5);96  const y = (v: number) => pt + (1 - v / ymax) * (H - pt - pb);97  const ticks = Array.from({ length: Math.floor(ymax / 10) + 1 }, (_, i) => i * 10);98  // repères temporels : premier jour de chaque mois (ou trimestre si long)99  const months: { d: Date; label: string }[] = [];100  const spanDays = (t1 - t0) / 86400000;101  const step = spanDays > 900 ? 6 : spanDays > 400 ? 3 : 1;102  const cur = new Date(t0); cur.setDate(1); cur.setMonth(cur.getMonth() + 1);103  while (cur.getTime() <= t1) {104    if (cur.getMonth() % step === 0) months.push({ d: new Date(cur), label: cur.toLocaleDateString("fr-CA", { month: "short", year: spanDays > 200 ? "2-digit" : undefined }) });105    cur.setMonth(cur.getMonth() + 1);106  }107  const path = (id: string) => pts.map((p, i) => `${i ? "L" : "M"}${x(p.date).toFixed(1)},${y(p.mean[id] ?? 0).toFixed(1)}`).join(" ");108  const band = (id: string) => {109    const up = pts.map((p) => `${x(p.date).toFixed(1)},${y((p.mean[id] ?? 0) + 1.28 * (p.sd[id] ?? 0)).toFixed(1)}`);110    const dn = [...pts].reverse().map((p) => `${x(p.date).toFixed(1)},${y((p.mean[id] ?? 0) - 1.28 * (p.sd[id] ?? 0)).toFixed(1)}`);111    return `M${up.join(" L")} L${dn.join(" L")} Z`;112  };113  const last = pts[pts.length - 1];114  const labels = ids.map((id) => ({ id, v: last.mean[id] ?? 0 })).sort((a, b) => b.v - a.v);115  // évite les chevauchements d'étiquettes116  let prevY = -Infinity;117  const labelY = labels.map((l) => { let yy = y(l.v); if (yy - prevY < 13) yy = prevY + 13; prevY = yy; return yy; });118  return (119    <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">120      {ticks.map((t) => (121        <g key={t}>122          <line x1={pl} x2={W - pr} y1={y(t)} y2={y(t)} stroke="var(--border)" strokeWidth={1} />123          <text x={pl - 6} y={y(t) + 3.5} fontSize={10.5} textAnchor="end" fill="var(--text-3)" className="num">{t}</text>124        </g>125      ))}126      {months.map((m, i) => (127        <g key={i}>128          <line x1={x(m.d.toISOString())} x2={x(m.d.toISOString())} y1={pt} y2={H - pb + 4} stroke="var(--border)" strokeWidth={1} />129          <text x={x(m.d.toISOString())} y={H - 8} fontSize={10.5} textAnchor="middle" fill="var(--text-3)">{m.label}</text>130        </g>131      ))}132      {markers.filter((m) => new Date(m.date).getTime() >= t0 && new Date(m.date).getTime() <= t1).map((m, i) => (133        <g key={`m${i}`}>134          <line x1={x(m.date)} x2={x(m.date)} y1={pt} y2={H - pb} stroke="var(--text-2)" strokeWidth={1} strokeDasharray="2 3" />135          <text x={x(m.date) + 3} y={pt + 9} fontSize={9.5} fill="var(--text-2)">{m.label}</text>136        </g>137      ))}138      {polls?.filter((p) => !from || p.date >= from).map((p, i) => ids.map((id) => p.values[id] === undefined ? null : (139        <circle key={`${i}-${id}`} cx={x(p.date)} cy={y(p.values[id])} r={2.6} fill={partyColor(id)} opacity={0.35} />140      )))}141      {showBand && ids.map((id) => <path key={`b${id}`} d={band(id)} fill={partyColor(id)} opacity={0.10} />)}142      {ids.map((id) => <path key={id} d={path(id)} fill="none" stroke={partyColor(id)} strokeWidth={2.4} strokeLinejoin="round" strokeLinecap="round" />)}143      {election2022 && ids.map((id) => election2022[id] !== undefined && (144        <g key={`e${id}`}><rect x={pl - 2} y={y(election2022[id]) - 1.5} width={5} height={3} fill={partyColor(id)} /></g>145      ))}146      {labels.map((l, i) => (147        <text key={l.id} x={W - pr + 6} y={labelY[i] + 4} fontSize={12} fontWeight={700} fill={partyColor(l.id)} className="num">{partyLabel(l.id)} {l.v.toFixed(1).replace(".", ",")}</text>148      ))}149    </svg>150  );151}152153// ---------------------------------------------------------------- VoteBars154export function VoteBars({ values, ci, max, className, order }: { values: Record<string, number>; ci?: Record<string, [number, number]>; max?: number; className?: string; order?: string[] }) {155  const ids = order ?? Object.keys(values).filter((k) => k !== "aut").sort((a, b) => values[b] - values[a]);156  const m = max ?? Math.max(...ids.map((i) => ci?.[i]?.[1] ?? values[i]), 10) * 1.08;157  return (158    <div className={cls("space-y-2", className)}>159      {ids.map((id) => (160        <div key={id} className="grid grid-cols-[44px_1fr_58px] items-center gap-3 text-[13.5px]">161          <span className="font-semibold num" style={{ color: partyColor(id) }}>{partyLabel(id)}</span>162          <div className="relative h-[14px] bg-surface-3 rounded-[4px] overflow-hidden">163            {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: partyColor(id), opacity: 0.25 }} />}164            <div className="absolute top-0 bottom-0 left-0 rounded-[4px]" style={{ width: `${(values[id] / m) * 100}%`, background: partyColor(id) }} />165          </div>166          <span className="num font-semibold text-right">{values[id].toFixed(1).replace(".", ",")} %</span>167        </div>168      ))}169    </div>170  );171}172173export function Legend({ ids, className }: { ids: string[]; className?: string }) {174  return (175    <div className={cls("flex flex-wrap gap-x-4 gap-y-1 text-[12.5px]", className)}>176      {ids.map((id) => (177        <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: partyColor(id) }} />{partyLabel(id)}</span>178      ))}179    </div>180  );181}182183export function Sparkline({ values, color, width = 120, height = 32, className }: { values: number[]; color: string; width?: number; height?: number; className?: string }) {184  if (values.length < 2) return null;185  const min = Math.min(...values), max = Math.max(...values), span = Math.max(0.001, max - min);186  const pts = values.map((v, i) => `${(i / (values.length - 1)) * width},${height - ((v - min) / span) * (height - 4) - 2}`).join(" ");187  return <svg width={width} height={height} className={className} aria-hidden><polyline points={pts} fill="none" stroke={color} strokeWidth={1.8} strokeLinejoin="round" /></svg>;188}189190export function Note({ children }: { children: ReactNode }) {191  return <p className="text-[12px] text-ink-3 leading-relaxed mt-2">{children}</p>;192}193
Files · 08f5606551574077c8600a5faf68d3daef5dbc10
Menu

Theme