import React, { useMemo, useRef, useEffect, useState } from "react"; import { DepartmentCorrelationPoint } from "../../../models/DepartmentCorrelation"; import { WIDTH, HEIGHT, MARGIN, padDomain, distanceToPoint, extent, scaleLinear } from "./scatterplot.utils"; import * as d3 from 'd3'; import { scaleLinear as d3ScaleLinear } from 'd3-scale'; export type HoverPoint = { x: number; y: number; collectedVolume: number; communeCode: string; rate: number; }; type Props = { series: Map; raw: DepartmentCorrelationPoint[]; selectedCommune: string | null; onHoverChange: (h: HoverPoint | null) => void; onSelectCommune: (region: string | null) => void; }; export const ScatterPlotChart: React.FC = ({ series, raw, selectedCommune, onHoverChange, onSelectCommune }) => { const canvasRef = useRef(null); const [hover, setHover] = useState(null); const innerW = WIDTH - MARGIN.left - MARGIN.right; const innerH = HEIGHT - MARGIN.top - MARGIN.bottom; const rates = useMemo(() => raw.map((d) => d.rate), [raw]); const collectedVolumes = useMemo(() => raw.map((d) => d.collectedVolume), [raw]); const xDomain = useMemo(() => padDomain(extent(rates)), [rates]); const yDomain = useMemo(() => padDomain(extent(collectedVolumes)), [collectedVolumes]); 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); // 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); }); // Points Array.from(series.entries()).forEach(([communeCode, pts]) => { ctx.beginPath(); const x = xScale(pts[0].rate); const y = yScale(pts[0].collectedVolume); // Outer circle ctx.fillStyle = "#000"; 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 const activeHover = hover ? hover : selectedCommune && series.has(selectedCommune) ? (() => { const pts = series.get(selectedCommune)!; const point = pts[0]; return { x: xScale(point.rate), y: yScale(point.collectedVolume), collectedVolume: point.collectedVolume, rate: point.rate, communeCode: selectedCommune, } as HoverPoint; })() : null; if (activeHover) { const { x, y, collectedVolume, rate, communeCode } = activeHover; // 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(communeCode, tooltipX + 8, tooltipY + 8); ctx.font = "12px sans-serif"; ctx.fillText(`${collectedVolume} — collectedVolume: ${rate.toFixed(2)}`, tooltipX + 8, tooltipY + 24); } }; useEffect(() => { drawChart(); }, [series, hover, selectedCommune]); 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 [communeCode, pts] of Array.from(series.entries())) { const p = pts[0]; const d = distanceToPoint( mouseX, mouseY, xScale(p.rate), yScale(p.collectedVolume), ); if (d < bestDist) { bestDist = d; best = { x: xScale(p.collectedVolume), y: yScale(p.rate), collectedVolume: p.collectedVolume, rate: p.rate, communeCode, }; } } if (best && bestDist < 10) setHover(best); else setHover(null); }; const handleClick = () => { if (hover) onSelectCommune(hover.communeCode); }; return (
setHover(null)} onClick={handleClick} />
); };