ScatterPlotChart.tsx 6,75 ko
Newer Older
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<string, DepartmentCorrelationPoint[]>;
  raw: DepartmentCorrelationPoint[];
  selectedCommune: string | null;
  onHoverChange: (h: HoverPoint | null) => void;
  onSelectCommune: (region: string | null) => void;
};

export const ScatterPlotChart: React.FC<Props> = ({
    series,
    raw,
    selectedCommune,
    onHoverChange,
    onSelectCommune
}) => {
    const canvasRef = useRef<HTMLCanvasElement | null>(null);
    const [hover, setHover] = useState<HoverPoint | null>(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<HTMLCanvasElement, 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 (
      <div className="chart-wrapper">
        <canvas
          ref={canvasRef}
          width={WIDTH}
          height={HEIGHT}
          className="chart-canvas"
          onMouseMove={handleMouseMove}
          onMouseLeave={() => setHover(null)}
          onClick={handleClick}
        />
      </div>
    );
};