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%
9.9 KB · 135 lines tsx
Raw Blame History
1"use client";23import { useMemo, useRef, useState } from "react";4import type { TrendPoint } from "@/lib/api";5import { cls, dateFr } from "@/lib/format";6import { PARTY_IDS, partyLabel, partyVar } from "@/lib/parties";7import { useMeasure } from "@/lib/use-measure";89/** Tendance interactive, dimensionnée à la largeur réelle (1 unité SVG = 1 px : les textes restent lisibles10 * sur téléphone). Réticule + infobulle ; sur écran étroit, les valeurs courantes passent sous le graphique. */11export function TrendChartInteractive({ trend, polls, from, height = 320, ids = PARTY_IDS, markers = [], election2022, className, showBand = true }: {12  trend: TrendPoint[]; polls?: { date: string; values: Record<string, number>; id?: string; pollster?: string }[]; from?: string; height?: number; ids?: string[];13  markers?: { date: string; label: string }[]; election2022?: Record<string, number> | null; className?: string; showBand?: boolean;14}) {15  const { ref, width } = useMeasure<HTMLDivElement>(900);16  const [range, setRange] = useState<string | undefined>(from);17  const pts = useMemo(() => (range ? trend.filter((t) => t.date >= range) : trend), [trend, range]);18  const [hover, setHover] = useState<number | null>(null);19  const svgRef = useRef<SVGSVGElement>(null);20  if (pts.length < 2) return <div className="text-ink-3 text-sm">Pas assez de données.</div>;21  const narrow = width < 560;22  const W = Math.max(300, width), H = narrow ? Math.min(height, 240) : height;23  const pl = 30, pr = narrow ? 10 : 86, pt = 14, pb = 26;24  const t0 = new Date(pts[0].date).getTime(), t1 = new Date(pts[pts.length - 1].date).getTime();25  const x = (d: string) => pl + ((new Date(d).getTime() - t0) / Math.max(1, t1 - t0)) * (W - pl - pr);26  const visPolls = (polls ?? []).filter((p) => p.date >= pts[0].date && p.date <= pts[pts.length - 1].date);27  const allVals = pts.flatMap((p) => ids.map((id) => p.mean[id] ?? 0));28  const pollVals = visPolls.flatMap((p) => ids.map((id) => p.values[id] ?? 0));29  const ymax = Math.min(100, Math.ceil((Math.max(...allVals, ...pollVals, 10) + 4) / 5) * 5);30  const y = (v: number) => pt + (1 - v / ymax) * (H - pt - pb);31  const ticks = Array.from({ length: Math.floor(ymax / 10) + 1 }, (_, i) => i * 10);32  const months: { d: Date; label: string }[] = [];33  const spanDays = (t1 - t0) / 86400000;34  const step = spanDays > 900 ? (narrow ? 12 : 6) : spanDays > 400 ? (narrow ? 6 : 3) : spanDays > 120 ? (narrow ? 2 : 1) : 1;35  const cur = new Date(t0); cur.setDate(1); cur.setMonth(cur.getMonth() + 1);36  while (cur.getTime() <= t1) {37    if (cur.getMonth() % step === 0) months.push({ d: new Date(cur), label: cur.toLocaleDateString("fr-CA", { month: "short", year: spanDays > 200 ? "2-digit" : undefined }) });38    cur.setMonth(cur.getMonth() + 1);39  }40  const path = (id: string) => pts.map((p, i) => `${i ? "L" : "M"}${x(p.date).toFixed(1)},${y(p.mean[id] ?? 0).toFixed(1)}`).join(" ");41  const band = (id: string) => {42    const up = pts.map((p) => `${x(p.date).toFixed(1)},${y((p.mean[id] ?? 0) + 1.28 * (p.sd[id] ?? 0)).toFixed(1)}`);43    const dn = [...pts].reverse().map((p) => `${x(p.date).toFixed(1)},${y((p.mean[id] ?? 0) - 1.28 * (p.sd[id] ?? 0)).toFixed(1)}`);44    return `M${up.join(" L")} L${dn.join(" L")} Z`;45  };46  const last = pts[pts.length - 1];47  const labels = ids.map((id) => ({ id, v: last.mean[id] ?? 0 })).sort((a, b) => b.v - a.v);48  const labelY = labels.reduce<number[]>((acc, l) => { const prev = acc.length ? acc[acc.length - 1] : -Infinity; const yy = y(l.v); acc.push(yy - prev < 13 ? prev + 13 : yy); return acc; }, []);49  const pick = (clientX: number) => {50    const r = svgRef.current!.getBoundingClientRect();51    const px = ((clientX - r.left) / r.width) * W;52    const t = t0 + ((px - pl) / (W - pl - pr)) * (t1 - t0);53    let best = 0, bd = Infinity;54    pts.forEach((p, i) => { const d = Math.abs(new Date(p.date).getTime() - t); if (d < bd) { bd = d; best = i; } });55    setHover(best);56  };57  const hp = hover !== null ? pts[hover] : null;58  const shown = hp ?? last;59  const hpolls = hp ? visPolls.filter((p) => Math.abs(new Date(p.date).getTime() - new Date(hp.date).getTime()) <= 2 * 86400000) : [];60  const refT = new Date(trend[trend.length - 1].date).getTime();61  const ranges: { label: string; from?: string }[] = [62    { label: "Campagne", from: "2026-08-20" }, { label: "6 mois", from: new Date(refT - 182 * 86400000).toISOString().slice(0, 10) },63    { label: "1 an", from: new Date(refT - 365 * 86400000).toISOString().slice(0, 10) }, { label: "Depuis 2022" },64  ];65  const tipLeft = hp ? Math.min(Math.max(8, x(hp.date) + 12), W - 172) : 0;66  return (67    <div className={cls("relative", className)} ref={ref}>68      <div className="flex flex-wrap items-center gap-1.5 mb-3">69        {ranges.map((r) => <button key={r.label} onClick={() => { setRange(r.from); setHover(null); }} className={cls("chip !py-1 !px-2.5 !text-[12px]", (range ?? "") === (r.from ?? "") && "chip-active")}>{r.label}</button>)}70        <span className="text-[12px] text-ink-3 ml-auto hidden sm:inline">Survolez pour lire la moyenne QC26 du jour</span>71      </div>72      <svg ref={svgRef} viewBox={`0 0 ${W} ${H}`} width={W} height={H} className="block max-w-full h-auto touch-pan-y" role="img" aria-label="Évolution des intentions de vote (moyenne QC26) et sondages"73        onPointerMove={(e) => pick(e.clientX)} onPointerDown={(e) => pick(e.clientX)} onPointerLeave={() => setHover(null)}>74        {ticks.map((t) => (75          <g key={t}>76            <line x1={pl} x2={W - pr} y1={y(t)} y2={y(t)} stroke="var(--grid)" strokeWidth={1} />77            <text x={pl - 6} y={y(t) + 3.5} fontSize={10.5} textAnchor="end" fill="var(--text-3)" fontFamily="var(--font-mono)">{t}</text>78          </g>79        ))}80        {months.map((m, i) => (81          <g key={i}>82            <line x1={x(m.d.toISOString())} x2={x(m.d.toISOString())} y1={pt} y2={H - pb + 4} stroke="var(--grid)" strokeWidth={1} />83            <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>84          </g>85        ))}86        {markers.filter((m) => new Date(m.date).getTime() >= t0 && new Date(m.date).getTime() <= t1).map((m, i) => (87          <g key={`m${i}`}>88            <line x1={x(m.date)} x2={x(m.date)} y1={pt} y2={H - pb} stroke="var(--text-2)" strokeWidth={1} strokeDasharray="2 3" />89            {!narrow && <text x={x(m.date) + 4} y={pt + 9} fontSize={9.5} fill="var(--text-2)" fontFamily="var(--font-mono)">{m.label}</text>}90          </g>91        ))}92        {visPolls.map((p, i) => ids.map((id) => p.values[id] === undefined ? null : (93          <circle key={`${i}-${id}`} cx={x(p.date)} cy={y(p.values[id])} r={narrow ? 2 : 2.6} fill={partyVar(id)} opacity={0.3} />94        )))}95        {showBand && ids.map((id) => <path key={`b${id}`} d={band(id)} fill={partyVar(id)} opacity={0.1} />)}96        {ids.map((id) => <path key={id} d={path(id)} fill="none" stroke={partyVar(id)} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />)}97        {election2022 && ids.map((id) => election2022[id] !== undefined && (98          <rect key={`e22${id}`} x={pl - 2} y={y(election2022[id]) - 1.5} width={5} height={3} fill={partyVar(id)} />99        ))}100        {hp ? (101          <g>102            <line x1={x(hp.date)} x2={x(hp.date)} y1={pt} y2={H - pb} stroke="var(--text)" strokeWidth={1} strokeOpacity={0.6} />103            {ids.map((id) => <circle key={`h${id}`} cx={x(hp.date)} cy={y(hp.mean[id] ?? 0)} r={4} fill={partyVar(id)} stroke="var(--surface)" strokeWidth={2} />)}104          </g>105        ) : 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} />)}106        {!narrow && labels.map((l, i) => (107          <text key={l.id} x={W - pr + 8} y={labelY[i] + 4} fontSize={12} fontWeight={600} fill="var(--text)" fontFamily="var(--font-sans)">108            <tspan fill={partyVar(l.id)}>■</tspan> {partyLabel(l.id)} <tspan fontFamily="var(--font-mono)">{(shown.mean[l.id] ?? 0).toFixed(1).replace(".", ",")}</tspan>109          </text>110        ))}111      </svg>112      {narrow && (113        <div className="grid grid-cols-5 gap-1.5 mt-2">114          {labels.map((l) => (115            <div key={l.id} className="inset px-1.5 py-1.5 text-center" style={{ borderTop: `3px solid ${partyVar(l.id)}` }}>116              <div className="mono text-[9.5px] uppercase tracking-[0.06em] text-ink-2">{partyLabel(l.id)}</div>117              <div className="mono text-[15px] font-semibold leading-tight">{(shown.mean[l.id] ?? 0).toFixed(1).replace(".", ",")}</div>118            </div>119          ))}120          <div className="col-span-5 text-[11px] text-ink-3 text-center">{hp ? `Moyenne QC26 au ${dateFr(hp.date, { day: "numeric", month: "short", year: "numeric" })}` : `Dernier point : ${dateFr(last.date, { day: "numeric", month: "short", year: "numeric" })} · touchez la courbe pour une autre date`}</div>121        </div>122      )}123      {hp && !narrow && (124        <div className="chart-tip" style={{ left: tipLeft, top: 40 }}>125          <div className="font-semibold text-ink mb-1">{dateFr(hp.date, { day: "numeric", month: "short", year: "numeric" })}</div>126          {ids.map((id) => ({ id, v: hp.mean[id] ?? 0, sd: hp.sd[id] ?? 0 })).sort((a, b) => b.v - a.v).map((r) => (127            <div key={r.id} className="flex items-center justify-between gap-4"><span className="inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(r.id) }} />{partyLabel(r.id)}</span><span className="mono">{r.v.toFixed(1).replace(".", ",")} <span className="text-ink-3">± {(1.28 * r.sd).toFixed(1).replace(".", ",")}</span></span></div>128          ))}129          {hpolls.length > 0 && <div className="text-ink-3 mt-1 pt-1 border-t border-border">{hpolls.length} sondage{hpolls.length > 1 ? "s" : ""} à ± 2 j{hpolls[0].pollster ? ` · ${hpolls.slice(0, 2).map((p) => p.pollster).join(", ")}` : ""}</div>}130        </div>131      )}132    </div>133  );134}135