How to display a calendar heat map monthly & weekly even for a specified time period for a day with d3.js (Next.js)
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 theHourlyHeatMap
component. It includeswidth
of typenumber
,height
of typenumber
, anddata
which is an array of objects with propertiesday
,hour
, andcount
. - The
InteractionData
type is also exported. It represents the data for the tooltip interaction, includingday
,count
,xPos
, andyPos
.
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
, anddata
using destructuring from theHeatmapProps
type. - Inside the component, a state variable
hoveredCell
is initialized using theuseState
hook. It represents the currently hovered cell in the heatmap and is initially set tonull
. - 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 thedata
,height
,setHoveredCell
, andwidth
props. - Also, the
HeatMapToolTip
component is rendered. It receives theheight
,interactionData
, andwidth
props. TheinteractionData
prop is set to thehoveredCell
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 theHeatMapToolTip
component. It includesinteractionData
of typeInteractionData
(ornull
),width
of typenumber
, andheight
of typenumber
. - The
TooltipRowProps
type is defined to represent the expected props for theTooltipRow
component. It includeslabel
of typestring
andvalue
of typestring
.
HeatMapToolTip :
- This is the main component that represents the tooltip for the heatmap visualization.
- It receives the props
interactionData
,width
, andheight
using destructuring from theTooltipProps
type. - It first checks if
interactionData
is falsy (null or undefined). If it is, the component returnsnull
, 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 anotherdiv
element representing the actual tooltip box. It has a CSS classstyles.tooltip
which is imported from thetooltip.module.css
file. It is positioned absolutely based on thexPos
andyPos
values provided by theinteractionData
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 theHourlyRenderer
component. It includeswidth
andheight
of typenumber
,data
as an array of objects with propertieshour
,day
, andcount
, andsetHoveredCell
as a function that acceptshoveredCell
of typeInteractionData | null
and returnsvoid
.
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
, andsetHoveredCell
using destructuring from theRendererProps
type. - It calculates the
boundsWidth
andboundsHeight
by subtracting the margin values from the givenwidth
andheight
.
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 ofday
andhour
from thedata
array. These values are stored inallYGroups
andallXGroups
respectively. - The
d3.extent
function is used to calculate the minimum and maximum values of thecount
property in thedata
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
andyScale
usingd3.scaleBand()
, which creates an ordinal scale for discrete values. ThexScale
uses theboundsWidth
as the range andallXGroups
as the domain, while theyScale
uses theboundsHeight
as the range andallYGroups
as the domain. Both scales have a padding of 0.05. - The
colorScale
is defined usingd3.scaleSequential()
to create a sequential scale for the colors. It interpolates between the colors#f0f0f0
(light gray) and#f54632
(dark red) based on thecount
values. The domain of the scale is set to[min, max]
, which represents the minimum and maximum values ofcount
.
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 thexScale
andyScale
to determine the position of each shape based on thehour
andday
values. - If the
count
of a data point is null or if the x or y position is not available, it returnsnull
. Otherwise, it creates arect
element with attributes such askey
,cursor
,fill
,height
,opacity
,r
,rx
,stroke
,width
,x
,y
,onMouseEnter
, andonMouseLeave
. ThesetHoveredCell
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 distincthour
inallXGroups
, it creates atext
element with attributes such askey
,dominantBaseline
,fontSize
,textAnchor
,x
, andy
. Thename
is used as the label text. - Similarly, for each distinct
day
inallYGroups
, it creates atext
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
andwidth
. - 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 asheight
,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
}
}
};
- The
xScale
function takes an objectd
as an argument. This object is expected to have adate
property. - The function checks if
d.date
exists (using optional chaining?.
), ensuring that it's notundefined
ornull
. - It calculates the start date of the month using
startOfMonth(new Date(d.date))
. This will give aDate
object representing the first day of the month. - It gets the current date as a
Date
object usingnew Date(d.date)
. - 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 informat
specifies the format string. - 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. - It finds the index of the start day in the
daysArray
usingfindIndex
. The comparison is case-insensitive by converting both strings to lowercase. - If the start day is not found in the
daysArray
, it throws an error. - 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. - 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
- It calculates the start date of the month using
startOfMonth(new Date(d.date))
. This will give aDate
object representing the first day of the month. - It gets the current date as a
Date
object usingnew Date(d.date)
. - 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 informat
specifies the format string. - 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. - 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. - 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. - 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)
. - It calculates the row index based on the day number divided by 7 using
Math.floor(dayNumber / 7)
. - The function returns an object with the
row
property representing the y position for the row (calculated asrowIndex * 50 + 0.5
) and theweekdays
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 theinterpolator
functiond3.interpolate('#f0f0f0', '#f5350f')
to create a color gradient from a light gray (#f0f0f0
) to a dark red (#f5350f
). Thedomain
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 tocolorScale
. 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
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