feat(heatmap): add HeatmapCanvas composable with three view layouts

- Daily: 53×7 GitHub-style grid with week grouping and left-padding
- Weekly: 52×1 single-row grid
- Cumulative: 53×7 grid using cumulative token data
- 6-level pink color gradient (light/dark mode)
- Canvas drawRoundRect with 11dp blocks, 3dp gap, 3dp radius
- Bottom month labels (Chinese), color legend, horizontal scroll
- Tap interaction with coordinate-to-cell mapping
This commit is contained in:
WaterRain 2026-08-06 07:21:07 +00:00
parent b8479eebf3
commit 6e4a5ff9cb
No known key found for this signature in database

View File

@ -0,0 +1,363 @@
package com.rainy.token.ui.heatmap
import android.graphics.Paint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rainy.token.ui.theme.inkMuted
import java.time.Instant
import java.time.ZoneOffset
// ── 热力图颜色 ──────────────────────────────────────────────
/** 亮色模式 6 级粉色梯度 */
private val LightHeatmapColors = listOf(
Color(0xFFEFE0E5), // Level 0: 极浅暖灰
Color(0xFFFFD6E0), // Level 1: 浅樱粉
Color(0xFFFFB3C6), // Level 2: 草莓粉柔
Color(0xFFFF85A2), // Level 3: 草莓粉
Color(0xFFFF6B8E), // Level 4: 草莓粉深
Color(0xFFE84973), // Level 5: 玫红
)
/** 暗色模式 6 级粉色梯度 */
private val DarkHeatmapColors = listOf(
Color(0xFF3A2A30), // Level 0: 暖深灰
Color(0xFF4A2E3A), // Level 1
Color(0xFF6B3D4A), // Level 2
Color(0xFF8B4D5A), // Level 3
Color(0xFFB86880), // Level 4
Color(0xFFD67F9A), // Level 5
)
/** 中文月份名称 */
private val ChineseMonthNames = listOf(
"一月", "二月", "三月", "四月", "五月", "六月",
"七月", "八月", "九月", "十月", "十一月", "十二月",
)
// ── 辅助类型与函数 ──────────────────────────────────────────
/** 月份标签 */
private data class MonthLabel(
val weekIndex: Int,
val label: String,
)
/** 从 UTC 时间戳获取星期几 (0=Sunday, 6=Saturday) */
private fun dayOfWeekFromTs(ts: Long): Int {
val dayOfWeek = Instant.ofEpochMilli(ts).atZone(ZoneOffset.UTC).dayOfWeek.value
return dayOfWeek % 7 // Sunday=7 → 0
}
/** 从 UTC 时间戳获取月份 (0=January) */
private fun monthFromTs(ts: Long): Int {
return Instant.ofEpochMilli(ts).atZone(ZoneOffset.UTC).monthValue - 1
}
/**
* 将每日数据按周分组(参考 react-activity-calendar 的 groupByWeeks 逻辑)。
* 周起始日为周日(weekStart = 0)。
* 返回 List<List<HeatmapDayData?>>,外层为周,内层为星期 0-6,null 表示空位。
*/
private fun groupByWeeks(dailyData: List<HeatmapDayData>): List<List<HeatmapDayData?>> {
if (dailyData.isEmpty()) return emptyList()
val firstDow = dayOfWeekFromTs(dailyData[0].dayTs)
// 左侧填充空位,使第一周从周日开始
val padded = ArrayList<HeatmapDayData?>(firstDow + dailyData.size)
repeat(firstDow) { padded.add(null) }
padded.addAll(dailyData)
val numWeeks = (padded.size + 6) / 7
return (0 until numWeeks).map { w ->
(0 until 7).map { d ->
val idx = w * 7 + d
if (idx < padded.size) padded[idx] else null
}
}
}
/** 从按周分组的每日数据计算月份标签 */
private fun getMonthLabels(weeks: List<List<HeatmapDayData?>>): List<MonthLabel> {
if (weeks.isEmpty()) return emptyList()
val rawLabels = mutableListOf<MonthLabel>()
var prevMonth = -1
for (weekIndex in weeks.indices) {
val firstDay = weeks[weekIndex].firstOrNull { it != null } ?: continue
val month = monthFromTs(firstDay.dayTs)
if (weekIndex == 0 || month != prevMonth) {
rawLabels.add(MonthLabel(weekIndex, ChineseMonthNames[month]))
prevMonth = month
}
}
return filterMonthLabels(rawLabels, weeks.size)
}
/** 从每周数据计算月份标签 */
private fun getWeeklyMonthLabels(weeklyData: List<HeatmapWeekData>): List<MonthLabel> {
if (weeklyData.isEmpty()) return emptyList()
val rawLabels = mutableListOf<MonthLabel>()
var prevMonth = -1
for (weekIndex in weeklyData.indices) {
val month = monthFromTs(weeklyData[weekIndex].weekStartTs)
if (weekIndex == 0 || month != prevMonth) {
rawLabels.add(MonthLabel(weekIndex, ChineseMonthNames[month]))
prevMonth = month
}
}
return filterMonthLabels(rawLabels, weeklyData.size)
}
/**
* 过滤月份标签:跳过间距不足的标签(参考 react-activity-calendar 的 label.ts)。
* - 第一个标签:若与第二个标签间距 < minWeeks,跳过
* - 最后一个标签:若剩余周数 < minWeeks,跳过
*/
private fun filterMonthLabels(rawLabels: List<MonthLabel>, totalWeeks: Int): List<MonthLabel> {
val minWeeks = 3
return rawLabels.filterIndexed { index, label ->
when {
index == 0 -> {
rawLabels.getOrNull(1)?.let { it.weekIndex - label.weekIndex >= minWeeks } ?: false
}
index == rawLabels.lastIndex -> {
totalWeeks - label.weekIndex >= minWeeks
}
else -> true
}
}
}
// ── HeatmapCanvas Composable ───────────────────────────────
/**
* Token 活动热力图 Canvas 绘制组件。
*
* 支持三种视图:
* - [HeatmapViewMode.DAILY]:53×7 格子网格,每日 Token 使用
* - [HeatmapViewMode.WEEKLY]:52×1 单行格子,每周 Token 总量
* - [HeatmapViewMode.CUMULATIVE]:53×7 格子网格,累计 Token
*
* 使用 Compose Canvas 绘制圆角矩形格子,底部显示中文月份标签,
* 支持水平滚动和格子点击。
*
* @param state UI 状态,包含数据与视图模式
* @param modifier 布局修饰符
* @param onDayClick 每日/累计视图格子点击回调
* @param onWeekClick 每周视图格子点击回调
*/
@Composable
fun HeatmapCanvas(
state: HeatmapUiState,
modifier: Modifier = Modifier,
onDayClick: (HeatmapDayData) -> Unit = {},
onWeekClick: (HeatmapWeekData) -> Unit = {},
) {
val darkTheme = isSystemInDarkTheme()
val colors = if (darkTheme) DarkHeatmapColors else LightHeatmapColors
val mutedColor = inkMuted()
val density = LocalDensity.current
// 格子参数
val blockSize = 11.dp
val gap = 3.dp
val cornerRadius = 3.dp
// 预计算 px 值(供 Canvas 绘制和点击检测共用)
val blockSizePx = with(density) { blockSize.toPx() }
val gapPx = with(density) { gap.toPx() }
val cellStepPx = blockSizePx + gapPx
val cornerRadiusPx = with(density) { cornerRadius.toPx() }
val isWeekly = state.viewMode == HeatmapViewMode.WEEKLY
// 构建网格数据(每日/累计视图用)
val gridWeeks = remember(state.dailyData, state.cumulativeData, state.viewMode) {
when (state.viewMode) {
HeatmapViewMode.DAILY -> groupByWeeks(state.dailyData)
HeatmapViewMode.CUMULATIVE -> groupByWeeks(state.cumulativeData)
HeatmapViewMode.WEEKLY -> emptyList()
}
}
// 月份标签
val monthLabels = remember(gridWeeks, state.weeklyData, isWeekly) {
if (isWeekly) getWeeklyMonthLabels(state.weeklyData) else getMonthLabels(gridWeeks)
}
val cols = if (isWeekly) state.weeklyData.size else gridWeeks.size
val rows = if (isWeekly) 1 else 7
// 加载中或无数据
if (state.loading || cols == 0) {
Box(
modifier = modifier
.fillMaxWidth()
.height(if (state.loading) 200.dp else 120.dp),
contentAlignment = Alignment.Center,
) {
if (state.loading) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
} else {
Text("暂无数据", color = mutedColor, fontSize = 14.sp)
}
}
return
}
// Canvas 尺寸计算
val canvasWidthDp = cols * (blockSize + gap) - gap
val gridHeightDp = rows * (blockSize + gap) - gap
val monthLabelHeightDp = 20.dp
val totalHeightDp = gridHeightDp + monthLabelHeightDp
val gridHeightPx = with(density) { gridHeightDp.toPx() }
val labelGapPx = with(density) { 4.dp.toPx() }
// 月份标签画笔
val monthLabelPaint = remember(mutedColor, density) {
Paint().apply {
textSize = with(density) { 12.sp.toPx() }
color = mutedColor.toArgb()
isAntiAlias = true
}
}
Column(
modifier = modifier.horizontalScroll(rememberScrollState()),
) {
// ── 热力图网格 + 月份标签 ──
Canvas(
modifier = Modifier
.size(canvasWidthDp, totalHeightDp)
.pointerInput(state, cellStepPx, blockSizePx) {
detectTapGestures(onTap = { offset ->
val col = (offset.x / cellStepPx).toInt()
val row = (offset.y / cellStepPx).toInt()
if (isWeekly) {
// 每周视图:单行,点击列索引对应周数据
if (col >= 0 && col < state.weeklyData.size &&
offset.y >= 0f && offset.y < blockSizePx
) {
onWeekClick(state.weeklyData[col])
}
} else {
// 每日/累计视图:点击 (col, row) 对应格子
if (col >= 0 && col < gridWeeks.size && row >= 0 && row < 7) {
val cell = gridWeeks[col][row]
if (cell != null) {
onDayClick(cell)
}
}
}
})
},
) {
// 绘制格子
if (isWeekly) {
// 每周视图:52 列 × 1 行
state.weeklyData.forEachIndexed { weekIndex, weekData ->
val x = weekIndex * cellStepPx
drawRoundRect(
color = colors[weekData.level.coerceIn(0, 5)],
topLeft = Offset(x, 0f),
size = Size(blockSizePx, blockSizePx),
cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx),
)
}
} else {
// 每日/累计视图:53 列 × 7 行
gridWeeks.forEachIndexed { weekIndex, week ->
week.forEachIndexed { dayIndex, dayData ->
if (dayData == null) return@forEachIndexed
val x = weekIndex * cellStepPx
val y = dayIndex * cellStepPx
drawRoundRect(
color = colors[dayData.level.coerceIn(0, 5)],
topLeft = Offset(x, y),
size = Size(blockSizePx, blockSizePx),
cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx),
)
}
}
}
// 绘制月份标签(底部)
drawIntoCanvas { canvas ->
val nativeCanvas = canvas.nativeCanvas
// labelY = 网格底部 + 间距 + 文字基线偏移
val labelY = gridHeightPx + labelGapPx - monthLabelPaint.ascent()
monthLabels.forEach { label ->
val x = label.weekIndex * cellStepPx
nativeCanvas.drawText(label.label, x, labelY, monthLabelPaint)
}
}
}
Spacer(Modifier.height(8.dp))
// ── 颜色图例(右下角)──
Row(
modifier = Modifier.width(canvasWidthDp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Text("少", color = mutedColor, fontSize = 12.sp)
Spacer(Modifier.width(4.dp))
for (level in 0..5) {
Box(
modifier = Modifier
.size(blockSize)
.background(colors[level], RoundedCornerShape(cornerRadius)),
)
if (level < 5) {
Spacer(Modifier.width(gap))
}
}
Spacer(Modifier.width(4.dp))
Text("多", color = mutedColor, fontSize = 12.sp)
}
}
}