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, -- YYYY-MM-DD
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)
}

颜色映射

学习强度映射到 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 {
// intensity: 0-4
const colors = [
'#ebedf0', // 0: 无学习(灰色)
'#9be9a8', // 1: 轻度(浅绿)
'#40c463', // 2: 中度(绿色)
'#30a14e', // 3: 较强(深绿)
'#216e39', // 4: 强力(最深绿)
]
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 // 53 周
const rows = 7 // 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)
}
}

Tooltip:点击查看当日详情

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 绘制需要优化:

  1. 数据缓存:aboutToAppear 时查询一次,切换用户时才重新查询
  2. Canvas 尺寸:cellSize=14, gap=3,总宽度约 53 * 17 = 901px,适配手机屏幕
  3. 离屏绘制:先在内存中构建 ImageBitmap,再一次性绘制到 Canvas

数据可视化的价值不是”好看”,是”一眼看到问题”。连续学习天数断了?某个月学习量骤降?热力图让这些问题无处藏身。