TimeSeriesChart.tsx 6,56 ko
Newer Older
import React, { useMemo, useRef } from "react";
import { RegionEvolutionPoint } from "../../../models/RegionEvolution";
import {
  WIDTH,
  HEIGHT,
  MARGIN,
  extent,
  padDomain,
  scaleLinear,
  buildPath,
  colorForRegion,
} 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[];
  hover: HoverPoint | null;
  selectedRegion: string | null;
  onHoverChange: (h: HoverPoint | null) => void;
  onSelectRegion: (region: string) => void;
};

export const TimeSeriesChart: React.FC<Props> = ({
  series,
  raw,
  hover,
  selectedRegion,
  onHoverChange,
  onSelectRegion,
}) => {
  const svgRef = useRef<SVGSVGElement | null>(null);

  const years = useMemo(() => raw.map((d) => d.year), [raw]);
  const rates = useMemo(() => raw.map((d) => d.rate), [raw]);

  const innerW = WIDTH - MARGIN.left - MARGIN.right;
  const innerH = HEIGHT - MARGIN.top - MARGIN.bottom;

  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]);

  function handleMouseMove(e: React.MouseEvent<SVGRectElement, MouseEvent>) {
    if (!svgRef.current) return;

    const rect = svgRef.current.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 (const p of pts) {
        const px = xScale(p.year);
        const py = yScale(p.rate);
        const d = (px - mouseX) ** 2 + (py - mouseY) ** 2;

        if (d < bestDist) {
          bestDist = d;
          best = { x: px, y: py, year: p.year, regionName, rate: p.rate };
        }
      }
    }

    if (best && bestDist < 500) onHoverChange(best);
    else onHoverChange(null);
  }

  return (
    <div className="chart-wrapper">
      <svg ref={svgRef} width={WIDTH} height={HEIGHT} className="chart-svg">
        <defs>
          <linearGradient id="chartBg" x1="0%" y1="0%" x2="0%" y2="100%">
            <stop offset="0%" className="gradient-stop-start" />
            <stop offset="100%" className="gradient-stop-end" />
          </linearGradient>
        </defs>

        <rect x={0} y={0} width={WIDTH} height={HEIGHT} className="chart-background" />

        <rect
          x={MARGIN.left}
          y={MARGIN.top}
          width={innerW}
          height={innerH}
          className="interaction-area"
          onMouseMove={handleMouseMove}
          onMouseLeave={() => onHoverChange(null)}
        />

        <line
          x1={MARGIN.left}
          y1={MARGIN.top + innerH}
          x2={MARGIN.left + innerW}
          y2={MARGIN.top + innerH}
          className="axis-line"
        />
        <line
          x1={MARGIN.left}
          y1={MARGIN.top}
          x2={MARGIN.left}
          y2={MARGIN.top + innerH}
          className="axis-line"
        />

        {xTicks.map((t) => {
          const x = xScale(t);
          const y = MARGIN.top + innerH;
          return (
            <g key={t} className="tick-group">
              <line x1={x} y1={y} x2={x} y2={y + 6} className="tick-line" />
              <text x={x} y={y + 22} textAnchor="middle" className="tick-label">
                {t}
              </text>
            </g>
          );
        })}

        {yTicks.map((t, i) => {
          const y = yScale(t);
          return (
            <g key={i} className="tick-group">
              <line x1={MARGIN.left - 6} y1={y} x2={MARGIN.left} y2={y} className="tick-line" />
              <text x={MARGIN.left - 10} y={y + 4} textAnchor="end" className="tick-label">
                {t.toFixed(1)}
              </text>
              <line
                x1={MARGIN.left}
                y1={y}
                x2={MARGIN.left + innerW}
                y2={y}
                className="grid-line"
              />
            </g>
          );
        })}

        {Array.from(series.entries()).map(([regionName, pts]) => {
          const path = buildPath(pts.map((p) => ({ x: xScale(p.year), y: yScale(p.rate) })));
          const isSelected = selectedRegion === regionName;
          const isHovered = hover?.regionName === regionName;

          return (
            <g
              key={regionName}
              className="line-group"
              onClick={() => onSelectRegion(regionName)}
            >
              <path
                d={path}
                className={`line-path-glow ${isSelected || isHovered ? "highlighted" : ""}`}
                stroke={colorForRegion(regionName)}
              />
              <path
                d={path}
                className={`line-path ${isSelected || isHovered ? "highlighted" : ""}`}
                stroke={colorForRegion(regionName)}
              />
            </g>
          );
        })}

        {hover && (
          <g className="tooltip-group">
            <circle
              cx={hover.x}
              cy={hover.y}
              r={6}
              className="tooltip-circle-outer"
              fill={colorForRegion(hover.regionName)}
            />
            <circle
              cx={hover.x}
              cy={hover.y}
              r={4}
              className="tooltip-circle-inner"
              fill={colorForRegion(hover.regionName)}
            />

            <rect
              x={hover.x + 10}
              y={hover.y - 40}
              width={240}
              height={52}
              className="tooltip-background"
            />

            <text x={hover.x + 18} y={hover.y - 20} className="tooltip-text-title">
              {hover.regionName}
            </text>
            <text x={hover.x + 18} y={hover.y - 6} className="tooltip-text-detail">
              {hover.year} — taux : {hover.rate.toFixed(2)}
            </text>
          </g>
        )}
      </svg>
    </div>
  );
};