import React, { useMemo, useRef, useEffect, useState } from "react"; import { RegionEvolutionPoint } from "../../../models/RegionEvolution"; import { WIDTH, HEIGHT, MARGIN, distanceToSegment, colorForRegion } from "./timeseries.utils"; import { extent, padDomain, scaleLinear, } from "./../../utils/chart.utils"; export type HoverPoint = { x: number; y: number; year: number; regionName: string; rate: number; }; type Props = { series: Map; raw: RegionEvolutionPoint[]; selectedRegion: string | null; onSelectRegion: (region: string | null) => void; }; export const TimeSeriesChart: React.FC = ({ series, raw, selectedRegion, onSelectRegion, }) => { const canvasRef = useRef(null); const [hover, setHover] = useState(null); const innerW = WIDTH - MARGIN.left - MARGIN.right; const innerH = HEIGHT - MARGIN.top - MARGIN.bottom; const years = useMemo(() => raw.map((d) => d.year), [raw]); const rates = useMemo(() => raw.map((d) => d.rate), [raw]); const xDomain = useMemo(() => extent(years), [years]); const yDomain = useMemo(() => padDomain(extent(rates)), [rates]); const xScale = useMemo( () => scaleLinear(xDomain, [MARGIN.left, MARGIN.left + innerW]), [xDomain, innerW] ); const yScale = useMemo( () => scaleLinear(yDomain, [MARGIN.top + innerH, MARGIN.top]), [yDomain, innerH] ); const xTicks = useMemo(() => { const [minY, maxY] = xDomain; const ticks: number[] = []; for (let y = Math.round(minY); y <= Math.round(maxY); y++) ticks.push(y); return ticks; }, [xDomain]); const yTicks = useMemo(() => { const [min, max] = yDomain; const count = 5; const step = (max - min) / (count - 1); return Array.from({ length: count }, (_, i) => min + i * step); }, [yDomain]); const drawChart = () => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.clearRect(0, 0, WIDTH, HEIGHT); // Background gradient const gradient = ctx.createLinearGradient(0, 0, 0, HEIGHT); gradient.addColorStop(0, "#00000000"); gradient.addColorStop(1, "#ffffff00"); ctx.fillStyle = gradient; ctx.fillRect(0, 0, WIDTH, HEIGHT); // Axes ctx.strokeStyle = "#333"; ctx.lineWidth = 1; // X axis ctx.beginPath(); ctx.moveTo(MARGIN.left, MARGIN.top + innerH); ctx.lineTo(MARGIN.left + innerW, MARGIN.top + innerH); ctx.stroke(); // Y axis ctx.beginPath(); ctx.moveTo(MARGIN.left, MARGIN.top); ctx.lineTo(MARGIN.left, MARGIN.top + innerH); ctx.stroke(); // X ticks ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.fillStyle = "#333"; xTicks.forEach((t) => { const x = xScale(t); const y = MARGIN.top + innerH; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + 6); ctx.stroke(); ctx.fillText(`${t}`, x, y + 8); }); // Y ticks ctx.textAlign = "right"; ctx.textBaseline = "middle"; yTicks.forEach((t) => { const y = yScale(t); ctx.beginPath(); ctx.moveTo(MARGIN.left - 6, y); ctx.lineTo(MARGIN.left, y); ctx.stroke(); // Grid line ctx.strokeStyle = "#ddd"; ctx.beginPath(); ctx.moveTo(MARGIN.left, y); ctx.lineTo(MARGIN.left + innerW, y); ctx.stroke(); ctx.strokeStyle = "#333"; ctx.fillText(t.toFixed(1), MARGIN.left - 10, y); }); // Lines Array.from(series.entries()).forEach(([regionName, pts]) => { const isSelected = selectedRegion === regionName; const isHovered = hover?.regionName === regionName; ctx.strokeStyle = colorForRegion(regionName); ctx.lineWidth = isSelected || isHovered ? 3 : 1.5; ctx.beginPath(); pts.forEach((p, i) => { const x = xScale(p.year); const y = yScale(p.rate); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); }); // Tooltip const activeHover = hover ? hover : selectedRegion && series.has(selectedRegion) ? (() => { const pts = series.get(selectedRegion)!; const last = pts[pts.length - 1]; return { x: xScale(last.year), y: yScale(last.rate), year: last.year, rate: last.rate, regionName: selectedRegion, } as HoverPoint; })() : null; if (activeHover) { const { x, y, regionName, year, rate } = activeHover; // Outer circle ctx.fillStyle = colorForRegion(regionName); ctx.beginPath(); ctx.arc(x, y, 6, 0, Math.PI * 2); ctx.fill(); // Inner circle ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); // Tooltip box const tooltipX = x + 10; const tooltipY = y - 40; ctx.fillStyle = "#333"; ctx.globalAlpha = 0.8; ctx.fillRect(tooltipX, tooltipY, 240, 52); ctx.globalAlpha = 1; // Tooltip text ctx.fillStyle = "#fff"; ctx.textAlign = "left"; ctx.textBaseline = "top"; ctx.font = "bold 12px sans-serif"; ctx.fillText(regionName, tooltipX + 8, tooltipY + 8); ctx.font = "12px sans-serif"; ctx.fillText(`${year} — taux: ${rate.toFixed(2)}`, tooltipX + 8, tooltipY + 24); } }; useEffect(() => { drawChart(); }, [series, hover, selectedRegion]); const handleMouseMove = (e: React.MouseEvent) => { const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; let best: HoverPoint | null = null; let bestDist = Infinity; for (const [regionName, pts] of Array.from(series.entries())) { for (let i = 0; i < pts.length - 1; i++) { const p0 = pts[i]; const p1 = pts[i + 1]; const d = distanceToSegment( mouseX, mouseY, xScale(p0.year), yScale(p0.rate), xScale(p1.year), yScale(p1.rate) ); if (d < bestDist) { const t = Math.max( 0, Math.min( 1, ((mouseX - xScale(p0.year)) * (xScale(p1.year) - xScale(p0.year)) + (mouseY - yScale(p0.rate)) * (yScale(p1.rate) - yScale(p0.rate))) / ((xScale(p1.year) - xScale(p0.year)) ** 2 + (yScale(p1.rate) - yScale(p0.rate)) ** 2) ) ); bestDist = d; best = { x: xScale(p0.year) + t * (xScale(p1.year) - xScale(p0.year)), y: yScale(p0.rate) + t * (yScale(p1.rate) - yScale(p0.rate)), year: Math.round(p0.year + t * (p1.year - p0.year)), rate: p0.rate + t * (p1.rate - p0.rate), regionName, }; } } } if (best && bestDist < 10) setHover(best); else setHover(null); }; const handleClick = () => { if (hover) onSelectRegion(hover.regionName); }; return (
setHover(null)} onClick={handleClick} />
); };