给 About 页加一张写作热力图
本站现在准备开始持续更新了,为了在 AI 的浪潮里留下一点痕迹。好吧,主要是 AI 太厉害了,最近用 AI 写代码,脑子都不用带了,为了防止大脑退化,我准备每天写点内容,记录记录学习、开发的过程,让大脑保持运作。
为了记录我的恒心与毅力,我准备仿着 GitHub 的热力图,记录我的发稿频率。当前博客主要就两类内容:长一点的随笔(essay)和一两句的絮语(bits),热力图把这两类都算进去。
数据口径
| 名字 | 定义 |
|---|---|
| 数据源 | 已发布的随笔(essay)+ 絮语(bits) |
| 活跃度 | 当天发布的内容总数。1 篇随笔 + 2 条絮语 计为 3 |
| 活跃日 | 当天活跃度 ≥ 1 的日子 |
| 日期 | 取 frontmatter 里的 date 字段,不是真实发布时间 |
| 草稿 | 不计入 |
| 窗口 | 默认 365 天,另保留 30、90 两个 preset |
强度档位与颜色映射:
| 档位 | 当日发布数 | 亮色 | 暗色 |
|---|---|---|---|
| 0 | 0 | 跟随背景 | 跟随背景 |
| 1 | 1 | #ead8cf | #6a4a4f |
| 2 | 2 | #d6a18e | #9d5e55 |
| 3 | 3 | #b8644d | #d07b64 |
| 4 | ≥4 | #8f382f | #f0a36f |
视图怎么搭
UI 这一块的思路分两步:先把 GitHub 那张图的结构拆清楚,再决定自己复刻时每一处怎么处理。
GitHub 热力图的结构
| 维度 | GitHub 的做法 |
|---|---|
| 网格 | 7 行 × 53 列。行 = 一周 7 天,列 = 一整年的每一周(跨年多出一列) |
| 单元格 | 约 10px 见方,圆角 2px,间距 2px |
| 颜色档 | 5 档(含 0) |
| 周起始 | 周日(美式) |
| 顶部标签 | 月份名,挂在那一周里包含月初的列上 |
| 左侧标签 | 周几,只显示三个:Mon / Wed / Fri |
| Hover | 自定义 tooltip,显示日期和 contribution 数 |
| 点击 | 跳转到当日的 commit 列表 |
| 容器 | 固定最大宽度,超出时横向滚动 |
自己复刻要回答的问题
外层容器
- 用什么元素:
<div>加aria-label,不需要 caption 所以不用<figure> - 最大宽度:不设固定
max-width,内层.activity-heatmap__canvas用width: max-content; min-width: 100%,由格子总宽撑开 - 窄屏策略:最后改成横向滚动了,没用一开始想的”自适应缩格子”。外层
.activity-heatmap__scroller加overflow-x: auto;@media (max-width: 480px)下把单元格从 9px 压到 8px 缓冲一下,但 53 列超过窄屏宽度时仍会出现横向滚动条。压格子尺寸只能撑到一定程度,再小就辨不出颜色档了,最后认了滚动条这个让步
网格本体
- 实现方式:CSS Grid,
grid-template-rows: repeat(7, ...)×grid-template-columns: repeat(N, ...),N 按窗口长度换算(365 天 → 52 或 53) - 周起始:周一。
getMondayFirstWeekdayIndex用(date.getUTCDay() + 6) % 7,让周一 = 0、周日 = 6。GitHub 是周日起,我换成周一起是因为中文语境里”一周从周一开始”更自然 - 起止补位:窗口的第一天大概率不落在周首,最左列前面要补几个”完全空的占位”(不是 level=0 的格子),让真正的第一天对齐到对应的行。最后一列同理。
单个格子
- 尺寸 / 间距 / 圆角:单元格
9px × 9px,间距3px,圆角2px;窄屏(max-width: 480px)下单元格压到8px,间距保持3px - 颜色由
data-level属性驱动,CSS 变量在组件根节点定义,色值见前面的口径表 - level=0 的格子用
color-mix(in srgb, var(--tg-fg) 7%, var(--tg-bg) 93%),让灰度跟着亮暗主题走,避免出现固定的硬灰格子
月份标签
- 顶部一行,挂在那一周里出现 1 号的列上
- 一列跨两个月时按哪个月算:换个思路。逻辑改成”只在包含 1 号的那一周挂这个月的标签”。一列对应连续 7 天,里面最多只会有一个”1 号”,所以根本不存在”一列同属两月、得选一个”的歧义。另外窗口起始日所在那一周强制挂一次,避免左侧一开头一直没标签
- 跨年那一周怎么处理:没做特殊处理。1 月 1 日所在那列就标”1月”,不显示年份,视觉上是”…11月 12月 1月…”一直接过去。如果以后要做年份分隔,再加一层逻辑(暂时不需要)
周几标签
- 左侧一列,只标周一、周三、周五,参考 GitHub 的省略策略
交互
- hover:用原生
title属性显示当天日期 + 发布数,不引 JS - 点击:不做。GitHub 的”跳到当日 commits”对应不到我的内容结构,没用
- 不做的还有:连续打卡天数、年度总结、趋势图、读者切换窗口的下拉
能预想到的坑
- 起止补的”空占位”和 level=0 的格子视觉上要能区分(一个完全不显示,一个显示成 color-mix 的浅色)
- 月份标签跨列对齐,尤其是跨年那一周挂哪一列
- 暗色模式下颜色饱和度需要压一档(朱砂在深色背景容易糊),具体偏移量见口径表
- 53 列在某些屏幕宽度下挤不下,自适应阈值要测
- 月份标签那一行的
grid-template-columns要和下面网格完全一致,否则列宽差一像素,月份就会和下面格子错开 - 周几标签那一列的
line-height要等于--activity-cell,否则”一/三/五/日”四个字垂直对不到对应的那一行格子 - 每个格子的
aria-label要把日期、随笔数、絮语数都拼出来(formatDayLabel),不然屏幕阅读器念出来只是一堆没意义的色块 - 原生
title属性的 hover 延迟在桌面端能用,但移动端等于没 tooltip;为了不引 JS 就先这样了
代码实现
ECharts、D3、calendar-heatmap 这些包都瞄了一眼,最后都没用。引一个库要付的代价:多几十 KB 的 JS、多一套 API 要熟、还可能跟自己的 CSS 主题打架。一张静态图为这些不划算,自己拼一个反倒省事。
最后结构就三个文件:src/pages/about/index.astro 拉数据选窗口;src/lib/activity.ts 按 UTC 日期归桶,生成窗口里每一天的 { essayCount, bitsCount, intensity };src/components/ActivityHeatmap.astro 拿 view model 渲染月份标签、周几、小方格、title 和 aria-label。整个组件在构建期算完,浏览器拿到的就是一堆带 data-level 的 <span>,没有客户端 JS,也不需要 API。
窗口常量和切换:
// src/lib/activity.ts
const WINDOW_DAY_COUNTS: Record<ActivityWindowPreset, number> = {
month: 30,
quarter: 90,
year: 365
};
// src/pages/about/index.astro
const activityWindow: ActivityWindowPreset = 'year';没把切换做成读者可见的 UI,我自己一年也就换那么一次,多个 select 出来反而碍眼。
命名上有个小纠结:preset 对外叫什么?“year” 既可以理解成自然年也可以理解成最近 365 天,对读者有歧义。但 month / quarter / year 写代码顺手、变量短,也懒得换。最后内外分开,代码里 preset 保留英文,对外文案统一翻成”近 N 天”:
const WINDOW_LABELS: Record<ActivityWindowPreset, string> = {
month: '近 30 天',
quarter: '近 90 天',
year: '近 365 天'
};最后还有个坑:本地预览时内容查询默认是带 draft 的,这对写文章本身挺方便,不用每改一次 frontmatter 才看得到效果。但热力图不一样,它记的是已发布的内容,草稿算进去的话,本地热热闹闹,一上线刷新发现少了好几格。所以 About 页里读取的时候显式写了 includeDraft: false:
const essays = await getVisibleEssays({ includeDraft: false });
const bits = await getSortedBits({ includeDraft: false });草稿照常本地预览,但不会提前算进写作足迹,等真正发布那天那格才亮。