给 About 页加一张写作热力图

发布于:2026-05-11 #Astro#写作#热力图 共 2,429 字 约 8 分钟

本站现在准备开始持续更新了,为了在 AI 的浪潮里留下一点痕迹。好吧,主要是 AI 太厉害了,最近用 AI 写代码,脑子都不用带了,为了防止大脑退化,我准备每天写点内容,记录记录学习、开发的过程,让大脑保持运作。

为了记录我的恒心与毅力,我准备仿着 GitHub 的热力图,记录我的发稿频率。当前博客主要就两类内容:长一点的随笔(essay)和一两句的絮语(bits),热力图把这两类都算进去。

数据口径

名字定义
数据源已发布的随笔(essay)+ 絮语(bits)
活跃度当天发布的内容总数。1 篇随笔 + 2 条絮语 计为 3
活跃日当天活跃度 ≥ 1 的日子
日期取 frontmatter 里的 date 字段,不是真实发布时间
草稿不计入
窗口默认 365 天,另保留 30、90 两个 preset

强度档位与颜色映射:

档位当日发布数亮色暗色
00跟随背景跟随背景
11#ead8cf#6a4a4f
22#d6a18e#9d5e55
33#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__canvaswidth: max-content; min-width: 100%,由格子总宽撑开
  • 窄屏策略:最后改成横向滚动了,没用一开始想的”自适应缩格子”。外层 .activity-heatmap__scrolleroverflow-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 渲染月份标签、周几、小方格、titlearia-label。整个组件在构建期算完,浏览器拿到的就是一堆带 data-level<span>,没有客户端 JS,也不需要 API。

窗口常量和切换:

TypeScript
UTF-8|9 Lines|
// 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 天”:

TypeScript
UTF-8|5 Lines|
const WINDOW_LABELS: Record<ActivityWindowPreset, string> = {
  month: '近 30 天',
  quarter: '近 90 天',
  year: '近 365 天'
};

最后还有个坑:本地预览时内容查询默认是带 draft 的,这对写文章本身挺方便,不用每改一次 frontmatter 才看得到效果。但热力图不一样,它记的是已发布的内容,草稿算进去的话,本地热热闹闹,一上线刷新发现少了好几格。所以 About 页里读取的时候显式写了 includeDraft: false

TypeScript
UTF-8|2 Lines|
const essays = await getVisibleEssays({ includeDraft: false });
const bits = await getSortedBits({ includeDraft: false });

草稿照常本地预览,但不会提前算进写作足迹,等真正发布那天那格才亮。