Highlight And Zoom Line Chart

Click and drag your mouse to make a selection to zoom in. Click the "Zoom Out" button to reset the zoom level.
import { useCallback, useState } from 'react';
import { LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip, ReferenceArea, MouseHandlerDataParam } from 'recharts';
import { RechartsDevtools } from '@recharts/devtools';

type Impressions = { name: number; cost: number; impression: number };

// #region Sample data
const impressionsData = [
  { name: 1, cost: 4.11, impression: 100 },
  { name: 2, cost: 2.39, impression: 120 },
  { name: 3, cost: 1.37, impression: 150 },
  { name: 4, cost: 1.16, impression: 180 },
  { name: 5, cost: 2.29, impression: 200 },
  { name: 6, cost: 3, impression: 499 },
  { name: 7, cost: 0.53, impression: 50 },
  { name: 8, cost: 2.52, impression: 100 },
  { name: 9, cost: 1.79, impression: 200 },
  { name: 10, cost: 2.94, impression: 222 },
  { name: 11, cost: 4.3, impression: 210 },
  { name: 12, cost: 4.41, impression: 300 },
  { name: 13, cost: 2.1, impression: 50 },
  { name: 14, cost: 8, impression: 190 },
  { name: 15, cost: 0, impression: 300 },
  { name: 16, cost: 9, impression: 400 },
  { name: 17, cost: 3, impression: 200 },
  { name: 18, cost: 2, impression: 50 },
  { name: 19, cost: 3, impression: 100 },
  { name: 20, cost: 7, impression: 100 },
];

// #endregion
type ZoomAndHighlightState = {
  left: string | number;
  right: string | number;
  refAreaLeft: string | number | undefined;
  refAreaRight: string | number | undefined;
  top: string | number;
  bottom: string | number;
  top2: string | number;
  bottom2: string | number;
  animation: boolean;
};

type AxisDomain = [string | number, string | number];

const initialState: ZoomAndHighlightState = {
  left: 'dataMin',
  right: 'dataMax',
  refAreaLeft: undefined,
  refAreaRight: undefined,
  top: 'dataMax+1',
  bottom: 'dataMin-1',
  top2: 'dataMax+20',
  bottom2: 'dataMin-20',
  animation: true,
};

const getAxisYDomain = (
  from: string | number | undefined,
  to: string | number | undefined,
  ref: keyof Impressions,
  offset: number,
): AxisDomain => {
  if (from != null && to != null) {
    const refData = impressionsData.slice(Number(from) - 1, Number(to));
    const firstDatum = refData[0];
    if (firstDatum == null) {
      return [initialState.bottom, initialState.top];
    }
    let bottom = firstDatum[ref];
    let top = firstDatum[ref];
    refData.forEach(d => {
      if (d[ref] > top) top = d[ref];
      if (d[ref] < bottom) bottom = d[ref];
    });

    return [(bottom | 0) - offset, (top | 0) + offset];
  }
  return [initialState.bottom, initialState.top];
};

const HighlightAndZoomLineChart = () => {
  const [zoomGraph, setZoomGraph] = useState<ZoomAndHighlightState>(initialState);

  const zoom = useCallback(() => {
    setZoomGraph((prev: ZoomAndHighlightState): ZoomAndHighlightState => {
      let { refAreaLeft, refAreaRight } = prev;

      if (refAreaLeft === refAreaRight || !refAreaRight) {
        return {
          ...prev,
          refAreaLeft: undefined,
          refAreaRight: undefined,
        };
      }

      if (refAreaLeft && refAreaRight && refAreaLeft > refAreaRight)
        [refAreaLeft, refAreaRight] = [refAreaRight, refAreaLeft];

      const [bottom, top] = getAxisYDomain(refAreaLeft, refAreaRight, 'cost', 1);
      const [bottom2, top2] = getAxisYDomain(refAreaLeft, refAreaRight, 'impression', 50);

      return {
        ...prev,
        refAreaLeft: undefined,
        refAreaRight: undefined,
        left: refAreaLeft ?? initialState.left,
        right: refAreaRight ?? initialState.right,
        bottom,
        top,
        bottom2,
        top2,
      };
    });
  }, [setZoomGraph]);

  const zoomOut = useCallback(() => {
    setZoomGraph(initialState);
  }, [setZoomGraph]);

  const onMouseDown = useCallback(
    (e: MouseHandlerDataParam) => {
      setZoomGraph((prev: ZoomAndHighlightState): ZoomAndHighlightState => ({ ...prev, refAreaLeft: e.activeLabel }));
    },
    [setZoomGraph],
  );

  const onMouseMove = useCallback(
    (e: MouseHandlerDataParam) => {
      setZoomGraph(prev => {
        if (prev.refAreaLeft) {
          return { ...prev, refAreaRight: e.activeLabel };
        }
        return prev;
      });
    },
    [setZoomGraph],
  );

  const { refAreaLeft, refAreaRight, left, right, top, bottom, top2, bottom2 } = zoomGraph;

  return (
    <div className="highlight-bar-charts" style={{ userSelect: 'none', width: '100%' }}>
      <button type="button" className="btn update" onClick={zoomOut}>
        Zoom Out
      </button>

      <LineChart
        style={{ width: '100%', maxWidth: '700px', maxHeight: '70vh', aspectRatio: 1.618 }}
        responsive
        data={impressionsData}
        onMouseDown={onMouseDown}
        onMouseMove={onMouseMove}
        onMouseUp={zoom}
      >
        <CartesianGrid />
        <XAxis allowDataOverflow dataKey="name" domain={[left, right]} type="number" />
        <YAxis allowDataOverflow domain={[bottom, top]} type="number" yAxisId="1" width="auto" />
        <YAxis orientation="right" allowDataOverflow domain={[bottom2, top2]} type="number" yAxisId="2" width="auto" />
        <Tooltip />
        <Line yAxisId="1" type="natural" dataKey="cost" animationDuration={300} />
        <Line yAxisId="2" type="natural" dataKey="impression" animationDuration={300} />

        {refAreaLeft && refAreaRight ? (
          <ReferenceArea
            yAxisId="1"
            x1={refAreaLeft}
            x2={refAreaRight}
            strokeOpacity={0.3}
            stroke="var(--color-border-2)"
            fill="var(--color-solid-7a)"
          />
        ) : null}
        <RechartsDevtools />
      </LineChart>
    </div>
  );
};

export default HighlightAndZoomLineChart;