ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

纯前端手账习惯打卡热力图:基于布隆过滤器与轻量统计的活跃度矩阵可视化

纯前端手账习惯打卡热力图:基于布隆过滤器与轻量统计的活跃度矩阵可视化

在手账爱好者的日常习惯里,翻开本子勾画今日的打卡方格,往往是一天中最具仪式感的片刻。无论是一杯清茶、三十分钟专注阅读、晨间瑜伽,还是写下一段手账随笔,方格从空白被渐渐填满的过程,记录着日常时光在生活里留下的温润痕迹。

很多数字习惯追踪产品将数据强绑定在远程服务器数据库上,一旦断网或云端服务波动,习惯打卡界面就会陷入漫长的加载旋转圈。对于“秋日手账杂货铺”这样主打离线隐私自足的应用来说,打卡追踪应当完全驻留在用户的本地浏览器里。

本文将带领大家从零实现一个纯前端、高颜值、零后端依赖的习惯打卡热力图矩阵(Habit Heatmap Matrix)。我们不仅会使用 HTML5 Canvas 绘制自适应高清像素比的柔和秋日色阶方格,还将结合轻量级布隆过滤器(Bloom Filter)与二进制位图(BitMap)压缩结构,让长达数年的打卡历史仅占用极小的本地内存,并实现连续打卡天数(Streak)的高性能计算。


架构设计:轻量位图存储与热力色阶

打卡行为在本质上是“日期到布尔值(或完成度数值)”的离散映射。如果按传统思路把每一天的打卡时间戳、任务 ID、完成状态保存为一长串 JSON 对象,存储在 IndexedDB 或 LocalStorage 中,随着记录天数与习惯项的增多,对象序列化和反序列化会导致不必要的内存膨胀。

为了追求极致轻巧与极速冷启动,我们将存储分为两层结构:

  1. 布隆过滤器(Bloom Filter)快查层:用于在毫秒级内预判某习惯在特定日期区间内是否存在打卡记录,避免无意义的遍历查询。
  2. 定长二进制位图(BitMap)存储层:以一年 365/366 天为基准,使用一段紧凑的Uint8Array存储每一天的完成状态(1 bit 代表一天),单习惯一整年的打卡数据仅占用不到 46 字节。
  3. Canvas 柔和色阶渲染器:基于设备物理像素比(DPR)动态缩放,采用秋日暖调色彩映射算法,支持平滑悬浮提示与月份分割标签。
+--------------------------------------------------------------+ | 用户交互层 (Vue 3 / 手账界面) | +--------------------------------------------------------------+ | v +--------------------------------------------------------------+ | 习惯管理调度器 (HabitTracker) | +--------------------------------------------------------------+ | | v v +-----------------------+ +-----------------------+ | 布隆过滤器去重快查层 | | 定长位图紧凑存储层 | | (轻量双重哈希快速校验)| | (Uint8Array 46 字节) | +-----------------------+ +-----------------------+ | v +--------------------------------------------------------------+ | Canvas 2D 离屏秋日色阶渲染引擎 | | (设备像素比自适应 + 贝塞尔圆角方格 + 触控碰撞感知) | +--------------------------------------------------------------+

核心实现:极轻量位图与布隆过滤器

在现代纯客户端应用中,纯纯的算法美感能带来惊人的性能回报。下面是专为离线手账打造的打卡位图管理器与简易布隆过滤器实现:

// utils/habit-storage.ts /** * 轻量级哈希函数(基于 MurmurHash3 的前端简化版) */ function simpleHash(str: string, seed: number): number { let h = seed ^ str.length; for (let i = 0; i < str.length; i++) { h = Math.imul(h ^ str.charCodeAt(i), 0x5bd1e995); h ^= h >>> 15; } return (h >>> 0); } /** * 客户端轻量布隆过滤器:用于快速排查习惯项在特定年份是否打过卡 */ export class CompactBloomFilter { private bitArray: Uint8Array; private size: number; constructor(sizeInBytes = 32) { this.size = sizeInBytes * 8; this.bitArray = new Uint8Array(sizeInBytes); } public add(key: string): void { const h1 = simpleHash(key, 0x12345678) % this.size; const h2 = simpleHash(key, 0x87654321) % this.size; this.setBit(h1); this.setBit(h2); } public mightContain(key: string): boolean { const h1 = simpleHash(key, 0x12345678) % this.size; const h2 = simpleHash(key, 0x87654321) % this.size; return this.getBit(h1) && this.getBit(h2); } private setBit(pos: number): void { const byteIndex = Math.floor(pos / 8); const bitIndex = pos % 8; this.bitArray[byteIndex] |= (1 << bitIndex); } private getBit(pos: number): boolean { const byteIndex = Math.floor(pos / 8); const bitIndex = pos % 8; return (this.bitArray[byteIndex] & (1 << bitIndex)) !== 0; } } /** * 年度打卡位图记录:以天数索引 (0 ~ 365) 为偏移量 */ export class YearHabitBitMap { public year: number; private buffer: Uint8Array; constructor(year: number, existingBuffer?: Uint8Array) { this.year = year; const isLeapYear = (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0); const totalDays = isLeapYear ? 366 : 365; const byteLength = Math.ceil(totalDays / 8); this.buffer = existingBuffer || new Uint8Array(byteLength); } /** * 将具体日期 (YYYY-MM-DD) 转换为一年中的天数序号 (0-based) */ public getDayOfYear(dateStr: string): number { const date = new Date(dateStr); const start = new Date(date.getFullYear(), 0, 0); const diff = date.getTime() - start.getTime(); const oneDay = 1000 * 60 * 60 * 24; return Math.floor(diff / oneDay) - 1; } /** * 记录某天打卡 */ public markDay(dayOfYear: number, completed = true): void { if (dayOfYear < 0 || dayOfYear >= this.buffer.length * 8) return; const byteIdx = Math.floor(dayOfYear / 8); const bitIdx = dayOfYear % 8; if (completed) { this.buffer[byteIdx] |= (1 << bitIdx); } else { this.buffer[byteIdx] &= ~(1 << bitIdx); } } /** * 查询某天是否已打卡 */ public isDayMarked(dayOfYear: number): boolean { if (dayOfYear < 0 || dayOfYear >= this.buffer.length * 8) return false; const byteIdx = Math.floor(dayOfYear / 8); const bitIdx = dayOfYear % 8; return (this.buffer[byteIdx] & (1 << bitIdx)) !== 0; } /** * 导出为 Base64 紧凑文本,用于持久化至 LocalStorage */ public serialize(): string { return btoa(String.fromCharCode(...this.buffer)); } /** * 从 Base64 还原位图 */ public static deserialize(year: number, base64: string): YearHabitBitMap { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return new YearHabitBitMap(year, bytes); } /** * 计算当前连续打卡天数(Streak) */ public calculateCurrentStreak(todayDayOfYear: number): number { let streak = 0; for (let i = todayDayOfYear; i >= 0; i--) { if (this.isDayMarked(i)) { streak++; } else { // 如果今天尚未打卡,但昨天打卡了,允许连续性顺延至昨天 if (i === todayDayOfYear) { continue; } break; } } return streak; } }

Canvas 秋日手账暖色阶矩阵渲染

传统的热力图常常采用高饱和度的翠绿色(类似程序员熟悉的代码贡献图),但在手账的氛围里,刺眼的绿光与米黄色纸质背景格格不入。我们定制了一组如秋日枫叶与麦穗般的温和色阶:

  • 未打卡底色:#f4ede2(温柔的羊皮纸浅底)
  • 1级活跃:#edd2b3(浅淡麦芽黄)
  • 2级活跃:#e5ab82(落叶暖橘)
  • 3级活跃:#d97d54(秋日赤红枫叶)
  • 4级活跃:#a94d3b(浓郁复古枣红)

下面是 Canvas 渲染与高清像素适配的高性能实现:

// components/HabitHeatmapCanvas.ts export interface HeatmapRenderOptions { canvas: HTMLCanvasElement; year: number; bitmap: YearHabitBitMap; cellSize?: number; cellGap?: number; cornerRadius?: number; onHoverCell?: (dateStr: string, completed: boolean, x: number, y: number) => void; } export class HabitHeatmapRenderer { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private options: Required<HeatmapRenderOptions>; // 秋日复古手账暖色阶 private palette = { empty: '#f4ede2', active: '#d97d54', border: 'rgba(180, 140, 110, 0.15)', monthText: '#8d7b68' }; constructor(options: HeatmapRenderOptions) { this.canvas = options.canvas; const context = this.canvas.getContext('2d'); if (!context) throw new Error('无法初始化 Canvas 2D 绘图上下文'); this.ctx = context; this.options = { canvas: options.canvas, year: options.year, bitmap: options.bitmap, cellSize: options.cellSize ?? 14, cellGap: options.cellGap ?? 3, cornerRadius: options.cornerRadius ?? 3, onHoverCell: options.onHoverCell ?? (() => {}) }; this.bindEvents(); } /** * 执行渲染:自适应视网膜屏幕 DPR */ public render(): void { const dpr = window.devicePixelRatio || 1; const weeksInYear = 53; const daysInWeek = 7; const { cellSize, cellGap } = this.options; const matrixWidth = weeksInYear * (cellSize + cellGap) + cellGap + 30; // 左侧预留星期标题 const matrixHeight = daysInWeek * (cellSize + cellGap) + cellGap + 24; // 顶部预留月份标题 this.canvas.width = matrixWidth * dpr; this.canvas.height = matrixHeight * dpr; this.canvas.style.width = `${matrixWidth}px`; this.canvas.style.height = `${matrixHeight}px`; this.ctx.scale(dpr, dpr); this.ctx.clearRect(0, 0, matrixWidth, matrixHeight); // 绘制月份提示行 this.renderMonthLabels(matrixWidth); // 遍历每一周、每一天进行圆角方格绘制 const startDate = new Date(this.options.year, 0, 1); const startDayOfWeek = startDate.getDay(); // 0 是周日 let currentDayOfYear = 0; const isLeapYear = (this.options.year % 4 === 0 && this.options.year % 100 !== 0) || (this.options.year % 400 === 0); const totalDays = isLeapYear ? 366 : 365; for (let w = 0; w < weeksInYear; w++) { for (let d = 0; d < 7; d++) { // 计算对应的年初偏移量 const dayIndex = w * 7 + d - startDayOfWeek; if (dayIndex < 0 || dayIndex >= totalDays) { continue; } const isCompleted = this.options.bitmap.isDayMarked(dayIndex); const x = 30 + w * (cellSize + cellGap); const y = 20 + d * (cellSize + cellGap); this.drawRoundedCell(x, y, cellSize, this.options.cornerRadius, isCompleted); } } } private drawRoundedCell(x: number, y: number, size: number, radius: number, completed: boolean): void { const ctx = this.ctx; ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.arcTo(x + size, y, x + size, y + size, radius); ctx.arcTo(x + size, y + size, x, y + size, radius); ctx.arcTo(x, y + size, x, y, radius); ctx.arcTo(x, y, x + size, y, radius); ctx.closePath(); ctx.fillStyle = completed ? this.palette.active : this.palette.empty; ctx.fill(); // 细微描边增强手账图钉般的物理感 ctx.lineWidth = 1; ctx.strokeStyle = this.palette.border; ctx.stroke(); } private renderMonthLabels(matrixWidth: number): void { const ctx = this.ctx; ctx.fillStyle = this.palette.monthText; ctx.font = '11px "PingFang SC", "Hiragino Sans GB", sans-serif'; ctx.textBaseline = 'top'; const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; monthNames.forEach((name, idx) => { // 粗略按 4.3 周一个月分布标签 const weekOffset = Math.floor(idx * 4.35); const x = 30 + weekOffset * (this.options.cellSize + this.options.cellGap); ctx.fillText(name, x, 2); }); } private bindEvents(): void { this.canvas.addEventListener('mousemove', (e) => { const rect = this.canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const { cellSize, cellGap } = this.options; const col = Math.floor((mouseX - 30) / (cellSize + cellGap)); const row = Math.floor((mouseY - 20) / (cellSize + cellGap)); if (col >= 0 && col < 53 && row >= 0 && row < 7) { const startDate = new Date(this.options.year, 0, 1); const dayIndex = col * 7 + row - startDate.getDay(); const isLeapYear = (this.options.year % 4 === 0 && this.options.year % 100 !== 0) || (this.options.year % 400 === 0); const totalDays = isLeapYear ? 366 : 365; if (dayIndex >= 0 && dayIndex < totalDays) { const targetDate = new Date(this.options.year, 0, dayIndex + 1); const dateStr = targetDate.toISOString().split('T')[0]; const completed = this.options.bitmap.isDayMarked(dayIndex); this.options.onHoverCell(dateStr, completed, e.clientX, e.clientY); } } }); } }

避坑指南与离线体验优化

在实现离线打卡热力图的过程中,有几个前端工程师容易忽视但对使用体验至关重要的细节:

1. 跨时区与夏令时的时间戳陷阱

很多开发者直接用Math.floor(Date.now() / 86400000)来计算天数。然而,如果在跨越夏令时(或者用户在不同时区间漫游旅行)时,一天的毫秒数并不严格等于86,400,000毫秒,这会导致打卡方格错位或年初计算偏移一整天。

在上面的实现中,我们采用new Date(year, month, day)并显式比对年份第 0 天的时间差,或者使用最新的Temporal.PlainDate(ECMAScript 2026),彻底剥离时区与冬夏令时带来的时间扰动。

2. Canvas 视网膜高清与圆角像素抗锯齿

如果直接在 CSS 中将宽高设为 800px × 120px,而不对 Canvas 的width与height乘以window.devicePixelRatio,在 iPhone 或 Mac 视网膜屏幕上,原本精细微小的圆角小方格会出现明显的毛刺虚边。通过双倍缓冲缩放和精准贝塞尔拟合,方格如同精心切割的贴纸,边缘锐利且带有温润的纸质质感。

3. 数据备份与秒级还原

因为我们将一整年的打卡数据压缩成了仅仅 46 字节的 Base64 字符串,十个习惯项连续记录十年的全部数据,加起来也不过 4.6 KB 左右。这种极致的体积使得用户一键导出 JSON 备份、甚至通过 URL 的 Hash 参数跨设备快速分享打卡成就成为可能,无需任何网络上传延迟。


听汐的打卡手记:方格里的微小坚持

习惯并不需要多么宏大的誓言。很多时候,它只是在手账的方格里落下一个安静的暖色圆角,像是给今天的生活盖上了一枚小小的印章。

写下这段 Canvas 热力图代码时,窗外恰好吹来一阵秋风,卷落了几片枯黄的落叶。屏幕上的每一个色块,就像一片片被收藏起来的秋叶。代码里的位图压缩与布隆过滤,虽是底层冰冷的二进制位运算,但当它们被赋予了温暖的秋日色调,并流淌在纯粹属于用户自己的离线浏览器里时,技术便有了守护日常点滴温柔的力量。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进