Activity Calendar has become popular after open-source platforms like Github, Kaggle starting used to show contributions data in this format. As a developer, people often tries to analyse what are the days I am productive the most. The measure might be a quantitative and not qualitative, but it gives a clear picture overall. We gonna build an Activity Calendar Component today in React from Scratch.
Activity Calendar or Graph is a heatmap graph distribution over months and days of week.
Getting Started
Starting from the basic cra-template of creact-react app
ActivityCalendar.jsx
import React from 'react';
function ActivityCalendar({ children, style, ...otherProps }) {
return (
<article className="ActivityCalendar" style={style} {...otherProps}>
<svg></svg>
{children}
</article>
);
}
export default ActivityCalendar;
This functional component has children and style props where children helps to populate any child component and style to populate with inline CSS style.
Implementation
Formatting Data
We need to have some utilities function to format data or generate an empty data. Few libraries that we need to install here are a colour utility library tinycolor2
and a date manipulation library date-fns
.
npm install tinycolor2 date-fns
NormalizeCalendarDays
util.js
import eachDayOfInterval from 'date-fns/eachDayOfInterval';
import formatISO from 'date-fns/formatISO';
import parseISO from 'date-fns/parseISO';
function normalizeCalendarDays(days) {
const daysMap = days.reduce((map, day) => {
map.set(day.date, day);
return map;
}, new Map());
return eachDayOfInterval({
start: parseISO(days[0].date),
end: parseISO(days[days.length - 1].date),
}).map((day) => {
const date = formatISO(day, { representation: 'date' });
if (daysMap.has(date)) {
return daysMap.get(date);
}
return {
date,
count: 0,
level: 0,
};
});
}
The function normalizeCalendarDays takes in list of days and creates a daysMap for each date. The daysMap is used to get all the days from starting date to ending date, and if a date is missing in the array days, a default count and level of 0 is added to the resulting array against that date.
Group by Weeks
utils.js
...
import parseISO from 'date-fns/parseISO';
import differenceInCalendarDays from 'date-fns/differenceInCalendarDays';
import getDay from 'date-fns/getDay';
import subWeeks from 'date-fns/subWeeks';
import nextDay from 'date-fns/nextDay';
...
export function groupByWeeks(days, weekStart) {
if (days.length === 0) return [];
// The calendar expects a continuous sequence of days, so fill gaps with empty activity.
const normalizedDays = normalizeCalendarDays(days);
// Determine the first date of the calendar. If the first contribution date is not
// specified week day the desired day one week earlier will be selected.
const firstDate = parseISO(normalizedDays[0].date);
const firstCalendarDate = getDay(firstDate) === weekStart ? firstDate : subWeeks(nextDay(firstDate, weekStart), 1);
// In order to correctly group contributions by week it is necessary to left pad the list,
// because the first date might not be desired week day.
const paddedDays = [
...Array(differenceInCalendarDays(firstDate, firstCalendarDate)).fill(undefined),
...normalizedDays,
];
return Array(Math.ceil(paddedDays.length / 7))
.fill(undefined)
.map((_, calendarWeek) => paddedDays.slice(calendarWeek * 7, calendarWeek * 7 + 7));
}
The function groupByWeeks takes in list of days (days) and weekStart as input parameters where it groups the given list of days into weeks starting with weekStart.
Get Labels for Months
utils.js
...
import nextDay from 'date-fns/nextDay';
import getMonth from 'date-fns/getMonth';
...
export const MIN_DISTANCE_MONTH_LABELS = 2;
export const DEFAULT_MONTH_LABELS = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
...
export function getMonthLabels(weeks, monthNames = DEFAULT_MONTH_LABELS) {
return weeks
.reduce((labels, week, index) => {
const firstWeekDay = week.find((day) => day !== undefined);
if (!firstWeekDay) {
throw new Error(`Unexpected error: Week is empty: [${week}]`);
}
const month = monthNames[getMonth(parseISO(firstWeekDay.date))];
const prev = labels[labels.length - 1];
if (index === 0 || prev.text !== month) {
return [
...labels,
{
x: index,
y: 0,
text: month,
},
];
}
return labels;
}, [])
.filter((label, index, labels) => {
if (index === 0) {
return labels[1] && labels[1].x - label.x > MIN_DISTANCE_MONTH_LABELS;
}
return true;
});
}
The function getMonthLabels takes in list of weeks and monthNames as input parameters. For each week, find the first day and get the monthName for the first day. If the last month in the label is not same or it's first value then x and y is set to 0 else return labels. Filter out the labels for each starting index with greater than minimum distance between the labels or other indexes.
Creating a Calendar Theme
utils.js
...
import getMonth from 'date-fns/getMonth';
import color from 'tinycolor2';
const DEFAULT_THEME = createCalendarTheme('#042a33');
...
export function createCalendarTheme(baseColor, emptyColor = color('white').darken(8).toHslString()) {
const base = color(baseColor);
if (!base.isValid()) {
return DEFAULT_THEME;
}
return {
level4: base.setAlpha(0.44).toHslString(),
level3: base.setAlpha(0.6).toHslString(),
level2: base.setAlpha(0.76).toHslString(),
level1: base.setAlpha(0.92).toHslString(),
level0: emptyColor,
};
}
The function createCalendarTheme takes baseColor and emptyColor as input parameters and returns a theme with 5 different colors for each level.
Helper Functions
utils.js
...
export const NAMESPACE = 'ActivityCalendar';
export const DEFAULT_WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export const DEFAULT_LABELS = {
months: DEFAULT_MONTH_LABELS,
weekdays: DEFAULT_WEEKDAY_LABELS,
totalCount: '{{count}} contributions in {{year}}',
legend: {
less: 'Less',
more: 'More',
},
};
...
export function getTheme(theme, color) {
if (theme) {
return Object.assign({}, DEFAULT_THEME, theme);
}
if (color) {
return createCalendarTheme(color);
}
return DEFAULT_THEME;
}
export function getClassName(name, styles) {
if (styles) {
return `${NAMESPACE}__${name} ${styles}`;
}
return `${NAMESPACE}__${name}`;
}
export function generateEmptyData() {
const year = new Date().getFullYear();
const days = eachDayOfInterval({
start: new Date(year, 0, 1),
end: new Date(year, 11, 31),
});
return days.map((date) => ({
date: formatISO(date, { representation: 'date' }),
count: 0,
level: 0,
}));
}
Calculating Size
ActivityCalendar.jsx
import React from 'react';
import { groupByWeeks } from './utils';
function ActivityCalendar({
blockMargin,
children,
color,
data,
fontSize,
hideMonthLabels,
labels: labelsProp,
style,
theme: themeProp,
weekStart,
...otherProps
}) {
if (data.length === 0) return null;
const weeks = groupByWeeks(data, weekStart);
const textHeight = hideMonthLabels ? 0 : fontSize + 2 * blockMargin;
function getDimensions() {
return {
width: weeks.length * (blockSize + blockMargin) - blockMargin,
height: textHeight + (blockSize + blockMargin) * 7 - blockMargin,
};
}
...
}
ActivityCalendar.defaultProps = {
blockMargin: 4,
blockSize: 12,
color: undefined,
fontSize: 14,
hideMonthLabels: false,
style: {},
weekStart: 0,
};
...
Using blockSize, blockMargin, length of weeks array and textHeight, calculate width and height of the Activity Calendar.
Adding Styles Module
styles.module.css
.calendar {
display: block;
max-width: 100%;
height: auto;
overflow: visible;
}
.calendar text {
fill: currentColor;
}
.block {
stroke: rgba(0, 0, 0, 0.1);
stroke-width: 1px;
shape-rendering: geometricPrecision;
}
.footer {
display: flex;
}
.legendColors {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.2em;
}
@keyframes loadingAnimation {
0% {
fill: var(--ActivityCalendar-loading);
}
50% {
fill: var(--ActivityCalendar-loading-active);
}
100% {
fill: var(--ActivityCalendar-loading);
}
}
Using CSS module, ActivityCalendar uses loadingAnimation and other necessary styles.
Rendering Blocks
ActivityCalendar.jsx
...
import { getClassName, getTheme, groupByWeeks, NAMESPACE } from './utils';
import styles from './styles.module.css';
import tinycolor from 'tinycolor2';
function ActivityCalendar({
blockMargin,
blockRadius,
blockSize,
children,
color,
data,
dateFormat,
fontSize,
hideMonthLabels,
loading,
labels: labelsProp,
style,
theme: themeProp,
weekStart,
...otherProps
}) {
if (loading) data = generateEmptyData();
...
const theme = getTheme(themeProp, color);
...
function renderBlocks() {
return weeks
.map((week, weekIndex) =>
week.map((day, dayIndex) => {
if (!day) {
return null;
}
const style = loading
? {
animation: `${styles.loadingAnimation} 1.5s ease-in-out infinite`,
animationDelay: `${weekIndex * 20 + dayIndex * 20}ms`,
}
: undefined;
return (
<rect
// {...getEventHandlers(day)}
x={0}
y={textHeight + (blockSize + blockMargin) * dayIndex}
width={blockSize}
height={blockSize}
fill={theme[`level${day.level}`]}
rx={blockRadius}
ry={blockRadius}
className={styles.block}
data-date={day.date}
key={day.date}
style={style}
/>
);
})
)
.map((week, x) => (
<g key={x} transform={`translate(${(blockSize + blockMargin) * x}, 0)`}>
{week}
</g>
));
}
const { width, height } = getDimensions();
const additionalStyles = {
maxWidth: width,
// Required for correct colors in CSS loading animation
[`--${NAMESPACE}-loading`]: theme.level0,
[`--${NAMESPACE}-loading-active`]: tinycolor(theme.level0).darken(8).toString(),
};
return (
<article className="ActivityCalendar" style={{ ...style, ...additionalStyles }} {...otherProps}>
<svg
className={getClassName('calendar', styles.calendar)}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
>
{renderBlocks()}
</svg>
{children}
</article>
);
}
...
The function renderBlocks renders coloured blocks for each day chunked in weeks and coloured according to the theme.
Rendering Labels
ActivityCalendar.jsx
...
import getDay from 'date-fns/getDay';
import { DEFAULT_LABELS, DEFAULT_WEEKDAY_LABELS, generateEmptyData, getClassName, getMonthLabels, getTheme, groupByWeeks, MIN_DISTANCE_MONTH_LABELS, NAMESPACE } from './utils';
...
function ActivityCalendar({
blockMargin,
blockRadius,
blockSize,
children,
color,
data,
dateFormat,
fontSize,
hideMonthLabels,
loading,
labels: labelsProp,
style,
showWeekdayLabels,
theme: themeProp,
weekStart,
...otherProps
}) {
if (loading) data = generateEmptyData();
if (data.length === 0) return null;
const weeks = groupByWeeks(data, weekStart);
const textHeight = hideMonthLabels ? 0 : fontSize + 2 * blockMargin;
const theme = getTheme(themeProp, color);
const labels = Object.assign({}, DEFAULT_LABELS, labelsProp);
...
function renderLabels() {
const style = {
fontSize,
};
if (!showWeekdayLabels && hideMonthLabels) {
return null;
}
return (
<>
{showWeekdayLabels && (
<g className={getClassName('legend-weekday')} style={style}>
{weeks[1].map((day, y) => {
if (!day || y % 2 === 0) {
return null;
}
const dayIndex = getDay(parseISO(day.date));
return (
<text
x={-2 * blockMargin}
y={textHeight + (fontSize / 2 + blockMargin) + (blockSize + blockMargin) * y}
textAnchor="end"
key={day.date}
>
{labels.weekdays ? labels.weekdays[dayIndex] : DEFAULT_WEEKDAY_LABELS[dayIndex]}
</text>
);
})}
</g>
)}
{!hideMonthLabels && (
<g className={getClassName('legend-month')} style={style}>
{getMonthLabels(weeks, labels.months).map(({ text, x }, index, labels) => {
// Skip the first month label if there's not enough space to the next one
if (index === 0 && labels[1] && labels[1].x - x <= MIN_DISTANCE_MONTH_LABELS) {
return null;
}
return (
<text x={(blockSize + blockMargin) * x} alignmentBaseline="hanging" key={x}>
{text}
</text>
);
})}
</g>
)}
</>
);
}
...
return (
<article className="ActivityCalendar" style={{ ...style, ...additionalStyles }} {...otherProps}>
<svg
className={getClassName('calendar', styles.calendar)}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
>
{!loading && renderLabels()}
{renderBlocks()}
</svg>
{children}
</article>
);
}
...
The function renderLabels renders labels for weeks and days based on hideMonthLabels(props) and showWeekdayLabels(props).
Render Footer
ActivityCalendar.jsx
...
import {
DEFAULT_LABELS,
DEFAULT_WEEKDAY_LABELS,
generateEmptyData,
getClassName,
getMonthLabels,
getTheme,
groupByWeeks,
MIN_DISTANCE_MONTH_LABELS,
NAMESPACE,
} from './utils';
...
function ActivityCalendar({
blockMargin,
blockRadius,
blockSize,
children,
color,
data,
dateFormat,
fontSize,
hideColorLegend,
hideMonthLabels,
hideTotalCount,
loading,
labels: labelsProp,
style,
showWeekdayLabels,
theme: themeProp,
weekStart,
...otherProps
}) {
...
const labels = Object.assign({}, DEFAULT_LABELS, labelsProp);
const totalCount = data.reduce((sum, day) => sum + day.count, 0);
const year = getYear(parseISO(data[0].date));
....
function renderFooter() {
if (hideTotalCount && hideColorLegend) {
return null;
}
return (
<footer className={getClassName('footer', styles.footer)} style={{ marginTop: 2 * blockMargin, fontSize }}>
{/* Placeholder */}
{loading && <div> </div>}
{!loading && !hideTotalCount && (
<div className={getClassName('count')}>
{labels.totalCount
? labels.totalCount
.replace('{{count}}', String(totalCount))
.replace('{{year}}', String(year))
: `${totalCount} contributions in ${year}`}
</div>
)}
{!loading && !hideColorLegend && (
<div className={getClassName('legend-colors', styles.legendColors)}>
<span style={{ marginRight: '0.4em' }}>{labels.legend.less ?? 'Less'}</span>
{Array(5)
.fill(undefined)
.map((_, index) => (
<svg width={blockSize} height={blockSize} key={index}>
<rect
width={blockSize}
height={blockSize}
fill={theme[`level${index}`]}
rx={blockRadius}
ry={blockRadius}
/>
</svg>
))}
<span style={{ marginLeft: '0.4em' }}>{labels.legend.more ?? 'More'}</span>
</div>
)}
</footer>
);
}
...
return (
<article className="ActivityCalendar" style={{ ...style, ...additionalStyles }} {...otherProps}>
<svg
className={getClassName('calendar', styles.calendar)}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
>
{!loading && renderLabels()}
{renderBlocks()}
</svg>
{renderFooter()}
{children}
</article>
);
}
...
The function renderFooter renders the footer with total no of contributions and showsLegend for colours used in the heatmap graph.
Final Code
ActivityCalendar.jsx
https://github.com/shivishbrahma/nuclear-reactor/blob/main/src/ActivityCalendar/ActivityCalendar.jsx
utils.js
https://github.com/shivishbrahma/nuclear-reactor/blob/main/src/ActivityCalendar/utils.js
styles.module.css
https://github.com/shivishbrahma/nuclear-reactor/blob/main/src/ActivityCalendar/styles.module.css
Use within App component
import ActivityCalendar from './ActivityCalendar';
import './App.css';
const DEFAULT_THEME = {
level4: '#216e39',
level3: '#30a14e',
level2: '#40c463',
level1: '#9be9a8',
level0: '#ebedf0',
},
calendarData = [
{
count: 0,
date: '2022-01-01',
level: 0,
},
{
count: 0,
date: '2022-01-02',
level: 0,
}
...
];
function App() {
return (
<div className="App">
<ActivityCalendar data={calendarData} theme={DEFAULT_THEME}/>
</div>
);
}
export default App;
Top comments (0)