🌡️手搓一个 GitHub 风格文章热力图:用纯 HTML/CSS/JS 可视化我的写作节奏

26

“你最近写博客勤快吗?”
不如让颜色说话。

作为一位入行 5 年的 Java 开发者,我越来越意识到:写博客不仅是记录,更是思考的沉淀。但文字太抽象,数据才直观。于是,我决定给自己做一个类似 GitHub 贡献热力图的可视化组件——只不过,它展示的不是代码提交,而是我在博客上发布的文章频率

今天,我把这个小项目开源思路和核心代码整理出来,希望对同样想打造个人知识仪表盘的朋友有所帮助。

🔍 动机:看见自己的“输出节奏”

GitHub 的绿色方块之所以让人上瘾,是因为它把抽象的努力变成了可感知的视觉反馈。我也想知道自己:

  • 哪些月份高产?

  • 是否总在周末偷懒?

  • 有没有连续写作的 streak?

于是,我基于博客的 RSS 源(https://blog.iiain.top/rss.xml),用原生 JavaScript 解析文章发布时间,并渲染出一张过去一年的文章发布热力图。

🛠️ 技术实现要点

整个组件完全使用 HTML + CSS Grid + 原生 JS 实现,无任何第三方依赖:

  1. 数据获取

    • 通过 fetch 请求 RSS XML

    • 兼容 <item>(RSS 2.0)和 <entry>(Atom)两种格式

    • 自动 fallback 到 mock 数据(应对 CORS 限制)

  2. 日期对齐

    • 以当前日期为终点,回溯 371 天(约 53 周)

    • 按周一为每周起始日对齐网格

  3. 热力分级

    • 根据每日文章数动态计算强度等级(0~4 级)

    • 使用蓝紫色系渐变(避开 GitHub 的绿色,更契合深色主题)

  4. 响应式布局

    • 横向滚动支持长周期展示

    • 悬停 tooltip 显示具体日期与文章数量

💡 为什么不用现成库?

其实有现成的热力图库(比如 cal-heatmap),但我选择手写,原因有三:

  • 轻量:最终代码不到 150 行逻辑,零依赖

  • 可控:样式、交互完全按自己审美定制

  • 学习:深入理解 CSS Grid 与日期算法的实战结合

📦 代码已就绪,欢迎复用!

完整 HTML 文件包含内联 JS 和 CSS,复制即用。你只需替换 RSS_URL 为你自己的博客 RSS 地址,就能拥有专属写作热力图。

<div class="container">
    <!-- 主布局:周标签 + 热力图 -->
    <div class="heatmap-layout">
        <!-- 周标签 -->
        <div class="week-labels-container">
            <div class="spacer"></div>
            <div class="week-label">周一</div>
            <div class="week-label">周二</div>
            <div class="week-label">周三</div>
            <div class="week-label">周四</div>
            <div class="week-label">周五</div>
            <div class="week-label">周六</div>
            <div class="week-label">周日</div>
        </div>

        <!-- 热力图区域 -->
        <div class="flex-1">
            <div id="month-labels"></div>
            <div id="contribution-grid" class="contribution-grid"></div>
        </div>
    </div>

    <!-- 图例 -->
    <div class="legend">
        <span>文章发布热力图</span>
        <span class="legend-text">更少</span>
        <div class="contribution-cell level-0 legend-cell"></div>
        <div class="contribution-cell level-1 legend-cell"></div>
        <div class="contribution-cell level-2 legend-cell"></div>
        <div class="contribution-cell level-3 legend-cell"></div>
        <div class="contribution-cell level-4 legend-cell"></div>
        <span>更多</span>
    </div>
</div>
<script>
    const RSS_URL = 'https://blog.iiain.top/rss.xml';

    function getLastYearDates() {
        const dates = [];
        const today = new Date();
        for (let i = 371; i >= 0; i--) {
            const d = new Date(today);
            d.setDate(today.getDate() - i);
            if (d <= today) dates.push(d);
        }
        return dates;
    }

    function formatDate(date) {
        return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
    }

    function generateMockActivityData() {
        const activityCounts = {};
        let maxCount = 0;
        const dates = getLastYearDates();
        dates.forEach(date => {
            if (Math.random() < 0.12) {
                const count = Math.floor(Math.random() * 4) + 1;
                const key = formatDate(date);
                activityCounts[key] = count;
                if (count > maxCount) maxCount = count;
            }
        });
        return {activityCounts, maxCount: maxCount || 1};
    }

    async function fetchActivityData(rssUrl) {
        try {
            const response = await fetch(rssUrl);
            const text = await response.text();
            const parser = new DOMParser();
            const xmlDoc = parser.parseFromString(text, "text/xml");
            if (xmlDoc.querySelector('parsererror')) throw new Error("XML parse error");

            const items = [...xmlDoc.querySelectorAll('item'), ...xmlDoc.querySelectorAll('entry')];
            const activityCounts = {};
            let maxCount = 0;

            items.forEach(post => {
                const dateEl = post.querySelector('pubDate') || post.querySelector('updated');
                if (dateEl) {
                    const date = new Date(dateEl.textContent);
                    if (!isNaN(date.getTime())) {
                        const key = formatDate(date);
                        activityCounts[key] = (activityCounts[key] || 0) + 1;
                        if (activityCounts[key] > maxCount) maxCount = activityCounts[key];
                    }
                }
            });

            return {activityCounts, maxCount: maxCount || 1};
        } catch (e) {
            console.warn("Fallback to mock data due to CORS or network error:", e);
            return generateMockActivityData();
        }
    }

    function renderHeatmap(activityCounts, maxCount) {
        const gridEl = document.getElementById('contribution-grid');
        const monthLabelsEl = document.getElementById('month-labels');
        gridEl.innerHTML = '';
        monthLabelsEl.innerHTML = '';

        const dates = getLastYearDates();
        const firstDate = dates[0];
        const MONTH_NAMES = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"];

        const dayOfWeekOffset = (firstDate.getDay() + 6) % 7; // 周一=0
        const totalDays = dates.length;
        const totalCols = Math.ceil((totalDays + dayOfWeekOffset) / 7);

        const CELL_WIDTH = 10;
        const GAP = 3;
        const colTemplate = `repeat(${totalCols}, ${CELL_WIDTH}px)`;
        gridEl.style.gridTemplateColumns = colTemplate;
        monthLabelsEl.style.gridTemplateColumns = colTemplate;

        // 前置空单元格
        for (let i = 0; i < dayOfWeekOffset; i++) {
            const dummy = document.createElement('div');
            dummy.className = 'contribution-cell level-0';
            dummy.style.gridColumn = '1';
            dummy.style.gridRow = (i + 1).toString();
            gridEl.appendChild(dummy);
        }

        let currentMonth = -1;
        dates.forEach((date, index) => {
            const dayKey = formatDate(date);
            const count = activityCounts[dayKey] || 0;

            let level = 0;
            if (count > 0) {
                const ratio = count / maxCount;
                if (ratio >= 0.75) level = 4;
                else if (ratio >= 0.5) level = 3;
                else if (ratio >= 0.25) level = 2;
                else level = 1;
            }

            const cell = document.createElement('div');
            cell.className = `contribution-cell level-${level}`;
            cell.title = `${date.getMonth() + 1}月${date.getDate()}日, ${count > 0 ? count + ' 篇文章' : '无文章'}`;

            const rowIndex = (date.getDay() + 6) % 7;
            cell.style.gridRow = (rowIndex + 1).toString();
            const colIndex = Math.floor((index + dayOfWeekOffset) / 7) + 1;
            cell.style.gridColumn = colIndex.toString();

            gridEl.appendChild(cell);

            if (date.getMonth() !== currentMonth) {
                const label = document.createElement('div');
                label.className = 'month-label-item';
                label.textContent = MONTH_NAMES[date.getMonth()];
                label.style.gridColumn = `${colIndex} / span 1`;
                monthLabelsEl.appendChild(label);
                currentMonth = date.getMonth();
            }
        });
    }

    window.onload = async () => {
        const data = await fetchActivityData(RSS_URL);
        renderHeatmap(data.activityCounts, data.maxCount);
    };
</script>
<style>
    /* 全局样式 */
    body {
        background-color: #1B1B1F;
        font-family: 'Inter', sans-serif;
        -webkit-font-smoothing: antialiased;
        -moz-osx-font-smoothing: grayscale;
        margin: 0;
        padding: 0;
    }

    .container {

        margin-right: 0px;
        margin-left: 0px;
        width: auto;
        padding-left: 0;
        padding-right: 0;
    }

    /* 主布局容器 */
    .heatmap-layout {
        display: flex;
    }

    /* 周标签区域 */
    .week-labels-container {
        display: flex;
        flex-direction: column;
        justify-content: space-between;
        height: 94px;
        margin-top: 16px;
        margin-right: 8px;
        font-size: 10px;
        color: #8b949e;
        flex-shrink: 0;
    }

    .week-labels-container .spacer {
        height: 16px;
    }

    .week-label {
        height: 13px;
        display: flex;
        align-items: center;
    }

    /* 热力图基础 */
    .contribution-grid {
        display: grid;
        grid-template-rows: repeat(7, 10px);
        gap: 3px;
        overflow-x: auto;
    }

    .contribution-cell {
        width: 10px;
        height: 10px;
        border-radius: 1px;
        /*background-color: rgba(255, 255, 255, 0.19);*/
        background-color: rgba(214, 214, 214, 0.45);
        transition: background-color 0.3s ease;
        cursor: pointer;
    }

    .level-0 { background-color: rgba(214, 214, 214, 0.45); }
    .level-1 { background-color: #3700b3; } /* 深蓝紫 */
    .level-2 { background-color: #5a38d9; } /* 中蓝紫 */
    .level-3 { background-color: #7b5cf5; } /* 蓝紫 */
    .level-4 { background-color: #9c83ff; } /* 极浅蓝紫(偏蓝) */

    /* Tooltip 悬停提示 */
    .contribution-cell[title]:hover::after {
        content: attr(title);
        position: absolute;
        background: rgba(48, 54, 61, 0.95);
        color: #c9d1d9;
        padding: 4px 8px;
        border-radius: 1px;
        font-size: 12px;
        white-space: nowrap;
        z-index: 10;
        transform: translateY(-100%) translateX(-50%);
        margin-left: 50%;
        pointer-events: none;
        top: -5px;
    }

    /* 月份标签 */
    #month-labels {
        display: grid;
        gap: 3px;
        margin-bottom: 6px;
        height: 16px;
        align-items: end;
    }

    .month-label-item {
        font-size: 10px;
        color: #8b949e;
        text-align: left;
        white-space: nowrap;
        overflow: visible;
    }

    /* 图例 */
    .legend {
        display: flex;
        height: 21px;
        align-items: center;
        gap: 4px;
        margin-top: 5px;
        font-size: 12px;
        color: #8b949e;
    }

    .legend-text {
        margin-left: 508px;
    }

    .legend-cell {
        width: 12px;
        height: 12px;
        border-radius: 1px;
    }

</style>

提示:如果你的博客不支持 CORS(大多数静态站点都不支持),页面会自动降级为模拟数据——但部署到同源环境(如嵌入博客页面)即可正常拉取真实数据。

RSS_URL 更换成你的

🌱 写在最后

这张图对我而言,不只是装饰。它提醒我:持续输出比偶尔爆发更重要。哪怕某天只写了一段话,那个小小的色块,也是思维存在的证明。

希望你也能找到属于自己的“热力节奏”。

📌 点击查看在线效果(部署后)
📂 代码已整合进我的博客项目,未来或开放 GitHub 仓库。

附:配色灵感来自深空与紫雾,致敬每一个深夜敲键盘的你。