"use client"; import { useMemo, useRef, useState } from "react"; import type { TrendPoint } from "@/lib/api"; import { cls, dateFr } from "@/lib/format"; import { PARTY_IDS, partyLabel, partyVar } from "@/lib/parties"; import { useMeasure } from "@/lib/use-measure"; /** Tendance interactive, dimensionnée à la largeur réelle (1 unité SVG = 1 px : les textes restent lisibles * sur téléphone). Réticule + infobulle ; sur écran étroit, les valeurs courantes passent sous le graphique. */ export function TrendChartInteractive({ trend, polls, from, height = 320, ids = PARTY_IDS, markers = [], election2022, className, showBand = true }: { trend: TrendPoint[]; polls?: { date: string; values: Record; id?: string; pollster?: string }[]; from?: string; height?: number; ids?: string[]; markers?: { date: string; label: string }[]; election2022?: Record | null; className?: string; showBand?: boolean; }) { const { ref, width } = useMeasure(900); const [range, setRange] = useState(from); const pts = useMemo(() => (range ? trend.filter((t) => t.date >= range) : trend), [trend, range]); const [hover, setHover] = useState(null); const svgRef = useRef(null); if (pts.length < 2) return
Pas assez de données.
; const narrow = width < 560; const W = Math.max(300, width), H = narrow ? Math.min(height, 240) : height; const pl = 30, pr = narrow ? 10 : 86, pt = 14, pb = 26; const t0 = new Date(pts[0].date).getTime(), t1 = new Date(pts[pts.length - 1].date).getTime(); const x = (d: string) => pl + ((new Date(d).getTime() - t0) / Math.max(1, t1 - t0)) * (W - pl - pr); const visPolls = (polls ?? []).filter((p) => p.date >= pts[0].date && p.date <= pts[pts.length - 1].date); const allVals = pts.flatMap((p) => ids.map((id) => p.mean[id] ?? 0)); const pollVals = visPolls.flatMap((p) => ids.map((id) => p.values[id] ?? 0)); const ymax = Math.min(100, Math.ceil((Math.max(...allVals, ...pollVals, 10) + 4) / 5) * 5); const y = (v: number) => pt + (1 - v / ymax) * (H - pt - pb); const ticks = Array.from({ length: Math.floor(ymax / 10) + 1 }, (_, i) => i * 10); const months: { d: Date; label: string }[] = []; const spanDays = (t1 - t0) / 86400000; const step = spanDays > 900 ? (narrow ? 12 : 6) : spanDays > 400 ? (narrow ? 6 : 3) : spanDays > 120 ? (narrow ? 2 : 1) : 1; const cur = new Date(t0); cur.setDate(1); cur.setMonth(cur.getMonth() + 1); while (cur.getTime() <= t1) { if (cur.getMonth() % step === 0) months.push({ d: new Date(cur), label: cur.toLocaleDateString("fr-CA", { month: "short", year: spanDays > 200 ? "2-digit" : undefined }) }); cur.setMonth(cur.getMonth() + 1); } const path = (id: string) => pts.map((p, i) => `${i ? "L" : "M"}${x(p.date).toFixed(1)},${y(p.mean[id] ?? 0).toFixed(1)}`).join(" "); const band = (id: string) => { const up = pts.map((p) => `${x(p.date).toFixed(1)},${y((p.mean[id] ?? 0) + 1.28 * (p.sd[id] ?? 0)).toFixed(1)}`); const dn = [...pts].reverse().map((p) => `${x(p.date).toFixed(1)},${y((p.mean[id] ?? 0) - 1.28 * (p.sd[id] ?? 0)).toFixed(1)}`); return `M${up.join(" L")} L${dn.join(" L")} Z`; }; const last = pts[pts.length - 1]; const labels = ids.map((id) => ({ id, v: last.mean[id] ?? 0 })).sort((a, b) => b.v - a.v); const labelY = labels.reduce((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; }, []); const pick = (clientX: number) => { const r = svgRef.current!.getBoundingClientRect(); const px = ((clientX - r.left) / r.width) * W; const t = t0 + ((px - pl) / (W - pl - pr)) * (t1 - t0); let best = 0, bd = Infinity; pts.forEach((p, i) => { const d = Math.abs(new Date(p.date).getTime() - t); if (d < bd) { bd = d; best = i; } }); setHover(best); }; const hp = hover !== null ? pts[hover] : null; const shown = hp ?? last; const hpolls = hp ? visPolls.filter((p) => Math.abs(new Date(p.date).getTime() - new Date(hp.date).getTime()) <= 2 * 86400000) : []; const refT = new Date(trend[trend.length - 1].date).getTime(); const ranges: { label: string; from?: string }[] = [ { label: "Campagne", from: "2026-08-20" }, { label: "6 mois", from: new Date(refT - 182 * 86400000).toISOString().slice(0, 10) }, { label: "1 an", from: new Date(refT - 365 * 86400000).toISOString().slice(0, 10) }, { label: "Depuis 2022" }, ]; const tipLeft = hp ? Math.min(Math.max(8, x(hp.date) + 12), W - 172) : 0; return (
{ranges.map((r) => )} Survolez pour lire la moyenne QC26 du jour
pick(e.clientX)} onPointerDown={(e) => pick(e.clientX)} onPointerLeave={() => setHover(null)}> {ticks.map((t) => ( {t} ))} {months.map((m, i) => ( {m.label} ))} {markers.filter((m) => new Date(m.date).getTime() >= t0 && new Date(m.date).getTime() <= t1).map((m, i) => ( {!narrow && {m.label}} ))} {visPolls.map((p, i) => ids.map((id) => p.values[id] === undefined ? null : ( )))} {showBand && ids.map((id) => )} {ids.map((id) => )} {election2022 && ids.map((id) => election2022[id] !== undefined && ( ))} {hp ? ( {ids.map((id) => )} ) : ids.map((id) => )} {!narrow && labels.map((l, i) => ( ■ {partyLabel(l.id)} {(shown.mean[l.id] ?? 0).toFixed(1).replace(".", ",")} ))} {narrow && (
{labels.map((l) => (
{partyLabel(l.id)}
{(shown.mean[l.id] ?? 0).toFixed(1).replace(".", ",")}
))}
{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`}
)} {hp && !narrow && (
{dateFr(hp.date, { day: "numeric", month: "short", year: "numeric" })}
{ids.map((id) => ({ id, v: hp.mean[id] ?? 0, sd: hp.sd[id] ?? 0 })).sort((a, b) => b.v - a.v).map((r) => (
{partyLabel(r.id)}{r.v.toFixed(1).replace(".", ",")} ± {(1.28 * r.sd).toFixed(1).replace(".", ",")}
))} {hpolls.length > 0 &&
{hpolls.length} sondage{hpolls.length > 1 ? "s" : ""} à ± 2 j{hpolls[0].pollster ? ` · ${hpolls.slice(0, 2).map((p) => p.pollster).join(", ")}` : ""}
}
)}
); }