TimeSeriesChart.tsx 7,43 ko
Newer Older
HayetFer's avatar
HayetFer a validé
import React, { useMemo, useRef, useEffect, useState } from "react";
import { RegionEvolutionPoint } from "../../../models/RegionEvolution";
import {
  WIDTH,
  HEIGHT,
  MARGIN,
  extent,
  padDomain,
  scaleLinear,
  colorForRegion,
HayetFer's avatar
HayetFer a validé
  distanceToSegment,
} from "./timeseries.utils";
import "./TimeSeries.css";

export type HoverPoint = {
  x: number;
  y: number;
  year: number;
  regionName: string;
  rate: number;
};

type Props = {
  series: Map<string, RegionEvolutionPoint[]>;
  raw: RegionEvolutionPoint[];
  selectedRegion: string | null;
  onHoverChange: (h: HoverPoint | null) => void;
HayetFer's avatar
HayetFer a validé
  onSelectRegion: (region: string | null) => void;
};

export const TimeSeriesChart: React.FC<Props> = ({
  series,
  raw,
  selectedRegion,
  onHoverChange,
  onSelectRegion,
}) => {
HayetFer's avatar
HayetFer a validé
  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;

HayetFer's avatar
HayetFer a validé
  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]);

HayetFer's avatar
HayetFer a validé
  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;
HayetFer's avatar
HayetFer a validé
      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<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 [regionName, pts] of Array.from(series.entries())) {
HayetFer's avatar
HayetFer a validé
      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) {
HayetFer's avatar
HayetFer a validé
          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;
HayetFer's avatar
HayetFer a validé
          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,
          };
HayetFer's avatar
HayetFer a validé
    if (best && bestDist < 10) setHover(best);
    else setHover(null);
  };

  const handleClick = () => {
    if (hover) onSelectRegion(hover.regionName);
  };

  return (
    <div className="chart-wrapper">
HayetFer's avatar
HayetFer a validé
      <canvas
        ref={canvasRef}
        width={WIDTH}
        height={HEIGHT}
        className="chart-canvas"
        onMouseMove={handleMouseMove}
        onMouseLeave={() => setHover(null)}
        onClick={handleClick}
      />