feat(heatmap): 年份选择器 + 每周视图柱状图 + 对抗审查修复

- 修复 4 个 heatmap 提交的编译错误与对抗审查问题:浮层锚定与跨年日期、
  Crossfade 快照、时区口径统一(useUtc8)、万级格式化溢出、周视图周日对齐
- 新增年份选择器:自然年语义、默认今年、三种视图跟随、下拉菜单、
  图例固定不随热力图滚动、打开默认滚到最右、0 token 天可点击查看
- 每周视图改为离散柱状图:列=自然周,高度按 token 排名比例映射 2~7 格
  (不同量级周高度可区分),0 用量周显示 Level 0 空白格(整列 7 格全绘制)
- 选中周整列主题色描边强调,浮层日期跨年带年份前缀
- 文档 docs/heatmap-plan.md 补充年份选择器/图例与滚动/柱状图章节
This commit is contained in:
WaterRain 2026-08-06 22:33:25 +00:00
parent 77cb664590
commit 962d8f1215
No known key found for this signature in database
9 changed files with 608 additions and 146 deletions

1
.gitignore vendored
View File

@ -21,3 +21,4 @@ release.jks
*.jks *.jks
tmp_cc/ tmp_cc/
goal.md goal.md
/reference/

View File

@ -29,7 +29,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@ -714,7 +714,7 @@ private fun HeatmapEntryCard(onOpenHeatmap: () -> Unit) {
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
imageVector = Icons.Filled.CalendarMonth, imageVector = Icons.Filled.DateRange,
contentDescription = null, contentDescription = null,
tint = StrawberryPink, tint = StrawberryPink,
modifier = Modifier.size(44.dp) modifier = Modifier.size(44.dp)

View File

@ -21,25 +21,37 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.inkMuted
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset import java.time.ZoneOffset
import kotlin.math.roundToInt
import kotlinx.coroutines.flow.first
// ── 热力图颜色 ────────────────────────────────────────────── // ── 热力图颜色 ──────────────────────────────────────────────
@ -77,15 +89,15 @@ private data class MonthLabel(
val label: String, val label: String,
) )
/** 从 UTC 时间戳获取星期几 (0=Sunday, 6=Saturday) */ /** 从时间戳获取星期几 (0=Sunday, 6=Saturday),按指定时区 */
private fun dayOfWeekFromTs(ts: Long): Int { private fun dayOfWeekFromTs(ts: Long, zone: ZoneOffset): Int {
val dayOfWeek = Instant.ofEpochMilli(ts).atZone(ZoneOffset.UTC).dayOfWeek.value val dayOfWeek = Instant.ofEpochMilli(ts).atOffset(zone).dayOfWeek.value
return dayOfWeek % 7 // Sunday=7 → 0 return dayOfWeek % 7 // Sunday=7 → 0
} }
/** 从 UTC 时间戳获取月份 (0=January) */ /** 从时间戳获取月份 (0=January),按指定时区 */
private fun monthFromTs(ts: Long): Int { private fun monthFromTs(ts: Long, zone: ZoneOffset): Int {
return Instant.ofEpochMilli(ts).atZone(ZoneOffset.UTC).monthValue - 1 return Instant.ofEpochMilli(ts).atOffset(zone).monthValue - 1
} }
/** /**
@ -93,10 +105,10 @@ private fun monthFromTs(ts: Long): Int {
* 周起始日为周日(weekStart = 0)。 * 周起始日为周日(weekStart = 0)。
* 返回 List<List<HeatmapDayData?>>,外层为周,内层为星期 0-6,null 表示空位。 * 返回 List<List<HeatmapDayData?>>,外层为周,内层为星期 0-6,null 表示空位。
*/ */
private fun groupByWeeks(dailyData: List<HeatmapDayData>): List<List<HeatmapDayData?>> { private fun groupByWeeks(dailyData: List<HeatmapDayData>, zone: ZoneOffset): List<List<HeatmapDayData?>> {
if (dailyData.isEmpty()) return emptyList() if (dailyData.isEmpty()) return emptyList()
val firstDow = dayOfWeekFromTs(dailyData[0].dayTs) val firstDow = dayOfWeekFromTs(dailyData[0].dayTs, zone)
// 左侧填充空位,使第一周从周日开始 // 左侧填充空位,使第一周从周日开始
val padded = ArrayList<HeatmapDayData?>(firstDow + dailyData.size) val padded = ArrayList<HeatmapDayData?>(firstDow + dailyData.size)
@ -113,7 +125,7 @@ private fun groupByWeeks(dailyData: List<HeatmapDayData>): List<List<HeatmapDayD
} }
/** 从按周分组的每日数据计算月份标签 */ /** 从按周分组的每日数据计算月份标签 */
private fun getMonthLabels(weeks: List<List<HeatmapDayData?>>): List<MonthLabel> { private fun getMonthLabels(weeks: List<List<HeatmapDayData?>>, zone: ZoneOffset): List<MonthLabel> {
if (weeks.isEmpty()) return emptyList() if (weeks.isEmpty()) return emptyList()
val rawLabels = mutableListOf<MonthLabel>() val rawLabels = mutableListOf<MonthLabel>()
@ -121,7 +133,7 @@ private fun getMonthLabels(weeks: List<List<HeatmapDayData?>>): List<MonthLabel>
for (weekIndex in weeks.indices) { for (weekIndex in weeks.indices) {
val firstDay = weeks[weekIndex].firstOrNull { it != null } ?: continue val firstDay = weeks[weekIndex].firstOrNull { it != null } ?: continue
val month = monthFromTs(firstDay.dayTs) val month = monthFromTs(firstDay.dayTs, zone)
if (weekIndex == 0 || month != prevMonth) { if (weekIndex == 0 || month != prevMonth) {
rawLabels.add(MonthLabel(weekIndex, ChineseMonthNames[month])) rawLabels.add(MonthLabel(weekIndex, ChineseMonthNames[month]))
@ -132,25 +144,6 @@ private fun getMonthLabels(weeks: List<List<HeatmapDayData?>>): List<MonthLabel>
return filterMonthLabels(rawLabels, weeks.size) 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)。 * 过滤月份标签:跳过间距不足的标签(参考 react-activity-calendar 的 label.ts)。
* - 第一个标签:若与第二个标签间距 < minWeeks,跳过 * - 第一个标签:若与第二个标签间距 < minWeeks,跳过
@ -177,15 +170,21 @@ private fun filterMonthLabels(rawLabels: List<MonthLabel>, totalWeeks: Int): Lis
* Token 活动热力图 Canvas 绘制组件。 * Token 活动热力图 Canvas 绘制组件。
* *
* 支持三种视图: * 支持三种视图:
* - [HeatmapViewMode.DAILY]:53×7 格子网格,每日 Token 使用 * - [HeatmapViewMode.DAILY]:约 53 列 × 7 行(列数按年浮动 52~54),每日 Token 使用
* - [HeatmapViewMode.WEEKLY]:52×1 单行格子,每周 Token 总量 * - [HeatmapViewMode.WEEKLY]:约 53 列 × 7 行,**离散柱状图**——列=自然周,
* - [HeatmapViewMode.CUMULATIVE]:53×7 格子网格,累计 Token * 整列 7 格全部绘制:底部 barHeight 格 = 该周等级颜色(0 用量周 1 格、非零周 2~7 格
* 按 token 排名比例),其余格 = Level 0 浅色(空白格不隐藏,网格完整);
* 选中周整列加主题色描边强调;列内格子不表示日期
* - [HeatmapViewMode.CUMULATIVE]:约 53 列 × 7 行,累计 Token
*
* 三种视图列数与网格尺寸一致(约 52~54 列 × 7 行),切换视图时布局保持一致。
* *
* 使用 Compose Canvas 绘制圆角矩形格子,底部显示中文月份标签, * 使用 Compose Canvas 绘制圆角矩形格子,底部显示中文月份标签,
* 支持水平滚动和格子点击。 * 支持水平滚动和格子点击。
* *
* @param state UI 状态,包含数据与视图模式 * @param state UI 状态,包含数据与视图模式
* @param modifier 布局修饰符 * @param modifier 布局修饰符
* @param selectedWeek 当前选中的周(每周视图绘制强调边框用,null=未选中)
* @param onDayClick 每日/累计视图格子点击回调 * @param onDayClick 每日/累计视图格子点击回调
* @param onWeekClick 每周视图格子点击回调 * @param onWeekClick 每周视图格子点击回调
*/ */
@ -197,13 +196,16 @@ fun HeatmapCanvas(
lessText: String = "", lessText: String = "",
moreText: String = "", moreText: String = "",
accessibilityDesc: String = "", accessibilityDesc: String = "",
onDayClick: (HeatmapDayData) -> Unit = {}, selectedWeek: HeatmapWeekData? = null,
onWeekClick: (HeatmapWeekData) -> Unit = {}, onDayClick: (HeatmapDayData, IntOffset) -> Unit = { _, _ -> },
onWeekClick: (HeatmapWeekData, IntOffset) -> Unit = { _, _ -> },
) { ) {
val darkTheme = isSystemInDarkTheme() val darkTheme = isSystemInDarkTheme()
val colors = if (darkTheme) DarkHeatmapColors else LightHeatmapColors val colors = if (darkTheme) DarkHeatmapColors else LightHeatmapColors
val mutedColor = inkMuted() val mutedColor = inkMuted()
val density = LocalDensity.current val density = LocalDensity.current
// 与全局图表设置一致的时区(分桶与显示口径统一)
val zone = if (state.useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
// 格子参数 // 格子参数
val blockSize = 11.dp val blockSize = 11.dp
@ -218,22 +220,29 @@ fun HeatmapCanvas(
val isWeekly = state.viewMode == HeatmapViewMode.WEEKLY val isWeekly = state.viewMode == HeatmapViewMode.WEEKLY
// 构建网格数据(每日/累计视图用) // 选中周强调样式(主题主色描边,明暗模式均可见)
val gridWeeks = remember(state.dailyData, state.cumulativeData, state.viewMode) { val selectionColor = MaterialTheme.colorScheme.primary
val selectionStrokePx = with(density) { 1.5.dp.toPx() }
// Canvas 在窗口中的位置(浮层锚点定位用)
var canvasWindowPos by remember { mutableStateOf(IntOffset.Zero) }
// 构建网格数据(每日/累计视图用于绘制与点击;每周视图仅用于列数与月份标签,
// 柱状图绘制/点击直接基于 weeklyData)
val gridWeeks = remember(state.dailyData, state.cumulativeData, state.viewMode, zone) {
when (state.viewMode) { when (state.viewMode) {
HeatmapViewMode.DAILY -> groupByWeeks(state.dailyData) HeatmapViewMode.DAILY, HeatmapViewMode.WEEKLY -> groupByWeeks(state.dailyData, zone)
HeatmapViewMode.CUMULATIVE -> groupByWeeks(state.cumulativeData) HeatmapViewMode.CUMULATIVE -> groupByWeeks(state.cumulativeData, zone)
HeatmapViewMode.WEEKLY -> emptyList()
} }
} }
// 月份标签 // 月份标签(三种视图共用同一计算,位置与每日视图完全一致)
val monthLabels = remember(gridWeeks, state.weeklyData, isWeekly) { val monthLabels = remember(gridWeeks, zone) {
if (isWeekly) getWeeklyMonthLabels(state.weeklyData) else getMonthLabels(gridWeeks) getMonthLabels(gridWeeks, zone)
} }
val cols = if (isWeekly) state.weeklyData.size else gridWeeks.size val cols = gridWeeks.size
val rows = if (isWeekly) 1 else 7 val rows = 7
// 加载中或无数据 // 加载中或无数据
if (state.loading || cols == 0) { if (state.loading || cols == 0) {
@ -253,8 +262,8 @@ fun HeatmapCanvas(
} }
// Canvas 尺寸计算 // Canvas 尺寸计算
val canvasWidthDp = cols * (blockSize + gap) - gap val canvasWidthDp = (blockSize + gap) * cols - gap
val gridHeightDp = rows * (blockSize + gap) - gap val gridHeightDp = (blockSize + gap) * rows - gap
val monthLabelHeightDp = 20.dp val monthLabelHeightDp = 20.dp
val totalHeightDp = gridHeightDp + monthLabelHeightDp val totalHeightDp = gridHeightDp + monthLabelHeightDp
@ -270,53 +279,90 @@ fun HeatmapCanvas(
} }
} }
Column( // 横向滚动状态(图例固定在外层,不随热力图滚动)
modifier = modifier val scrollState = rememberScrollState()
.horizontalScroll(rememberScrollState())
.semantics { contentDescription = accessibilityDesc.ifEmpty { "Token activity heatmap, ${if (isWeekly) state.weeklyData.size else state.dailyData.size} days" } }, // 打开页面 / 切换视图 / 切换年份时,横向滚动自动定位到最右(最新数据)
) { LaunchedEffect(cols, isWeekly, state.selectedYear) {
// ── 热力图网格 + 月份标签 ── snapshotFlow { scrollState.maxValue }.first { it > 0 }
Canvas( scrollState.scrollTo(scrollState.maxValue)
}
Column(modifier = modifier) {
// ── 热力图网格 + 月份标签(可横向滚动)──
Column(
modifier = Modifier modifier = Modifier
.size(canvasWidthDp, totalHeightDp) .horizontalScroll(scrollState)
.pointerInput(state, cellStepPx, blockSizePx) { .semantics { contentDescription = accessibilityDesc.ifEmpty {
if (isWeekly) "Token activity heatmap, ${state.weeklyData.size} weeks"
else "Token activity heatmap, ${state.dailyData.size} days"
} },
) {
Canvas(
modifier = Modifier
.size(canvasWidthDp, totalHeightDp)
.onGloballyPositioned { canvasWindowPos = it.positionInWindow().round() }
.pointerInput(state, cellStepPx, blockSizePx, gridWeeks) {
detectTapGestures(onTap = { offset -> detectTapGestures(onTap = { offset ->
// 点击落在格子间隙内时不触发(Minor:命中精度)
if (offset.x % cellStepPx >= blockSizePx || offset.y % cellStepPx >= blockSizePx) {
return@detectTapGestures
}
val col = (offset.x / cellStepPx).toInt() val col = (offset.x / cellStepPx).toInt()
val row = (offset.y / cellStepPx).toInt() val row = (offset.y / cellStepPx).toInt()
if (col < 0 || col >= gridWeeks.size || row < 0 || row >= rows) {
return@detectTapGestures
}
val cellCenterX = (col * cellStepPx + blockSizePx / 2f).roundToInt()
val cellCenterY = (row * cellStepPx + blockSizePx / 2f).roundToInt()
val anchor = IntOffset(canvasWindowPos.x + cellCenterX, canvasWindowPos.y + cellCenterY)
if (isWeekly) { if (isWeekly) {
// 每周视图:单行,点击列索引对应周数据 // 每周视图(柱状图):该列任意格子都对应同一周 → 点击触发该周浮层;
if (col >= 0 && col < state.weeklyData.size && // 0 用量周也有 1 格 Level 0 浅色格,同样可点击(显示「使用了0token」)
offset.y >= 0f && offset.y < blockSizePx val weekData = state.weeklyData.getOrNull(col) ?: return@detectTapGestures
) { onWeekClick(weekData, anchor)
onWeekClick(state.weeklyData[col])
}
} else { } else {
// 每日/累计视图:点击 (col, row) 对应格子 // 每日/累计视图:点击 (col, row) 对应格子(空位不可点)
if (col >= 0 && col < gridWeeks.size && row >= 0 && row < 7) { val cell = gridWeeks[col][row] ?: return@detectTapGestures
val cell = gridWeeks[col][row] onDayClick(cell, anchor)
if (cell != null) {
onDayClick(cell)
}
}
} }
}) })
}, },
) { ) {
// 绘制格子 // 绘制格子:
// - 每日/累计:7 行网格按天等级绘制,null 空位不绘制
// - 每周:离散柱状图——列=自然周,整列 7 格全部绘制:
// 底部 barHeight 格 = 该周等级颜色(柱:0 用量周 1 格 Level 0、非零周 2~7 格),
// 其余格 = Level 0 浅色(空白格不隐藏,网格完整);
// 选中周整列加主题色描边强调
if (isWeekly) { if (isWeekly) {
// 每周视图:52 列 × 1 行
state.weeklyData.forEachIndexed { weekIndex, weekData -> state.weeklyData.forEachIndexed { weekIndex, weekData ->
val level = weekData.level.coerceIn(0, 5)
val barHeight = weekData.barHeight.coerceIn(1, 7)
val isSelected = weekData == selectedWeek
val x = weekIndex * cellStepPx val x = weekIndex * cellStepPx
drawRoundRect( for (row in 0 until rows) {
color = colors[weekData.level.coerceIn(0, 5)], val inBar = row >= rows - barHeight
topLeft = Offset(x, 0f), val y = row * cellStepPx
size = Size(blockSizePx, blockSizePx), drawRoundRect(
cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx), color = if (inBar) colors[level] else colors[0],
) topLeft = Offset(x, y),
size = Size(blockSizePx, blockSizePx),
cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx),
)
if (isSelected) {
drawRoundRect(
color = selectionColor,
topLeft = Offset(x, y),
size = Size(blockSizePx, blockSizePx),
cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx),
style = Stroke(width = selectionStrokePx),
)
}
}
} }
} else { } else {
// 每日/累计视图:53 列 × 7 行
gridWeeks.forEachIndexed { weekIndex, week -> gridWeeks.forEachIndexed { weekIndex, week ->
week.forEachIndexed { dayIndex, dayData -> week.forEachIndexed { dayIndex, dayData ->
if (dayData == null) return@forEachIndexed if (dayData == null) return@forEachIndexed
@ -342,13 +388,14 @@ fun HeatmapCanvas(
nativeCanvas.drawText(label.label, x, labelY, monthLabelPaint) nativeCanvas.drawText(label.label, x, labelY, monthLabelPaint)
} }
} }
}
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// ── 颜色图例(右下角)── // ── 颜色图例(右下角,固定不随热力图横向滚动)──
Row( Row(
modifier = Modifier.width(canvasWidthDp), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {

View File

@ -10,16 +10,18 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Popup
import androidx.compose.material3.PopupProperties
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -31,18 +33,30 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R import com.rainy.token.R
import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.inkMuted
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset import java.time.ZoneOffset
import kotlin.math.roundToInt
/** /**
* Token 活动热力图页面。 * Token 活动热力图页面。
@ -60,12 +74,20 @@ fun HeatmapScreen(
// ── 浮层状态 ── // ── 浮层状态 ──
var selectedDay: HeatmapDayData? by remember { mutableStateOf(null) } var selectedDay: HeatmapDayData? by remember { mutableStateOf(null) }
var selectedWeek: HeatmapWeekData? by remember { mutableStateOf(null) } var selectedWeek: HeatmapWeekData? by remember { mutableStateOf(null) }
// 浮层锚点:被点击格子中心在窗口中的位置
var popupAnchor by remember { mutableStateOf<IntOffset?>(null) }
// 浮层自身尺寸(用于锚点偏移计算)
var popupSize by remember { mutableStateOf(IntSize.Zero) }
val density = LocalDensity.current
// ── i18n 字符串 ── // ── i18n 字符串 ──
val noDataText = stringResource(R.string.heatmap_no_data) val noDataText = stringResource(R.string.heatmap_no_data)
val lessText = stringResource(R.string.heatmap_legend_less) val lessText = stringResource(R.string.heatmap_legend_less)
val moreText = stringResource(R.string.heatmap_legend_more) val moreText = stringResource(R.string.heatmap_legend_more)
val accessibilityDesc = stringResource(R.string.heatmap_accessibility_desc, if (state.viewMode == HeatmapViewMode.WEEKLY) state.weeklyData.size else state.dailyData.size)
// ── 年份选择器状态 ──
var yearMenuExpanded by remember { mutableStateOf(false) }
val selectYearDesc = stringResource(R.string.heatmap_select_year)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.load() viewModel.load()
@ -75,7 +97,44 @@ fun HeatmapScreen(
containerColor = Color.Transparent, containerColor = Color.Transparent,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.heatmap_title)) }, title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.heatmap_title))
if (state.availableYears.isNotEmpty()) {
Spacer(Modifier.width(12.dp))
// 年份下拉选择器
Text(
text = "${state.selectedYear} ▾",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.clickable { yearMenuExpanded = true }
.padding(horizontal = 6.dp, vertical = 4.dp)
.semantics { contentDescription = "$selectYearDesc: ${state.selectedYear}" }
)
DropdownMenu(
expanded = yearMenuExpanded,
onDismissRequest = { yearMenuExpanded = false }
) {
state.availableYears.sortedDescending().forEach { year ->
DropdownMenuItem(
text = { Text("$year") },
onClick = {
yearMenuExpanded = false
viewModel.setYear(year)
// 切换年份时关闭浮层
selectedDay = null
selectedWeek = null
popupAnchor = null
}
)
}
}
}
}
},
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
@ -95,7 +154,7 @@ fun HeatmapScreen(
// ── 视图切换器 ── // ── 视图切换器 ──
Row( Row(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(horizontal = 4.dp, bottom = 12.dp) modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 12.dp)
) { ) {
HeatmapViewMode.entries.forEach { mode -> HeatmapViewMode.entries.forEach { mode ->
val label = when (mode) { val label = when (mode) {
@ -114,6 +173,7 @@ fun HeatmapScreen(
// 切换视图时关闭浮层 // 切换视图时关闭浮层
selectedDay = null selectedDay = null
selectedWeek = null selectedWeek = null
popupAnchor = null
} }
) )
} }
@ -125,27 +185,40 @@ fun HeatmapScreen(
animationSpec = tween(150), animationSpec = tween(150),
label = "heatmap_view" label = "heatmap_view"
) { mode -> ) { mode ->
// 用 mode 参数构造视图快照:过渡期间新旧两个分支渲染各自模式,
// 否则两者都读最新 viewMode,动画会变成空操作
// 无障碍描述也在分支内按 mode 计算,避免淡出分支播报错误的模式
val desc = if (mode == HeatmapViewMode.WEEKLY) {
stringResource(R.string.heatmap_accessibility_desc_weekly, state.weeklyData.size)
} else {
stringResource(R.string.heatmap_accessibility_desc, state.dailyData.size)
}
HeatmapCanvas( HeatmapCanvas(
state = state, state = state.copy(viewMode = mode),
noDataText = noDataText, noDataText = noDataText,
lessText = lessText, lessText = lessText,
moreText = moreText, moreText = moreText,
accessibilityDesc = accessibilityDesc, accessibilityDesc = desc,
onDayClick = { dayData -> selectedWeek = selectedWeek,
// 再次点击同一格子则关闭浮层 onDayClick = { dayData, anchor ->
// 再次点击同一格子则关闭浮层(0 token 的天也可点击查看)
if (selectedDay == dayData) { if (selectedDay == dayData) {
selectedDay = null selectedDay = null
popupAnchor = null
} else { } else {
selectedDay = dayData selectedDay = dayData
selectedWeek = null selectedWeek = null
popupAnchor = anchor
} }
}, },
onWeekClick = { weekData -> onWeekClick = { weekData, anchor ->
if (selectedWeek == weekData) { if (selectedWeek == weekData) {
selectedWeek = null selectedWeek = null
popupAnchor = null
} else { } else {
selectedWeek = weekData selectedWeek = weekData
selectedDay = null selectedDay = null
popupAnchor = anchor
} }
} }
) )
@ -155,25 +228,62 @@ fun HeatmapScreen(
} }
} }
// ── 浮层 Popup ── // ── 浮层 Popup(锚定到被点击格子的中心位置)──
val day = selectedDay val day = selectedDay
val week = selectedWeek val week = selectedWeek
val anchor = popupAnchor
if (day != null || week != null) { if (day != null || week != null) {
val useUtc8 = state.useUtc8
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
val popupText = when { val popupText = when {
day != null -> "${formatDateChinese(day.dayTs)} 使用了${formatTokenChinese(day.tokens)}token" day != null -> "${formatDateChinese(day.dayTs, useUtc8, state.currentYear)} 使用了${formatTokenChinese(day.tokens)}token"
week != null -> { week != null -> {
val start = formatDateChinese(week.weekStartTs) val start = formatDateChinese(week.weekStartTs, useUtc8, state.selectedYear)
val endTs = week.weekStartTs + 6L * 86_400_000L // 末周/跨年周只显示到该周落在所选年内的有效日期(数据最后一天),
val end = formatDateChinese(endTs) // 避免出现未绘制的未来/次年空位日期;数据同源,不依赖实时时钟
val dataEndTs = state.dailyData.lastOrNull()?.dayTs ?: (week.weekStartTs + 6L * 86_400_000L)
val endTs = minOf(week.weekStartTs + 6L * 86_400_000L, dataEndTs)
// 首列跨年(start 在上一年)时,end 与 start 不同年 → end 也强制带年份前缀,避免范围歧义
val startDate = Instant.ofEpochMilli(week.weekStartTs).atOffset(zone).toLocalDate()
val endDate = Instant.ofEpochMilli(endTs).atOffset(zone).toLocalDate()
val end = if (endDate.year != startDate.year) {
"${endDate.year}年${endDate.monthValue}月${endDate.dayOfMonth}日"
} else {
formatDateChinese(endTs, useUtc8, state.selectedYear)
}
"$start-$end 使用了${formatTokenChinese(week.tokens)}token" "$start-$end 使用了${formatTokenChinese(week.tokens)}token"
} }
else -> "" else -> ""
} }
// 浮层相对锚点的偏移:水平居中、优先显示在格子上方 8dp 处;
// 上方空间不足时翻转到下方;x/y 均 clamp 在窗口内
val gapPx = with(density) { 8.dp.toPx() }.roundToInt()
val edgePaddingPx = with(density) { 8.dp.toPx() }.roundToInt()
val configuration = LocalConfiguration.current
val windowWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }.roundToInt()
val windowHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() }.roundToInt()
val maxX = (windowWidthPx - popupSize.width - edgePaddingPx).coerceAtLeast(edgePaddingPx)
val maxY = (windowHeightPx - popupSize.height - edgePaddingPx).coerceAtLeast(edgePaddingPx)
val popupOffset = if (anchor != null) {
val x = (anchor.x - popupSize.width / 2).coerceIn(edgePaddingPx, maxX)
val y = if (anchor.y - popupSize.height - gapPx >= edgePaddingPx) {
// 上方放得下:显示在格子上方
(anchor.y - popupSize.height - gapPx).coerceIn(edgePaddingPx, maxY)
} else {
// 上方放不下:翻转到格子下方
(anchor.y + gapPx).coerceIn(edgePaddingPx, maxY)
}
IntOffset(x, y)
} else {
IntOffset.Zero
}
Popup( Popup(
alignment = androidx.compose.ui.Alignment.TopCenter, alignment = Alignment.TopStart,
offset = popupOffset,
onDismissRequest = { onDismissRequest = {
selectedDay = null selectedDay = null
selectedWeek = null selectedWeek = null
popupAnchor = null
}, },
properties = PopupProperties(focusable = true) properties = PopupProperties(focusable = true)
) { ) {
@ -181,7 +291,9 @@ fun HeatmapScreen(
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
tonalElevation = 3.dp, tonalElevation = 3.dp,
shadowElevation = 6.dp, shadowElevation = 6.dp,
modifier = Modifier.padding(horizontal = 8.dp) modifier = Modifier
.padding(horizontal = 8.dp)
.onGloballyPositioned { popupSize = it.size }
) { ) {
Text( Text(
text = popupText, text = popupText,
@ -200,27 +312,43 @@ fun HeatmapScreen(
* - 亿级(≥1亿):1.9亿 * - 亿级(≥1亿):1.9亿
* - 万级(≥1万):11.2万 / 1124.5万 * - 万级(≥1万):11.2万 / 1124.5万
* - 万以下:原始数字 * - 万以下:原始数字
*
* 注意:万级先舍入到一位小数再判断是否升亿(如 99999999 → "1.0亿",而非 "10000.0万")。
*/ */
private fun formatTokenChinese(tokens: Long): String { private fun formatTokenChinese(tokens: Long): String {
return when { return when {
tokens >= 1_0000_0000 -> { tokens >= 1_0000_0000 -> {
val v = tokens / 1_0000_0000.0 val v = tokens / 1_0000_0000.0
if (v >= 100) "${v.toInt()}亿" if (v >= 100) "${v.roundToInt()}亿"
else "${"%.1f".format(v)}亿" else "${"%.1f".format(v)}亿"
} }
tokens >= 1_0000 -> { tokens >= 1_0000 -> {
val v = tokens / 1_0000.0 val v = tokens / 1_0000.0
"${"%.1f".format(v)}万" // 始终保留一位小数 // 先舍入到一位小数,避免 "99999999 → 10000.0万"
val rounded = (v * 10).roundToInt() / 10.0
if (rounded >= 10000) {
// 万级溢出升亿:9999.99万 → 1.0亿
val v2 = rounded / 10000.0
if (v2 >= 100) "${v2.roundToInt()}亿"
else "${"%.1f".format(v2)}亿"
} else {
"${"%.1f".format(v)}万" // 始终保留一位小数
}
} }
else -> "$tokens" else -> "$tokens"
} }
} }
/** /**
* 将 UTC 时间戳格式化为 "7月9日" 格式(月+日,不加年)。 * 将时间戳格式化为 "7月9日" 格式(月+日),按与数据分桶一致的时区。
* 日期所在年份与 [yearContext] 不同时,前缀加年份(如 "2024年7月9日")。
*
* 日浮层传 [currentYear](查看往年时带年份前缀);周浮层两端传 [selectedYear],
* 使跨年周(如 12月28日-次年1月3日)的结束日带 "2026年" 前缀,避免范围倒挂。
*/ */
private fun formatDateChinese(ts: Long): String { private fun formatDateChinese(ts: Long, useUtc8: Boolean, yearContext: Int): String {
val instant = Instant.ofEpochMilli(ts) val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
val date = instant.atOffset(ZoneOffset.UTC).toLocalDate() val date = Instant.ofEpochMilli(ts).atOffset(zone).toLocalDate()
return "${date.monthValue}月${date.dayOfMonth}日" val yearPrefix = if (date.year != yearContext) "${date.year}年" else ""
return "$yearPrefix${date.monthValue}月${date.dayOfMonth}日"
} }

View File

@ -2,19 +2,24 @@ package com.rainy.token.ui.heatmap
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.rainy.token.data.local.ChartSettingsStore
import com.rainy.token.data.local.UsageCache import com.rainy.token.data.local.UsageCache
import com.rainy.token.data.local.UsageRecord import com.rainy.token.data.local.UsageRecord
import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.service.ServiceType
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.time.Instant
import java.time.ZoneOffset
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
@ -35,35 +40,48 @@ data class HeatmapWeekData(
val weekStartTs: Long, // 该周起始日时间戳 val weekStartTs: Long, // 该周起始日时间戳
val tokens: Long, // 该周 7 天累计 token 数 val tokens: Long, // 该周 7 天累计 token 数
val level: Int, // 0-5 颜色等级 val level: Int, // 0-5 颜色等级
val barHeight: Int, // 柱状图高度(格数):0 用量周=1(Level 0 浅色格,可见可点),非零周=2~7(按 token 排名比例)
) )
/** UI 状态 */ /** UI 状态 */
data class HeatmapUiState( data class HeatmapUiState(
val loading: Boolean = true, val loading: Boolean = true,
val viewMode: HeatmapViewMode = HeatmapViewMode.DAILY, val viewMode: HeatmapViewMode = HeatmapViewMode.DAILY,
val selectedYear: Int = 0, // 当前选中年份(0=尚未加载)
val currentYear: Int = 0, // 今年(浮层日期判断是否显示年份用)
val availableYears: List<Int> = emptyList(), // 可选年份(最早数据年份..今年,降序展示)
val dailyData: List<HeatmapDayData> = emptyList(), val dailyData: List<HeatmapDayData> = emptyList(),
val weeklyData: List<HeatmapWeekData> = emptyList(), val weeklyData: List<HeatmapWeekData> = emptyList(),
val cumulativeData: List<HeatmapDayData> = emptyList(), val cumulativeData: List<HeatmapDayData> = emptyList(),
val colorLevels: IntArray = IntArray(6), // [0, p25, p50, p75, p95, max] val colorLevels: IntArray = IntArray(6), // [0, p25, p50, p75, p95, max]
val useUtc8: Boolean = false, // 与全局图表设置一致:true=UTC+8 分桶,false=UTC
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other !is HeatmapUiState) return false if (other !is HeatmapUiState) return false
return loading == other.loading && return loading == other.loading &&
viewMode == other.viewMode && viewMode == other.viewMode &&
selectedYear == other.selectedYear &&
currentYear == other.currentYear &&
availableYears == other.availableYears &&
dailyData == other.dailyData && dailyData == other.dailyData &&
weeklyData == other.weeklyData && weeklyData == other.weeklyData &&
cumulativeData == other.cumulativeData && cumulativeData == other.cumulativeData &&
colorLevels.contentEquals(other.colorLevels) colorLevels.contentEquals(other.colorLevels) &&
useUtc8 == other.useUtc8
} }
override fun hashCode(): Int { override fun hashCode(): Int {
var result = loading.hashCode() var result = loading.hashCode()
result = 31 * result + viewMode.hashCode() result = 31 * result + viewMode.hashCode()
result = 31 * result + selectedYear
result = 31 * result + currentYear
result = 31 * result + availableYears.hashCode()
result = 31 * result + dailyData.hashCode() result = 31 * result + dailyData.hashCode()
result = 31 * result + weeklyData.hashCode() result = 31 * result + weeklyData.hashCode()
result = 31 * result + cumulativeData.hashCode() result = 31 * result + cumulativeData.hashCode()
result = 31 * result + colorLevels.contentHashCode() result = 31 * result + colorLevels.contentHashCode()
result = 31 * result + useUtc8.hashCode()
return result return result
} }
} }
@ -110,17 +128,24 @@ internal data class QuantileLevels(val thresholds: LongArray) {
class HeatmapViewModel @Inject constructor( class HeatmapViewModel @Inject constructor(
private val cacheProvider: Provider<UsageCache>, private val cacheProvider: Provider<UsageCache>,
private val credentialRepository: CredentialRepository, private val credentialRepository: CredentialRepository,
private val chartSettingsStore: ChartSettingsStore,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(HeatmapUiState()) private val _uiState = MutableStateFlow(HeatmapUiState())
val uiState: StateFlow<HeatmapUiState> = _uiState.asStateFlow() val uiState: StateFlow<HeatmapUiState> = _uiState.asStateFlow()
private var loaded = false private var loaded = false
private var allViewsResult: AllViewsResult? = null // 全量记录缓存(仅在 load 时拉取一次,切换年份/视图时复用)
private var records: List<UsageRecord> = emptyList()
private var useUtc8 = false
// 按年缓存三视图计算结果,切换年份不必重复计算(跨线程读写,用并发容器)
private val yearResults = ConcurrentHashMap<Int, AllViewsResult>()
// 最近一次请求的年份(防止快速连续切换时旧协程后完成覆盖新选择)
private var latestRequestedYear = 0
/** /**
* Composable 层在 LaunchedEffect 中调用一次即可。 * Composable 层在 LaunchedEffect 中调用一次即可。
* 三种视图数据全部在此次加载中计算好,切换视图不需要重新加载。 * 首次加载拉取全量记录并计算默认年份(今年)的三视图数据。
*/ */
fun load() { fun load() {
if (loaded) return if (loaded) return
@ -130,7 +155,7 @@ class HeatmapViewModel @Inject constructor(
/** 切换视图模式(仅改 State,不重新加载数据) */ /** 切换视图模式(仅改 State,不重新加载数据) */
fun setViewMode(mode: HeatmapViewMode) { fun setViewMode(mode: HeatmapViewMode) {
val result = allViewsResult val result = yearResults[_uiState.value.selectedYear]
val levels = result?.let { val levels = result?.let {
when (mode) { when (mode) {
HeatmapViewMode.DAILY -> it.dailyLevels HeatmapViewMode.DAILY -> it.dailyLevels
@ -141,6 +166,34 @@ class HeatmapViewModel @Inject constructor(
_uiState.update { it.copy(viewMode = mode, colorLevels = levels?.toIntArray() ?: it.colorLevels) } _uiState.update { it.copy(viewMode = mode, colorLevels = levels?.toIntArray() ?: it.colorLevels) }
} }
/** 切换年份:用缓存的记录按所选自然年重新聚合三视图 */
fun setYear(year: Int) {
if (year == _uiState.value.selectedYear || records.isEmpty()) return
latestRequestedYear = year
viewModelScope.launch {
val result = yearResults[year] ?: withContext(Dispatchers.Default) {
computeAllViews(records, useUtc8, year).also { yearResults[year] = it }
}
// 期间用户又切了别的年份:丢弃过期结果,避免旧协程覆盖新选择
if (year != latestRequestedYear) return@launch
val mode = _uiState.value.viewMode
val levels = when (mode) {
HeatmapViewMode.DAILY -> result.dailyLevels
HeatmapViewMode.WEEKLY -> result.weeklyLevels
HeatmapViewMode.CUMULATIVE -> result.cumulativeLevels
}
_uiState.update {
it.copy(
selectedYear = year,
dailyData = result.dailyData,
weeklyData = result.weeklyData,
cumulativeData = result.cumulativeData,
colorLevels = levels.toIntArray(),
)
}
}
}
// ── 内部逻辑 ──────────────────────────────────────────── // ── 内部逻辑 ────────────────────────────────────────────
private suspend fun workspaceId(): String? { private suspend fun workspaceId(): String? {
@ -155,22 +208,42 @@ class HeatmapViewModel @Inject constructor(
return return
} }
val cache = cacheProvider.get() val cache = cacheProvider.get()
// 与全局图表设置一致:读取 UTC+8 / UTC 分桶偏好
useUtc8 = chartSettingsStore.useUtc8Flow.first()
// 拉取全量记录 + 计算可选年份(在 IO 线程,避免主线程遍历大列表)
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
val currentYear = Instant.now().atOffset(zone).year
val (fetched, availableYears) = withContext(Dispatchers.Default) {
val all = cache.getRecords(wid)
all to computeAvailableYears(all, zone, currentYear)
}
records = fetched
latestRequestedYear = currentYear
// 所有重操作放在 Dispatchers.Default 上 // 所有重操作放在 Dispatchers.Default 上
val result = withContext(Dispatchers.Default) { val result = withContext(Dispatchers.Default) {
val records = cache.getRecords(wid) computeAllViews(fetched, useUtc8, currentYear).also { yearResults[currentYear] = it }
computeAllViews(records)
} }
allViewsResult = result // 重操作完成后按当前视图模式写入对应的分位阈值(加载期间可能已切换视图)
val mode = _uiState.value.viewMode
val levels = when (mode) {
HeatmapViewMode.DAILY -> result.dailyLevels
HeatmapViewMode.WEEKLY -> result.weeklyLevels
HeatmapViewMode.CUMULATIVE -> result.cumulativeLevels
}
_uiState.update { _uiState.update {
it.copy( it.copy(
loading = false, loading = false,
selectedYear = currentYear,
currentYear = currentYear,
availableYears = availableYears,
useUtc8 = useUtc8,
dailyData = result.dailyData, dailyData = result.dailyData,
weeklyData = result.weeklyData, weeklyData = result.weeklyData,
cumulativeData = result.cumulativeData, cumulativeData = result.cumulativeData,
colorLevels = result.dailyLevels.toIntArray(), colorLevels = levels.toIntArray(),
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -178,50 +251,78 @@ class HeatmapViewModel @Inject constructor(
} }
} }
/** 可选年份列表:最早有数据的年份 ~ 今年;无数据时只有今年 */
private fun computeAvailableYears(records: List<UsageRecord>, zone: ZoneOffset, currentYear: Int): List<Int> {
val minYear = records.minOfOrNull { Instant.ofEpochMilli(it.timeCreated).atOffset(zone).year }
?: currentYear
return (minYear..currentYear).toList()
}
// ── 聚合计算 ──────────────────────────────────────────── // ── 聚合计算 ────────────────────────────────────────────
companion object { companion object {
private const val DAY_MS = 86_400_000L private const val DAY_MS = 86_400_000L
private const val RANGE_DAYS = 365
/** /**
* 从全部记录计算三种视图数据 + 颜色等级阈值。 * 按指定自然年从全部记录计算三种视图数据 + 颜色等级阈值。
* 返回 (dailyData, weeklyData, cumulativeData, colorLevels) *
* - 今年:范围 = 1月1日 ~ 今天(滚动 365 天窗口被年份选择器取代)
* - 往年:范围 = 1月1日 ~ 12月31日(完整自然年)
* - 周视图:周日对齐,第一列为该年 1月1日 所在周(可能含去年 12 月空位),
* 最后一列为结束日所在周(今年=今天所在周;往年可能跨到次年 1 月初),
* 列数按年份浮动(约 52~54 列),不丢数据
* - 三种视图的分位数均按所选年份独立计算(颜色随年份数据变化是预期行为)
*
* @param useUtc8 true=按 UTC+8 零点分桶(与全局图表设置一致),false=按 UTC 零点分桶
* @param year 目标自然年
*/ */
internal fun computeAllViews(records: List<UsageRecord>): AllViewsResult { internal fun computeAllViews(records: List<UsageRecord>, useUtc8: Boolean = false, year: Int): AllViewsResult {
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val todayTs = startOfDayTs(now, zone)
val currentYear = Instant.ofEpochMilli(now).atOffset(zone).year
val isCurrentYear = year == currentYear
// 1. 按天分桶聚合 token 数 // 1. 年份边界(该时区下的自然年)
val todayTs = now / DAY_MS * DAY_MS val yearStartTs = Instant.parse("${year}-01-01T00:00:00Z")
val rangeStartTs = todayTs - (RANGE_DAYS - 1) * DAY_MS .atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()
val yearEndTs = if (isCurrentYear) {
todayTs
} else {
Instant.parse("${year}-12-31T00:00:00Z")
.atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()
}
val totalDays = ((yearEndTs - yearStartTs) / DAY_MS).toInt() + 1
// 聚合每一天的 token // 2. 按天分桶聚合 token 数(按所选时区的自然日零点切日)
val dayTokenMap = HashMap<Long, Long>() val dayTokenMap = HashMap<Long, Long>()
for (r in records) { for (r in records) {
val dayTs = r.timeCreated / DAY_MS * DAY_MS val dayTs = startOfDayTs(r.timeCreated, zone)
if (dayTs < rangeStartTs || dayTs > todayTs) continue if (dayTs < yearStartTs || dayTs > yearEndTs) continue
val tokens = r.inputTokens + r.cacheReadTokens + r.reasoningTokens + r.outputTokens val tokens = r.inputTokens + r.cacheReadTokens + r.reasoningTokens + r.outputTokens
dayTokenMap.merge(dayTs, tokens, Long::plus) dayTokenMap.merge(dayTs, tokens, Long::plus)
} }
// 2. 构建完整的每日数据列表(365 天,从 rangeStartTs 到 todayTs) // 3. 构建完整的每日数据列表(1月1日 ~ 结束日)
val dailyRaw = ArrayList<HeatmapDayData>(RANGE_DAYS) val dailyRaw = ArrayList<HeatmapDayData>(totalDays)
for (i in 0 until RANGE_DAYS) { for (i in 0 until totalDays) {
val ts = rangeStartTs + i * DAY_MS val ts = yearStartTs + i * DAY_MS
val tokens = dayTokenMap[ts] ?: 0L val tokens = dayTokenMap[ts] ?: 0L
dailyRaw.add(HeatmapDayData(dayTs = ts, tokens = tokens, level = 0)) dailyRaw.add(HeatmapDayData(dayTs = ts, tokens = tokens, level = 0))
} }
// 3. 计算每日视图颜色等级 // 4. 计算每日视图颜色等级
val dailyLevels = computeQuantileLevels(dailyRaw.map { it.tokens }) val dailyLevels = computeQuantileLevels(dailyRaw.map { it.tokens })
val dailyData = dailyRaw.map { it.copy(level = dailyLevels.getColorLevel(it.tokens)) } val dailyData = dailyRaw.map { it.copy(level = dailyLevels.getColorLevel(it.tokens)) }
// 4. 每周视图:从最早日数据开始,每 7 天一组 // 5. 每周视图:周日对齐,第一列=1月1日所在周,最后一列=结束日所在周
val (weeklyData, weeklyLevels) = buildWeeklyData(dailyRaw) val firstWeekStart = startOfWeekTs(yearStartTs, zone)
val lastWeekStart = startOfWeekTs(yearEndTs, zone)
val (weeklyData, weeklyLevels) = buildWeeklyData(dailyRaw, firstWeekStart, lastWeekStart)
// 5. 累计视图:从第一天到当天的累计总和 // 6. 累计视图:从 1月1日 到结束日的累计总和
var cumulativeSum = 0L var cumulativeSum = 0L
val cumulativeRaw = ArrayList<HeatmapDayData>(RANGE_DAYS) val cumulativeRaw = ArrayList<HeatmapDayData>(totalDays)
for (d in dailyRaw) { for (d in dailyRaw) {
cumulativeSum += d.tokens cumulativeSum += d.tokens
cumulativeRaw.add(HeatmapDayData(dayTs = d.dayTs, tokens = cumulativeSum, level = 0)) cumulativeRaw.add(HeatmapDayData(dayTs = d.dayTs, tokens = cumulativeSum, level = 0))
@ -234,29 +335,84 @@ class HeatmapViewModel @Inject constructor(
} }
/** /**
* 构建每周视图数据。 * 构建每周视图数据:周日对齐,从 firstWeekStart 到 lastWeekStart(含)每周一列。
* 从 rangeStartTs 对应的那天开始,每 7 天为一组。 *
* 每列聚合该周落在 [yearStartTs, yearEndTs] 范围内的天数(dailyRaw 只含年内天数,
* 首列跨前一年 12 月的部分、末列跨次年 1 月初的部分天然不计入),
* 周总量决定该列统一的热度等级;渲染层(HeatmapCanvas)按 barHeight
* 从底部向上绘制柱状图(0 用量周=1 格浅色,非零周=2~7 格)。
*/ */
private fun buildWeeklyData(dailyRaw: List<HeatmapDayData>): Pair<List<HeatmapWeekData>, QuantileLevels> { private fun buildWeeklyData(
val weeks = ArrayList<HeatmapWeekData>() dailyRaw: List<HeatmapDayData>,
var i = 0 firstWeekStart: Long,
while (i < 52 * 7 && i < dailyRaw.size) { lastWeekStart: Long,
val weekEnd = minOf(i + 7, dailyRaw.size) ): Pair<List<HeatmapWeekData>, QuantileLevels> {
val numWeeks = ((lastWeekStart - firstWeekStart) / (7L * DAY_MS)).toInt() + 1
val weeks = ArrayList<HeatmapWeekData>(numWeeks)
for (w in 0 until numWeeks) {
val ws = firstWeekStart + w * 7L * DAY_MS
val we = ws + 7L * DAY_MS
var weekTokens = 0L var weekTokens = 0L
for (j in i until weekEnd) { for (d in dailyRaw) {
weekTokens += dailyRaw[j].tokens if (d.dayTs >= ws && d.dayTs < we) weekTokens += d.tokens
} }
weeks.add(HeatmapWeekData( weeks.add(HeatmapWeekData(
weekStartTs = dailyRaw[i].dayTs, weekStartTs = ws,
tokens = weekTokens, tokens = weekTokens,
level = 0, level = 0,
barHeight = 1,
)) ))
i += 7
} }
// 计算每周视图颜色等级 // 颜色等级:6 级分位数(供颜色与图例使用)
val weekLevels = computeQuantileLevels(weeks.map { it.tokens }) val weekLevels = computeQuantileLevels(weeks.map { it.tokens })
return weeks.map { it.copy(level = weekLevels.getColorLevel(it.tokens)) } to weekLevels // 柱高:非零周按 token 去重排序的排名比例映射 2~7 格(排名不同高度必不同),
// 0 用量周固定 1 格(Level 0 浅色,可见可点击)
val barHeights = computeBarHeights(weeks.map { it.tokens })
return weeks.mapIndexed { i, w ->
w.copy(level = weekLevels.getColorLevel(w.tokens), barHeight = barHeights[i])
} to weekLevels
}
/**
* 计算每周视图的柱状图高度(格数)。
*
* - 0 用量周:1 格(Level 0 浅色格,作为「空白周」的可见标记,不隐藏)
* - 非零周:按 token 值去重排序,排名比例映射到 2~7 格:
* height = 2 + rankIndex * 5 / (uniqueCount - 1)(rankIndex 0-based)
* 唯一非零值只有 1 个时该周为 7 格(满柱)
*
* 用排名比例而非分位档,保证不同量级的周(如 3.7亿 vs 10.7亿)高度不同。
*/
private fun computeBarHeights(tokens: List<Long>): IntArray {
val uniqueNonZero = tokens.filter { it > 0L }.distinct().sorted()
val n = uniqueNonZero.size
return IntArray(tokens.size) { i ->
val t = tokens[i]
when {
t <= 0L -> 1
n == 1 -> 7
else -> 2 + uniqueNonZero.indexOf(t) * 5 / (n - 1)
}
}
}
/** 返回时间戳在指定时区下的自然日零点(epoch 毫秒) */
private fun startOfDayTs(ts: Long, zone: ZoneOffset): Long {
return Instant.ofEpochMilli(ts).atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()
}
/** 返回时间戳在指定时区下所在周的周日零点(0=Sunday) */
private fun startOfWeekTs(ts: Long, zone: ZoneOffset): Long {
val dow = dayOfWeekFromTs(ts, zone)
return startOfDayTs(ts, zone) - dow * DAY_MS
}
/** 时间戳在指定时区下的星期几 (0=Sunday, 6=Saturday) */
private fun dayOfWeekFromTs(ts: Long, zone: ZoneOffset): Int {
val dow = Instant.ofEpochMilli(ts).atOffset(zone).dayOfWeek.value
return dow % 7 // Sunday=7 → 0
} }
// ── 分位数颜色等级计算 ────────────────────────────── // ── 分位数颜色等级计算 ──────────────────────────────

View File

@ -504,4 +504,6 @@
<string name="heatmap_legend_less">少</string> <string name="heatmap_legend_less">少</string>
<string name="heatmap_legend_more">多</string> <string name="heatmap_legend_more">多</string>
<string name="heatmap_accessibility_desc">Token 活动热力图,%1$d 天数据</string> <string name="heatmap_accessibility_desc">Token 活动热力图,%1$d 天数据</string>
<string name="heatmap_accessibility_desc_weekly">Token 活动热力图,%1$d 周数据</string>
<string name="heatmap_select_year">选择年份</string>
</resources> </resources>

View File

@ -504,4 +504,6 @@
<string name="heatmap_legend_less">少</string> <string name="heatmap_legend_less">少</string>
<string name="heatmap_legend_more">多</string> <string name="heatmap_legend_more">多</string>
<string name="heatmap_accessibility_desc">Token 活動熱力圖,%1$d 天資料</string> <string name="heatmap_accessibility_desc">Token 活動熱力圖,%1$d 天資料</string>
<string name="heatmap_accessibility_desc_weekly">Token 活動熱力圖,%1$d 週資料</string>
<string name="heatmap_select_year">選擇年份</string>
</resources> </resources>

View File

@ -507,4 +507,6 @@
<string name="heatmap_legend_less">Less</string> <string name="heatmap_legend_less">Less</string>
<string name="heatmap_legend_more">More</string> <string name="heatmap_legend_more">More</string>
<string name="heatmap_accessibility_desc">Token activity heatmap, %1$d days</string> <string name="heatmap_accessibility_desc">Token activity heatmap, %1$d days</string>
<string name="heatmap_accessibility_desc_weekly">Token activity heatmap, %1$d weeks</string>
<string name="heatmap_select_year">Select year</string>
</resources> </resources>

124
docs/heatmap-plan.md Normal file
View File

@ -0,0 +1,124 @@
# Token 活动热力图 — 设计方案
> 参考实现:`reference/react-activity-calendar`(grubersjoe/react-activity-calendar,MIT)
> 该库为 React/SVG Web 组件,本项目用 Jetpack Compose Canvas 重新实现,仅参考其布局逻辑和数据结构。
---
## 一、入口与数据源
- 主页新增一个入口,点击进入独立的 Token 活动热力图页面
- **只统计 OCGO 服务的数据**,不涉及 CCGO / Codex / Ollama
## 二、页面布局
- 标题左对齐:「Token 活动」
- 标题右侧视图切换:每日 / 每周 / 累计
- 选中项:主要文字颜色 + 中等字重
- 未选中项:弱文字颜色
- 不使用大号按钮背景或明显的 Tab 胶囊样式
## 三、每日视图(默认)
- GitHub Contribution Calendar 结构:
- 53 列 × 7 行
- 每列代表一周,每行代表星期中的一天
- 越靠右越接近当前日期
- 按所选自然年显示:今年 = 1月1日 ~ 今天;往年 = 1月1日 ~ 12月31日(完整自然年,取代早期「最近滚动 365 天」窗口,详见「十一、年份选择器」)
- 格子参数:
- 尺寸 10~12dp
- 间距 3dp
- 圆角 3dp
- 月份标签:12sp,底部显示
- 不显示左侧星期标签
> 注:2026-08 起已改为**年份选择器(自然年)**取代固定滚动 365 天窗口,详见「十一、年份选择器」。
## 四、颜色等级
- 6 级:Level 0(空白)+ Level 1~5(逐渐加深的粉色)
- 使用项目现有配色(Color.kt 中的粉色系)
- **映射方案:分位数法**
- 收集所有非零日 Token 数,排序,计算分位阈值
- Level 0:token = 0(空白)
- Level 1:0 ~ 25% 分位
- Level 2:25% ~ 50% 分位
- Level 3:50% ~ 75% 分位
- Level 4:75% ~ 95% 分位
- Level 5:95% 以上
- 颜色会随数据变化是预期行为(与 GitHub 热力图一致)
## 五、每周视图
- 与每日视图**共用同一 7 行 × 约 53 列网格尺寸**(列数按所选自然年浮动 52~54),切换视图时布局保持一致
- 语义为**离散柱状图**:横轴 = 自然周,每一列 = 一周,纵向高度 = 该周 Token 用量
- 每列从底部向上填充格子,用量越多填充越高;**整列 7 格全部绘制**:底部 barHeight 格 = 该周等级颜色(柱),其余格 = Level 0 浅色(空白格不隐藏,网格完整),最大高度 = 7 格满柱
- **高度映射**:非零周按 token 去重排序的排名比例映射 2~7 格(height = 2 + rankIndex×5/(uniqueCount-1),排名不同高度必不同,如 3.7亿 → 2 格、10.7亿 → 7 格;唯一非零值时满柱 7 格)
- 填充格使用该周的等级颜色(6 级粉色分位,与图例"少→多"一致)
- 列内格子**不表示周日~周六**(与每日视图的日期语义解耦)
- 周列与日视图一致按周日对齐;第一列 = 该年 1月1日 所在周(可能含去年 12 月空位),最后一列 = 结束日所在周(今年 = 今天所在周,往年可能跨到次年 1 月初)
- 点击:每周视图列内任意格子可点击查看该周浮层("X月X日-X月X日 使用了X token"),含 0 用量周(显示"使用了0token");**选中周整列加主题色描边强调**(再次点击或点击其他周取消)
## 六、累计视图
- 和每日视图同样的小格子风格
- 53 列 × 7 行
- 每个格子的颜色代表从起始日到该日的累计 Token 总量
- 同样用分位数法映射到 6 级
## 七、格子交互
- 点击格子显示浮层,内容为日期和当天使用的 Token 数
- 浮层格式示例:
```
7月9日 使用了1.9亿token
7月10日 使用了1124.5万token
7月11日 使用了11.2万token
7月12日 使用了16783token
```
(Token 数按数量级自然中文读法显示)
- 再次点击或点击外部关闭浮层
- 不需要"查看详情"按钮
- 热力格支持键盘聚焦,提供日期与 Token 数的无障碍描述
## 八、视图切换动画
- 切换视图使用约 150ms 的淡入淡出
- 不要明显的滑动或弹跳动画
## 九、Token 定义
- 热力图中的「使用 token」= `inputTokens + cacheReadTokens + reasoningTokens + outputTokens`
- `inputTokens`:未命中缓存的输入 token
- `cacheReadTokens`:命中缓存的输入 token
- `reasoningTokens`:推理 token(思维链 CoT)
- `outputTokens`:输出 token
- 不包含 `cacheWrite5mTokens` 和 `cacheWrite1hTokens`(缓存写入 token)
## 十、Token 数显示格式
- 按数量级自然转换为中文读法:
- 亿级:1.9亿
- 千万级:1124.5万
- 万级:11.2万
- 万以下:原始数字(如 16783)
## 十一、年份选择器(2026-08 新增)
- 标题「Token 活动」右侧为年份下拉选择器(「2026 ▾」),点击弹出年份列表
- 语义:按**自然年**查看
- 今年:1月1日 ~ 今天(默认选中年份)
- 往年:1月1日 ~ 12月31日(完整自然年)
- 取代原先固定「最近滚动 365 天」窗口
- 可选年份范围:最早有数据的年份 ~ 今年
- 三种视图(每日/每周/累计)均跟随所选年份,分位数按所选年份独立计算(颜色随年份数据变化是预期行为)
- 周视图按自然年时列数浮动(52~54 列,闰年且 1月1日 为周六时可达 54 列):
- 第一列为该年 1月1日 所在周(可能含去年 12 月空位)
- 最后一列为结束日所在周(今年=今天所在周;往年可能跨到次年 1 月初)
- 浮层日期:日期所在年份与今年不同时前缀加年份(如「2024年7月9日 使用了1.9亿token」)
## 十二、图例与滚动(2026-08 新增)
- 底部「少 → 多」颜色图例固定显示,不随热力图横向滚动
- 页面打开 / 切换视图 / 切换年份时,热力图横向滚动自动定位到最右(最新数据)
- 0 token 的天可以点击查看浮层(显示「7月9日 使用了0token」);每周视图 0 用量周显示 1 格 Level 0 浅色格、同样可点击(柱状图语义,见「五、每周视图」)