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:
parent
b8479eebf3
commit
6e4a5ff9cb
363
app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt
Normal file
363
app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user