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:
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
- Initial setup and structure
- Data models and sample data
- Responsive timeline map
- Putting nodes on timeline
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
.scssmodules; 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
scssmodules 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
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-termcommitmentthat 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 straightstemfor aproject, and abridgeor a continuous arc for acommitment.
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 ComponentsTimelineMapandNodeCard-
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 SVGpaths to the nodes from the baseline -
src/lib/timeline/engine/index.ts- will export theTimelineEngineclass, responsible for the calculations
-
Glancing through this structure, you might ask:
Hey, is all of this stuff in the
/enginefolder really necessary? 🤔 Couldn't we simply put both the rendering, and the calculations inside theTimelineMapcomponent?
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
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";
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";
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;
}
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
}
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;
}
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",
},
...
];
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;
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;
Not much at the moment, but we'll be getting there!
Responsive timeline map
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;
};
Wait a second! What the heck is a
domainthough?
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;
}
}
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 thewidthparameter 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,
]);
}
}
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");
...
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);
}
}
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();
}
...
}
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>
);
}
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();
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);
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);
}, []);
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]);
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}
/>
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;
}
}
}
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
8iny1={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
widthandheightis 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;
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>
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}>
...
)
...
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]);
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}
/>
);
})}
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;
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 }) => {
...
}
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]);
...
}
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>
)}
)
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();
}
...
}
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]);
...
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;
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;
...
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();
}
...
}
We should have a nice responsive chart, that looks something like this:
Putting nodes on timeline
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;
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) {
...
}
...
}
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>
);
};
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();
}
}
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);
}
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:
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)