☰
原生JavaScript+SVG实现GitHub风格阅读热力图
2026/10/13 3:11:16 网站建设 项目流程

在实际的工程练习中,GitHub Heatmap最常见的载体是 GitHub 个人主页上的 contribution graph:用一整年的方格记录每天的提交活跃度。如果把同样一套坐标和配色逻辑迁移到阅读场景,就能得到一个非常直观的个人阅读可视化页面,这就是“GitHub Heatmap for Reading”项目要做的事。

它的核心不是“做一个好玩的仪表盘”,而是解决一个很实际的问题:阅读习惯很难坚持,原因往往是反馈太慢、记录太繁琐。如果每天读了多少分钟,像 GitHub 提交记录一样自动变成一个小方块,颜色越深代表读得越久,坚持的动力会强很多。本文会从零实现一个不依赖任何第三方框架的阅读热力图页面,使用原生 JavaScript、SVG 和 localStorage 完成数据记录、热力图渲染、连续天数统计和数据导入导出。

这个项目适合前端初学者、对 GitHub 贡献图实现机制好奇的开发者,以及想用编程方式记录个人习惯的人。完成之后,你会得到一个可以直接双击运行的单页应用,也能理解 GitHub contribution graph 的坐标生成逻辑,包括周起点、月份标签、颜色分档和跨年统计接口的设计方式。

1. 从 GitHub 贡献图到阅读热力图:先理解坐标模型

1.1 GitHub 热力图本质是“日期 + 活跃度”的二维矩阵

GitHub 主页上的 contribution graph 看起来像一张布满小方块的图表,但它的数据结构并不复杂。本质上,它是一张二维矩阵,横轴代表一年中的第几周,纵轴代表周一至周日,矩阵中每个小方格的坐标对应一个具体日期,方格颜色对应当天的提交数量或活跃等级。

这里有一个非常关键的几何关系:

  • 每一列有 7 个小方格,表示一周中的 7 天。
  • 一整年的数据大约需要 52 列或 53 列。
  • 列与列之间有小间隙,便于区分周。
  • 顶部显示月份标签,用来定位某一天大约在几月。

很多人在自己实现类似图表时,会直接按照“第 1 天到第 365 天”的顺序从左到右平铺排列,最终效果是 365 个正方形排成一行。这种做法的确能表示日期,但完全失去了“周”这个维度,也看不出周末和工作日的阅读差异。正确做法是先把一年中的每一天映射到“周 + 星期”的坐标系里,再渲染到页面上。

一年的日数是固定的,但第 1 天并不一定是周一,所以计算时需要引入一个“周偏移量”,让 1 月 1 日落在正确的星期位置。很多错位问题都出在这里:如果把周一当第 0 行、周日当第 6 行,但又直接用 JavaScript 的getDay()返回值作为行号,就会出现 1 月 1 日永远落在周日那一行的问题。

1.2 阅读行为映射到热力图的三种字段

阅读行为和 GitHub 提交行为不完全一样。GitHub 的关键指标是 commit 次数,而阅读可以有很多口径:

数据维度说明优点缺点
打卡天数当天是否阅读记录成本最低无法体现阅读量差异
阅读时长当天阅读了多少分钟稳定、可量化需要主动计时
页数 / 章节数当天读了多少页与书籍强相关页码和阅读速度不稳定

推荐以“阅读分钟数”作为主指标。它的单位统一,不同书籍之间可比,也更容易设置目标。在本文项目中,每天的分钟数会映射成 5 个颜色等级:

等级阅读分钟数范围颜色
00 分钟灰白色
11 到 29 分钟浅绿
230 到 59 分钟中绿
360 到 89 分钟深绿
490 分钟及以上最深绿

采用离散等级而不是连续颜色,是为了降低视觉噪音。如果从 1 分钟到 300 分钟都渲染成连续渐变色,整张图会变成一片深绿色,很难看出“今天有没有读”。按 30 分钟为一个档位分档,能保留明显的边界感。

2. 环境准备:这次不需要安装任何依赖

2.1 前置条件与运行方式

这个项目是纯前端实现,不需要 Node.js,不需要 npm 安装,也不需要数据库。浏览器只需要支持 ES6 语法和 localStorage,目前主流的 Chrome、Edge、Firefox 都满足要求。

最快速的学习方式是把三个文件写在同一目录,然后直接双击index.html打开。不过需要注意,部分浏览器在file://协议下对本地文件读取有限制,所以如果后续要测试导出和导入功能,建议启动一个简单的静态服务器:

cd reading-heatmap python -m http.server 8080

然后访问http://localhost:8080。

日常学习环境用本地页面就够了。如果要部署到生产环境,建议把文件托管到任意静态站点服务上,比如 GitHub Pages。部署前需要额外考虑数据备份,因为 localStorage 只存在当前浏览器中,换设备、清缓存都会丢,所以项目里会加入 JSON 导出和导入功能。

生产环境还需要注意几个点:

  • HTTPS 环境下 localStorage 才能稳定使用,部分浏览器对隐私模式下的 localStorage 有写入限制。
  • 如果做成公开页面,不建议把个人阅读记录直接写进前端代码,应该改成后端接口或只存放在本地。
  • 多设备同步需要后端存储或网盘同步,单靠 localStorage 做不到。

2.2 目录结构与项目拆分

推荐使用以下目录结构:

reading-heatmap/ ├── index.html ├── style.css └── app.js

这种拆分方式便于维护。index.html负责页面骨架,style.css负责格子尺寸、颜色和布局,app.js负责日期计算、数据读写和渲染逻辑。

也可以把所有代码写进一个 HTML 文件,适合快速演示。但如果后续要扩展成多视图页面、添加导入导出功能,还是拆分文件更清晰。

2.3 数据模型设计

本地存储的数据结构非常关键。设计成对象而不是数组,可以减少查找成本:

{ "year": 2025, "records": { "2025-01-01": 45, "2025-01-02": 0, "2025-01-03": 70 } }

records的 key 使用 ISO 格式日期字符串YYYY-MM-DD,value 是当天阅读分钟数。不使用Date对象作为 key,是因为对象转字符串后会变成类似Wed Jan 01 2025 08:00:00 GMT+0800的格式,排序和比较都容易出错。ISO 字符串可以按字典序排序,也可以直接传给后端。

这里要特别提醒一个 JavaScript 的日期陷阱:new Date('2025-01-01')在大多数浏览器中会被解析为 UTC 零点,而本地时间可能是当天早上 8 点或前一天晚上。如果再用getDate()取日期,容易得到错误结果。项目中统一使用new Date(year, monthIndex, day)构造本地日期,避免时区干扰。

2.4 基础工具函数

先定义两个最常用的函数:

function getDayKey(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } function parseDayKey(key) { const parts = key.split('-'); return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])); }

getDayKey把 Date 对象转成标准字符串,parseDayKey把字符串转回本地时间。这两个函数是数据读写的基础,后面所有日期比较都依赖它们。

3. 实现核心渲染:SVG 网格与坐标计算

3.1 周偏移量的计算

日期坐标计算是本文最容易出错的环节,需要先理解“周偏移量”这个概念。

假设你希望第一行是周一。如果 2025 年 1 月 1 日是周三,那么 1 月 1 日在矩阵中应该位于第 2 行(0 开始计数,周一为第 0 行)。这个“从周一开始计算行号”的偏移量,就是周偏移量。

代码实现如下:

function getYearOffset(year) { const firstDay = new Date(year, 0, 1).getDay(); // getDay():周日 0,周一 1 ... 周六 6 // 把周一变成 0,周日变成 6 return (firstDay + 6) % 7; }

如果不做+6的处理,直接用getDay()返回值,那么周一会被当作第 1 行,而不是第 0 行。这样的结果就是整个热力图比真实日期向右下方偏移一格。

算出偏移量后,任意日期可以映射到行列坐标:

function getCellPosition(dayIndex, year) { const offset = getYearOffset(year); const col = Math.floor((dayIndex + offset) / 7); const row = (dayIndex + offset) % 7; return { row, col }; }

dayIndex表示当前日期是当年的第几天,0 代表 1 月 1 日。col表示第几列,row表示这一周的第几天。

3.2 计算一年需要的总列数

不同年份的总天数不同,闰年 366 天,平年 365 天。再加上 1 月 1 日可能不是周一,所以第一周可能只有 2 到 6 天,最后一列也可能不完整。总列数可以用向上取整计算:

function isLeapYear(year) { return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; } function totalWeeks(year) { const days = isLeapYear(year) ? 366 : 365; const offset = getYearOffset(year); return Math.ceil((days + offset) / 7); }

比如 2025 年是平年,1 月 1 日是周三,偏移量为 2,那么(365 + 2) / 7向上取整得到 53 列。这个数字决定了 SVG 画布的宽度。

3.3 生成月份标签

月份标签需要找到每个月的 1 号在哪一列。计算方式是用“当月 1 号”和“当年 1 月 1 号”的毫秒差,换算成天数,再套用列坐标公式:

function getMonthLabels(year) { const labels = []; for (let m = 0; m < 12; m++) { const target = Date.UTC(year, m, 1); const first = Date.UTC(year, 0, 1); const dayIndex = Math.round((target - first) / 86400000); const col = Math.floor((dayIndex + getYearOffset(year)) / 7); labels.push({ col: col, text: `${m + 1}月` }); } return labels; }

使用Date.UTC是为了避免时区导致的毫秒差误差。如果使用new Date(year, m, 1) - new Date(year, 0, 1),在部分时区下,因为夏令时会得到不是整倍数的毫秒差,用Math.floor可能导致多算或少算一天。这里使用Math.round更稳妥。

3.4 用 SVG 渲染整年热力图

SVG 渲染比不断操作 DOM 的div方案性能更好。一年只有 365 个rect,使用 SVG 原生元素可以看到清晰的坐标结构,也方便在格子上绑定事件。

网格渲染主体:

function renderHeatmap() { const year = CURRENT_YEAR; const weeks = totalWeeks(year); const cellSize = 12; const gap = 3; const width = weeks * (cellSize + gap); const height = 7 * (cellSize + gap); svg.innerHTML = ''; // 月份标签层 const monthLabels = getMonthLabels(year); monthLabels.forEach(item => { const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', item.col * (cellSize + gap)); text.setAttribute('y', 10); text.setAttribute('font-size', '10'); text.textContent = item.text; svg.appendChild(text); }); // 日期格子层 const firstDate = new Date(year, 0, 1); for (let dayIndex = 0; dayIndex < (isLeapYear(year) ? 366 : 365); dayIndex++) { const date = new Date(year, 0, 1); date.setDate(firstDate.getDate() + dayIndex); const key = getDayKey(date); const minutes = records[key] || 0; const pos = getCellPosition(dayIndex, year); const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('data-key', key); rect.setAttribute('x', pos.col * (cellSize + gap) + 1); rect.setAttribute('y', pos.row * (cellSize + gap) + 20); rect.setAttribute('width', cellSize); rect.setAttribute('height', cellSize); rect.setAttribute('rx', 2); rect.setAttribute('fill', getColor(minutes)); if (key === getDayKey(new Date())) { rect.setAttribute('stroke', '#333'); rect.setAttribute('stroke-width', '2'); } svg.appendChild(rect); } }

这里的y坐标从 20 开始,是为了给顶部的月份标签留出空间。如果不留这个高度,1 月的标签会和第一行格子重叠。

3.5 颜色分档函数

function getLevel(minutes) { if (minutes <= 0) return 0; if (minutes < 30) return 1; if (minutes < 60) return 2; if (minutes < 90) return 3; return 4; } const COLOR_LEVELS = [ '#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39' ]; function getColor(minutes) { return COLOR_LEVELS[getLevel(minutes)]; }

这组颜色接近 GitHub 经典绿色系。实际项目中可以根据主题替换成蓝色、橙色或自定义色板,只要保持 5 个档位的数组结构即可。

4. 阅读打卡交互与数据持久化

4.1 单击增加、双击自定义、右键清空

热力图渲染出来后,核心交互是“把某一天标记为已阅读”。为了兼顾效率和健壮性,设计三种操作:

  • 左键单击:按步长增加当天阅读分钟数,默认步长 30 分钟。
  • 双击:弹出输入框,手动输入任意分钟数。
  • 右键:清除当天记录。

在事件处理上使用事件委托,避免给 365 个rect各自绑定事件:

const svg = document.getElementById('heatmap'); svg.addEventListener('click', (e) => { const target = e.target.closest('rect[data-key]'); if (!target) return; const key = target.getAttribute('data-key'); const current = records[key] || 0; const step = Number(document.getElementById('step-input').value) || 30; const nextLevel = (getLevel(current) + 1) % 5; records[key] = nextLevel * step; saveData(); render(); }); svg.addEventListener('dblclick', (e) => { const target = e.target.closest('rect[data-key]'); if (!target) return; const key = target.getAttribute('data-key'); const input = prompt('输入当天阅读分钟数', String(records[key] || 30)); const num = Number(input); if (Number.isFinite(num) && num >= 0) { records[key] = Math.floor(num); saveData(); render(); } }); svg.addEventListener('contextmenu', (e) => { const target = e.target.closest('rect[data-key]'); if (!target) return; e.preventDefault(); const key = target.getAttribute('data-key'); delete records[key]; saveData(); render(); });

单击按“等级”循环递增,而不是直接加 30 分钟,原因是等级模式和颜色分档一致。用户看到浅绿格子点一下变成中绿,再点一下变成深绿,反馈非常直观。缺点是如果步长不是 30,等级和颜色之间的对应关系会发生变化,所以把步长输入框的默认值固定为 30。

4.2 localStorage 读写

保存数据时,需要把整个对象序列化后写入 localStorage:

const STORAGE_KEY = 'reading-heatmap-v1'; function saveData() { const payload = { year: CURRENT_YEAR, records: records, updatedAt: new Date().toISOString() }; localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } function loadData() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return {}; const parsed = JSON.parse(raw); if (parsed.year !== CURRENT_YEAR) { if (confirm('检测到往年数据,是否清空并开始新的一年?')) { return {}; } } return parsed.records || {}; } catch (err) { console.warn('读取本地数据失败,使用空数据', err); return {}; } }

confirm弹窗用于处理跨年场景。如果用户在 2025 年记录了数据,2026 年重新打开页面,原来的数据会保留在 localStorage 中,但不会显示在新一年的热力图里。是否清空让用户决定,避免误删。

4.3 导出和导入

数据导出通过 Blob 实现,不依赖后端:

function exportData() { const payload = { year: CURRENT_YEAR, records: records, updatedAt: new Date().toISOString() }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `reading-heatmap-${CURRENT_YEAR}.json`; a.click(); URL.revokeObjectURL(url); }

导入数据时,需要校验 year 字段是否匹配当前年份:

async function importData(file) { const text = await file.text(); const data = JSON.parse(text); if (data.year !== CURRENT_YEAR) { alert('导入文件里的年份与当前年份不一致'); return; } records = data.records || {}; saveData(); render(); }

导出文件可以直接备份到网盘、Git 仓库或任何文件存储服务中。这样即使浏览器数据丢失,也能通过导入恢复整年的阅读记录。

5. 统计指标:从热力图到阅读习惯

5.1 统计口径先定清楚

数据展示出来之后,还需要回答几个更抽象的问题:“我坚持了多少天”“总共读了多少分钟”“年度目标完成了几成”。这些指标对应不同口径:

指标计算口径说明
总阅读天数records中值大于 0 的条数只要当天有记录就算
总阅读分钟所有 value 之和汇总分钟数
连续阅读天数从今天或昨天开始向前连续非零的天数当天没读时从昨天算
年度目标进度总分钟数 / 目标分钟数目标可配置

“连续阅读天数”的口径需要特别注意。如果今天还没打卡,直接算连续天数会得到 0,但这会打击用户积极性。GitHub 的 contributions 图不会显示连续天数,但很多习惯类应用会选择“从今天或昨天开始算”,因为今天还没结束,不能判断今天是否会被打破连续记录。

实现代码:

function calcStreak() { let streak = 0; const cursor = new Date(); if (!(records[getDayKey(cursor)] > 0)) { cursor.setDate(cursor.getDate() - 1); } while (records[getDayKey(cursor)] > 0) { streak++; cursor.setDate(cursor.getDate() - 1); } return streak; }

5.2 年度目标

目标值应该允许配置。默认目标是 36500 分钟,约等于每天 1 小时。用户可以在输入框中修改目标,并看到进度条:

const targetMinutes = Number(document.getElementById('target-input').value) || 36500; const totalMinutes = Object.values(records).reduce((sum, value) => sum + value, 0); const percent = Math.min(100, Math.round((totalMinutes / targetMinutes) * 100));

进度条可以用一个简单的div实现,宽度按百分比变化。这里需要注意的是,如果目标被改成很小,百分比可能超过 100,所以要用Math.min做上限。

5.3 统计区域渲染

把统计信息和热力图放在同一页面,而不是分开。这样用户打完卡后,可以立刻看到连续天数和年度目标的变化,形成即时反馈。

6. 运行与验证

6.1 启动步骤

以本地静态服务器方式启动为例:

cd reading-heatmap python -m http.server 8080

浏览器打开http://localhost:8080后,应该看到:

  • 顶部有 12 个月份标签。
  • 中间是本年度的热力图,今天的格子带边框。
  • 右侧或下方有统计信息区。
  • 底部有步长输入框、目标输入框、导出和导入按钮。

6.2 验证清单

拿到页面后,按顺序执行以下操作:

验证项操作预期结果
颜色映射点击今天格子 1 次格子变为浅绿色
连续点击连点 4 次格子颜色逐级加深后归零
右键清除对任意格子点右键格子变回灰色,当天记录被删除
刷新持久化点击后刷新页面格子状态保持,统计数据不变
导出点击导出下载reading-heatmap-2025.json文件
导入清空数据后导入文件数据恢复,热力图重新显示
日期对齐查看 1 月 1 日所在行列1 月 1 日应处于正确的星期位置

6.3 日期对齐的验证方法

日期对齐最容易出错的地方在第一列和最后一个月。可以打开浏览器控制台执行:

getCellPosition(0, 2025);

返回结果应该是一个{ row: 2, col: 0 }之类的对象,其中的row要等于getYearOffset(2025)。如果row是 0,说明偏移量被错误地计算成了 0,需要检查getYearOffset中的(firstDay + 6) % 7。

用真实日期验证 2025 年 2 月 1 日:先计算它是不是当年的第 31 天,再确认它落在哪一列的哪一行。

7. 常见问题与排查

7.1 点击格子没有反应

现象:鼠标点击任何格子,颜色都不变化。

排查路径:

  1. 打开浏览器控制台,确认没有 JavaScript 报错。
  2. 检查rect元素是否带有>localStorage.removeItem('reading-heatmap-v1');

    7.2 整张热力图所有日期都偏移了一天

    现象:1 月 1 日出现在周五或周六的列里,但实际应该是周三。

    原因:getCellPosition中使用了new Date(year, 0, 1).getDay()作为行号,没有转换成以周一为第 0 天的坐标系统。

    处理方式:

    function getYearOffset(year) { const firstDay = new Date(year, 0, 1).getDay(); return (firstDay + 6) % 7; }

    7.3 刷新后数据消失

    可能原因有三种:

    • 浏览器处于隐私模式,localStorage 写入被拒绝。
    • 存储 key 不一致。比如页面通过不同的域名或端口的 HTTP 服务打开,localStorage 的隔离作用域不同。
    • 数据已经写入,但loadData()解析失败,JSON 数据被清空。

    检查方式:

    console.log(localStorage.getItem('reading-heatmap-v1'));

    如果返回null,说明没有写入成功或 key 不对。如果返回一段 JSON,检查其中records字段是否存在。

    7.4 月份标签重复

    现象:1 月和 2 月的标签出现在同一列。

    原因:不同月份的第一天可能落在同一列。实际上这种情况并不算错误,因为如果 1 月 31 日是周日,2 月 1 日就是下一周的周一,列号会比 1 月 1 日大;但如果两个月第一天所在的列相同,说明月份边界不在列边界上,标签自然会重叠。

    处理方式:给标签增加水平偏移,或者在标签重叠时只显示前一个。更简单的方案是只在每年 1 月和 7 月显示标签,减少视觉重叠。

    7.5 手机上格子太小

    桌面端 12px 的格子在手机上很难点击。解决方案是通过 CSS 的@media查询调整格子尺寸,或者把 SVG 的width改为百分比并设置min-width。

    也可以在移动端使用touch事件,需要注意浏览器对click事件在移动端的 300 毫秒延迟问题。如果不做复杂手势,直接用click在大多数现代浏览器上也能工作。

    8. 最佳实践与扩展方向

    8.1 发布前检查清单

    在把这个页面投入使用或发布到 GitHub Pages 之前,建议逐项检查:

    检查项说明
    年份是否正确页面使用系统当前年份,跨年需要确认逻辑
    数据备份每月导出一次 JSON 并放到安全位置
    本地存储可用非隐私模式、非无痕模式
    日期坐标验证抽查几个特殊日期:1 月 1 日、12 月 31 日、今天
    浏览器兼容至少测试 Chrome 和 Safari
    移动端布局确认热力图在手机上可以横向滚动
    颜色可辨识度绿色色板对色弱用户是否友好,可改用蓝色或增加文字提示
    代码可维护性工具函数、常量、渲染函数是否分离,是否留有注释

    8.2 扩展方向

    当前版本只支持单一年份、单一维度的阅读时长统计。继续迭代时可以按以下方向扩展:

    1. 多书籍维度:给records增加按书名分组的字段,比如{ "2025-01-01": { "book": "书名", "minutes": 45 } },然后按书籍过滤热力图颜色。
    2. 周视图和月视图:在热力图顶部增加切换按钮,以周为单位查看最近 8 周的格子。
    3. 主题色切换:增加蓝色、橙色、紫色等色板,适配不同阅读主题。
    4. 多设备同步:把 JSON 同步到后端接口或支持 WebDAV 的网盘,避免换设备数据丢失。
    5. 年度回顾报告:12 月 31 日之后生成一份年度阅读报告,包含最常阅读的时段、连续最长天数、总页数估算。
    6. PWA 离线访问:将页面打包成 PWA,在手机上添加到桌面,具备离线打开能力。
    7. 导入 Markdown 笔记:每天阅读时写的笔记可以按日期关联到热力图格子,点击格子查看当天笔记。

    8.3 给学习者的练习建议

    如果想把“GitHub Heatmap for Reading”这个项目变成一次完整的前端练习,建议按以下顺序完成:

    1. 复刻坐标计算函数,自己设计一个 7 行 52 列的控制台输出版本,打印正确日期位置。
    2. 不使用任何库,用 Canvas 重写热力图渲染逻辑,理解 Canvas 和 SVG 在重绘性能上的差异。
    3. 加入 IndexedDB 存储,保存更复杂的历史记录。
    4. 用 Node.js 写一个命令行工具,从 JSON 文件直接生成 SVG 图片,用于在个人博客中展示年度阅读热力图。
    5. 把页面部署到 GitHub Pages,让其他人可以通过链接访问你的公开阅读热力图。

    最关键的一点是:不要直接复制开源热力图库。自己实现一次坐标计算、颜色映射和事件交互之后,再遇到任何日历类功能,都只需要调整数据源和渲染层,而不需要重新理解底层逻辑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询