资讯动态

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

发布时间:2026/10/13 22:40:19 来源:尧图企业网站定制
在手账爱好者的日常习惯里翻开本子勾画今日的打卡方格往往是一天中最具仪式感的片刻。无论是一杯清茶、三十分钟专注阅读、晨间瑜伽还是写下一段手账随笔方格从空白被渐渐填满的过程记录着日常时光在生活里留下的温润痕迹。很多数字习惯追踪产品将数据强绑定在远程服务器数据库上一旦断网或云端服务波动习惯打卡界面就会陷入漫长的加载旋转圈。对于“秋日手账杂货铺”这样主打离线隐私自足的应用来说打卡追踪应当完全驻留在用户的本地浏览器里。本文将带领大家从零实现一个纯前端、高颜值、零后端依赖的习惯打卡热力图矩阵Habit Heatmap Matrix。我们不仅会使用 HTML5 Canvas 绘制自适应高清像素比的柔和秋日色阶方格还将结合轻量级布隆过滤器Bloom Filter与二进制位图BitMap压缩结构让长达数年的打卡历史仅占用极小的本地内存并实现连续打卡天数Streak的高性能计算。架构设计轻量位图存储与热力色阶打卡行为在本质上是“日期到布尔值或完成度数值”的离散映射。如果按传统思路把每一天的打卡时间戳、任务 ID、完成状态保存为一长串 JSON 对象存储在 IndexedDB 或 LocalStorage 中随着记录天数与习惯项的增多对象序列化和反序列化会导致不必要的内存膨胀。为了追求极致轻巧与极速冷启动我们将存储分为两层结构布隆过滤器Bloom Filter快查层用于在毫秒级内预判某习惯在特定日期区间内是否存在打卡记录避免无意义的遍历查询。定长二进制位图BitMap存储层以一年 365/366 天为基准使用一段紧凑的Uint8Array存储每一天的完成状态1 bit 代表一天单习惯一整年的打卡数据仅占用不到 46 字节。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: RequiredHeatmapRenderOptions; // 秋日复古手账暖色阶 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.PlainDateECMAScript 2026彻底剥离时区与冬夏令时带来的时间扰动。2. Canvas 视网膜高清与圆角像素抗锯齿如果直接在 CSS 中将宽高设为 800px × 120px而不对 Canvas 的width与height乘以window.devicePixelRatio在 iPhone 或 Mac 视网膜屏幕上原本精细微小的圆角小方格会出现明显的毛刺虚边。通过双倍缓冲缩放和精准贝塞尔拟合方格如同精心切割的贴纸边缘锐利且带有温润的纸质质感。3. 数据备份与秒级还原因为我们将一整年的打卡数据压缩成了仅仅 46 字节的 Base64 字符串十个习惯项连续记录十年的全部数据加起来也不过 4.6 KB 左右。这种极致的体积使得用户一键导出 JSON 备份、甚至通过 URL 的 Hash 参数跨设备快速分享打卡成就成为可能无需任何网络上传延迟。听汐的打卡手记方格里的微小坚持习惯并不需要多么宏大的誓言。很多时候它只是在手账的方格里落下一个安静的暖色圆角像是给今天的生活盖上了一枚小小的印章。写下这段 Canvas 热力图代码时窗外恰好吹来一阵秋风卷落了几片枯黄的落叶。屏幕上的每一个色块就像一片片被收藏起来的秋叶。代码里的位图压缩与布隆过滤虽是底层冰冷的二进制位运算但当它们被赋予了温暖的秋日色调并流淌在纯粹属于用户自己的离线浏览器里时技术便有了守护日常点滴温柔的力量。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑