Lloyd Richards .dev
VISX
D3
SIMULATION
June 09, 2023

Play and Stop Timeline

Using the Brush from Visx; creating a timeline that is automaticaly moves with a set of controls to start and stop the timeline.

Followinf in the success of the 028 lab, I'm not going to use the @visx/brush component to create a timeline that is automaticaly moves across time. The idea is to have a timeline that is moving across time, and have some controls that can be used to start and stop the timeline.

With a bit of work, I was able to get the timeline to move across time, and have a button that can be used to start and stop the timeline. I also added forward and rewind buttons and have a speed property which changes the number days it jumps each tick.

How it works

I started with the base <Timeline /> component from 028 and added some buttons to the bottom. These I styles and could hook into the onClick() to start and stop the animation.

const [selected, setSelected] = useState<[Date, Date]>([
  intervals[0],
  intervals[1],
]);
const [window, setWindow] = useState<[Date, Date]>([
  intervals[0],
  intervals[1],
]);
const [count, setCount] = useState<number>(0);
const [speed, setSpeed] = useState<number>(0);

The state is used to keep track of the selected and window intervals, the count of the current interval, and the speed at which the timeline moves.

const onBrushChange = (domain: Bounds | null) => {
  if (!domain) return;
  setSelected([new Date(domain.x0), new Date(domain.x1)]);
};
 
const ontTick = () => {
  if (!brushRef.current) return;
  brushRef.current.updateBrush((prevBrush) => {
    const newStart = addDays(window[0], speed);
    const newEnd = addDays(window[1], speed);
    const newExtent = brushRef.current!.getExtent(
      { x: xScale(newStart) },
      { x: xScale(newEnd) },
    );
    setWindow([newStart, newEnd]);
    const newState: BaseBrushState = {
      ...prevBrush,
      start: { y: newExtent.y0, x: newExtent.x0 },
      end: { y: newExtent.y1, x: newExtent.x1 },
      extent: newExtent,
    };
    return newState;
  });
};
 
const onReset = () => {
  setSpeed(0);
  setWindow([intervals[0], intervals[1]]);
  setSelected([intervals[0], intervals[1]]);
  setCount(0);
  if (!brushRef.current) return;
  brushRef.current.updateBrush((prevBrush) => {
    const newExtent = brushRef.current!.getExtent(
      { x: xScale(intervals[0]) },
      { x: xScale(intervals[1]) },
    );
    const newState: BaseBrushState = {
      ...prevBrush,
      start: { y: newExtent.y0, x: newExtent.x0 },
      end: { y: newExtent.y1, x: newExtent.x1 },
      extent: newExtent,
    };
    return newState;
  });
};

The onBrushChange() function is used to update the selected interval when the brush is moved. The onTick() function is used to update the window interval and move the brush. The onReset() function is used to reset the timeline to the start.

useInterval(
  () => {
    setCount(count + 1);
    ontTick();
  },
  speed != 0 ? 200 : null,
);

The useInterval() hook is from usehooks-ts is used to call the onTick() function every 200ms if the speed is not 0. This is used to animate the timeline.

Ideas for improvement

import { Brush } from "@visx/brush";
import { AxisTop } from "@visx/axis";
import BaseBrush, { BaseBrushState } from "@visx/brush/lib/BaseBrush";
import { Bounds } from "@visx/brush/lib/types";
import { max, scaleBand, scaleTime } from "d3";
import { addDays, eachMonthOfInterval, format } from "date-fns";
import { FC, useMemo, useRef, useState } from "react";
import { FastForward, Pause, Play, Rewind } from "lucide-react";
import { useInterval } from "usehooks-ts";
const channels = [
  {
    channel: "CH 1",
    start: new Date("2021-01-01"),
    end: new Date("2021-02-01"),
  },
  {
    channel: "CH 2",
    start: new Date("2021-02-01"),
    end: new Date("2021-05-01"),
  },
  {
    channel: "CH 2",
    start: new Date("2021-08-01"),
    end: new Date("2021-09-01"),
  },
  {
    channel: "CH 3",
    start: new Date("2021-04-01"),
    end: new Date("2021-07-01"),
  },
];
 
const timeDomain = [new Date("2021-01-01"), new Date("2022-01-01")];
const channelDomain = Array.from(new Set(channels.map((c) => c.channel)));
 
interface ChartProps {
  height: number;
  width: number;
}
 
export const Timeline: FC<ChartProps> = ({ height, width }) => {
  const brushRef = useRef<BaseBrush | null>(null);
  // Dimensions
  const margin = {
    top: 24,
    bottom: 32,
    left: 48,
    right: 0,
  };
  const innerHeight = height - margin.top - margin.bottom;
  const innerWidth = width - margin.left - margin.right;
  const intervals = eachMonthOfInterval({
    start: timeDomain[0],
    end: timeDomain[1],
  });
 
  // Scales
  const xScale = scaleTime().domain(timeDomain).range([0, innerWidth]);
  const yScale = scaleBand()
    .domain(channelDomain)
    .range([0, innerHeight])
    .paddingInner(0.1);
 
  // Brush
  const initialBrushPosition = useMemo(
    () => ({
      start: { x: xScale(intervals[0]) },
      end: { x: xScale(intervals[1]) },
    }),
    [xScale], // eslint-disable-line react-hooks/exhaustive-deps
  );
 
  // Handlers
  const [selected, setSelected] = useState<[Date, Date]>([
    intervals[0],
    intervals[1],
  ]);
  const [window, setWindow] = useState<[Date, Date]>([
    intervals[0],
    intervals[1],
  ]);
  const [count, setCount] = useState<number>(0);
  const [speed, setSpeed] = useState<number>(0);
 
  const onBrushChange = (domain: Bounds | null) => {
    if (!domain) return;
    setSelected([new Date(domain.x0), new Date(domain.x1)]);
  };
 
  const ontTick = () => {
    if (!brushRef.current) return;
    brushRef.current.updateBrush((prevBrush) => {
      const newStart = addDays(window[0], speed);
      const newEnd = addDays(window[1], speed);
      const newExtent = brushRef.current!.getExtent(
        { x: xScale(newStart) },
        { x: xScale(newEnd) },
      );
      setWindow([newStart, newEnd]);
      const newState: BaseBrushState = {
        ...prevBrush,
        start: { y: newExtent.y0, x: newExtent.x0 },
        end: { y: newExtent.y1, x: newExtent.x1 },
        extent: newExtent,
      };
      return newState;
    });
  };
 
  const onReset = () => {
    setSpeed(0);
    setWindow([intervals[0], intervals[1]]);
    setSelected([intervals[0], intervals[1]]);
    setCount(0);
    if (!brushRef.current) return;
    brushRef.current.updateBrush((prevBrush) => {
      const newExtent = brushRef.current!.getExtent(
        { x: xScale(intervals[0]) },
        { x: xScale(intervals[1]) },
      );
      const newState: BaseBrushState = {
        ...prevBrush,
        start: { y: newExtent.y0, x: newExtent.x0 },
        end: { y: newExtent.y1, x: newExtent.x1 },
        extent: newExtent,
      };
      return newState;
    });
  };
 
  useInterval(
    () => {
      setCount(count + 1);
      ontTick();
    },
    speed != 0 ? 200 : null,
  );
 
  return (
    <div
      style={{
        background: "light grey",
        height: height,
        width: width,
        overflow: "scroll",
      }}
    >
      <svg width={width} height={margin.top + innerHeight}>
        <g transform={`translate(${margin.left} ${margin.top})`}>
          {/* Axis */}
          <AxisTop
            scale={xScale}
            tickFormat={(d) => format(d as Date, "LLL")}
          />
          {intervals.map((i, idx) => (
            <line
              key={`grid-${idx}`}
              x1={xScale(i)}
              x2={xScale(i)}
              y1={0}
              y2={height}
              stroke="grey"
              strokeOpacity={0.5}
            />
          ))}
          {/* Labels */}
          {channels.map((c, i) => (
            <text
              key={`text-${i}`}
              x={-margin.left}
              y={(yScale(c.channel) || 0) + yScale.bandwidth() / 2}
              fill="black"
              alignmentBaseline="middle"
            >
              {c.channel}
            </text>
          ))}
          {/* Rects */}
          {channels.map((c, i) => (
            <rect
              key={`rect-${i}`}
              rx={4}
              x={xScale(c.start)}
              y={yScale(c.channel) || 0}
              width={clampZero(xScale(c.end) - xScale(c.start))}
              height={yScale.bandwidth()}
              fill={
                intersect(selected, [c.start, c.end]) ? "tomato" : "SteelBlue"
              }
            />
          ))}
        </g>
        <g transform={`translate(${margin.left} ${margin.top})`}>
          <Brush
            xScale={xScale}
            yScale={yScale}
            height={innerHeight}
            width={innerWidth}
            margin={margin}
            innerRef={brushRef}
            initialBrushPosition={initialBrushPosition}
            resizeTriggerAreas={[]}
            onChange={onBrushChange}
            onClick={onReset}
          />
        </g>
      </svg>
      <div className="not-prose dark:prose-invert grid h-8 grid-flow-col grid-cols-3 items-center gap-1">
        <div className="flex gap-2">
          <button
            onClick={() => setSpeed(speed - 1)}
            className="bg-primary text-primary-foreground hover:bg-primary/90 w-36 rounded px-4 py-2"
          >
            <Rewind />
          </button>
          <button
            onClick={() => setSpeed(speed == 0 ? 1 : 0)}
            className="bg-primary text-primary-foreground hover:bg-primary/90 w-36 rounded px-4 py-2"
          >
            {speed != 0 ? <Pause /> : <Play />}
          </button>
          <button
            onClick={() => setSpeed(speed + 1)}
            className="bg-primary text-primary-foreground hover:bg-primary/90 w-36 rounded px-4 py-2"
          >
            <FastForward />
          </button>
        </div>
        <p className="line-clamp-1">
          | {selected != undefined ? format(selected[0], "LLL d, yyy") : null} -
          - - {selected != undefined ? format(selected[1], "LLL d, yyy") : null}{" "}
          |
        </p>
      </div>
    </div>
  );
};
 
const clampZero = (n: number) => max([n, 0]) || 0;
const intersect = (a: [Date, Date], b: [Date, Date]) =>
  a[0] <= b[1] && b[0] <= a[1];