DEV Community

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

Posted on Originally published at zdcreatech.com

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

Hi there! In this tutorial, we are going to build a fun project that you can use as a foundation for your interactive portfolio.

Some time ago, when I decided to update my portfolio and started looking for inspiration, I ran into an issue: there are a lot of bombastic examples out there, but most of them, if not all, are a better fit for visuals-heavy careers: designers of all types, architects, artists. As a software developer, even if you do a lot of front-end, sometimes it's hard to turn a great project you worked on into a great visual story. Moreover, if you want to showcase different types of data, career milestones, academic track, events, awards, it doesn't become better. An idea came to me: what if a portfolio could be in the form of a "map", spread across a timeline?

The outcome was my current portfolio, built with SvelteKit and D3.js. My friends and former colleagues liked it, and suggested turning it into a tutorial, so here we are! However, because React is a far more popular library than Svelte, I decided to make it a React tutorial. If you didn't plan to use React in the portfolio of your own - no worries, the fundamental parts will transfer to other popular front-end libraries.

We are going to use the power of D3.js for calculating the layout, and React for rendering the UI.

This is going to be a two-part series, and the end result of our work would look something like this:

Final result

Interactive demo

If you want to build something similar and use it in your own project, let's dive in!

All of the code is available in the dedicated GitHub repository, with the folders inside the src/ directory containing the code for the corresponding sections. Feel free to clone/fork or simply open it as a read-along while working through the tutorial.

This post is also available in Spanish on my website.

Table of contents

Prerequisites, tech stack and scope

We are going to go through the project step by step; however, it is going to be more of an intermediate tutorial. To feel comfortable throughout the sections, my recommendation would be to:

  • Be familiar with TypeScript; we're not going super-fancy, but there'll be some generics here and there
  • Know your way around React.js; finished the official docs or went through a bootcamp would be a good milestone
  • Be acquainted with SVG structure and main parts; no need to be an Adobe Illustrator or Inkscape guru, but recognizing core parts of an SVG image would help
  • Understand CSS/SCSS modules; we're not going to use Tailwind or CSS-in-JS, going with a more "neutral" option, while still having the benefit of scoped styles

Of course, if you don't check all the boxes, and I still encourage you to dare and try, but be ready for your browser tabs panel go VROOOM as you look up the additional materials.

When it comes to tech stack, we're going to use a standard Vite project initializer with TypeScript template, Eslint and Prettier for code linting and formatting, and a few additional libraries:

  • D3.js - for calculating the layout positions, and, further in the tutorial, easings and interpolations for the resize animation
  • sass-embedded - for using .scss modules; full counterpart of the sass package, but compatible with the recent versions of:
  • vite-plugin-sass-dts - a plugin for Vite that generates types for scss modules on the fly
  • classnames - a library for dynamic styles
  • react-use - a toolbox of React hooks; here we'll use it for measuring elements' dimensions.

Another note, to keep this tutorial just enormous, and not gargantuan, we're going to skip some of the features from my original project, including:

  • Entrance animations
  • Switching to inverted vertical layout on mobile
  • Actual behavior on expanding the card

Those are all interesting topics, but I'm sure that after going through the tutorial, you'd be able to implement them on your own, and bring more creativity to it!

Initial setup and structure

Code for the section

Take a look at the repository linked above, you can clone or open it as a reference. It is a basic Vite+React application: it has some basic global CSS defined in the index.css, the entry point is main.tsx, and in vite.config.ts we have the sassDts plugin that will generate TypeScript types for SCSS modules.

The interesting folder is /lib - here lives the code we're going to work on. But before we jump into the code, let's think for a moment about what we're about to build.

To formulate our key requirements in a few sentences:

We are going to build a React component that will take career stages' and projects' data as props, render a responsive timeline, and distribute the data items/nodes in a temporal manner over the X-axis in the form of cards, putting items on different lanes below and above baseline to avoid collisions. The calculations of the items' positions are going to be delegated to a separate module. An item can be a single date project (e.g. conference talk), or a long-term commitment that can either be a range or to be open-ended (e.g. a full-time position in a company). Items will be connected to the central axis with lines, and different lines will represent different temporal options: simple straight stem for a project, and a bridge or a continuous arc for a commitment.

Now, let's get back to the /lib folder. Here, we have the scaffold of the structure we've just discussed:

  • src/lib/timeline/data - this will hold the sample data: a history of education, career milestones and projects. In a real-life, this data would likely come from an API or from the filesystem in a more sophisticated manner, but for our purposes, a simple exported JS array of objects would do
  • src/lib/timeline/components - contains React Components TimelineMap and NodeCard
    • src/lib/timeline/components/TimelineMap - this will be our "main" component for rendering the timeline. It will take the data as props, pass it to the processing engine, and then render the data over the x and y axes
    • src/lib/timeline/components/NodeCard - this will take the data for an item as props, and then render it as a card
  • src/lib/timeline/engine - contains the core parts of the module handling calculations for the month and year ticks, and the nodes' positions
    • src/lib/timeline/engine/constants.ts - a place where we'll define some shared constants
    • src/lib/timeline/engine/types.ts - here, the shared interfaces and types will live
    • src/lib/timeline/engine/lanes.ts - will contain the logic for distributing the nodes over the lanes and avoiding collisions
    • src/lib/timeline/engine/paths.ts - will contain the logic for drawing SVG paths to the nodes from the baseline
    • src/lib/timeline/engine/index.ts - will export the TimelineEngine class, responsible for the calculations

Glancing through this structure, you might ask:

Hey, is all of this stuff in the /engine folder really necessary? 🤔 Couldn't we simply put both the rendering, and the calculations inside the TimelineMap component?

That's a good question! In this great video, Cory Cone provides a great overview on three possible ways of using D3.js with React: D3-driven chart, scoped D3 chart, and React-owned chart. In our case, we're going with the third approach: React-owned rendering, with D3 handling the "math". Ideally, TimelineMap is not going to know anything (or, at least, very little) about the underlying engine's inner shenanigans. It will pass the state updating handlers to the engine and be busy rendering the state to the screen. The code related to the calculations is going to be rather large, and dissecting it further into the modules will help us keep the code readable and clean.

Copy the stub files to your editor (if you've started from scratch), and let's start modelling the data!

Data models and sample data

Code for the section

First, let us define the interface that will shape the input data. I'd start with the primitives. We know that we're going to have two types of items - one-shot project and long-term commitment, let's add them to the src/lib/engine/types.ts

export type NodeType = "commitment" | "project";
Enter fullscreen mode Exit fullscreen mode

We also know that some of the items will be above, and some below the baseline. However, we probably want some space for a manual override, in case we want a specific node to always be above or below:

export type DisplaySide = "above" | "below";
Enter fullscreen mode Exit fullscreen mode

Now, we're ready for the interface for the data itself. I would call it TimelineNodeData. It will have an id, title, type, preferredSide and some other metadata necessary to render a nice-looking card.

export interface TimelineNodeData {
  id: string;
  type: NodeType;
  title: string; // Position at a company or name of the project/event
  organization?: string; // Could be a company or a school
  organizationLogoUrl?: string;
  thumbnailUrl?: string;
  summary: string;
  date: string;
  endDate?: string; // Used only for `commitment`
  ongoing?: boolean; // Additional boolean flag for convenience; Used only for `commitment`
  preferredSide?: DisplaySide;
}
Enter fullscreen mode Exit fullscreen mode

But this lacks any information about the coordinates! Let's extend our interface with the layout-related fields, and call it TimelineNode, as it will represent the actual node on the timeline:

export interface TimelineNode extends TimelineNodeData {
  x: number; // x-axis position for the `NodeCard`
  y: number; // y-axis position for the `NodeCard`, i.e. y of its lane

  side: DisplaySide;
  startX: number; // point on the baseline/axis where the bridge/stem originates
  endX?: number; // point on the baseline/axis where the bridge returns back (only for `commitment` with endDate)
  laneIndex: number; // index of the lane from 0 to N
}
Enter fullscreen mode Exit fullscreen mode

Last but not least, in our TimelineMap, we will draw some month and year ticks, and having an interface for them would be handy. Let's call it TimelineTick, and it will have the date, position x, and a label - month or year:

export interface TimelineTick {
  date: Date;
  x: number;
  label: string;
}
Enter fullscreen mode Exit fullscreen mode

These few interfaces will be the glue of our application. Now we can finally define some sample data in the /lib/timeline/data.index.ts:

import type { TimelineNodeData } from "../engine/types";

export const data: TimelineNodeData[] = [
// Commitments - education and long-running positions
{
  id: "university-undergraduate-degree",
  type: "commitment",
  date: "2017-09-01",
  endDate: "2021-07-01",
  title: "Bachelor of Science",
  organization: "University of Applied Technology",
  organizationLogoUrl: "/logos/university.svg",
  thumbnailUrl:
    "https://images.unsplash.com/photo-1531482615713-2afd69097998?auto=format&fit=crop&w=1200&q=80",
  summary:
    "Completed a Bachelor of Science focused on technology, engineering, and applied problem solving.",
  preferredSide: "below",
  },
...
];
Enter fullscreen mode Exit fullscreen mode

I've prepared some sample data in the corresponding folder for a character (let's call her Jane Doe) with an epic education and employment history. Feel free to use it, or fill it in with your own data right away. Please note that the logos' URLs are relative and point to the /public folder.

Finally, we can show something on the screen! Modify the src/App.tsx and src/lib/timeline/components/TimelineMap/index.tsx to show the stringified data:

App.tsx

import "./App.css";
import { data } from "./lib/timeline/data";
import TimelineMap from "./lib/timeline/components/TimelineMap";

function App() {
  return (
    <>
      <main className="main">
        <TimelineMap data={data} />
      </main>
    </>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

TimelineMap/index.tsx

import type { TimelineNodeData } from "../../engine/types";
import styles from "./styles.module.scss";
import classNames from "classnames/bind";

const cx = classNames.bind(styles);

type Props = {
  data: TimelineNodeData[];
};

const TimelineMap: React.FC<Props> = ({ data }) => {
  return <>{JSON.stringify(data)}</>;
};

export default TimelineMap;
Enter fullscreen mode Exit fullscreen mode

Stringified data on the screen

Interactive demo

Not much at the moment, but we'll be getting there!

Responsive timeline map

Code for the section

Our next step is to render the timeline map's foundation: a responsive SVG with baseline and year and month ticks. We'll be working on our engine class TimelineEngine located in src/lib/timeline/engine/index.ts and the TimelineMap React component in src/lib/timeline/components/TimelineMap/index.tsx.

Let's start with the engine. Essentially, it's going to be a TypeScript class that takes the input data, dates range, information about screen size and a few handlers from the TimelineMap component. It will calculate the layout, and then use the passed-in handlers to update the stateful values and trigger the UI re-render. It will expose a single method resize that will run when the dimensions change.

Based on this, we can define the constructor parameters for the class:

src/lib/timeline/engine/index.ts

type ConstructorParameters = {
  data: TimelineNodeData[];
  width: number;
  domainStart: Date;
  domainEnd: Date;
  onUpdateTicks: (yearTicks: TimelineTick[], monthTicks: TimelineTick[]) => void;
};
Enter fullscreen mode Exit fullscreen mode

Wait a second! What the heck is a domain though?

That's a good question! Here we're going to use some of the D3's terminology. For creating a responsive temporal layout, we're going to use the scaleTime function from the d3-scale package. The function takes two parameters: domain and range. The former is a very fancy (and math-y!) name for an array of numbers that are going to be scaled, in our case, that would be the start and the end date of our timeline. The latter is basically a range of pixels into which we're going to squeeze our dates.

Let's add some private properties and a constructor to the TimelineEngine class:

import { scaleTime, type ScaleTime } from "d3-scale";
...
export class TimelineEngine {
  private xScale: ScaleTime<number, number>;
  private domainStart: Date;
  private domainEnd: Date;
  private onUpdateTicks: (yearTicks: TimelineTick[], monthTicks: TimelineTick[]) => void;

  constructor({
    width,
    domainStart = new Date(2017, 0, 1),
    domainEnd = new Date(),
    onUpdateTicks,
  }: ConstructorParameters) {
    this.domainStart = domainStart;
    this.domainEnd = domainEnd;
    this.onUpdateTicks = onUpdateTicks;
  }
}
Enter fullscreen mode Exit fullscreen mode

Since Jane Doe's portfolio in the sample data starts in ~2017, it makes sense to make 2017 the start of our timeline, and to make the present date the end of the domain.

So far so good! However, what is xScale? Why haven't we used the width parameter yet?

We're about to! The xScale will be an instance of ScaleTime that we will use to get the coordinates of the ticks for years and months. Let's initialize it:

...
export class TimelineEngine {
  ...
  constructor({
    width,
    domainStart = new Date(2017, 0, 1),
    domainEnd = new Date(),
    onUpdateTicks,
  }: ConstructorParameters) {
    ...

    this.xScale = scaleTime()
      // The earliest and latest dates on the timeline
      .domain([this.domainStart, this.domainEnd])
      // The range of px from the left-most side to the right-most side
      .range([
        0,
        width,
      ]);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, we have what we need to calculate the positions of the ticks and create the TimelineTick[] array! We'll need to import some helper methods from d3 and initialize a couple of formatters:

...
import { timeFormat, timeMonth, timeYear } from "d3";

const formatYear = timeFormat("%Y");
const formatMonth = timeFormat("%b");
...
Enter fullscreen mode Exit fullscreen mode

We can now implement a private helper method for getting the ticks, called updateTicks. Place it below the constructor:

...
export class TimelineEngine {
  ...
  constructor({
    ...
  })

  ...
  private updateTicks() {
    const yearTicks: TimelineTick[] = timeYear
      .range(this.domainStart, this.domainEnd)
      .map((date) => ({
        date,
        x: this.xScale(date),
        label: formatYear(date),
      }));

    const monthTicks: TimelineTick[] = timeMonth
      .range(this.domainStart, this.domainEnd)
      .filter((date) => date.getMonth() !== 0) // Skip January, as it matches the positions of `yearTicks`
      .map((date) => ({
        date,
        x: this.xScale(date),
        label: formatMonth(date),
      }));

    this.onUpdateTicks(yearTicks, monthTicks);
  }
}
Enter fullscreen mode Exit fullscreen mode

Inside the method, we use the .range() method of timeYear and timeMonth, which returns an array of Dates Date[]. Then, we chain a JS Array.map() to iterate over the ranges and return arrays that hold objects in the shape of TimelineTick. And then we store the result in the variables yearTicks and monthTicks.

A-ha, but what about the this.onUpdateTicks(yearTicks, monthTicks) call?

That's the most interesting part of the method! Here, we're actually updating the UI. The onUpdateTicks parameter is a function that would come from the "React world", and it is our bridge (a very restricted one, so as not to break React's rendering!) from the calculated values inside TimelineEngine into the UI itself. We will run this function on mount, and during resizing, to support a responsive design.

However, instead of calling it directly at the end of the constructor, let's add another private helper method for synchronizing the layout. Add it below the constructor and call at the bottom of the constructor:

...
export class TimelineEngine {
  ...
  constructor({
    ...

    this.syncLayout();
  })

  ...
  private syncLayout() {
    this.updateTicks();
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

Now, we're ready to start updating the UI! Open up the TimelineMap component, and let's add the following code to it:

import React, { Fragment, useCallback, useEffect, useRef, useState } from "react";
import styles from "./styles.module.scss";
import classNames from "classnames/bind";
import type { TimelineNodeData, TimelineTick } from "../../engine/types";
import { TimelineEngine } from "../../engine";

const cx = classNames.bind(styles);

type Props = {
  data: TimelineNodeData[];
};

const domainStart = new Date(2017, 0, 1);
const domainEnd = new Date();

const TimelineMap: React.FC<Props> = ({ data }) => {
  const timelineEngineInstanceRef = useRef<TimelineEngine>(null);

  const [yearTicks, setYearTicks] = useState<TimelineTick[]>([]);
  const [monthTicks, setMonthTicks] = useState<TimelineTick[]>([]);

  const handleUpdateTicks = useCallback((yearTicks: TimelineTick[], monthTicks: TimelineTick[]) => {
    setYearTicks(yearTicks);
    setMonthTicks(monthTicks);
  }, []);

  useEffect(() => {
    if (!timelineEngineInstanceRef.current) {
      // Instantiate the TimelineEngine if not present
      timelineEngineInstanceRef.current = new TimelineEngine({
        data,
        domainStart: new Date(2017, 0, 1),
        domainEnd: new Date(),
        width: window.innerWidth,
        onUpdateTicks: handleUpdateTicks,
      });
    }
  }, [data, handleUpdateTicks]);

  return (
    <div className={styles["scroll-wrapper"]}>
      {!window.innerWidth ? null : (
        <div className={styles["inner-content"]} style={{ width: window.innerWidth }}>
          <svg
            className={styles["svg-map"]}
            style={{
              width: window.innerWidth,
              height: window.innerHeight,
            }}
            aria-hidden={true}
          >
            {/* Baseline */}
            <line className={styles["baseline"]} x1={0} x2={contentWidth} y1={axisY} y2={axisY} />

            {/* Month ticks */}
            {monthTicks.map((tick, i) => {
              return (
                <line
                  key={i}
                  className={cx(styles["month-tick"])}
                  x1={tick.x}
                  x2={tick.x}
                  y1={axisY - 8 / 2}
                  y2={axisY + 8 / 2}
                />
              );
            })}

            {/* Year ticks */}
            {yearTicks.map((tick, i) => {
              return (
                <Fragment key={i}>
                  <text className={styles["year-label"]} x={tick.x} y={18}>
                    {tick.label}
                  </text>
                  <line
                    className={styles["year-tick"]}
                    x1={tick.x}
                    x2={tick.x}
                    y1={0}
                    y2={window.innerHeight}
                  />
                  <text
                    className={styles["year-label"]}
                    x={tick.x}
                    y={window.innerHeight - 12}
                  >
                    {tick.label}
                  </text>
                </Fragment>
              );
            })}
          </svg>
        </div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Wow! A lot is going on here. Let's dissect the key parts.

First, we declare module variables for the start and end date, which we're going to pass to the TimelineEngine. They are declared outside of the TimelineMap's body and would be re-initialized on every render, so they are not needed in the hooks' dependencies arrays.

const domainStart = new Date(2017, 0, 1);
const domainEnd = new Date();
Enter fullscreen mode Exit fullscreen mode

Then, we declare a ref that will hold an instance of TimelineEngine, initially being null (Notice how having TimelineEngine as a class gives us a convenient interface right away to be used in the useRef<INTERFACE>() generic).

const timelineEngineInstanceRef = useRef<TimelineEngine>(null);
Enter fullscreen mode Exit fullscreen mode

After that, we declare state variables yearTicks and monthTicks, and a unified handler handleUpdateTicks that combines updating them.

const [yearTicks, setYearTicks] = useState<TimelineTick[]>([]);
const [monthTicks, setMonthTicks] = useState<TimelineTick[]>([]);

const handleUpdateTicks = useCallback((yearTicks: TimelineTick[], monthTicks: TimelineTick[]) => {
  setYearTicks(yearTicks);
  setMonthTicks(monthTicks);
}, []);
Enter fullscreen mode Exit fullscreen mode

Inside the useEffect, we instantiate the TimelineEngine if it's not there yet, and store it inside the timelineEngineInstanceRef.current. We pass the previously declared domainStart and domainEnd, data, the handleUpdateTicks handler, and for the width we pass the current window.innerWidth (We're in the client-side browser world, and this is relatively safe).

useEffect(() => {
  if (!timelineEngineInstanceRef.current) {
    // Instantiate the TimelineEngine if not present
    timelineEngineInstanceRef.current = new TimelineEngine({
      data,
      domainStart: domainStart,
      domainEnd: domainEnd,
      width: window.innerWidth,
      onUpdateTicks: handleUpdateTicks,
    });
  }
}, [data, handleUpdateTicks]);
Enter fullscreen mode Exit fullscreen mode

Finally, we render the actual UI. We have a couple of <div /> wrappers, an <svg /> that will contain our graphics, and inside of it we draw the baseline right in the middle of the screen by diving the screen height in half

<line
  className={styles["baseline"]}
  x1={0}
  x2={window.innerWidth}
  y1={window.innerHeight / 2}
  y2={window.innerHeight / 2}
/>
Enter fullscreen mode Exit fullscreen mode

and then we map over the stateful yearTicks and monthTicks to render longer screen-height lines for years, and shorter ticks across the baseline.

If you're working from scratch, the linter is likely all red right now, because it cannot find the required CSS classes. Add the following SCSS code to src/lib/timeline/components/TimelineMap/styles.module.scss:

.scroll-wrapper {
  position: relative;
  width: 100%;
  height: 100%;
  min-height: 420px;
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  touch-action: pan-x pan-y;
  overscroll-behavior-x: contain;
  scrollbar-width: thin;

  .inner-content {
    position: relative;
    height: 100%;
    min-height: 420px;
    margin-inline: auto;

    .svg-map {
      position: absolute;
      inset: 0;
      pointer-events: none;
    }

    .baseline {
      stroke: var(--timeline-map-color-stroke);
      stroke-width: 1;
    }

    .month-tick {
      stroke: var(--timeline-map-color-stroke);
      stroke-width: 1;
      opacity: 1;
    }

    .year-tick {
      stroke: var(--timeline-map-color-stroke);
      stroke-width: 1;
      opacity: 0.65;
    }

    .year-label {
      fill: var(--timeline-map-color-text-label);
      font-size: 0.75rem;
      text-anchor: middle;
      font-variant-numeric: tabular-nums;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Oof, that was a lot of code! If you take a look at the screen right now, you should see a chart with a baseline and month ticks over it, year ticks with labels. Yes, we finally got something interesting on the screen, but before we proceed with the nodes, we could do better.

Our current approach is kinda "naive", and has some obvious flaws:

  • We have a lot of "magic numbers", especially around month ticks and year ticks' offsets. Right now, we may remember that 8 in y1={window.innerHeight / 2 - 8 / 2} is actually a height of a month tick, but we will forget it in an hour.
  • The way we find the width and height is very fragile. In a real-life application, our portfolio is likely going to be a part of a larger layout, with navbars, footers, elements below, above, and aside from the chart. We need to make the width and height scoped to the area we render our chart in.
  • Our layout is not responsive. Like, at all! We probably want to have a certain min and max width of the space between two year ticks, to squeeze the layout on smaller screens down to a certain point, and stretch it up a bit on larger screens.
  • The year ticks labels are cut-off, especially on the left side. We want to add some "air" to our chart and let it breath a bit 🧘

Let's address those one by one!

First, time to add some constants to the src/lib/timeline/engine/constants.ts:

// Month and year ticks dimensions
export const MONTH_TICK_HEIGHT_PX = 8;
export const YEAR_LABEL_TOP_OFFSET_PX = 18;
export const YEAR_LABEL_BOTTOM_OFFSET_PX = 12;
Enter fullscreen mode Exit fullscreen mode

And use them where appropriate:

...
{/* Month ticks */}
{monthTicks.map((tick, i) => {
  return (
    <line
      key={i}
      className={cx(styles["month-tick"])}
      x1={tick.x}
      x2={tick.x}
      y1={window.innerHeight / 2 - MONTH_TICK_HEIGHT_PX / 2}
      y2={window.innerHeight / 2 + MONTH_TICK_HEIGHT_PX / 2}
    />
  );
})}
...
<text className={styles["year-label"]} x={tick.x} y={YEAR_LABEL_TOP_OFFSET_PX}>
  {tick.label}
</text>
...
<text
  className={styles["year-label"]}
  x={tick.x}
  y={window.innerHeight / 2 - YEAR_LABEL_BOTTOM_OFFSET_PX}
>
  {tick.label}
</text>
Enter fullscreen mode Exit fullscreen mode

Now, let's say goodbye to the elephant in the room: window.innerHeight / 2 and the like. We're going to measure not the window, but the scroll element container with a handy hook from react-use:

...
const [scrollElementRef, { width: scrollWrapperWidth, height: scrollWrapperHeight }] =
  useMeasure<HTMLDivElement>();
...
  return (
    <div className={styles["scroll-wrapper"]} ref={scrollElementRef}>
  ...
  )
...
Enter fullscreen mode Exit fullscreen mode

We can now get rid of window.innerHeight and window.innerHeight / 2, and introduce a derived variable axisY:

// Position of the baseline
const axisY = useMemo(() => scrollWrapperHeight / 2, [scrollWrapperHeight]);
Enter fullscreen mode Exit fullscreen mode

and then update all of the instances of window.innerHeight / 2:

...
{/* Baseline */}
<line className={styles["baseline"]} x1={0} x2={contentWidth} y1={axisY} y2={axisY} />
...
{/* Month ticks */}
{monthTicks.map((tick, i) => {
  return (
    <line
      key={i}
      className={cx(styles["month-tick"])}
      x1={tick.x}
      x2={tick.x}
      y1={axisY - MONTH_TICK_HEIGHT_PX / 2}
      y2={axisY + MONTH_TICK_HEIGHT_PX / 2}
    />
  );
})}
Enter fullscreen mode Exit fullscreen mode

Ok, and then to the responsive layout. We want to have a min and max value for the horizontal space between two year ticks, which will help us find the minContentWidth and maxContentWidth, and then actually calculate the contentWidth that we want. Let's add the new constants to src/lib/timeline/engine/constants.ts:

// Timeline horizontal space between years
// Below this density, we scroll instead of squeezing further
export const MIN_TIMELINE_HORIZONTAL_SPACE_PER_YEAR_PX = 70;
// Above this density, we stop stretching and center instead
export const MAX_TIMELINE_HORIZONTAL_SPACE_PER_YEAR_PX = 180;
Enter fullscreen mode Exit fullscreen mode

And then, just above TimelineMap (once again, avoiding re-initialization on every render), add minContentWidth and maxContentWidth as a multiplication of the value by the number of years on the timeline:

...
const domainStart = new Date(2017, 0, 1);
const domainEnd = new Date();
const domainYears = domainEnd.getFullYear() - domainStart.getFullYear();
const minContentWidth =
  MIN_TIMELINE_HORIZONTAL_SPACE_PER_YEAR_PX * domainYears;
const maxContentWidth =
  MAX_TIMELINE_HORIZONTAL_SPACE_PER_YEAR_PX * domainYears;

const TimelineMap: React.FC<Props> = ({ data }) => {
  ...
}
Enter fullscreen mode Exit fullscreen mode

With those in place, we can define a derived variable contentWidth. We don't want to exceed the maxContentWidth constant, but choose the largest between the width of the scroll wrapper and minContentWidth:

const TimelineMap: React.FC<Props> = ({ data }) => {
  ...
  const contentWidth = useMemo(() => {
    return Math.min(Math.max(scrollWrapperWidth, minContentWidth), maxContentWidth);
  }, [scrollWrapperWidth]);
  ...
}
Enter fullscreen mode Exit fullscreen mode

And then use it instead of the window.innerWidth in all of the cases:

...
useEffect(() => {
  if (!contentWidth) {
    return;
  }

  if (!timelineEngineInstanceRef.current) {
    // Instantiate the TimelineEngine if not present
    timelineEngineInstanceRef.current = new TimelineEngine({
      data,
      domainStart: domainStart,
      domainEnd: domainEnd,
      width: contentWidth,
      onUpdateTicks: handleUpdateTicks,
    });
  }
}, [contentWidth, data, handleUpdateTicks]);
...
return (
  <div className={styles["scroll-wrapper"]} ref={scrollElementRef}>
    {contentWidth === 0 || scrollWrapperHeight === 0 ? null : (
      <div className={styles["inner-content"]} style={{ width: contentWidth }}>
        <svg
          className={styles["svg-map"]}
          style={{
            width: contentWidth,
            height: scrollWrapperHeight,
          }}
          aria-hidden={true}
        >
          {/* Baseline */}
          <line className={styles["baseline"]} x1={0} x2={contentWidth} y1={axisY} y2={axisY} />
          ...
        </svg>
      </div>
    )}
  )
Enter fullscreen mode Exit fullscreen mode

That's now better! Now we can make our layout responsive, by implementing a public resize method in the TimelineEngine:

...
export class TimelineEngine {
  ...
  constructor({
    ...
  }: ConstructorParameters) {
    ...
  }

  resize(newWidth: number) {
    this.xScale = scaleTime()
      .domain([this.domainStart, this.domainEnd])
      .range([0, newWidth]);

    this.syncLayout();
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

And use the resize method inside the useEffect in TimelineMap. Essentially, the only value here that is going to update is contentWidth, so if the timelineEngineInstanceRef.current already has been initialized we run the resizing logic:

...
useEffect(() => {
  if (!contentWidth) {
    return;
  }

  if (!timelineEngineInstanceRef.current) {
    // Instantiate the TimelineEngine if not present
    timelineEngineInstanceRef.current = new TimelineEngine({
      data,
      domainStart: domainStart,
      domainEnd: domainEnd,
      width: contentWidth,
      onUpdateTicks: handleUpdateTicks,
    });
  } else {
    // Run `resize` method on change
    timelineEngineInstanceRef.current.resize(contentWidth);
  }
}, [contentWidth, data, handleUpdateTicks]);
...
Enter fullscreen mode Exit fullscreen mode

And finally, we can address the horizontal bleeds of the timeline map. Let's add a new variable to the src/lib/timeline/engine/constants.ts:

// Horizontal space on both ends of the timeline content area
export const TIMELINE_HORIZONTAL_PADDING_PX = 80;
Enter fullscreen mode Exit fullscreen mode

And incorporate it in both TimelineMap:

...
const minContentWidth =
  MIN_TIMELINE_HORIZONTAL_SPACE_PER_YEAR_PX * domainYears + TIMELINE_HORIZONTAL_PADDING_PX * 2;
const maxContentWidth =
  MAX_TIMELINE_HORIZONTAL_SPACE_PER_YEAR_PX * domainYears + TIMELINE_HORIZONTAL_PADDING_PX * 2;
...
Enter fullscreen mode Exit fullscreen mode

and in TimelineEngine:

...
export class TimelineEngine {
  ...

  constructor({
    width,
    ...
  }: ConstructorParameters) {
    ...

    this.xScale = scaleTime()
      .domain([this.domainStart, this.domainEnd])
      .range([
        TIMELINE_HORIZONTAL_PADDING_PX,
        Math.max(width - TIMELINE_HORIZONTAL_PADDING_PX, TIMELINE_HORIZONTAL_PADDING_PX),
      ]);

    this.syncLayout();
  }

  resize(newWidth: number) {
    this.xScale.range([
      TIMELINE_HORIZONTAL_PADDING_PX,
      Math.max(newWidth - TIMELINE_HORIZONTAL_PADDING_PX, TIMELINE_HORIZONTAL_PADDING_PX),
    ]);

    this.syncLayout();
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

We should have a nice responsive chart, that looks something like this:

Responsive timeline map

Interactive demo

Putting nodes on timeline

Code for the section

Things are about to get interesting! For a breath of fresh air, let's work for a bit in another file. Open the src/lib/timeline/components/NodeCard/index.tsx, and instead of the scaffold code, add the following:

import type { TimelineNode } from "../../engine/types";
import styles from "./styles.module.scss";
import classNames from "classnames/bind";

const cx = classNames.bind(styles);

type Props = {
  node: TimelineNode;
};

const NodeCard: React.FC<Props> = ({ node }) => {
  return (
    <div
      style={{
        position: "absolute",
        left: `${node.x}px`,
        top: `${node.y}px`,
        transform: "translate(-50%, -50%)",
        background: "gray",
        color: "white",
        padding: "4px 6px",
      }}
    >
      {node.id}
    </div>
  );
};

export default NodeCard;
Enter fullscreen mode Exit fullscreen mode

Don't mind the inline styles, we're going to address it later. For now, we just want to render a gray box with the node.id, and place it based on the node.x and node.y. We want to center it, so we apply a transform trick: transform: "translate(-50%, -50%)". This way, we'll simplify the coordinate calculations a bit.

Now, let's get back to the TimelineEngine. add the following new code to the ConstructorParameters, constructor, and the class' body:

...
type ConstructorParameters = {
  ...
  axisY: number; // the y of the baseline/axis, content height / 2
  onUpdateNodes: (nodes: TimelineNode[]) => void;
};

export class TimelineEngine {
  ...
  private timelineNodes: TimelineNode[];
  private onUpdateNodes: (nodes: TimelineNode[]) => void;

  constructor({
    ...
    axisY,
    onUpdateNodes,
  }: ConstructorParameters) {
    ...
    this.onUpdateNodes = onUpdateNodes;

    ...

    // Initialize the nodes
    this.timelineNodes = data.map((d) => ({
      ...d,
      x: 0,
      y: axisY,
      startX: 0,
      endX: undefined,
      side: "above", // placeholder for now
      laneIndex: 0, // placeholder for now
    }));

    ...
  }

  resize(newWidth: number, newAxisY: number) {
    ...
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

Here, we've finally added the timelineNodes property, as well as the handler onUpdateNodes (will work the same as the onUpdateTicks), and a simple initialization logic. We also now provide the y position of the baseline as the axisY.

To get something on the screen, let's modify the TimelineMap:

...
const TimelineMap: React.FC<Props> = ({ data }) => {
  ...
  const [nodes, setNodes] = useState<TimelineNode[]>([]);
  const handleSetNodes = useCallback((updatedNodes: TimelineNode[]) => {
    setNodes(updatedNodes);
  }, []);
  ...
  useEffect(() => {
    if (!contentWidth || !axisY) {
      return;
    }

    if (!timelineEngineInstanceRef.current) {
      // Instantiate the TimelineEngine if not present
      timelineEngineInstanceRef.current = new TimelineEngine({
        data,
        domainStart: domainStart,
        domainEnd: domainEnd,
        width: contentWidth,
        axisY,
        onUpdateNodes: handleSetNodes,
        onUpdateTicks: handleUpdateTicks,
      });
    } else {
      // Run `resize` method on change
      timelineEngineInstanceRef.current.resize(contentWidth, axisY);
    }
  }, [contentWidth, axisY, data, handleSetNodes, handleUpdateTicks]);
  ...
  return (
    <div className={styles["scroll-wrapper"]} ref={scrollElementRef}>
      {contentWidth === 0 || scrollWrapperHeight === 0 ? null : (
        <div className={styles["inner-content"]} style={{ width: contentWidth }}>
          <svg...>
          </svg>
          {/* Node cards */}
          {nodes.map((node) => {
            return <NodeCard key={node.id} node={node} />;
          })}
        </div>
      )}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Same as with the yearTicks and monthTicks, we now have a stateful value nodes and a handler handleSetNodes for updating them. We then pass the new handler and the now-required axisY to the new TimelineEngine(), and iterate over the nodes inside the <div className={styles["inner-content"]}/> (but outside of the <svg/> element!)

Now the nodes will be squished at the beginning of the baseline. Let's distribute them across the x axis. Back to the TimelineEngine, we'll define a new ad-hoc NodeLayout interface, and couple of new private methods:

...
// Local helper type that holds only data for the coordinates
type NodeLayout = Pick<TimelineNode, "x" | "y" | "startX" | "endX" | "side" | "laneIndex">;
...
export class TimelineEngine {
  ...
  private syncLayout() {
    this.onUpdateNodes([...this.timelineNodes]);
    this.updateTicks();
  }

  // Helper function to determine the end date of a node
  private endOf(d: TimelineNodeData): Date {
    if (d.endDate) {
      return new Date(d.endDate);
    } else if (d.ongoing) {
      return this.domainEnd;
    } else {
      return new Date(d.date);
    }
  }

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

        return [
          node.id,
          {
            // Since `endOf` returns the same date for a `project` we don't need any `if...else`'s here:
            // (x + x) / 2 = x
            x: (startX + endX) / 2,
            y: axisY,
            startX,
            endX: node.endDate || node.ongoing ? endX : undefined,
            side: "above", // placeholder for now
            laneIndex: 0, // placeholder for now
          },
        ];
      }),
    );
  }

  private applyLayout(layout: Map<string, NodeLayout>) {
    for (const node of this.timelineNodes) {
      const target = layout.get(node.id)!;

      node.x = target.x;
      node.y = target.y;
      node.startX = target.startX;
      node.endX = target.endX;
      node.side = target.side;
      node.laneIndex = target.laneIndex;
    }
    this.syncLayout();
  }
}
Enter fullscreen mode Exit fullscreen mode

The core update here is the calculateLayout method, whose only parameter is the axisY. Its purpose is to calculate the appropriate x and y positions for the nodes, and store them in a Map. We added a tiny helper function endOf that helps to find the end date of the node (as you remember, we have three cases: one-shot project, terminated commitment, and ongoing commitment). In the applyLayout method we iterate over the timelineNodes property array, and look up the coordinates. We've also updated the syncLayout method to update both the ticks, and the nodes.

Then, let's use the new methods:

  constructor({
    ...
  }: ConstructorParameters) {
    ...

    const targetLayout = this.calculateLayout(axisY);

    this.applyLayout(targetLayout);
  }

  resize(newWidth: number, newAxisY: number) {
    const targetLayout = this.calculateLayout(newAxisY);

    this.applyLayout(targetLayout);
  }
Enter fullscreen mode Exit fullscreen mode

The code we've just worked out might seem a bit over-complicated at first (Why the heck do we need a separate calculateLayout?!), but this is going to pay off really well in the final section 😉

Now, we should see something like this, a responsive mess squished on the baseline:

Nodes added to the baseline

Interactive demo

If you got to this point, you're awesome! 💪 (and I hope you didn't do it all in one go!) But now please take a break before you proceed, because Part II will start with a tough task: we are going to distribute the nodes over the lanes. After that, it will be significantly more chill: we will be drawing some SVGs, tweaking the visuals, and implementing animations.

See you in the next part!

Top comments (0)