DEV Community

Cover image for Timeline portfolio map with React and D3.js | Part II
Dmitry
Dmitry

Posted on Originally published at zdcreatech.com

Timeline portfolio map with React and D3.js | Part II

This is Part II of a two-part tutorial series where we create an interactive timeline-based portfolio project. The first part can be found linked in the dev.to series, or on my website.

Welcome back! This is the final part of the tutorial. We laid a solid foundation in the previous sections, and now all of our updates will be visual. We are going to proceed with toughest part first: distributing the items on the lanes/rows above and beneath the baseline; and then we'll be applying visual improvements and go through a small de-brief in the end.

Table of contents

Putting nodes on lanes

Code for the section

Before we begin, let me ask you something: do you like greedy algorithms?

Oh, don't answer, I'm sure you love them as much as I do! And for distributing our nodes across the lanes, we're going to use a greedy approach in practice.

Most of our work will be in the src/lib/timeline/engine/lanes.ts. Let's add the helper interfaces and a stub for the function assignLanes that we are going to use inside the TimelineEngine.calculateLayout method:

import type { DisplaySide } from "./types";

export interface LaneAssignment {
  side: DisplaySide;
  laneIndex: number;
}

interface LaneInput {
  id: string;
  startMs: number;
  endMs: number;
  preferredSide?: DisplaySide;
}

function getOppositeSide(side: DisplaySide) {
  if (side === "above") {
    return "below";
  }

  return "above";
}

// Distributes the nodes over the lanes, with 0 being the first level from the baseline/axis
export function assignLanes(
  nodes: LaneInput[],
  requiredGapMs: number,
): Map<string, LaneAssignment> {
}
Enter fullscreen mode Exit fullscreen mode

LaneAssignment is going to be the return type of our function. It will be then assigned to the node's side and laneIndex, and used for calculating the actual value of y.

The LaneInput might look a bit weird. Why milliseconds? Because we want to be precise, and our chart is a temporal chart, milliseconds would be a good measurement when reasoning about the positions, before they get scaled into the actual pixels. This is the same reason why the requiredGapMs parameter is also in milliseconds. That would be the gap used for detecting the collisions between the cards, and deciding whether or not we should add a new lane, or try and put the node above/below the baseline.

Buckle up, because now we need to add the body of the assignLanes. Please look into the comments, because in this case it's easier to explain what's going on right inside of the code:

// Distributes the nodes over the lanes, with 0 being the first level from the baseline/axis
export function assignLanes(
  nodes: LaneInput[],
  requiredGapMs: number,
): Map<string, LaneAssignment> {
  // Sort nodes from earliest to latest, so each lane only needs to track
  // the end time of its most recently assigned node.
  const sortedNodes = nodes.toSorted((a, b) => a.startMs - b.startMs);

  // For each side, store the end time of the last node occupying each lane.
  // The index in the `above` and `below` arrays is the lane number.
  const laneEnds: Record<DisplaySide, number[]> = {
    above: [],
    below: [],
  };

  const result = new Map<string, LaneAssignment>();

  for (const node of sortedNodes) {
    // When no side is preferred, we start with the side that currently has fewer lanes
    // This would keep the timeline map more balanced across the baseline/axis
    const lessUsedSide: DisplaySide =
      laneEnds.above.length <= laneEnds.below.length ? "above" : "below";

    // If a node has a preferred side, we try it first.
    // Otherwise, try the currently less-used side first.
    // In both cases, the opposite side is the fallback
    const sidesToTry: DisplaySide[] = node.preferredSide
      ? [node.preferredSide, getOppositeSide(node.preferredSide)]
      : [lessUsedSide, getOppositeSide(lessUsedSide)];

    let placed = false;

    for (const side of sidesToTry) {
      const lanes = laneEnds[side];

      // Find the first existing lane whose previous node leaves
      // enough horizontal space before this node starts.
      for (let i = 0; i < lanes.length; i++) {
        if (lanes[i] + requiredGapMs <= node.startMs) {
          lanes[i] = node.endMs;
          result.set(node.id, { side, laneIndex: i });
          placed = true;
          break;
        }
      }

      if (placed) {
        break;
      }
    }

    if (!placed) {
      // If we couldn't place a node, it means that none of the existing lanes would do,
      // because there will be a collision. Thus, we create a new lane on the first side from `sidesToTry`
      const side = sidesToTry[0];

      laneEnds[side].push(node.endMs);

      result.set(node.id, {
        side,
        laneIndex: laneEnds[side].length - 1,
      });
    }
  }

  return result;
}
Enter fullscreen mode Exit fullscreen mode

Wow, collision detection is sure fun! But now that we have our assignLanes in place, we can finally use it inside the calculateLayout method of the TimelineEngine class. But before we do that, let's add a couple of new constants to the src/lib/timeline/engine/constants.ts:

...

// Width of the `NodeCard` in px
export const CARD_WIDTH_PX = 260;

// Timeline lanes' size - how far the items on the row/lane are from the baseline/axis
// Distance of lane 0 from the baseline axis
export const LANE_BASE_OFFSET_PX = 90;
// Additional distance per lane
export const LANE_GAP_PX = 110;
Enter fullscreen mode Exit fullscreen mode

Maybe you're starting to guess where we are going with these! We are going to use the CARD_WIDTH_PX to build requiredGapMs for collision detection, and calculate the y based on the LANE_BASE_OFFSET_PX + (laneIndex * LANE_GAP_PX). Let's use those in the TimelineEngine:

...
private calculateLayout(axisY: number) {
  const domain = this.xScale.domain();
  const range = this.xScale.range();

  const msPerPx = (domain[1].getTime() - domain[0].getTime()) / (range[1] - range[0]);

  const cardWidthMs = CARD_WIDTH_PX * msPerPx;

  const lanes = assignLanes(
    this.timelineNodes.map((node) => ({
      id: node.id,
      startMs: new Date(node.date).getTime(),
      endMs: this.endOf(node).getTime(),
      preferredSide: node.preferredSide,
    })),
    cardWidthMs,
  );

  return new Map<string, NodeLayout>(
    this.timelineNodes.map((node) => {
      const startX = this.xScale(new Date(node.date));
      const endX = this.xScale(this.endOf(node));

      const { side, laneIndex } = lanes.get(node.id)!;
      const direction = side === "above" ? -1 : 1;

      return [
        node.id,
        {
          x: (startX + endX) / 2,
          y: axisY + direction * (LANE_BASE_OFFSET_PX + laneIndex * LANE_GAP_PX),
          startX,
          endX: node.endDate || node.ongoing ? endX : undefined,
          side,
          laneIndex,
        },
      ];
    }),
  );
}
...
Enter fullscreen mode Exit fullscreen mode

I believe the code we've just created is relatively self-explanatory. One thing to point out is the direction part:

const direction = side === "above" ? -1 : 1;
Enter fullscreen mode Exit fullscreen mode

Don't forget that in the coordinate system going up means negative value, and going down is positive!

One final touch, let's fix the NodeCard's width using our constant and add a formatted date under the node.id (the formatNodeTime can be found in the utils directory):

import { CARD_WIDTH_PX } from "../../engine/constants";
...
const NodeCard: React.FC<Props> = ({ node }) => {
  return (
    <div
      style={{
        ...
        width: `${CARD_WIDTH_PX}px`,
      }}
    >
      {node.id}
      <br />
      {formatNodeTime(node)}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

With these figured out, now we're talking! Notice how the items adapt to different screen sizes:

Nodes distributed on the lanes

Interactive demo

Now it's time to connect them to the baseline with some SVG paths.

Drawing lines to nodes

Code for the section

In this section, most of our work will be inside the src/lib/timeline/engine/paths.ts, building functions that return the string value for the d attribute of a <path /> element.

Let's handle the most basic case first: a one-shot project:

// Simple vertical connector for a `project` node
export function stemPath(x: number, laneY: number, axisY: number): string {
  return `
    M ${x} ${axisY} 
    L ${x} ${laneY}
  `;
}
Enter fullscreen mode Exit fullscreen mode

Here, we take the x of the NodeCard, y of its lane, and the baseline's y position. Then we M move the imaginary pen to the point on the baseline where the node is located, and L draw a line upwards or downwards.

Let's use it in the TimelineMap right away. Inside the <svg /> element, add the following code:

{/* Paths to the nodes */}
{nodes.map((node) => {
  let pathData: string = "";

  pathData = stemPath(node.startX, node.y, axisY);

  return (
    <path
      key={node.id}
      className={cx(styles["connector"], {
        commitment: node.type === "commitment",
        project: node.type === "project",
      })}
      d={pathData}
      fill="none"
    />
  );
})}
Enter fullscreen mode Exit fullscreen mode

and add some CSS to the src/lib/timeline/components/TimelineMap/styles.module.scss:

.scroll-wrapper {
  ...
  .inner-content {
    ...
    .connector {
      stroke-width: 3;
      opacity: 0.5;

      &.commitment {
        stroke: var(--color-commitment);
      }
      &.project {
        stroke: var(--color-project);
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, the projects should have a blue-ish line pointing to them. Nice!

But we need to also handle commitments, which are a bit more complex. Add another function to the paths.ts:

// Builds a simple square bridge between the start and the end of a `commitment` node
export function bridgePathSimple(
  startX: number,
  endX: number,
  laneY: number,
  axisY: number,
  options: { openEnded?: boolean } = {},
): string {
  const { openEnded = false } = options;

  // Move the starting point, draw a line from the axis to the lane
  const rise = `
    M ${startX} ${axisY} 
    L ${startX} ${laneY}
  `;

  // `ongoing` is `true`: rise once, and then simply draw a line towards the end, i.e. the end of the domain in this case
  if (openEnded) {
    return `
      ${rise} 
      L ${endX} ${laneY}
    `;
  }

  // Draw a line to the `endX` along the lane and then draw a line back to the axis
  return `
    ${rise} 
    L ${endX} ${laneY}
    L ${endX} ${axisY}
  `;
}
Enter fullscreen mode Exit fullscreen mode

The idea here is the same: M move the pen to the x on the baseline, draw a simple stem first, and then either L draw a line towards the end of the chart for ongoing commitments, or return it back to the baseline.

Let's use it inside TimelineMap:

...
{/* Paths to the nodes */}
{nodes.map((node) => {
  let pathData: string;

  if (node.endX !== undefined) {
    pathData = bridgePathSimple(node.startX, node.endX, node.y, axisY, {
      openEnded: node.ongoing,
    });
  } else {
    pathData = stemPath(node.startX, node.y, axisY);
  }

  return (
    <path
      key={node.id}
      className={cx(styles["connector"], {
        commitment: node.type === "commitment",
        project: node.type === "project",
      })}
      d={pathData}
      fill="none"
    />
  );
})}
...
Enter fullscreen mode Exit fullscreen mode

Note: because of our endOf method, the node.endX is never undefined for the ongoing commitments, it's set to the end of the domain.

That's not too bad, but we probably want something smoother, rather than just these square-ish shapes. Let's make the corners rounded via Q, the Quadratic Bézier curve command:

// Builds a smooth bridge between the start and the end of a `commitment` node
export function bridgePathCurved(
  startX: number,
  endX: number,
  laneY: number,
  axisY: number,
  options: { curve?: number; openEnded?: boolean } = {},
): string {
  const { curve = 70, openEnded = false } = options;

  // The control point of the rise, pulling the line in its direction to create the curve.
  const riseQuadraticBezierControlPoint = `${startX} ${laneY}`;

  // Half of the distance between the start and the end of the x
  const span = (endX - startX) / 2;
  // If the passed-in curve is larger than the span, we get weird shapes, so we take the min value between the two
  const _curve = Math.min(curve, span);
  // The larger the `riseEndX` value in this case, the smoother will be the curve
  const riseEndX = startX + _curve;
  // Then end point, where the curve ends on the lanes
  const riseQuandraticBezierEndPoint = `${riseEndX} ${laneY}`;

  const rise = `
    M ${startX} ${axisY} 
    Q ${riseQuadraticBezierControlPoint}, ${riseQuandraticBezierEndPoint}
  `;

  // `ongoing` is `true`: rise once, and then simply draw a line towards the end, i.e. the end of the domain in this case
  if (openEnded) {
    return `${rise} L ${endX} ${laneY}`;
  }

  // Since we have a curve here, the `endX` needs to be adjusted by subtracting the value we assigned to `_curve`
  // The smaller this value, the smoother will be the curve
  const fallStartX = endX - _curve;

  // The control point of the fall, pulling the line in its direction to create the curve.
  const fallQuadraticBezierControlPoint = `${endX} ${laneY}`;
  // Then end point, where the curve ends on the baseline/axis
  const fallQuandraticBezierEndPoint = `${endX} ${axisY}`;

  return `
    ${rise} 
    L ${fallStartX} ${laneY} 
    Q ${fallQuadraticBezierControlPoint}, ${fallQuandraticBezierEndPoint}
  `;
}
Enter fullscreen mode Exit fullscreen mode

and use it:

...
let pathData: string;

if (node.endX !== undefined) {
  pathData = bridgePathCurved(node.startX, node.endX, node.y, axisY, {
    openEnded: node.ongoing,
  });
} else {
  pathData = stemPath(node.startX, node.y, axisY);
}
...
Enter fullscreen mode Exit fullscreen mode

Please refer to the detailed explanations in the code comments, and play around with the default curve value to see how the corner of the bridge changes. Comprehending the Q command has its twists and curves at first, and I also can refer you to an amazing article by the unmatched guru of front-end, Josh Comeau.

The current result of our work should look something like this:

Connectors to the nodes

Interactive demo

But I think we can help Jane Doe to have an even better portfolio, given how many images and copy she has prepared! Let's make the NodeCard component nice.

Rich content cards

Code for the section

Most of our work in this section will be inside the src/lib/timeline/components/NodeCard directory. We will also add a new event handler passed from the App.tsx down to the card, and a new constant CARD_CLOSED_HEIGHT_PX that we'll use to make sure the hovered/touched cards open downwards.

src/lib/timeline/engine/constants.ts

...
// Height of the `NodeCard` in its initial state
export const CARD_CLOSED_HEIGHT_PX = 68;
Enter fullscreen mode Exit fullscreen mode

src/App.tsx

...
function App() {
  const handleOnNodeExpand = (node: TimelineNode) => {
    alert(
      `
Triggered expand for node with id "${node.id}"
______________

Here you would put the logic for the most interesting parts: opening the modal, handling a programmatic redirect, etc. 
You could also extend the function to pass the DomRect of the card, or a ref to the element itself to implement visual effects and animations.
      `,
    );
  };

  return (
    <>
      <main className="main">
        <TimelineMap data={data} onNodeExpand={handleOnNodeExpand} />
      </main>
    </>
  );
}
...
Enter fullscreen mode Exit fullscreen mode

src/lib/timeline/components/TimelineMap/index.tsx

...
type Props = {
  data: TimelineNodeData[];
  onNodeExpand: (node: TimelineNode) => void;
};
...
const TimelineMap: React.FC<Props> = ({ data, onNodeExpand }) => {
  ...
  {/* Node cards */}
  {nodes.map((node) => {
    return <NodeCard key={node.id} node={node} onExpand={onNodeExpand} />;
  })}
  ...
}
Enter fullscreen mode Exit fullscreen mode

src/lib/timeline/components/NodeCard/index.tsx

import { Fragment, useEffect, useRef, useState } from "react";
import styles from "./styles.module.scss";
import classNames from "classnames/bind";
import { CARD_CLOSED_HEIGHT_PX, CARD_WIDTH_PX } from "../../engine/constants";
import { type TimelineNode } from "../../engine/types";
import formatNodeTime from "../../../utils/formatNodeTime";

const cx = classNames.bind(styles);

type Props = {
  node: TimelineNode;
  onExpand: (node: TimelineNode) => void;
};

const NodeCard: React.FC<Props> = ({ node, onExpand }) => {
  const cardButtonRef = useRef<HTMLButtonElement>(null);
  const [previewOpen, setPreviewOpen] = useState(false);

  const handlePointerEnter = (e: React.PointerEvent<HTMLButtonElement>) => {
    if (e.pointerType === "mouse") {
      setPreviewOpen(true);
    }
  };
  const handlePointerLeave = (e: React.PointerEvent<HTMLButtonElement>) => {
    if (e.pointerType === "mouse") {
      setPreviewOpen(false);
    }
  };

  const expand = () => {
    onExpand(node);
  };

  const handleClick = () => {
    if (!previewOpen) {
      setPreviewOpen(true);
      return;
    }
    expand();
  };

  useEffect(() => {
    if (!previewOpen || !cardButtonRef.current) {
      return;
    }

    const onDocPointerDown = (e: PointerEvent) => {
      if (!cardButtonRef.current?.contains(e.target as HTMLElement)) {
        setPreviewOpen(false);
      }
    };

    document.addEventListener("pointerdown", onDocPointerDown);
    return () => {
      document.removeEventListener("pointerdown", onDocPointerDown);
    };
  }, [previewOpen]);

  return (
    <div
      className={cx(styles["wrapper"], {
        open: previewOpen,
      })}
      style={{
        left: `${node.x}px`,
        // Makes sure that the card is centered vertically
        // we avoid transform(-50%, -50%) for both x and y to make sure the card always opens downwards
        top: `calc(${node.y}px - ${CARD_CLOSED_HEIGHT_PX / 2}px)`,
      }}
    >
      <button
        ref={cardButtonRef}
        type="button"
        className={cx(styles["card-button"], {
          commitment: node.type === "commitment",
          project: node.type === "project",
          open: previewOpen,
        })}
        style={
          {
            "--card-width": `${CARD_WIDTH_PX}px`,
          } as React.CSSProperties
        }
        onPointerEnter={handlePointerEnter}
        onPointerLeave={handlePointerLeave}
        onClick={handleClick}
        aria-expanded={previewOpen}
      >
        <div className={styles["content"]}>
          {node.thumbnailUrl ? (
            <div
              className={cx(styles["thumbnail-wrapper"], {
                hidden: !previewOpen,
                visible: previewOpen,
              })}
            >
              <img alt={node.title} src={node.thumbnailUrl} />
            </div>
          ) : null}
          <div className={styles["title"]}>{node.title}</div>

          <div className={styles["organization"]}>
            {node.organizationLogoUrl ? (
              <img
                alt={node.organization}
                className={styles["logo"]}
                width={24}
                height={24}
                src={node.organizationLogoUrl}
              />
            ) : null}
            <span className={styles["name"]}>{node.organization}</span>
          </div>
          {previewOpen ? (
            <Fragment>
              <div className={styles["date"]}>{formatNodeTime(node)}</div>
              <div className={styles["summary"]}>{node.summary}</div>
              <div className={styles["hint"]}>Click to expand</div>
            </Fragment>
          ) : null}
        </div>
      </button>
    </div>
  );
};

export default NodeCard;
Enter fullscreen mode Exit fullscreen mode

src/lib/timeline/components/NodeCard/styles.module.scss

.wrapper {
  position: absolute;
  transform: translateX(-50%);
  z-index: 1;

  flex-shrink: 0;

  &.open {
    z-index: 2;
  }

  .card-button {
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 10px 14px;

    background-color: var(--node-card-color-background);
    border: 1px solid var(--node-card-color-border);
    border-radius: 10px;

    cursor: pointer;

    width: var(--card-width, 260px);

    transition: all 0.5s ease;

    &.commitment {
      border-top: 3px solid var(--color-commitment);
    }

    &.project {
      border-top: 3px solid var(--color-project);
    }

    &:focus,
    &:hover {
      outline: none;
      box-shadow: var(--node-card-shadow);
      border-bottom-color: var(--node-card-color-border--focus);
      border-left-color: var(--node-card-color-border--focus);
      border-right-color: var(--node-card-color-border--focus);
    }

    &.open {
      transform: scale(1.03);
    }

    .content {
      .thumbnail-wrapper {
        overflow: hidden;

        display: flex;
        justify-content: center;

        margin-bottom: 0.25rem;

        height: 0px;

        &.hidden {
          display: none;
        }

        &.visible {
          animation: 0.6s expand ease forwards;
        }

        img {
          width: 100%;
          border-radius: 8px;
          object-fit: cover;
        }

        @keyframes expand {
          from {
            height: 0px;
          }
          to {
            height: 120px;
          }
        }
      }

      .title {
        font-weight: 600;
        font-size: 0.7rem;
        color: var(--node-card-text-primary);
        line-height: 1.3;
        margin-bottom: 0.15rem;

        font-family: var(--mono);
      }

      .organization {
        display: flex;
        flex-direction: row;
        align-items: center;
        justify-content: center;
        gap: 0.4rem;

        .logo {
          width: 18px;
          object-fit: contain;
        }

        .name {
          color: var(--node-card-text-secondary);
        }
      }

      .date {
        font-size: 0.5rem;
        color: var(--node-card-text-secondary);
        text-align: center;
        margin-bottom: 0.2rem;
      }

      .summary {
        font-size: 0.72rem;
        color: var(--node-card-text-secondary);
        margin: 0;
        line-height: 1.3;
        text-align: justify;
        text-wrap: pretty;
        hyphens: auto;
        margin-bottom: 0.2rem;
      }

      .hint {
        font-size: 0.65rem;
        color: var(--color-accent);
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Most of the code here is pretty standard React with SCSS modules and classnames/bind library. The only bit worth mentioning, is this part:

...
style={
  {
    "--card-width": `${CARD_WIDTH_PX}px`,
  } as React.CSSProperties
}
...
Enter fullscreen mode Exit fullscreen mode

Which is a nice way to pass a CSS variable value to a component, which can be useful, especially in pure client-side apps with a lot of animations.

The cards now look real nice, and help to build up an engaging visual story!

Rich content cards

Interactive demo

The only problem is, now when we resize the screen, abrupt jumping of the cards over the lanes becomes an eyesore. Let's make the movement smooth and on par with the cards' aesthetics!

Animating resize

Code for the section

If you made it to this part, you are a champ! Before we wrap up, let's add a final touch to Jane Doe's portfolio (or yours, if you decided to use your own data right away): make the screen resizing smooth.

There's a ton of ways to implement animations on the web, GSAP, Motion, to name a few. But in our case, we already have a powerful tool inside of our codebase: the dr-ease module and the interpolateNumber(a, b) function.

Let's import them in src/lib/timeline/engine/index.ts, where we'll do the majority of the work for this final section.

...
import { easeCubicInOut, interpolateNumber, timeFormat, timeMonth, timeYear } from "d3";
...
Enter fullscreen mode Exit fullscreen mode

For our animation, we're going to use the browser API native requestAnimationFrame. Instead of simply calling this.applyLayout inside of the resize method, we're going to implement a new private method animateTo, and pass our targetLayout to it. We will also have a new property animationFrame for cancelling the animation, and a new public method destroy, that we're going to add to the cleanup function in the TimelineMap.

Let's lay the foundation first:

src/lib/timeline/engine/index.ts

...
export class TimelineEngine {
  ...
  private animationFrame: number | undefined;
  ...
  resize(newWidth: number, newAxisY: number) {
    ...
    const targetLayout = this.calculateLayout(newAxisY);

    this.animateTo(targetLayout);
  }

  // Now we also have a destroy function, to avoid memory leaks if the `TimelineMap` unmounts during the animation
  destroy() {
    if (this.animationFrame !== undefined) {
      cancelAnimationFrame(this.animationFrame);
    }
  }
  ...
  private animateTo(targetLayout: Map<string, NodeLayout>) {
  }
}
Enter fullscreen mode Exit fullscreen mode

src/lib/timeline/components/TimelineMap/index.tsx

...
const TimelineMap: React.FC<Props> = ({ data, onNodeExpand }) => {
  ...
  useEffect(() => {
    return () => {
      timelineEngineInstanceRef.current?.destroy();
    };
  }, []);
  ...
};
Enter fullscreen mode Exit fullscreen mode

Let's get to business, and implement animateTo. Refer to the explanations in the comments, as they are knit pretty tight to the code:

private animateTo(targetLayout: Map<string, NodeLayout>) {
  // This cancels the next frame, in case the user resizes the window again while the current animation is still running
  // `resize` will calculate a new target layout and start a new animation from the nodes' current positions
  if (this.animationFrame !== undefined) {
    cancelAnimationFrame(this.animationFrame);
  }

  const startTime = performance.now();
  const duration = 400;

  // Create interpolation functions from each node's current position
  // to its target position and store them in a Map.
  // The created functions are equivalent to
  // (t) => a * (1 - t) + b * t
  // and will be evaluated on each animation frame
  const interpolatedLayout = new Map(
    this.timelineNodes.map((node) => [
      node.id,
      {
        x: interpolateNumber(node.x, targetLayout.get(node.id)!.x),
        y: interpolateNumber(node.y, targetLayout.get(node.id)!.y),
        startX: interpolateNumber(node.startX, targetLayout.get(node.id)!.startX),
        endX:
          node.endX !== undefined && targetLayout.get(node.id)!.endX !== undefined
            ? interpolateNumber(node.endX, targetLayout.get(node.id)!.endX!)
            : undefined,
      },
    ]),
  );

  // Callback function passed to `requestAnimationFrame`, that will run on each frame
  // and update nodes' positions, will run until the target layout is reached
  const animationStep = (now: number) => {
    // Calculate the animation progress as a value between 0 and 1, where
    // 0 is the start, and 1 is the end
    const progress = (now - startTime) / duration;
    // `d3-ease` functions expect a normalized value in the [0, 1] range
    // https://d3js.org/d3-ease
    const progressNormalized = Math.min(progress, 1);

    // A cubic ease-in-out, so the animation starts and ends smoothly
    const t = easeCubicInOut(progressNormalized);

    // Iterate over the nodes to evaluate the interpolation for the current frame
    // and apply new coordinates to the node
    for (const node of this.timelineNodes) {
      const interpolation = interpolatedLayout.get(node.id)!;

      node.x = interpolation.x(t);
      node.y = interpolation.y(t);
      node.startX = interpolation.startX(t);

      // `.endX` can be undefined, so interpolate only if present
      if (interpolation.endX) {
        node.endX = interpolation.endX(t);
      }

      // Once the transition finishes, update the side and the lane
      // These are not coordinates, but discrete layout properties, so we don't need to interpolate them
      if (progress === 1) {
        const target = targetLayout.get(node.id)!;
        node.side = target.side;
        node.laneIndex = target.laneIndex;
      }
    }

    // Update the stateful values in the `TimelineMap`, re-rendering the UI with the new positions
    this.syncLayout();

    // Continue the animation loop while the transition is in progress.
    // If we haven't reached 1 yet, call `requestAnimationFrame`
    if (progress < 1) {
      this.animationFrame = requestAnimationFrame(animationStep);
    } else {
      // If we did, don't call `requestAnimationFrame`, and set the `animationFrame` to `undefined`
      this.animationFrame = undefined;
    }
  };

  // Start the animation loop
  this.animationFrame = requestAnimationFrame(animationStep);
}
Enter fullscreen mode Exit fullscreen mode

Usually the most confusing part about animation frames is starting and ending the loop. In this case, the essential part is, we call requestAnimationFrame with the next step while the progress value is still less than 1, and we don't call it, if progress has reached 1.

...
  {
    ...
    // Continue the animation loop while the transition is in progress.
    // If we haven't reached 1 yet, call `requestAnimationFrame`
    if (progress < 1) {
      this.animationFrame = requestAnimationFrame(animationStep);
    } else {
      // If we did, don't call `requestAnimationFrame`, and set the `animationFrame` to `undefined`
      this.animationFrame = undefined;
    }
  };

  // Start the animation loop
  this.animationFrame = requestAnimationFrame(animationStep);
...
Enter fullscreen mode Exit fullscreen mode

It's not a conventional while loop, and can take a few re-reads to wrap your head around it, but it opens up a ton of possibilities!

Now the resizing looks smooth and fluid:

Animated resizing

Interactive demo

Wrap up

Congratulations, you made it to the finish line! 🎉 And, hopefully, learned a bit more about React, D3.js, and requestAnimationFrame.

When it comes to D3, we barely scratched the surface, using only the fraction of its power, but I'm sure exploring the immense capabilities of it will now feel less like embarking into a terra incognita (with a torch in form of scaleTime and interpolateNumber that we used!)

We certainly can make the initial project better, adding more features and visual effects. Some ideas for you to consider, from easier to harder level of challenge:

  • Actual card expand behavior: right now it's just a placeholder alert, but you probably want to direct the user to a full article about the item. It could be a modal, a new page in a separate tab, or a combination of both!
  • Inverted vertical layout on mobile: sure, our responsive layout is not bad for a tablet or resizing a desktop display for one half of the screen, but many people would like to see your work on mobile, as well. I would address it with a vertical and years-inverted layout. For this, you'd need to add a couple of new properties and methods to the TimelineEngine class (or maybe go full OOP and make two version of the class for horizontal and vertical layouts!), tweak the paths.ts, lanes.ts, and add a couple of constants for vertical layout.
  • Ticks and baseline entrance animation: in my original project, I used Svelte's built-in transitions. In React, I think a good approach could be to use something like Motion to wrap the NodeCard, baseline and paths with the special animatable components.
  • Twists and turns for the nodes' connectors: who told us that they should be straight lines?! (Okay, I did, but let's skip that part) You can try and make the connector lines follow a path around other cards, avoiding collisions, and connecting from different sides. Might be tricky, and I can imagine paths.ts and lanes.ts would need to know a bit more about each other.
  • Let the user drag the cards around: make it a real map! This is probably going to be the most intense, and I imagine that TimelineEngine class would become driven by a forceSimulation, with a custom collision detection for bounding boxes (note that the original forceSimulation is originally aimed at circle nodes with a radius property!)

And, of course, share the results of your work on the socials, the comments for this post, or the issues in the repository! Don't hesitate to reach out in the comments, via email or LinkedIn.

That would be it for this tutorial! If you liked it, please like, share and bookmark it here and on the socials. I hope it helps you to build a great portfolio to showcase your awesome achievements, or sparks an idea for another project.

Have a good one!

Top comments (2)

Collapse
 
kyisaiah47 profile image
Isaiah Kim •

I'm curious how equal startMs values are handled, since the lane result can then depend on input order.

Collapse
 
dmitryjima profile image
Dmitry •

That's a great question, Isaiah, and you're totally right: in case of the same exact date, the order of the elements in the initial passed-in array starts to matter, because when we sort them in the assignLanes here:

const sortedNodes = nodes.toSorted((a, b) => a.startMs - b.startMs);
Enter fullscreen mode Exit fullscreen mode

an element initially located closer to the beginning of the array would be processed earlier than the subsequent one with the same date, so the result might be unexpected. In theory, it would matter, say, if there were two events on the same day and at the same time. And, we also have the preferredSide override to consider:

Regular case
Regular case: two items with the same exact date and time. test-1 processed first, it will be above, test-2 will be below the baseline


Override case
An override case: two items with the same exact date and time, but test-1 has a preferredSide. It will be below, and test-2 (processed second) will be above


However, I think given the nature of the data in the project, we are very unlikely to encounter two portfolio positions with the same exact date 😄, but that's certainly worth considering for projects where it might be a common case in the data