Python 51.6%
TypeScript 46.7%
CSS 1.7%
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