
在实际的工程练习中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-DDvalue 是当天阅读分钟数。不使用Date对象作为 key是因为对象转字符串后会变成类似Wed Jan 01 2025 08:00:00 GMT0800的格式排序和比较都容易出错。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 读写保存数据时需要把整个对象序列化后写入 localStorageconst 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 点击格子没有反应现象鼠标点击任何格子颜色都不变化。排查路径打开浏览器控制台确认没有 JavaScript 报错。检查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 扩展方向当前版本只支持单一年份、单一维度的阅读时长统计。继续迭代时可以按以下方向扩展多书籍维度给records增加按书名分组的字段比如{ 2025-01-01: { book: 书名, minutes: 45 } }然后按书籍过滤热力图颜色。周视图和月视图在热力图顶部增加切换按钮以周为单位查看最近 8 周的格子。主题色切换增加蓝色、橙色、紫色等色板适配不同阅读主题。多设备同步把 JSON 同步到后端接口或支持 WebDAV 的网盘避免换设备数据丢失。年度回顾报告12 月 31 日之后生成一份年度阅读报告包含最常阅读的时段、连续最长天数、总页数估算。PWA 离线访问将页面打包成 PWA在手机上添加到桌面具备离线打开能力。导入 Markdown 笔记每天阅读时写的笔记可以按日期关联到热力图格子点击格子查看当天笔记。8.3 给学习者的练习建议如果想把“GitHub Heatmap for Reading”这个项目变成一次完整的前端练习建议按以下顺序完成复刻坐标计算函数自己设计一个 7 行 52 列的控制台输出版本打印正确日期位置。不使用任何库用 Canvas 重写热力图渲染逻辑理解 Canvas 和 SVG 在重绘性能上的差异。加入 IndexedDB 存储保存更复杂的历史记录。用 Node.js 写一个命令行工具从 JSON 文件直接生成 SVG 图片用于在个人博客中展示年度阅读热力图。把页面部署到 GitHub Pages让其他人可以通过链接访问你的公开阅读热力图。最关键的一点是不要直接复制开源热力图库。自己实现一次坐标计算、颜色映射和事件交互之后再遇到任何日历类功能都只需要调整数据源和渲染层而不需要重新理解底层逻辑。