eallion

大大的小蜗牛

机会总是垂青于有准备的人!
mastodon
github
twitter
steam
telegram
keybase
email

CSS and JS Implementing Blog Heatmap

View live effect: 👉Homepage / 👉Statistics Page

TL;DR#

Too long; didn't read, just look at the code 👇

  1. Include style.css
  2. Create HTML container
  3. Include heatmap.js

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 since my theme can accommodate it on the homepage, I started tinkering with it again. I tried several versions, and there are many similar libraries available online:

Pros and cons:

  • ECharts.js is inconvenient for controlling details and adapting to mobile devices, and the resource files are relatively large;
  • Heat.js was discovered during testing of Cal-Heatmap.js;
  • 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, and it has been delayed until today to complete. During the tinkering with Twitter Year Progress, I completed the drawing of the annual calendar small squares and used it directly.

1. JS to Build Heatmap#

1. Prepare Blog Data#

During Hugo build, retrieve article data from the past year:

// Get 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 accordingly:

{
    "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 is adapted for mobile devices, showing 6 months of data on regular mobile devices, while smaller devices, such as 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 regardless of whether it's 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 rendering: one year ago today is not necessarily Monday. Therefore, when choosing the start date for the heatmap, you need to consider the Monday of the week where today last year falls as the starting point.

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 the day of the week for today is less than that of today last year, it will lead to rendering 52 weeks (one year) of small squares, and then the content for today and the rest of this week cannot be rendered. Therefore, you need to check today's day of the week and append it to the annual calendar small 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 different color depths based on the count word count, which corresponds to the CSS heatmap_day_level_num style. The count is divided 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 for that day; multiple articles will be combined for calculation.
  • post data-post is the number of articles for that day.
  • title data-title is the title of the article for that day.
  • date data-date is the date for that day in Jan 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 highlights some details to pay attention to; below is the complete JS:

// Get 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 to 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 small 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>&nbsp;</span> <!-- Use space for days that do not need to be displayed -->
            <span>Wed</span>
            <span>&nbsp;</span>
            <span>Fri</span>
            <span>&nbsp;</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 small 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 reflecting 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>&nbsp;</span>
            <span>Wed</span>
            <span>&nbsp;</span>
            <span>Fri</span>
            <span>&nbsp;</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>
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.