How to display a calendar heat map monthly & weekly even for a specified time period for a day with d3.js (Next.js)

Amit rai
27 min readJun 4, 2023

--

Heatmap

Heatmaps provide a compelling visualization technique for analyzing data and uncovering patterns or trends. In this article, we will delve into the process of building a heatmap using D3.js and React. Our heatmap will enable us to present data in a grid-like format resembling a calendar.

One of the notable features of our heatmap implementation is the ability to adapt the time frame based on user preferences. When the user selects a duration exceeding a week, we automatically switch to displaying data at the daily level. This dynamic adjustment allows users to explore their data with enhanced granularity, whether they want to examine a single day or analyze a whole week’s worth of information.

By incorporating this flexibility, our heatmap offers users a seamless experience, empowering them to seamlessly switch between different timeframes according to their data analysis requirements.

Setting Up the Project

Before we dive into the implementation, let’s make sure we have the necessary tools and dependencies set up. Make sure you have Node.js and npm (Node Package Manager) installed on your machine. Once that’s done, we can proceed with the project setup.

We are not going to start from scratch instead we can quickly download the starter project from https://github.com/stevefrenzel/thankyounext template.

Installing d3.js & date-fns

npm install d3
npm install date-fns --save
# or
yarn add date-fns

Since the data is time-based, for the hourly heatmap, users typically have the option to select a specific date from a datepicker interface. This allows them to choose a specific date for which they want to view the hourly heatmap.

https://react-tailwindcss-datepicker.vercel.app/ I use this but we can use any datepicker of choice. I wont implement this & will assume we have received a date range.

For a heat map displaying the number of visitors to a web app, we need to show the data hour by hour. To achieve this, we first determine the selected time range provided by the user. If the range is less than 7 days, we display the data on an hourly basis. If the range is equal to or greater than 7 days, we switch to a daily view.

Assuming we have the selected start date and end date, let’s assume from June 1st to June 5th, we can format the data as an array of objects. Each object represents a specific hour and contains the corresponding count. For example, for June 1st at 00:00 hour count:10, we would have an object with the count of visitors.

This formatted data can then be used to populate the heat map, where each hour corresponds to a specific cell in the grid, and the color intensity represents the number of visitors during that hour.

export const hourlyData = [
{ day: '1st June', hour: 0, count: 0 }, //1st June at hour 00 total count 0
{ day: '2nd June', hour: 0, count: 0 },
{ day: '3rd June', hour: 0, count: 0 },
{ day: '4th June', hour: 0, count: 0 },
{ day: '5th June', hour: 0, count: 0 },
{ day: '1st June', hour: 1, count: 0 },
{ day: '2nd June', hour: 1, count: 0 },
{ day: '3rd June', hour: 1, count: 0 },
{ day: '4th June', hour: 1, count: 0 },
{ day: '5th June', hour: 1, count: 0 },
{ day: '1st June', hour: 2, count: 0 },
{ day: '2nd June', hour: 2, count: 0 },
{ day: '3rd June', hour: 2, count: 0 },
{ day: '4th June', hour: 2, count: 0 },
{ day: '5th June', hour: 2, count: 0 },
{ day: '1st June', hour: 3, count: 0 },
{ day: '2nd June', hour: 3, count: 0 },
{ day: '3rd June', hour: 3, count: 0 },
{ day: '4th June', hour: 3, count: 0 },
{ day: '5th June', hour: 3, count: 0 },
{ day: '1st June', hour: 4, count: 0 },
{ day: '2nd June', hour: 4, count: 0 },
{ day: '3rd June', hour: 4, count: 0 },
{ day: '4th June', hour: 4, count: 0 },
{ day: '5th June', hour: 4, count: 0 },
{ day: '1st June', hour: 5, count: 0 },
{ day: '2nd June', hour: 5, count: 0 },
{ day: '3rd June', hour: 5, count: 0 },
{ day: '4th June', hour: 5, count: 0 },
{ day: '5th June', hour: 5, count: 0 },
{ day: '1st June', hour: 6, count: 0 },
{ day: '2nd June', hour: 6, count: 0 },
{ day: '3rd June', hour: 6, count: 0 },
{ day: '4th June', hour: 6, count: 0 },
{ day: '5th June', hour: 6, count: 0 },
{ day: '1st June', hour: 7, count: 0 },
{ day: '2nd June', hour: 7, count: 0 },
{ day: '3rd June', hour: 7, count: 0 },
{ day: '4th June', hour: 7, count: 0 },
{ day: '5th June', hour: 7, count: 0 },
{ day: '1st June', hour: 8, count: 0 },
{ day: '2nd June', hour: 8, count: 0 },
{ day: '3rd June', hour: 8, count: 0 },
{ day: '4th June', hour: 8, count: 0 },
{ day: '5th June', hour: 8, count: 0 },
{ day: '1st June', hour: 9, count: 0 },
{ day: '2nd June', hour: 9, count: 0 },
{ day: '3rd June', hour: 9, count: 0 },
{ day: '4th June', hour: 9, count: 0 },
{ day: '5th June', hour: 9, count: 0 },
{ day: '1st June', hour: 10, count: 13 },// 1st june 10 o clock count is 13
{ day: '2nd June', hour: 10, count: 0 },
{ day: '3rd June', hour: 10, count: 0 },
{ day: '4th June', hour: 10, count: 0 },
{ day: '5th June', hour: 10, count: 0 },
{ day: '1st June', hour: 11, count: 0 },
{ day: '2nd June', hour: 11, count: 0 },
{ day: '3rd June', hour: 11, count: 0 },
{ day: '4th June', hour: 11, count: 0 },
{ day: '5th June', hour: 11, count: 0 },
{ day: '1st June', hour: 12, count: 0 },
{ day: '2nd June', hour: 12, count: 0 },
{ day: '3rd June', hour: 12, count: 0 },
{ day: '4th June', hour: 12, count: 0 },
{ day: '5th June', hour: 12, count: 14 },
{ day: '1st June', hour: 13, count: 0 },
{ day: '2nd June', hour: 13, count: 0 },
{ day: '3rd June', hour: 13, count: 0 },
{ day: '4th June', hour: 13, count: 0 },
{ day: '5th June', hour: 13, count: 0 },
{ day: '1st June', hour: 14, count: 0 },
{ day: '2nd June', hour: 14, count: 0 },
{ day: '3rd June', hour: 14, count: 0 },
{ day: '4th June', hour: 14, count: 0 },
{ day: '5th June', hour: 14, count: 0 },
{ day: '1st June', hour: 15, count: 0 },
{ day: '2nd June', hour: 15, count: 0 },
{ day: '3rd June', hour: 15, count: 0 },
{ day: '4th June', hour: 15, count: 0 },
{ day: '5th June', hour: 15, count: 0 },
{ day: '1st June', hour: 16, count: 0 },
{ day: '2nd June', hour: 16, count: 10 },
{ day: '3rd June', hour: 16, count: 0 },
{ day: '4th June', hour: 16, count: 0 },
{ day: '5th June', hour: 16, count: 0 },
{ day: '1st June', hour: 17, count: 0 },
{ day: '2nd June', hour: 17, count: 0 },
{ day: '3rd June', hour: 17, count: 0 },
{ day: '4th June', hour: 17, count: 0 },
{ day: '5th June', hour: 17, count: 0 },
{ day: '1st June', hour: 18, count: 0 },
{ day: '2nd June', hour: 18, count: 0 },
{ day: '3rd June', hour: 18, count: 0 },
{ day: '4th June', hour: 18, count: 15 },
{ day: '5th June', hour: 18, count: 0 },
{ day: '1st June', hour: 19, count: 0 },
{ day: '2nd June', hour: 19, count: 0 },
{ day: '3rd June', hour: 19, count: 0 },
{ day: '4th June', hour: 19, count: 0 },
{ day: '5th June', hour: 19, count: 0 },
{ day: '1st June', hour: 20, count: 0 },
{ day: '2nd June', hour: 20, count: 0 },
{ day: '3rd June', hour: 20, count: 0 },
{ day: '4th June', hour: 20, count: 0 },
{ day: '5th June', hour: 20, count: 0 },
{ day: '1st June', hour: 21, count: 0 },
{ day: '2nd June', hour: 21, count: 0 },
{ day: '3rd June', hour: 21, count: 0 },
{ day: '4th June', hour: 21, count: 0 },
{ day: '5th June', hour: 21, count: 0 },
{ day: '1st June', hour: 22, count: 0 },
{ day: '2nd June', hour: 22, count: 0 },
{ day: '3rd June', hour: 22, count: 0 },
{ day: '4th June', hour: 22, count: 0 },
{ day: '5th June', hour: 22, count: 0 },
{ day: '1st June', hour: 23, count: 0 },
{ day: '2nd June', hour: 23, count: 0 },
{ day: '3rd June', hour: 23, count: 5 },
{ day: '4th June', hour: 23, count: 0 },
{ day: '5th June', hour: 23, count: 0 },
];

In the above hourlyData variable the formatted data can then be used to populate the heat map, where each hour corresponds to a specific cell in the grid, and the color intensity represents the number of visitors during that hour.export const hourlyData

Now we need two thing one is a tootltip and other is the heatmap renderer component here tooltip will show number of count and day like in below image

We are going to import both of these in HourlyHeatMap.tsx

import React, { useState } from 'react';

import { HeatMapToolTip } from './common/HeatMapToolTip';

import { HourlyRenderer } from './HourlyRenderer';

type HeatmapProps = {
width: number;
height: number;
data: { day: string; hour: string; count: number }[];
};

export type InteractionData = {
day: string;
count: number;
xPos: number;
yPos: number;
};

export function HourlyHeatMap({ width, height, data }: HeatmapProps) {
const [hoveredCell, setHoveredCell] = useState<InteractionData | null>(null);

return (
<div style={{ position: 'relative' }}>
<HourlyRenderer data={data} height={height} setHoveredCell={setHoveredCell} width={width} />
<HeatMapToolTip height={height} interactionData={hoveredCell} width={width} />
</div>
);
}

Lets go over the code lines one by one

import React, { useState } from 'react';
import { HeatMapToolTip } from './common/HeatMapToolTip';
import { HourlyRenderer } from './HourlyRenderer';

The code imports necessary dependencies: React and useState from the 'react' library, the HeatMapToolTip component from './common/HeatMapToolTip', and the HourlyRenderer component from './HourlyRenderer'.

type HeatmapProps = {
width: number;
height: number;
data: { day: string; hour: string; count: number }[];
};

export type InteractionData = {
day: string;
count: number;
xPos: number;
yPos: number;
};
  • The code defines a type HeatmapProps which represents the expected props for the HourlyHeatMap component. It includes width of type number, height of type number, and data which is an array of objects with properties day, hour, and count.
  • The InteractionData type is also exported. It represents the data for the tooltip interaction, including day, count, xPos, and yPos.
export function HourlyHeatMap({ width, height, data }: HeatmapProps) {
const [hoveredCell, setHoveredCell] = useState<InteractionData | null>(null);

return (
<div style={{ position: 'relative' }}>
<HourlyRenderer data={data} height={height} setHoveredCell={setHoveredCell} width={width} />
<HeatMapToolTip height={height} interactionData={hoveredCell} width={width} />
</div>
);
}
  • This is the main component that represents the hourly heatmap visualization.
  • It receives props width, height, and data using destructuring from the HeatmapProps type.
  • Inside the component, a state variable hoveredCell is initialized using the useState hook. It represents the currently hovered cell in the heatmap and is initially set to null.
  • The component returns a div with position: 'relative' style to establish a relative positioning context for its child elements.
  • Inside the div, the HourlyRenderer component is rendered. It receives the data, height, setHoveredCell, and width props.
  • Also, the HeatMapToolTip component is rendered. It receives the height, interactionData, and width props. The interactionData prop is set to the hoveredCell state value.

HourlyRenderer Component: The HourlyRenderer component is imported . It is expected to render the actual heatmap visualization based on the provided data, height, setHoveredCell, and width props.

The HeatMapToolTip component is imported from './common/HeatMapToolTip'. It represents the tooltip that appears when hovering over cells in the heatmap. It receives the height, interactionData, and width props.

now for the Tooltip

import React from 'react';
import { type InteractionData } from '../HourlyHeatMap';

import styles from './tooltip.module.css';

type TooltipProps = {
interactionData: InteractionData | null;
width: number;
height: number;
};

export function HeatMapToolTip({ interactionData, width, height }: TooltipProps) {
if (!interactionData) {
return null;
}

return (
<div
style={{
width,
height,
position: 'absolute',
top: 0,
left: 0,
pointerEvents: 'none',
}}
>

<div
className={styles.tooltip}
style={{
position: 'absolute',
left: interactionData.xPos, // x position
top: interactionData.yPos, // y position
}}
>
<TooltipRow label="Date" value={interactionData.day} />
<TooltipRow label="User Count" value={String(interactionData.count)} />
</div>
</div>
);
}

type TooltipRowProps = {
label: string;
value: string;
};

function TooltipRow({ label, value }: TooltipRowProps) {
return (
<div>
<b>{label}</b>
<span>: </span>
<span>{value}</span>
</div>
);
}

Lets go through this one by one

import React from 'react';
import { type InteractionData } from '../HourlyHeatMap';
import styles from './tooltip.module.css';

The code imports the necessary dependencies: React, InteractionData type from the ../HourlyHeatMap module, and the CSS styles from the tooltip.module.css file.

type TooltipProps = {
interactionData: InteractionData | null;
width: number;
height: number;
};

type TooltipRowProps = {
label: string;
value: string;
};
  • The code defines a type TooltipProps which represents the expected props for the HeatMapToolTip component. It includes interactionData of type InteractionData (or null), width of type number, and height of type number.
  • The TooltipRowProps type is defined to represent the expected props for the TooltipRow component. It includes label of type string and value of type string.

HeatMapToolTip :

  • This is the main component that represents the tooltip for the heatmap visualization.
  • It receives the props interactionData, width, and height using destructuring from the TooltipProps type.
  • It first checks if interactionData is falsy (null or undefined). If it is, the component returns null, indicating that no tooltip should be rendered.
  • If interactionData exists, the tooltip will be rendered.
  • The tooltip is rendered inside a wrapper div element that covers the entire area of the visualization. It is positioned absolutely at the top-left corner (0, 0) and has a fixed width and height.
  • Inside the wrapper div, there is another div element representing the actual tooltip box. It has a CSS class styles.tooltip which is imported from the tooltip.module.css file. It is positioned absolutely based on the xPos and yPos values provided by the interactionData prop.
  • Inside the tooltip box, two instances of the TooltipRow component are rendered with different labels and values. These represent the rows of the tooltip displaying the label and corresponding value.
function TooltipRow({ label, value }: TooltipRowProps) {
return (
<div>
<b>{label}</b>
<span>: </span>
<span>{value}</span>
</div>
);
}

This is a separate function component that represents a row inside the tooltip.

some CSS for tooltip.module.css

.tooltip {
position: absolute;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 4px;
color: white;
font-size: 12px;
padding: 4px;
margin-left: 15px;
transform: translateY(-50%);
}

/* Add an arrow */
.tooltip:after {
content: '';
position: absolute;
border-width: 5px; /* Arrow width */
left: -10px; /* Arrow width * 2 */
top: 50%;
transform: translateY(-50%);
border-color: transparent black transparent transparent;
}

Now the Hourly Renderer function

import * as d3 from 'd3';
import React, { useMemo } from 'react';

import { type InteractionData } from './HourlyHeatMap';

const MARGIN = { top: 10, right: 50, bottom: 30, left: 50 };

type RendererProps = {
width: number;
height: number;
data: { hour: string; day: string; count: number }[];
setHoveredCell: (hoveredCell: InteractionData | null) => void;
};

export function HourlyRenderer({ width, height, data, setHoveredCell }: RendererProps) {
// The bounds (=area inside the axis) is calculated by substracting the margins
const boundsWidth = width - MARGIN.right - MARGIN.left;
const boundsHeight = height - MARGIN.top - MARGIN.bottom;

const allYGroups = useMemo(() => [...new Set(data.map(d => d.day))], [data]);
const allXGroups = useMemo(() => [...new Set(data.map(d => d.hour))], [data]);

const [min = 0, max = 0] = d3.extent(data.map(d => d.count)); // extent can return [undefined, undefined], default to [0,0] to fix types

const xScale = useMemo(
() => d3.scaleBand().range([0, boundsWidth]).domain(allXGroups).padding(0.05),
[data, width]
);

const yScale = useMemo(
() => d3.scaleBand().range([boundsHeight, 0]).domain(allYGroups).padding(0.05),
[data, height]
);

const colorScale = d3
.scaleSequential()
.interpolator(d3.interpolate('#f0f0f0', '#f54632'))
.domain([min, max]);

// Build the rectangles
const allShapes = data.map((d, i) => {
const x = xScale(d.hour);
const y = yScale(d.day);

if (d.count === null || !x || !y) {
return;
}

return (
<rect
key={i}
cursor="pointer"
fill={colorScale(d.count)}
height={yScale.bandwidth()}
opacity={1}
r={4}
rx={5}
stroke="white"
width={xScale.bandwidth()}
x={xScale(d.hour)}
y={yScale(d.day)}
onMouseEnter={e => {
setHoveredCell({
day: `${d.day} ${convertToAMPM(d.hour)}`,
xPos: x + xScale.bandwidth() + MARGIN.left,
yPos: y + xScale.bandwidth() / 2 + MARGIN.top,
count: Math.round(d.count * 100) / 100,
});
}}
onMouseLeave={() => setHoveredCell(null)}
/>
);
});

const xLabels = allXGroups.map((name, i) => {
const x = xScale(name);

if (!x) {
return null;
}

return (
<text
key={i}
dominantBaseline="middle"
fontSize={10}
textAnchor="middle"
x={x + xScale.bandwidth() / 2}
y={boundsHeight + 10}
>
{name}
</text>
);
});

const yLabels = allYGroups.map((name, i) => {
const y = yScale(name);

if (!y) {
return null;
}

return (
<text
key={i}
dominantBaseline="middle"
fontSize={10}
textAnchor="end"
x={-5}
y={y + yScale.bandwidth() / 2}
>
{name}
</text>
);
});

return (
<svg height={height} width={width}>
<g
height={boundsHeight}
transform={`translate(${[MARGIN.left, MARGIN.top].join(',')})`}
width={boundsWidth}
>
{allShapes}
{xLabels}
{yLabels}
</g>
</svg>
);
}

function convertToAMPM(hour) {
if (hour === 0) {
return '12 AM';
}
if (hour === 12) {
return '12 PM';
}
if (hour < 12) {
return `${hour} AM`;
}

return `${hour - 12} PM`;
}

Lets understand this

import * as d3 from 'd3';
import React, { useMemo } from 'react';
import { type InteractionData } from './HourlyHeatMap';

The code imports the d3 library for data visualization, the React library, and the InteractionData type from the ./HourlyHeatMap module.

const MARGIN = { top: 10, right: 50, bottom: 30, left: 50 };

type RendererProps = {
width: number;
height: number;
data: { hour: string; day: string; count: number }[];
setHoveredCell: (hoveredCell: InteractionData | null) => void;
};
  • The code defines a constant MARGIN object that represents the margin values for the heatmap visualization.
  • The RendererProps type represents the expected props for the HourlyRenderer component. It includes width and height of type number, data as an array of objects with properties hour, day, and count, and setHoveredCell as a function that accepts hoveredCell of type InteractionData | null and returns void.
export function HourlyRenderer({ width, height, data, setHoveredCell }: RendererProps) {
// The bounds (=area inside the axis) is calculated by subtracting the margins
const boundsWidth = width - MARGIN.right - MARGIN.left;
const boundsHeight = height - MARGIN.top - MARGIN.bottom;

// ... (more code) ...
}
  • This is the main component that renders the heatmap visualization.
  • It receives the props width, height, data, and setHoveredCell using destructuring from the RendererProps type.
  • It calculates the boundsWidth and boundsHeight by subtracting the margin values from the given width and height.

Preparing the Data

const allYGroups = useMemo(() => [...new Set(data.map(d => d.day))], [data]);
const allXGroups = useMemo(() => [...new Set(data.map(d => d.hour))], [data]);

const [min = 0, max = 0] = d3.extent(data.map(d => d.count));
  • The code uses the useMemo hook to memoize the distinct values of day and hour from the data array. These values are stored in allYGroups and allXGroups respectively.
  • The d3.extent function is used to calculate the minimum and maximum values of the count property in the data array. The resulting values are stored in [min, max], with default values of 0 if the extent is undefined.
const xScale = useMemo(
() => d3.scaleBand().range([0, boundsWidth]).domain(allXGroups).padding(0.05),
[data, width]
);

const yScale = useMemo(
() => d3.scaleBand().range([boundsHeight, 0]).domain(allYGroups).padding(0.05),
[data, height]
);

const colorScale = d3
.scaleSequential()
.interpolator(d3.interpolate('#f0f0f0', '#f54632'))
.domain([min, max]);
  • The code defines the xScale and yScale using d3.scaleBand(), which creates an ordinal scale for discrete values. The xScale uses the boundsWidth as the range and allXGroups as the domain, while the yScale uses the boundsHeight as the range and allYGroups as the domain. Both scales have a padding of 0.05.
  • The colorScale is defined using d3.scaleSequential() to create a sequential scale for the colors. It interpolates between the colors #f0f0f0 (light gray) and #f54632 (dark red) based on the count values. The domain of the scale is set to [min, max], which represents the minimum and maximum values of count.
const allShapes = data.map((d, i) => {
// ... (omitted for brevity) ...
});
  • The code maps over the data array and creates a shape (rectangle) for each data point. It uses the xScale and yScale to determine the position of each shape based on the hour and day values.
  • If the count of a data point is null or if the x or y position is not available, it returns null. Otherwise, it creates a rect element with attributes such as key, cursor, fill, height, opacity, r, rx, stroke, width, x, y, onMouseEnter, and onMouseLeave. The setHoveredCell function is called with the corresponding interaction data when hovering over a shape.
const xLabels = allXGroups.map((name, i) => {
// ... (more code) ...
});

const yLabels = allYGroups.map((name, i) => {
// ... (more code) ...
});
  • The code creates arrays of text elements for the x-axis and y-axis labels. For each distinct hour in allXGroups, it creates a text element with attributes such as key, dominantBaseline, fontSize, textAnchor, x, and y. The name is used as the label text.
  • Similarly, for each distinct day in allYGroups, it creates a text element for the y-axis labels.
return (
<svg height={height} width={width}>
<g
height={boundsHeight}
transform={`translate(${[MARGIN.left, MARGIN.top].join(',')})`}
width={boundsWidth}
>
{allShapes}
{xLabels}
{yLabels}
</g>
</svg>
);
  • The component returns an SVG element with the given height and width.
  • Inside the SVG, a g (group) element is used as the container for all the shapes, x-axis labels, and y-axis labels.
  • The g element has attributes such as height, transform (to translate the group based

also a small helper function convertToAMPM just to app AM & PM at the end of the hour.

and we have our hourly heatmap

Now, for the daily heatmap, we will focus on the heatmap renderer part. This means we will be rendering the heatmap based on daily data.

Monthly Data:

export const monthlyData = [
{ date: '2023-06-01', count: 0 },
{ date: '2023-06-02', count: 0 },
{ date: '2023-06-03', count: 0 },
{ date: '2023-06-04', count: 0 },
{ date: '2023-06-05', count: 0 },
{ date: '2023-06-06', count: 0 },
{ date: '2023-06-07', count: 0 },
{ date: '2023-06-08', count: 0 },
{ date: '2023-06-09', count: 0 },
{ date: '2023-06-10', count: 0 },
{ date: '2023-06-11', count: 0 },
{ date: '2023-06-12', count: 0 },
{ date: '2023-06-13', count: 63 },
{ date: '2023-06-14', count: 8 },
{ date: '2023-06-15', count: 13 },
{ date: '2023-06-16', count: 10 },
{ date: '2023-06-17', count: 5 },
{ date: '2023-06-18', count: 15 },
{ date: '2023-06-19', count: 14 },
{ date: '2023-06-20', count: 0 },
{ date: '2023-06-21', count: 0 },
{ date: '2023-06-22', count: 8 },
{ date: '2023-06-23', count: 0 },
{ date: '2023-06-24', count: 0 },
{ date: '2023-06-25', count: 0 },
{ date: '2023-06-26', count: 0 },
{ date: '2023-06-27', count: 0 },
{ date: '2023-06-28', count: 0 },
{ date: '2023-06-29', count: 0 },
{ date: '2023-06-30', count: 0 },
{ date: '2023-06-30', count: 0 },
];

The monthlyData array represents the count of visitors for each day in the month of June 2023. Each object in the array contains two properties: date and count.

  • The date property represents the date in the format 'YYYY-MM-DD'. It ranges from June 1, 2023, to June 30, 2023, with each entry representing a specific day in that month.
  • The count property represents the number of visitors recorded for that particular day. It indicates the count of visitors on each corresponding date.

For example, on June 13, 2023, there were 63 visitors recorded, while on June 20, 2023, there were no visitors recorded (count: 0). The array provides a chronological record of visitor counts for the month of June 2023.

import * as d3 from 'd3';
import { differenceInCalendarDays, format, getDay, startOfMonth } from 'date-fns';
import React, { useMemo } from 'react';

import { type InteractionData } from './MonthlyHeatMap';

const daysArray = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const xAxisVal = [100, 150, 200, 250, 300, 350, 400];

const MARGIN = { top: 20, right: 50, bottom: 30, left: 50 };

type RendererProps = {
width: number;
height: number;
data: { date: string; count: number }[];
setHoveredCell: (hoveredCell: InteractionData | null) => void;
};

export function MonthlyRenderer({ width, height, data, setHoveredCell }: RendererProps) {
// The bounds (=area inside the axis) is calculated by substracting the margins
const boundsWidth = width - MARGIN.right - MARGIN.left;
const boundsHeight = height - MARGIN.top - MARGIN.bottom;

const cellSize = Math.min(
Math.floor(boundsWidth / 7), // Divide the available width equally among 7 columns
Math.floor(boundsHeight / 4) // Divide the available height equally among 4 rows
);

const allYGroups = useMemo(() => [...daysArray], [data]);

const [min = 0, max = 0] = d3.extent(data.map(d => d.count)); // extent can return [undefined, undefined], default to [0,0] to fix types

const xScale = d => {
if (d?.date) {
const startDate = startOfMonth(new Date(d.date));
const currentDate = new Date(d.date);
const startDay = format(new Date(d.date), 'E'); // Get the day of the week of the starting date (0-6, where Sunday is 0)
const numDays = differenceInCalendarDays(currentDate, startDate);

const indexInDatesArray = daysArray.findIndex(
day => day.toLowerCase() === startDay.toLowerCase()
);

if (indexInDatesArray === -1) {
throw new Error('Invalid day');
} else {
const xPosition = 100 + (indexInDatesArray % 7) * 50;

return xPosition; // Adjust the padding as needed
}
}
};

const yScale = d => {
if (d?.date) {
const startDate = startOfMonth(new Date(d.date));
const currentDate = new Date(d.date);
const weekdays = format(new Date(d.date), 'E'); // Get the day of the week of the starting date (0-6, where Sunday is 0)
const dayNumber = getDay(new Date(d.date));
const numDays = differenceInCalendarDays(currentDate, startDate);
// 0 is Sunday, 6 is Saturday
const startDay = getDay(startDate); // 0 for Sunday, 1 for Monday, and so on
const rowIndex = Math.floor((startDay + numDays) / 7); // Calculate the row index (0-based)

const row = Math.floor(dayNumber / 7); // Calculate the row index based on the iteration index

return {
row: rowIndex * 50 + 0.5,
weekdays,
}; // Adjust the padding as needed
}
};

const colorScale = d3
.scaleSequential()
.interpolator(d3.interpolate('#f0f0f0', '#f5350f'))
.domain([min, max]);

const textScale = d3
.scaleSequential()
.interpolator(d3.interpolate('#f5350f', '#f0f0f0'))
.domain([min, max]);

// Build the rectangles
// eslint-disable-next-line @typescript-eslint/no-shadow, array-callback-return
const allShapes = data.map(d => {
if (d.date) {
const x = xScale(d);
const y = yScale(d);
const { row } = y;
return (
<>
<rect
key={d.date}
cursor="pointer"
fill={colorScale(d.count)}
height={cellSize - 6}
opacity={1}
r={4}
rx={5}
stroke="#ffffff" // Set the stroke color
strokeWidth={2} // Set the stroke width
width={cellSize - 5}
x={x + cellSize} // Apply the offset to the x position: ;
y={row}
onMouseEnter={e => {
e.target.setAttribute('stroke', `${rgbToHex(textScale(d.count))}`);
e.target.setAttribute('strokeWidth', 2);
setHoveredCell({
day: `${format(new Date(d.date), 'do MMM')}`,
xPos: x + cellSize * 5 + 10,
yPos: row + cellSize + MARGIN.top + 10, // Add an offset of 10 pixels below the rectangle
count: Math.round(d.count * 100) / 100,
});
}}
onMouseLeave={e => {
setHoveredCell(null);
e.target.setAttribute('stroke', '#fff');
}}
/>
<text
key={`${d.date}-text`}
dominantBaseline="middle"
style={{
fontWeight: 'bold',
fill: `${rgbToHex(textScale(d.count))}`,
shapeRendering: 'auto',
fontSize: 10, // Set the shape rendering to "auto" for smooth text
fontFamily: 'Arial, sans-serif',
}}
textAnchor="middle"
x={x + (cellSize - 2) + cellSize / 2} // Position the text at the center of the rectangle
y={row + (cellSize - 2) / 2} // Position the text at the center of the rectangle
>
{format(new Date(d.date), 'd')} {/* Display the number inside the rectangle */}
</text>
</>
);
}
});

// eslint-disable-next-line @typescript-eslint/no-shadow
const xAxisLabels = allYGroups.map((weekday, i) => (
<text
key={i}
dominantBaseline="middle"
fontSize={14}
style={{
fontWeight: 'bold',
fill: '#595758',
shapeRendering: 'auto', // Set the shape rendering to "auto" for smooth text
}}
textAnchor="middle"
x={xAxisVal[i] + cellSize + 35 / 2}
y={-14} // Adjust the y-position as needed
>
{weekday.slice(0, 3)}
{/* Display the first three letters of the weekday */}
</text>
));

return (
<svg viewBox="0 0 800 400">
<defs>
<filter height="140%" id="drop-shadow" width="140%" x="-20%" y="-20%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2" />
<feOffset dx="1" dy="1" result="offsetblur" />
<feFlood floodColor="#000000" floodOpacity="0.5" />
<feComposite in2="offsetblur" operator="in" />
<feMerge>
<feMergeNode />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>

<g
height={boundsHeight}
transform={`translate(${[MARGIN.left, MARGIN.top].join(',')})`}
width={boundsWidth}
>
{xAxisLabels}
{allShapes}
</g>
</svg>
);
}

function rgbToHex(rgbColor) {
// Extract the individual RGB values from the input string
const regex = /rgb\((\d+),\s?(\d+),\s?(\d+)\)/;
const match = rgbColor.match(regex);

if (match) {
const r = Number(match[1]);
const g = Number(match[2]);
const b = Number(match[3]);

// Convert each RGB value to its corresponding hexadecimal value
const toHex = value => {
const hex = value.toString(16);

return hex.length === 1 ? `0${hex}` : hex;
};

const red = toHex(r);
const green = toHex(g);
const blue = toHex(b);

// Combine the hexadecimal values to form the hex code
const hexCode = `#${red}${green}${blue}`;

return hexCode;
}

// Return null if the input is not in the correct format
return null;
}

It renders a monthly heatmap using D3 and React. Let’s go through the code and explain its functionality in detail:

import * as d3 from 'd3';
import { differenceInCalendarDays, format, getDay, startOfMonth } from 'date-fns';
import React, { useMemo } from 'react';

import { type InteractionData } from './MonthlyHeatMap';

These lines import the necessary libraries and modules for the code. d3 is a popular data visualization library, and date-fns provides functions for working with dates. The code also imports the InteractionData type from a file called MonthlyHeatMap.

const daysArray = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const xAxisVal = [100, 150, 200, 250, 300, 350, 400];

These two arrays define the names of the days of the week (daysArray) and the x-axis positions for the weekday labels (xAxisVal).

const MARGIN = { top: 20, right: 50, bottom: 30, left: 50 };

This object defines the margin values for the heatmap. It specifies the top, right, bottom, and left margins.

type RendererProps = {
width: number;
height: number;
data: { date: string; count: number }[];
setHoveredCell: (hoveredCell: InteractionData | null) => void;
};

This type definition defines the prop types expected by the MonthlyRenderer component. It includes width and height for the dimensions of the heatmap, data for the array of data points containing date and count properties, and setHoveredCell for a function that takes an InteractionData object or null.

export function MonthlyRenderer({ width, height, data, setHoveredCell }: RendererProps) {
// ...
}

This is the main function component MonthlyRenderer. It takes the props width, height, data, and setHoveredCell from the RendererProps type. The component returns the JSX for rendering the heatmap.

const boundsWidth = width - MARGIN.right - MARGIN.left;
const boundsHeight = height - MARGIN.top - MARGIN.bottom;

These two lines calculate the width and height of the heatmap’s inner bounds by subtracting the margins from the overall width and height props.

const cellSize = Math.min(
Math.floor(boundsWidth / 7),
Math.floor(boundsHeight / 4)
);

This line calculates the cellSize by dividing the available width and height equally among 7 columns and 4 rows, respectively, and taking the minimum value.

const allYGroups = useMemo(() => [...daysArray], [data]);

This line uses the useMemo hook to memoize the allYGroups array. It copies the daysArray to allYGroups, ensuring it only recomputes when the data prop changes.

const [min = 0, max = 0] = d3.extent(data.map(d => d.count));

This line uses D3’s extent function to calculate the minimum and maximum values of the count property in the data array.

const xScale = d => {
if (d?.date) {
// Get the start date of the month
const startDate = startOfMonth(new Date(d.date));
// Get the current date
const currentDate = new Date(d.date);
// Get the day of the week of the starting date (0-6, where Sunday is 0)
const startDay = format(new Date(d.date), 'E');
// Calculate the number of days between the current date and the start date
const numDays = differenceInCalendarDays(currentDate, startDate);

// Find the index of the start day in the daysArray (case-insensitive comparison)
const indexInDatesArray = daysArray.findIndex(
day => day.toLowerCase() === startDay.toLowerCase()
);

// If the start day is not found in the daysArray, throw an error
if (indexInDatesArray === -1) {
throw new Error('Invalid day');
} else {
// Calculate the x position based on the index of the start day
const xPosition = 100 + (indexInDatesArray % 7) * 50;

return xPosition; // Adjust the padding as needed
}
}
};
  1. The xScale function takes an object d as an argument. This object is expected to have a date property.
  2. The function checks if d.date exists (using optional chaining ?.), ensuring that it's not undefined or null.
  3. It calculates the start date of the month using startOfMonth(new Date(d.date)). This will give a Date object representing the first day of the month.
  4. It gets the current date as a Date object using new Date(d.date).
  5. It uses format(new Date(d.date), 'E') to format the start date as the day of the week (e.g., "Mon" for Monday). The 'E' parameter in format specifies the format string.
  6. It calculates the number of days between the current date and the start date using differenceInCalendarDays(currentDate, startDate). This function calculates the difference in days between two dates.
  7. It finds the index of the start day in the daysArray using findIndex. The comparison is case-insensitive by converting both strings to lowercase.
  8. If the start day is not found in the daysArray, it throws an error.
  9. Otherwise, it calculates the x position based on the index of the start day. The formula 100 + (indexInDatesArray % 7) * 50 distributes the x positions evenly across the 7 columns, with an additional padding of 100 units.
  10. The calculated x position is returned from the function.
const yScale = d => {
if (d?.date) {
// Get the start date of the month
const startDate = startOfMonth(new Date(d.date));
// Get the current date
const currentDate = new Date(d.date);
// Get the day of the week of the starting date (0-6, where Sunday is 0)
const weekdays = format(new Date(d.date), 'E');
// Get the day number of the current date (0-6, where Sunday is 0)
const dayNumber = getDay(new Date(d.date));
// Calculate the number of days between the current date and the start date
const numDays = differenceInCalendarDays(currentDate, startDate);

// Get the day of the week (0-6) for the start date (0 for Sunday, 1 for Monday, and so on)
const startDay = getDay(startDate);
// Calculate the row index (0-based) based on the start day and the number of days
const rowIndex = Math.floor((startDay + numDays) / 7);

// Calculate the row index based on the day number divided by 7
const row = Math.floor(dayNumber / 7);

return {
row: rowIndex * 50 + 0.5, // Calculate the y position for the row
weekdays,
};
}
};

Same for yScale

  1. It calculates the start date of the month using startOfMonth(new Date(d.date)). This will give a Date object representing the first day of the month.
  2. It gets the current date as a Date object using new Date(d.date).
  3. It uses format(new Date(d.date), 'E') to format the start date as the day of the week (e.g., "Mon" for Monday). The 'E' parameter in format specifies the format string.
  4. It gets the day number (0–6) of the current date using getDay(new Date(d.date)). This function returns the day of the week for a given date, where Sunday is 0 and Saturday is 6.
  5. It calculates the number of days between the current date and the start date using differenceInCalendarDays(currentDate, startDate). This function calculates the difference in days between two dates.
  6. It gets the day of the week (0–6) for the start date using getDay(startDate). This function returns the day of the week for a given date, where Sunday is 0 and Saturday is 6.
  7. It calculates the row index (0-based) based on the start day and the number of days. This is done by adding the start day and the number of days, dividing the sum by 7, and taking the floor value using Math.floor((startDay + numDays) / 7).
  8. It calculates the row index based on the day number divided by 7 using Math.floor(dayNumber / 7).
  9. The function returns an object with the row property representing the y position for the row (calculated as rowIndex * 50 + 0.5) and the weekdays property representing the formatted day of the week.
const allShapes = data.map(d => {
if (d.date) {
// Calculate the x position using the xScale function
const x = xScale(d);
// Calculate the y position and get the row index from the yScale function
const y = yScale(d);
const { row } = y;

return (
<>
{/* Generate a rectangle */}
<rect
key={d.date}
cursor="pointer"
fill={colorScale(d.count)}
height={cellSize - 6}
opacity={1}
r={4}
rx={5}
stroke="#ffffff" // Set the stroke color
strokeWidth={2} // Set the stroke width
width={cellSize - 5}
x={x + cellSize} // Apply the offset to the x position
y={row}
onMouseEnter={e => {
// Set the stroke color and width when mouse enters the rectangle
e.target.setAttribute('stroke', `${rgbToHex(textScale(d.count))}`);
e.target.setAttribute('strokeWidth', 2);
// Set the hovered cell information
setHoveredCell({
day: `${format(new Date(d.date), 'do MMM')}`,
xPos: x + cellSize * 5 + 10,
yPos: row + cellSize + MARGIN.top + 10, // Add an offset of 10 pixels below the rectangle
count: Math.round(d.count * 100) / 100,
});
}}
onMouseLeave={e => {
// Reset the stroke color and width when mouse leaves the rectangle
setHoveredCell(null);
e.target.setAttribute('stroke', '#fff');
}}
/>
{/* Generate a text element */}
<text
key={`${d.date}-text`}
dominantBaseline="middle"
style={{
fontWeight: 'bold',
fill: `${rgbToHex(textScale(d.count))}`,
shapeRendering: 'auto',
fontSize: 10, // Set the shape rendering to "auto" for smooth text
fontFamily: 'Arial, sans-serif',
}}
textAnchor="middle"
x={x + (cellSize - 2) + cellSize / 2} // Position the text at the center of the rectangle
y={row + (cellSize - 2) / 2} // Position the text at the center of the rectangle
>
{format(new Date(d.date), 'd')} {/* Display the number inside the rectangle */}
</text>
</>
);
}
});
  • The code processes a data array and creates rectangles and text elements based on the data.
  • The position of each shape is determined using x and y scales.
  • The attributes of the shapes, such as fill color, stroke color, and text content, are determined based on the data properties.
  • Mouse events are handled to update a state variable when a shape is hovered over.
  • The code uses React syntax to render the shapes and apply the necessary styles and event handlers.
  const [min = 0, max = 0] = d3.extent(data.map(d => d.count));  
const colorScale = d3
.scaleSequential()
.interpolator(d3.interpolate('#f0f0f0', '#f5350f'))
.domain([min, max]);

const textScale = d3
.scaleSequential()
.interpolator(d3.interpolate('#f5350f', '#f0f0f0'))
.domain([min, max]);
  • colorScale is a sequential scale that maps a numeric domain to a continuous range of colors. It uses the interpolator function d3.interpolate('#f0f0f0', '#f5350f') to create a color gradient from a light gray (#f0f0f0) to a dark red (#f5350f). The domain is defined by [min, max], which represents the minimum and maximum values of the data. The scale will map values within this domain to corresponding colors in the range.
  • textScale is another sequential scale similar to colorScale. However, it uses a reversed color gradient from dark red (#f5350f) to light gray (#f0f0f0). This scale is typically used for setting the color of text elements based on the data values.

This helper function takes an RGB color string as input and converts it to a hexadecimal color code.

function rgbToHex(rgbColor) {
// Extract the individual RGB values from the input string
const regex = /rgb\((\d+),\s?(\d+),\s?(\d+)\)/;
const match = rgbColor.match(regex);

if (match) {
const r = Number(match[1]);
const g = Number(match[2]);
const b = Number(match[3]);

// Convert each RGB value to its corresponding hexadecimal value
const toHex = value => {
const hex = value.toString(16);

return hex.length === 1 ? `0${hex}` : hex;
};

const red = toHex(r);
const green = toHex(g);
const blue = toHex(b);

// Combine the hexadecimal values to form the hex code
const hexCode = `#${red}${green}${blue}`;

return hexCode;
}

// Return null if the input is not in the correct format
return null;
}

Finally we generate an SVG visualization with a drop shadow effect, x-axis labels, and a collection of shapes based on the provided allShapes data.

<svg viewBox="0 0 800 400">
<defs>
<filter height="140%" id="drop-shadow" width="140%" x="-20%" y="-20%">
<!-- Apply a drop shadow effect using various filter elements -->
<feGaussianBlur in="SourceAlpha" stdDeviation="2" />
<feOffset dx="1" dy="1" result="offsetblur" />
<feFlood floodColor="#000000" floodOpacity="0.5" />
<feComposite in2="offsetblur" operator="in" />
<feMerge>
<feMergeNode />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>

<g
height={boundsHeight}
transform={`translate(${[MARGIN.left, MARGIN.top].join(',')})`}
width={boundsWidth}
>
{/* Render the x-axis labels */}
{xAxisLabels}

{/* Render all the shapes (rectangles and text) */}
{allShapes}
</g>
</svg>

the result

June Month HeatMap

This flexibility allows us to generate monthlyData arrays for different months by adjusting the start and end dates accordingly, providing a versatile way to analyze visitor patterns over specific time periods.

full code can be found here
https://github.com/aroirocks/d3heatmap

--

--

Amit rai

Passionate developer with a strong focus on Node.js, TypeScript, React, and Next.js. I have a deep love for coding and strive to create efficient application