View live effect: 👉Homepage / 👉Statistics Page
TL;DR#
Too long; didn't read, just look at the code 👇
Introduction#
About five or six years ago, I experimented with heatmaps on Typecho, and it was quite convenient to use jQuery back then. However, there were no suitable places to put the heatmap in some blog themes, so I gave up. Recently, heatmaps for blogs have gained popularity again, and my theme can accommodate it on the homepage, so I started tinkering with it again. During this time, I tried several versions, and there are many similar libraries available online:
- ECharts.js
- Heat.js (abandoned before launch)
- contributions-calendar
- d3.js + Cal-Heatmap.js
Pros and cons:
- ECharts.js is inconvenient for controlling details and adapting to mobile devices; the resource files are relatively large.
- Heat.js led to the discovery of Cal-Heatmap.js during testing.
- Cal-Heatmap.js is specifically designed for heatmaps but requires referencing multiple libraries and plugins.
Since Koobai published "HUGO Tinkering Notes on Heatmap / Paragraph Navigation", I said I would tinker with a pure CSS version of the heatmap, but it was delayed until today. During the process of tinkering with Twitter Year Progress, I completed the drawing of the annual calendar squares and directly used it.
1. JS to Build the Heatmap#
1. Prepare Blog Data#
During Hugo's build, retrieve the article data from the past year:
// Retrieve article data from the past year
{{ $pages := where .Site.RegularPages "Date" ">" (now.AddDate -1 0 0) }}
{{ $pages := $pages.Reverse }}
var blogInfo = {
"pages": [
{{ range $index, $element := $pages }}
{
"title": "{{ replace (replace .Title "《" "〈") "》" "〉" }}",
"date": "{{ .Date.Format "2006-01-02" }}",
"year": "{{ .Date.Format "2006" }}",
"month": "{{ .Date.Format "01" }}",
"day": "{{ .Date.Format "02" }}",
"word_count": "{{ .WordCount }}"
}{{ if ne (add $index 1) (len $pages) }},{{ end }}
{{ end }}
]
};
// console.log(blogInfo)
This JS will retrieve the following example data and store it in blogInfo
. If you need slug
, summary
, or other data, follow the above code as a template:
{
"pages": [
{
"title": "Implementing Blog Heatmap with CSS and JS",
"date": "2024-04-30",
"year": "2024",
"month": "04",
"day": "30",
"word_count": "685"
}
]
}
2. Render Months#
The number of months displayed in let monthNames = ['Jan', 'Feb', 'Mar']
can be customized. It adapts to mobile devices, displaying 6 months of data on regular mobile devices, while smaller devices like the iPhone SE / Pixel 4 only show 5 months of data.
let currentDate = new Date();
currentDate.setFullYear(currentDate.getFullYear() - 1);
let startDate;
let monthDiv = document.querySelector('.month');
let monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
if (window.innerWidth <= 375 ) { // iPhone SE
numMonths = 5;
} else if (window.innerWidth < 768 ) { // iPad Mini
numMonths = 6;
} else {
numMonths = 12;
}
let startMonthIndex = (currentDate.getMonth() - (numMonths - 1) + 12) % 12;
for (let i = startMonthIndex; i < startMonthIndex + numMonths; i++) {
let monthSpan = document.createElement('span');
let monthIndex = i % 12;
monthSpan.textContent = monthNames[monthIndex];
monthDiv.appendChild(monthSpan);
}
The dynamically generated months are displayed in <div class="month">
, so whether using TailwindCSS or traditional CSS, the month
class cannot be removed.
<div class="month heatmap_month"> <!-- 👈 Must have [month] -->
<span>Nov</span>
<span>Dec</span>
<span>Jan</span>
<span>Feb</span>
<span>Mar</span>
<span>Apr</span>
</div>
3. startDate: Starting Date Renders from Monday#
If you simply render 52 weeks (one year) of small squares from today, it's quite simple. However, there is a common-sense issue with this approach: one year ago today is not necessarily a Monday
. Therefore, when choosing the start date for the heatmap, you need to consider the Monday
of the week where today last year
falls.
function getWeekDay(date) {
const day = date.getDay();
return day === 0 ? 6 : day - 1;
}
4. endDate: If the End Date Today
Exceeds Calendar Range#
Combining point 3, if today's
weekday number is smaller than last year's today
's weekday number, it will lead to rendering 52 weeks (one year) of small squares, and today
and the rest of this week
's content will not render. Therefore, you need to check today's weekday number and append it to the annual calendar squares.
const startDate = getStartDate();
const endDate = new Date();
const weekDay = getWeekDay(startDate);
let currentWeek = createWeek();
container.appendChild(currentWeek);
let currentDate = startDate;
let i = 0;
while (currentDate <= endDate) {
if (i % 7 === 0 && i !== 0) {
currentWeek = createWeek();
container.appendChild(currentWeek);
}
i++;
currentDate.setDate(currentDate.getDate() + 1);
}
5. Render Small Squares and Tooltip#
Each small square displays a different color depth based on the count
of words, which corresponds to the CSS heatmap_day_level_num
style. The count
is segmented into 4 levels: 1-1000
, 1000-2000
, 2000-3000
, 3000+
.
My blog also renders count
, post
, title
, and date
data for the Tooltip.
count
data-count is the word count of articles on that day; multiple articles will be combined.post
data-post is the number of articles on that day.title
data-title is the title of the article on that day.date
data-date is the date of that day inJan 2, 2006
en-US format.
When the mouse hovers over the small square, a <div class="tooltip">
tag is created using the values of data-title=""
, data-count=""
, data-post=""
, and data-date=""
.
function createDay(date, title, count, post) {
const day = document.createElement("div");
day.className = "heatmap_day";
day.setAttribute("data-title", title);
day.setAttribute("data-count", count);
day.setAttribute("data-post", post);
day.setAttribute("data-date", date);
day.addEventListener("mouseenter", function () {
const tooltip = document.createElement("div");
tooltip.className = "heatmap_tooltip";
let tooltipContent = "";
if (post && parseInt(post, 10) !== 0) {
tooltipContent += '<span class="heatmap_tooltip_post">' + 'Total ' + post + ' articles' + '</span>';
}
if (count && parseInt(count, 10) !== 0) {
tooltipContent += '<span class="heatmap_tooltip_count">' + ' ' + count + ' words;' + '</span>';
}
if (title && parseInt(title, 10) !== 0) {
tooltipContent += '<span class="heatmap_tooltip_title">' + title + '</span>';
}
if (date) {
tooltipContent += '<span class="heatmap_tooltip_date">' + date + '</span>';
}
tooltip.innerHTML = tooltipContent;
day.appendChild(tooltip);
});
day.addEventListener("mouseleave", function () {
const tooltip = day.querySelector(".heatmap_tooltip");
if (tooltip) {
day.removeChild(tooltip);
}
});
if (count == 0) {
day.classList.add("heatmap_day_level_0");
} else if (count > 0 && count < 1000) {
day.classList.add("heatmap_day_level_1");
} else if (count >= 1000 && count < 2000) {
day.classList.add("heatmap_day_level_2");
} else if (count >= 2000 && count < 3000) {
day.classList.add("heatmap_day_level_3");
} else {
day.classList.add("heatmap_day_level_4");
}
return day;
}
2. Complete heatmap.js {#heatmapjs}#
The previous breakdown includes some details that need attention; below is the complete JS:
// Retrieve article data from the past year
{{ $pages := where .Site.RegularPages "Date" ">" (now.AddDate -1 0 0) }}
{{ $pages := $pages.Reverse }}
var blogInfo = {
"pages": [
{{ range $index, $element := $pages }}
{
"title": "{{ replace (replace .Title "《" "〈") "》" "〉" }}",
"date": "{{ .Date.Format "2006-01-02" }}",
"year": "{{ .Date.Format "2006" }}",
"month": "{{ .Date.Format "01" }}",
"day": "{{ .Date.Format "02" }}",
"word_count": "{{ .WordCount }}"
}{{ if ne (add $index 1) (len $pages) }},{{ end }}
{{ end }}
]
};
// console.log(blogInfo)
let currentDate = new Date();
currentDate.setFullYear(currentDate.getFullYear() - 1);
let startDate;
let monthDiv = document.querySelector('.month');
let monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
if (window.innerWidth < 768) {
numMonths = 6;
} else {
numMonths = 12;
}
let startMonthIndex = (currentDate.getMonth() - (numMonths - 1) + 12) % 12;
for (let i = startMonthIndex; i < startMonthIndex + numMonths; i++) {
let monthSpan = document.createElement('span');
let monthIndex = i % 12;
monthSpan.textContent = monthNames[monthIndex];
monthDiv.appendChild(monthSpan);
}
function getStartDate() {
const today = new Date();
if (window.innerWidth < 768) {
numMonths = 6;
} else {
numMonths = 12;
}
const startDate = new Date(today.getFullYear(), today.getMonth() - numMonths + 1, 1, today.getHours(), today.getMinutes(), today.getSeconds());
while (startDate.getDay() !== 1) {
startDate.setDate(startDate.getDate() + 1);
}
return startDate;
}
function getWeekDay(date) {
const day = date.getDay();
return day === 0 ? 6 : day - 1;
}
function createDay(date, title, count, post) {
const day = document.createElement("div");
day.className = "heatmap_day";
day.setAttribute("data-title", title);
day.setAttribute("data-count", count);
day.setAttribute("data-post", post);
day.setAttribute("data-date", date);
day.addEventListener("mouseenter", function () {
const tooltip = document.createElement("div");
tooltip.className = "heatmap_tooltip";
let tooltipContent = "";
if (post && parseInt(post, 10) !== 0) {
tooltipContent += '<span class="heatmap_tooltip_post">' + 'Total ' + post + ' articles' + '</span>';
}
if (count && parseInt(count, 10) !== 0) {
tooltipContent += '<span class="heatmap_tooltip_count">' + ' ' + count + ' words;' + '</span>';
}
if (title && parseInt(title, 10) !== 0) {
tooltipContent += '<span class="heatmap_tooltip_title">' + title + '</span>';
}
if (date) {
tooltipContent += '<span class="heatmap_tooltip_date">' + date + '</span>';
}
tooltip.innerHTML = tooltipContent;
day.appendChild(tooltip);
});
day.addEventListener("mouseleave", function () {
const tooltip = day.querySelector(".heatmap_tooltip");
if (tooltip) {
day.removeChild(tooltip);
}
});
if (count == 0 ) {
day.classList.add("heatmap_day_level_0");
} else if (count > 0 && count < 1000) {
day.classList.add("heatmap_day_level_1");
} else if (count >= 1000 && count < 2000) {
day.classList.add("heatmap_day_level_2");
} else if (count >= 2000 && count < 3000) {
day.classList.add("heatmap_day_level_3");
} else {
day.classList.add("heatmap_day_level_4");
}
return day;
}
function createWeek() {
const week = document.createElement('div');
week.className = 'heatmap_week';
return week;
}
function createHeatmap() {
const container = document.getElementById('heatmap');
const startDate = getStartDate();
const endDate = new Date();
const weekDay = getWeekDay(startDate);
let currentWeek = createWeek();
container.appendChild(currentWeek);
let currentDate = startDate;
let i = 0;
while (currentDate <= endDate) {
if (i % 7 === 0 && i !== 0) {
currentWeek = createWeek();
container.appendChild(currentWeek);
}
const dateString = `${currentDate.getFullYear()}-${("0" + (currentDate.getMonth()+1)).slice(-2)}-${("0" + (currentDate.getDate())).slice(-2)}`;
const articleDataList = blogInfo.pages.filter(page => page.date === dateString);
if (articleDataList.length > 0) {
const titles = articleDataList.map(data => data.title);
const title = titles.map(t => `《${t}》`).join('<br />');
let count = 0;
let post = articleDataList.length;
articleDataList.forEach(data => {
count += parseInt(data.word_count, 10);
});
const formattedDate = formatDate(currentDate);
const day = createDay(formattedDate, title, count, post);
currentWeek.appendChild(day);
} else {
const formattedDate = formatDate(currentDate);
const day = createDay(formattedDate, '', '0', '0');
currentWeek.appendChild(day);
}
i++;
currentDate.setDate(currentDate.getDate() + 1);
}
}
function formatDate(date) {
const options = { month: 'short', day: 'numeric', year: 'numeric' };
return date.toLocaleDateString('en-US', options);
}
createHeatmap();
3. HTML DIV Container {#html}#
Prepare the HTML container for rendering the Heatmap. My blog uses TailwindCSS, but to write this article, I have converted it into traditional CSS styles, effectively re-implementing it with CSS. All layouts use Flexbox, and to adapt to mobile devices, JS detects screen width to dynamically generate months and annual calendar squares. Two breakpoints are set: one for the iPhone SE at 375 width and one for the iPad Mini at 768 width, which can be seen in the subsequent JS.
<div class="heatmap_container"> <!-- All layouts use Flexbox -->
<div class="heatmap_content">
<div class="heatmap_week">
<span>Mon</span>
<span> </span> <!-- Use space for days that do not need to be displayed -->
<span>Wed</span>
<span> </span>
<span>Fri</span>
<span> </span>
<span>Sun</span>
</div>
<div class="heatmap_main">
<div class="month heatmap_month">
<!-- js detects screen width to dynamically generate months -->
</div>
<div id="heatmap" class="heatmap">
<!-- js detects screen width to dynamically generate annual calendar squares -->
</div>
</div>
</div>
<div class="heatmap_footer">
<div class="heatmap_less">Less</div>
<div class="heatmap_level">
<span class="heatmap_level_item heatmap_level_0"></span>
<span class="heatmap_level_item heatmap_level_1"></span>
<span class="heatmap_level_item heatmap_level_2"></span>
<span class="heatmap_level_item heatmap_level_3"></span>
<span class="heatmap_level_item heatmap_level_4"></span>
</div>
<div class="heatmap_more">More</div>
</div>
</div>
4. Traditional style.css {#style}#
The CSS styles are modeled after GitHub's color scheme, with Dark mode resembling GitHub Dimmed's color scheme.
:root {
/* GitHub Light Color */
--ht-main: #334155;
--ht-day-bg: #ebedf0;
--ht-tooltip: #24292f;
--ht-tooltip-bg: #fff;
--ht-lv-0: #ebedf0;
--ht-lv-1: #9be9a8;
--ht-lv-2: #40c463;
--ht-lv-3: #30a14e;
--ht-lv-4: #216e39;
}
[data-theme="dark"] {
/* GitHub Dark Dimmed Color */
--ht-main: #94a3b8;
--ht-day-bg: #161b22;
--ht-tooltip: #24292f;
--ht-tooltip-bg: #fff;
--ht-lv-0: #161b22;
--ht-lv-1: #0e4429;
--ht-lv-2: #006d32;
--ht-lv-3: #26a641;
--ht-lv-4: #39d353;
}
.heatmap_container {
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 10px;
line-height: 12px;
color: var(--ht-main);
}
.heatmap_content {
display: flex;
flex-direction: row;
align-items: flex-end
}
.heatmap_week {
display: flex;
margin-top: 0.25rem;
margin-right: 0.25rem;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
text-align: right
}
.heatmap_main {
display: flex;
flex-direction: column
}
.heatmap_month {
display: flex;
margin-top: 0.25rem;
margin-right: 0.25rem;
flex-direction: column;
justify-content: space-around;
align-items: flex-end;
text-align: right;
}
.heatmap {
display: flex;
flex-direction: row;
height: 84px;
}
.heatmap_footer {
display: flex;
margin-top: 0.5rem;
align-items: center
}
.heatmap_level {
display: flex;
gap: 2px;
margin: 0 0.25rem;
flex-direction: row;
align-items: center;
width: max-content;
height: 10px
}
.heatmap_level_item {
display: block;
border-radius: 0.125rem;
width: 10px;
height: 10px;
}
.heatmap_level_0 {
background: var(--ht-lv-0);
}
.heatmap_level_1 {
background: var(--ht-lv-1);
}
.heatmap_level_2 {
background: var(--ht-lv-2);
}
.heatmap_level_3 {
background: var(--ht-lv-3);
}
.heatmap_level_4 {
background: var(--ht-lv-4);
}
.heatmap_week {
display: flex;
flex-direction: column;
}
.heatmap_day {
width: 10px;
height: 10px;
background-color: var(--ht-day-bg);
margin: 1px;
border-radius: 2px;
display: inline-block;
position: relative;
}
.heatmap_tooltip {
position: absolute;
bottom: 12px;
left: 50%;
width: max-content;
color: var(--ht-tooltip);
background-color: var(--ht-tooltip-bg);
font-size: 12px;
line-height: 16px;
padding: 8px;
border-radius: 3px;
white-space: pre-wrap;
opacity: 1;
transition: 0.3s;
z-index: 1000;
text-align: right;
transform: translateX(-50%);
}
.heatmap_tooltip_count,
.heatmap_tooltip_post {
display: inline-block;
}
.heatmap_tooltip_title,
.heatmap_tooltip_date {
display: block;
}
.heatmap_tooltip_date {
margin: 0 0.25rem;
}
.heatmap_day_level_0 {
background-color: var(--ht-lv-0);
}
.heatmap_day_level_1 {
background-color: var(--ht-lv-1);
}
.heatmap_day_level_2 {
background-color: var(--ht-lv-2);
}
.heatmap_day_level_3 {
background-color: var(--ht-lv-3);
}
.heatmap_day_level_4 {
background-color: var(--ht-lv-4);
}
5. TailwindCSS Styles#
<div class="flex flex-col items-end text-[10px] leading-[12px] text-neutral-700 dark:text-neutral-400">
<div class="flex flex-row items-end">
<div class="flex flex-col justify-end items-end mr-1 mt-1 text-right">
<span>Mon</span>
<span> </span>
<span>Wed</span>
<span> </span>
<span>Fri</span>
<span> </span>
<span>Sun</span>
</div>
<div class="heatmap flex flex-col">
<div class="month mb-1 flex justify-around">
</div>
<div class="h-[84px]">
<div id="heatmap" class="flex flex-row"></div>
</div>
</div>
</div>
<div class="flex mt-2 items-center">
<span class="">Less</span>
<div class="flex flex-row items-center gap-[2px] w-max h-[10px] mx-1">
<span class="block w-[10px] h-[10px] rounded-sm bg-[#ebedf0] dark:bg-[#161b22]"></span>
<span class="block w-[10px] h-[10px] rounded-sm bg-[#9be9a8] dark:bg-[#0e4429]"></span>
<span class="block w-[10px] h-[10px] rounded-sm bg-[#40c463] dark:bg-[#006d32]"></span>
<span class="block w-[10px] h-[10px] rounded-sm bg-[#30a14e] dark:bg-[#26a641]"></span>
<span class="block w-[10px] h-[10px] rounded-sm bg-[#216e39] dark:bg-[#39d353]"></span>
</div>
<span class="">More</span>
</div>
</div>