easy-word 的学习热力图类似 GitHub 的提交热力图——按日期展示学习强度,让学生和家长一眼看到学习习惯。实现方案是 ArkUI Canvas 组件 + SQLite 聚合查询。
数据模型 热力图的数据源是 daily_logs 表,每天一条记录:
1 2 3 4 5 6 7 8 9 CREATE TABLE IF NOT EXISTS daily_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL , date TEXT NOT NULL , words_learned INTEGER DEFAULT 0 , words_reviewed INTEGER DEFAULT 0 , accuracy REAL DEFAULT 0.0 , UNIQUE (user_id, date ) )
查询最近 365 天的数据:
1 2 3 4 5 6 7 8 async getHeatmapData (userId : string , days : number = 365 ): Promise <DailyLog []> { const startDate = formatDate (Date .now () - days * 24 * 60 * 60 * 1000 ) const predicates = new relationalStore.RdbPredicates ('daily_logs' ) predicates.equalTo ('user_id' , userId) predicates.greaterThanOrEqualTo ('date' , startDate) predicates.orderByAsc ('date' ) return this .query (predicates) }
graph TD
A["daily_logs 表"] --> B["按日期聚合"]
B --> C["计算学习强度"]
C --> D["映射到颜色"]
D --> E["Canvas 绘制"]
颜色映射 学习强度映射到 5 级颜色,类似 GitHub 的绿色渐变:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 function getHeatColor (intensity : number ): string { const colors = [ '#ebedf0' , '#9be9a8' , '#40c463' , '#30a14e' , '#216e39' , ] return colors[Math .min (intensity, 4 )] } function calcIntensity (learned : number , reviewed : number ): number { const total = learned + reviewed if (total === 0 ) return 0 if (total <= 5 ) return 1 if (total <= 15 ) return 2 if (total <= 30 ) return 3 return 4 }
Canvas 绘制 ArkUI 的 Canvas 组件和 Web Canvas API 类似:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 @Component struct HeatmapChart { @Prop data : DailyLog [] = [] private settings : RenderingContextSettings = new RenderingContextSettings (true ) private context : CanvasRenderingContext2D = new CanvasRenderingContext2D (this .settings ) build ( ) { Canvas (this .context ) .width ('100%' ) .height (120 ) .onReady (() => this .draw ()) } draw ( ) { const ctx = this .context const cellSize = 14 const gap = 3 const cols = 53 const rows = 7 ctx.clearRect (0 , 0 , cols * (cellSize + gap), rows * (cellSize + gap)) const dateMap = new Map (this .data .map (d => [d.date , d])) const today = new Date () for (let week = 0 ; week < cols; week++) { for (let day = 0 ; day < rows; day++) { const date = new Date (today) date.setDate (date.getDate () - (cols - 1 - week) * 7 - (6 - day)) const dateStr = formatDate (date.getTime ()) const log = dateMap.get (dateStr) const intensity = log ? calcIntensity (log.words_learned , log.words_reviewed ) : 0 ctx.fillStyle = getHeatColor (intensity) ctx.fillRect ( week * (cellSize + gap), day * (cellSize + gap), cellSize, cellSize ) } } } }
月份标签和星期标签 在热力图上方绘制月份标签,左侧绘制星期标签:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 drawMonthLabels (ctx : CanvasRenderingContext2D , cellSize : number , gap : number ) { ctx.fillStyle = '#666' ctx.font = '10px sans-serif' const months = ['1月' , '2月' , '3月' , '4月' , '5月' , '6月' , '7月' , '8月' , '9月' , '10月' , '11月' , '12月' ] for (let m = 0 ; m < 12 ; m++) { const weekIndex = Math .floor (m * 4.33 ) ctx.fillText (months[m], weekIndex * (cellSize + gap), -5 ) } } drawWeekLabels (ctx : CanvasRenderingContext2D , cellSize : number , gap : number ) { ctx.fillStyle = '#666' ctx.font = '10px sans-serif' const days = ['日' , '一' , '二' , '三' , '四' , '五' , '六' ] for (let d = 0 ; d < 7 ; d += 2 ) { ctx.fillText (days[d], -20 , d * (cellSize + gap) + 10 ) } }
Canvas 没有原生的点击事件绑定到单个绘制元素。解决方案是在 Canvas 上覆盖一个透明的点击区域,根据点击坐标计算是哪一天:
1 2 3 4 5 6 7 8 9 10 11 12 .onClick ((event ) => { const x = event.x const y = event.y const week = Math .floor (x / (cellSize + gap)) const day = Math .floor (y / (cellSize + gap)) const date = this .weekToDate (week, day) const log = this .dateMap .get (date) if (log) { this .showTooltip (log) } })
统计卡片 热力图下方显示汇总统计:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 calcStreak (logs : DailyLog []): number { let maxStreak = 0 let currentStreak = 0 for (const log of logs) { if (log.words_learned + log.words_reviewed > 0 ) { currentStreak++ maxStreak = Math .max (maxStreak, currentStreak) } else { currentStreak = 0 } } return maxStreak } calcTotalLearned (logs : DailyLog []): number { return logs.reduce ((sum, l ) => sum + l.words_learned , 0 ) }
性能优化 365 天的数据量不大(~365 条记录),但 Canvas 绘制需要优化:
数据缓存 :aboutToAppear 时查询一次,切换用户时才重新查询
Canvas 尺寸 :cellSize=14, gap=3,总宽度约 53 * 17 = 901px,适配手机屏幕
离屏绘制 :先在内存中构建 ImageBitmap,再一次性绘制到 Canvas
数据可视化的价值不是”好看”,是”一眼看到问题”。连续学习天数断了?某个月学习量骤降?热力图让这些问题无处藏身。