diff --git a/.gitignore b/.gitignore index 91b2124..7f66874 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ release.jks *.jks tmp_cc/ goal.md +/reference/ diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt index 3d27300..7444ae0 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt @@ -29,7 +29,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons 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.Refresh import androidx.compose.material.icons.filled.Settings @@ -714,7 +714,7 @@ private fun HeatmapEntryCard(onOpenHeatmap: () -> Unit) { verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Filled.CalendarMonth, + imageVector = Icons.Filled.DateRange, contentDescription = null, tint = StrawberryPink, modifier = Modifier.size(44.dp) diff --git a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt index 189ea8a..5e7bf80 100644 --- a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt +++ b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt @@ -21,25 +21,37 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.setValue +import androidx.compose.runtime.snapshotFlow 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.Stroke 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.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.round import androidx.compose.ui.unit.sp import com.rainy.token.ui.theme.inkMuted import java.time.Instant import java.time.ZoneOffset +import kotlin.math.roundToInt +import kotlinx.coroutines.flow.first // ── 热力图颜色 ────────────────────────────────────────────── @@ -77,15 +89,15 @@ private data class MonthLabel( val label: String, ) -/** 从 UTC 时间戳获取星期几 (0=Sunday, 6=Saturday) */ -private fun dayOfWeekFromTs(ts: Long): Int { - val dayOfWeek = Instant.ofEpochMilli(ts).atZone(ZoneOffset.UTC).dayOfWeek.value +/** 从时间戳获取星期几 (0=Sunday, 6=Saturday),按指定时区 */ +private fun dayOfWeekFromTs(ts: Long, zone: ZoneOffset): Int { + val dayOfWeek = Instant.ofEpochMilli(ts).atOffset(zone).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 +/** 从时间戳获取月份 (0=January),按指定时区 */ +private fun monthFromTs(ts: Long, zone: ZoneOffset): Int { + return Instant.ofEpochMilli(ts).atOffset(zone).monthValue - 1 } /** @@ -93,10 +105,10 @@ private fun monthFromTs(ts: Long): Int { * 周起始日为周日(weekStart = 0)。 * 返回 List>,外层为周,内层为星期 0-6,null 表示空位。 */ -private fun groupByWeeks(dailyData: List): List> { +private fun groupByWeeks(dailyData: List, zone: ZoneOffset): List> { if (dailyData.isEmpty()) return emptyList() - val firstDow = dayOfWeekFromTs(dailyData[0].dayTs) + val firstDow = dayOfWeekFromTs(dailyData[0].dayTs, zone) // 左侧填充空位,使第一周从周日开始 val padded = ArrayList(firstDow + dailyData.size) @@ -113,7 +125,7 @@ private fun groupByWeeks(dailyData: List): List>): List { +private fun getMonthLabels(weeks: List>, zone: ZoneOffset): List { if (weeks.isEmpty()) return emptyList() val rawLabels = mutableListOf() @@ -121,7 +133,7 @@ private fun getMonthLabels(weeks: List>): List for (weekIndex in weeks.indices) { val firstDay = weeks[weekIndex].firstOrNull { it != null } ?: continue - val month = monthFromTs(firstDay.dayTs) + val month = monthFromTs(firstDay.dayTs, zone) if (weekIndex == 0 || month != prevMonth) { rawLabels.add(MonthLabel(weekIndex, ChineseMonthNames[month])) @@ -132,25 +144,6 @@ private fun getMonthLabels(weeks: List>): List return filterMonthLabels(rawLabels, weeks.size) } -/** 从每周数据计算月份标签 */ -private fun getWeeklyMonthLabels(weeklyData: List): List { - if (weeklyData.isEmpty()) return emptyList() - - val rawLabels = mutableListOf() - 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,跳过 @@ -177,15 +170,21 @@ private fun filterMonthLabels(rawLabels: List, totalWeeks: Int): Lis * Token 活动热力图 Canvas 绘制组件。 * * 支持三种视图: - * - [HeatmapViewMode.DAILY]:53×7 格子网格,每日 Token 使用 - * - [HeatmapViewMode.WEEKLY]:52×1 单行格子,每周 Token 总量 - * - [HeatmapViewMode.CUMULATIVE]:53×7 格子网格,累计 Token + * - [HeatmapViewMode.DAILY]:约 53 列 × 7 行(列数按年浮动 52~54),每日 Token 使用 + * - [HeatmapViewMode.WEEKLY]:约 53 列 × 7 行,**离散柱状图**——列=自然周, + * 整列 7 格全部绘制:底部 barHeight 格 = 该周等级颜色(0 用量周 1 格、非零周 2~7 格 + * 按 token 排名比例),其余格 = Level 0 浅色(空白格不隐藏,网格完整); + * 选中周整列加主题色描边强调;列内格子不表示日期 + * - [HeatmapViewMode.CUMULATIVE]:约 53 列 × 7 行,累计 Token + * + * 三种视图列数与网格尺寸一致(约 52~54 列 × 7 行),切换视图时布局保持一致。 * * 使用 Compose Canvas 绘制圆角矩形格子,底部显示中文月份标签, * 支持水平滚动和格子点击。 * * @param state UI 状态,包含数据与视图模式 * @param modifier 布局修饰符 + * @param selectedWeek 当前选中的周(每周视图绘制强调边框用,null=未选中) * @param onDayClick 每日/累计视图格子点击回调 * @param onWeekClick 每周视图格子点击回调 */ @@ -197,13 +196,16 @@ fun HeatmapCanvas( lessText: String = "", moreText: String = "", accessibilityDesc: String = "", - onDayClick: (HeatmapDayData) -> Unit = {}, - onWeekClick: (HeatmapWeekData) -> Unit = {}, + selectedWeek: HeatmapWeekData? = null, + onDayClick: (HeatmapDayData, IntOffset) -> Unit = { _, _ -> }, + onWeekClick: (HeatmapWeekData, IntOffset) -> Unit = { _, _ -> }, ) { val darkTheme = isSystemInDarkTheme() val colors = if (darkTheme) DarkHeatmapColors else LightHeatmapColors val mutedColor = inkMuted() val density = LocalDensity.current + // 与全局图表设置一致的时区(分桶与显示口径统一) + val zone = if (state.useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC // 格子参数 val blockSize = 11.dp @@ -218,22 +220,29 @@ fun HeatmapCanvas( 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) { - HeatmapViewMode.DAILY -> groupByWeeks(state.dailyData) - HeatmapViewMode.CUMULATIVE -> groupByWeeks(state.cumulativeData) - HeatmapViewMode.WEEKLY -> emptyList() + HeatmapViewMode.DAILY, HeatmapViewMode.WEEKLY -> groupByWeeks(state.dailyData, zone) + HeatmapViewMode.CUMULATIVE -> groupByWeeks(state.cumulativeData, zone) } } - // 月份标签 - val monthLabels = remember(gridWeeks, state.weeklyData, isWeekly) { - if (isWeekly) getWeeklyMonthLabels(state.weeklyData) else getMonthLabels(gridWeeks) + // 月份标签(三种视图共用同一计算,位置与每日视图完全一致) + val monthLabels = remember(gridWeeks, zone) { + getMonthLabels(gridWeeks, zone) } - val cols = if (isWeekly) state.weeklyData.size else gridWeeks.size - val rows = if (isWeekly) 1 else 7 + val cols = gridWeeks.size + val rows = 7 // 加载中或无数据 if (state.loading || cols == 0) { @@ -253,8 +262,8 @@ fun HeatmapCanvas( } // Canvas 尺寸计算 - val canvasWidthDp = cols * (blockSize + gap) - gap - val gridHeightDp = rows * (blockSize + gap) - gap + val canvasWidthDp = (blockSize + gap) * cols - gap + val gridHeightDp = (blockSize + gap) * rows - gap val monthLabelHeightDp = 20.dp val totalHeightDp = gridHeightDp + monthLabelHeightDp @@ -270,53 +279,90 @@ fun HeatmapCanvas( } } - Column( - modifier = modifier - .horizontalScroll(rememberScrollState()) - .semantics { contentDescription = accessibilityDesc.ifEmpty { "Token activity heatmap, ${if (isWeekly) state.weeklyData.size else state.dailyData.size} days" } }, - ) { - // ── 热力图网格 + 月份标签 ── - Canvas( + // 横向滚动状态(图例固定在外层,不随热力图滚动) + val scrollState = rememberScrollState() + + // 打开页面 / 切换视图 / 切换年份时,横向滚动自动定位到最右(最新数据) + LaunchedEffect(cols, isWeekly, state.selectedYear) { + snapshotFlow { scrollState.maxValue }.first { it > 0 } + scrollState.scrollTo(scrollState.maxValue) + } + + Column(modifier = modifier) { + // ── 热力图网格 + 月份标签(可横向滚动)── + Column( modifier = Modifier - .size(canvasWidthDp, totalHeightDp) - .pointerInput(state, cellStepPx, blockSizePx) { + .horizontalScroll(scrollState) + .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 -> + // 点击落在格子间隙内时不触发(Minor:命中精度) + if (offset.x % cellStepPx >= blockSizePx || offset.y % cellStepPx >= blockSizePx) { + return@detectTapGestures + } val col = (offset.x / 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 (col >= 0 && col < state.weeklyData.size && - offset.y >= 0f && offset.y < blockSizePx - ) { - onWeekClick(state.weeklyData[col]) - } + // 每周视图(柱状图):该列任意格子都对应同一周 → 点击触发该周浮层; + // 0 用量周也有 1 格 Level 0 浅色格,同样可点击(显示「使用了0token」) + val weekData = state.weeklyData.getOrNull(col) ?: return@detectTapGestures + onWeekClick(weekData, anchor) } else { - // 每日/累计视图:点击 (col, row) 对应格子 - if (col >= 0 && col < gridWeeks.size && row >= 0 && row < 7) { - val cell = gridWeeks[col][row] - if (cell != null) { - onDayClick(cell) - } - } + // 每日/累计视图:点击 (col, row) 对应格子(空位不可点) + val cell = gridWeeks[col][row] ?: return@detectTapGestures + onDayClick(cell, anchor) } }) }, ) { - // 绘制格子 + // 绘制格子: + // - 每日/累计:7 行网格按天等级绘制,null 空位不绘制 + // - 每周:离散柱状图——列=自然周,整列 7 格全部绘制: + // 底部 barHeight 格 = 该周等级颜色(柱:0 用量周 1 格 Level 0、非零周 2~7 格), + // 其余格 = Level 0 浅色(空白格不隐藏,网格完整); + // 选中周整列加主题色描边强调 if (isWeekly) { - // 每周视图:52 列 × 1 行 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 - drawRoundRect( - color = colors[weekData.level.coerceIn(0, 5)], - topLeft = Offset(x, 0f), - size = Size(blockSizePx, blockSizePx), - cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx), - ) + for (row in 0 until rows) { + val inBar = row >= rows - barHeight + val y = row * cellStepPx + drawRoundRect( + 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 { - // 每日/累计视图:53 列 × 7 行 gridWeeks.forEachIndexed { weekIndex, week -> week.forEachIndexed { dayIndex, dayData -> if (dayData == null) return@forEachIndexed @@ -342,13 +388,14 @@ fun HeatmapCanvas( nativeCanvas.drawText(label.label, x, labelY, monthLabelPaint) } } + } } Spacer(Modifier.height(8.dp)) - // ── 颜色图例(右下角)── + // ── 颜色图例(右下角,固定不随热力图横向滚动)── Row( - modifier = Modifier.width(canvasWidthDp), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt index 1cbdccf..c2cf455 100644 --- a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt @@ -10,16 +10,18 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons 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.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Popup -import androidx.compose.material3.PopupProperties import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -31,18 +33,30 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip 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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics 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.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.rainy.token.R import com.rainy.token.ui.theme.inkMuted import java.time.Instant import java.time.ZoneOffset +import kotlin.math.roundToInt /** * Token 活动热力图页面。 @@ -60,12 +74,20 @@ fun HeatmapScreen( // ── 浮层状态 ── var selectedDay: HeatmapDayData? by remember { mutableStateOf(null) } var selectedWeek: HeatmapWeekData? by remember { mutableStateOf(null) } + // 浮层锚点:被点击格子中心在窗口中的位置 + var popupAnchor by remember { mutableStateOf(null) } + // 浮层自身尺寸(用于锚点偏移计算) + var popupSize by remember { mutableStateOf(IntSize.Zero) } + val density = LocalDensity.current // ── i18n 字符串 ── val noDataText = stringResource(R.string.heatmap_no_data) val lessText = stringResource(R.string.heatmap_legend_less) 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) { viewModel.load() @@ -75,7 +97,44 @@ fun HeatmapScreen( containerColor = Color.Transparent, topBar = { 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 = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) @@ -95,7 +154,7 @@ fun HeatmapScreen( // ── 视图切换器 ── Row( 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 -> val label = when (mode) { @@ -114,6 +173,7 @@ fun HeatmapScreen( // 切换视图时关闭浮层 selectedDay = null selectedWeek = null + popupAnchor = null } ) } @@ -125,27 +185,40 @@ fun HeatmapScreen( animationSpec = tween(150), label = "heatmap_view" ) { 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( - state = state, + state = state.copy(viewMode = mode), noDataText = noDataText, lessText = lessText, moreText = moreText, - accessibilityDesc = accessibilityDesc, - onDayClick = { dayData -> - // 再次点击同一格子则关闭浮层 + accessibilityDesc = desc, + selectedWeek = selectedWeek, + onDayClick = { dayData, anchor -> + // 再次点击同一格子则关闭浮层(0 token 的天也可点击查看) if (selectedDay == dayData) { selectedDay = null + popupAnchor = null } else { selectedDay = dayData selectedWeek = null + popupAnchor = anchor } }, - onWeekClick = { weekData -> + onWeekClick = { weekData, anchor -> if (selectedWeek == weekData) { selectedWeek = null + popupAnchor = null } else { selectedWeek = weekData selectedDay = null + popupAnchor = anchor } } ) @@ -155,25 +228,62 @@ fun HeatmapScreen( } } - // ── 浮层 Popup ── + // ── 浮层 Popup(锚定到被点击格子的中心位置)── val day = selectedDay val week = selectedWeek + val anchor = popupAnchor if (day != null || week != null) { + val useUtc8 = state.useUtc8 + val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC 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 -> { - val start = formatDateChinese(week.weekStartTs) - val endTs = week.weekStartTs + 6L * 86_400_000L - val end = formatDateChinese(endTs) + val start = formatDateChinese(week.weekStartTs, useUtc8, state.selectedYear) + // 末周/跨年周只显示到该周落在所选年内的有效日期(数据最后一天), + // 避免出现未绘制的未来/次年空位日期;数据同源,不依赖实时时钟 + 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" } 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( - alignment = androidx.compose.ui.Alignment.TopCenter, + alignment = Alignment.TopStart, + offset = popupOffset, onDismissRequest = { selectedDay = null selectedWeek = null + popupAnchor = null }, properties = PopupProperties(focusable = true) ) { @@ -181,7 +291,9 @@ fun HeatmapScreen( shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), tonalElevation = 3.dp, shadowElevation = 6.dp, - modifier = Modifier.padding(horizontal = 8.dp) + modifier = Modifier + .padding(horizontal = 8.dp) + .onGloballyPositioned { popupSize = it.size } ) { Text( text = popupText, @@ -200,27 +312,43 @@ fun HeatmapScreen( * - 亿级(≥1亿):1.9亿 * - 万级(≥1万):11.2万 / 1124.5万 * - 万以下:原始数字 + * + * 注意:万级先舍入到一位小数再判断是否升亿(如 99999999 → "1.0亿",而非 "10000.0万")。 */ private fun formatTokenChinese(tokens: Long): String { return when { tokens >= 1_0000_0000 -> { val v = tokens / 1_0000_0000.0 - if (v >= 100) "${v.toInt()}亿" + if (v >= 100) "${v.roundToInt()}亿" else "${"%.1f".format(v)}亿" } tokens >= 1_0000 -> { 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" } } /** - * 将 UTC 时间戳格式化为 "7月9日" 格式(月+日,不加年)。 + * 将时间戳格式化为 "7月9日" 格式(月+日),按与数据分桶一致的时区。 + * 日期所在年份与 [yearContext] 不同时,前缀加年份(如 "2024年7月9日")。 + * + * 日浮层传 [currentYear](查看往年时带年份前缀);周浮层两端传 [selectedYear], + * 使跨年周(如 12月28日-次年1月3日)的结束日带 "2026年" 前缀,避免范围倒挂。 */ -private fun formatDateChinese(ts: Long): String { - val instant = Instant.ofEpochMilli(ts) - val date = instant.atOffset(ZoneOffset.UTC).toLocalDate() - return "${date.monthValue}月${date.dayOfMonth}日" +private fun formatDateChinese(ts: Long, useUtc8: Boolean, yearContext: Int): String { + val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC + val date = Instant.ofEpochMilli(ts).atOffset(zone).toLocalDate() + val yearPrefix = if (date.year != yearContext) "${date.year}年" else "" + return "$yearPrefix${date.monthValue}月${date.dayOfMonth}日" } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapViewModel.kt b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapViewModel.kt index b203c69..0c373fa 100644 --- a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapViewModel.kt @@ -2,19 +2,24 @@ package com.rainy.token.ui.heatmap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.rainy.token.data.local.ChartSettingsStore import com.rainy.token.data.local.UsageCache import com.rainy.token.data.local.UsageRecord import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.domain.model.Credential import com.rainy.token.domain.service.ServiceType import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Instant +import java.time.ZoneOffset import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Provider @@ -35,35 +40,48 @@ data class HeatmapWeekData( val weekStartTs: Long, // 该周起始日时间戳 val tokens: Long, // 该周 7 天累计 token 数 val level: Int, // 0-5 颜色等级 + val barHeight: Int, // 柱状图高度(格数):0 用量周=1(Level 0 浅色格,可见可点),非零周=2~7(按 token 排名比例) ) /** UI 状态 */ data class HeatmapUiState( val loading: Boolean = true, val viewMode: HeatmapViewMode = HeatmapViewMode.DAILY, + val selectedYear: Int = 0, // 当前选中年份(0=尚未加载) + val currentYear: Int = 0, // 今年(浮层日期判断是否显示年份用) + val availableYears: List = emptyList(), // 可选年份(最早数据年份..今年,降序展示) val dailyData: List = emptyList(), val weeklyData: List = emptyList(), val cumulativeData: List = emptyList(), 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 { if (this === other) return true if (other !is HeatmapUiState) return false return loading == other.loading && viewMode == other.viewMode && + selectedYear == other.selectedYear && + currentYear == other.currentYear && + availableYears == other.availableYears && dailyData == other.dailyData && weeklyData == other.weeklyData && cumulativeData == other.cumulativeData && - colorLevels.contentEquals(other.colorLevels) + colorLevels.contentEquals(other.colorLevels) && + useUtc8 == other.useUtc8 } override fun hashCode(): Int { var result = loading.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 + weeklyData.hashCode() result = 31 * result + cumulativeData.hashCode() result = 31 * result + colorLevels.contentHashCode() + result = 31 * result + useUtc8.hashCode() return result } } @@ -110,17 +128,24 @@ internal data class QuantileLevels(val thresholds: LongArray) { class HeatmapViewModel @Inject constructor( private val cacheProvider: Provider, private val credentialRepository: CredentialRepository, + private val chartSettingsStore: ChartSettingsStore, ) : ViewModel() { private val _uiState = MutableStateFlow(HeatmapUiState()) val uiState: StateFlow = _uiState.asStateFlow() private var loaded = false - private var allViewsResult: AllViewsResult? = null + // 全量记录缓存(仅在 load 时拉取一次,切换年份/视图时复用) + private var records: List = emptyList() + private var useUtc8 = false + // 按年缓存三视图计算结果,切换年份不必重复计算(跨线程读写,用并发容器) + private val yearResults = ConcurrentHashMap() + // 最近一次请求的年份(防止快速连续切换时旧协程后完成覆盖新选择) + private var latestRequestedYear = 0 /** * Composable 层在 LaunchedEffect 中调用一次即可。 - * 三种视图数据全部在此次加载中计算好,切换视图不需要重新加载。 + * 首次加载拉取全量记录并计算默认年份(今年)的三视图数据。 */ fun load() { if (loaded) return @@ -130,7 +155,7 @@ class HeatmapViewModel @Inject constructor( /** 切换视图模式(仅改 State,不重新加载数据) */ fun setViewMode(mode: HeatmapViewMode) { - val result = allViewsResult + val result = yearResults[_uiState.value.selectedYear] val levels = result?.let { when (mode) { HeatmapViewMode.DAILY -> it.dailyLevels @@ -141,6 +166,34 @@ class HeatmapViewModel @Inject constructor( _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? { @@ -155,22 +208,42 @@ class HeatmapViewModel @Inject constructor( return } 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 上 val result = withContext(Dispatchers.Default) { - val records = cache.getRecords(wid) - computeAllViews(records) + computeAllViews(fetched, useUtc8, currentYear).also { yearResults[currentYear] = it } } - 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 { it.copy( loading = false, + selectedYear = currentYear, + currentYear = currentYear, + availableYears = availableYears, + useUtc8 = useUtc8, dailyData = result.dailyData, weeklyData = result.weeklyData, cumulativeData = result.cumulativeData, - colorLevels = result.dailyLevels.toIntArray(), + colorLevels = levels.toIntArray(), ) } } catch (e: Exception) { @@ -178,50 +251,78 @@ class HeatmapViewModel @Inject constructor( } } + /** 可选年份列表:最早有数据的年份 ~ 今年;无数据时只有今年 */ + private fun computeAvailableYears(records: List, zone: ZoneOffset, currentYear: Int): List { + val minYear = records.minOfOrNull { Instant.ofEpochMilli(it.timeCreated).atOffset(zone).year } + ?: currentYear + return (minYear..currentYear).toList() + } + // ── 聚合计算 ──────────────────────────────────────────── companion object { 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): AllViewsResult { + internal fun computeAllViews(records: List, useUtc8: Boolean = false, year: Int): AllViewsResult { + val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC val now = System.currentTimeMillis() + val todayTs = startOfDayTs(now, zone) + val currentYear = Instant.ofEpochMilli(now).atOffset(zone).year + val isCurrentYear = year == currentYear - // 1. 按天分桶聚合 token 数 - val todayTs = now / DAY_MS * DAY_MS - val rangeStartTs = todayTs - (RANGE_DAYS - 1) * DAY_MS + // 1. 年份边界(该时区下的自然年) + val yearStartTs = Instant.parse("${year}-01-01T00:00:00Z") + .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() for (r in records) { - val dayTs = r.timeCreated / DAY_MS * DAY_MS - if (dayTs < rangeStartTs || dayTs > todayTs) continue + val dayTs = startOfDayTs(r.timeCreated, zone) + if (dayTs < yearStartTs || dayTs > yearEndTs) continue val tokens = r.inputTokens + r.cacheReadTokens + r.reasoningTokens + r.outputTokens dayTokenMap.merge(dayTs, tokens, Long::plus) } - // 2. 构建完整的每日数据列表(365 天,从 rangeStartTs 到 todayTs) - val dailyRaw = ArrayList(RANGE_DAYS) - for (i in 0 until RANGE_DAYS) { - val ts = rangeStartTs + i * DAY_MS + // 3. 构建完整的每日数据列表(1月1日 ~ 结束日) + val dailyRaw = ArrayList(totalDays) + for (i in 0 until totalDays) { + val ts = yearStartTs + i * DAY_MS val tokens = dayTokenMap[ts] ?: 0L dailyRaw.add(HeatmapDayData(dayTs = ts, tokens = tokens, level = 0)) } - // 3. 计算每日视图颜色等级 + // 4. 计算每日视图颜色等级 val dailyLevels = computeQuantileLevels(dailyRaw.map { it.tokens }) val dailyData = dailyRaw.map { it.copy(level = dailyLevels.getColorLevel(it.tokens)) } - // 4. 每周视图:从最早日数据开始,每 7 天一组 - val (weeklyData, weeklyLevels) = buildWeeklyData(dailyRaw) + // 5. 每周视图:周日对齐,第一列=1月1日所在周,最后一列=结束日所在周 + val firstWeekStart = startOfWeekTs(yearStartTs, zone) + val lastWeekStart = startOfWeekTs(yearEndTs, zone) + val (weeklyData, weeklyLevels) = buildWeeklyData(dailyRaw, firstWeekStart, lastWeekStart) - // 5. 累计视图:从第一天到当天的累计总和 + // 6. 累计视图:从 1月1日 到结束日的累计总和 var cumulativeSum = 0L - val cumulativeRaw = ArrayList(RANGE_DAYS) + val cumulativeRaw = ArrayList(totalDays) for (d in dailyRaw) { cumulativeSum += d.tokens cumulativeRaw.add(HeatmapDayData(dayTs = d.dayTs, tokens = cumulativeSum, level = 0)) @@ -234,29 +335,84 @@ class HeatmapViewModel @Inject constructor( } /** - * 构建每周视图数据。 - * 从 rangeStartTs 对应的那天开始,每 7 天为一组。 + * 构建每周视图数据:周日对齐,从 firstWeekStart 到 lastWeekStart(含)每周一列。 + * + * 每列聚合该周落在 [yearStartTs, yearEndTs] 范围内的天数(dailyRaw 只含年内天数, + * 首列跨前一年 12 月的部分、末列跨次年 1 月初的部分天然不计入), + * 周总量决定该列统一的热度等级;渲染层(HeatmapCanvas)按 barHeight + * 从底部向上绘制柱状图(0 用量周=1 格浅色,非零周=2~7 格)。 */ - private fun buildWeeklyData(dailyRaw: List): Pair, QuantileLevels> { - val weeks = ArrayList() - var i = 0 - while (i < 52 * 7 && i < dailyRaw.size) { - val weekEnd = minOf(i + 7, dailyRaw.size) + private fun buildWeeklyData( + dailyRaw: List, + firstWeekStart: Long, + lastWeekStart: Long, + ): Pair, QuantileLevels> { + val numWeeks = ((lastWeekStart - firstWeekStart) / (7L * DAY_MS)).toInt() + 1 + val weeks = ArrayList(numWeeks) + + for (w in 0 until numWeeks) { + val ws = firstWeekStart + w * 7L * DAY_MS + val we = ws + 7L * DAY_MS var weekTokens = 0L - for (j in i until weekEnd) { - weekTokens += dailyRaw[j].tokens + for (d in dailyRaw) { + if (d.dayTs >= ws && d.dayTs < we) weekTokens += d.tokens } weeks.add(HeatmapWeekData( - weekStartTs = dailyRaw[i].dayTs, + weekStartTs = ws, tokens = weekTokens, level = 0, + barHeight = 1, )) - i += 7 } - // 计算每周视图颜色等级 + // 颜色等级:6 级分位数(供颜色与图例使用) 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): 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 } // ── 分位数颜色等级计算 ────────────────────────────── diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 5066ba1..c9385ce 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -504,4 +504,6 @@ 少 多 Token 活动热力图,%1$d 天数据 + Token 活动热力图,%1$d 周数据 + 选择年份 \ No newline at end of file diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index 87ab95b..80b4db2 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -504,4 +504,6 @@ 少 多 Token 活動熱力圖,%1$d 天資料 + Token 活動熱力圖,%1$d 週資料 + 選擇年份 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 697e447..1066810 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -507,4 +507,6 @@ Less More Token activity heatmap, %1$d days + Token activity heatmap, %1$d weeks + Select year diff --git a/docs/heatmap-plan.md b/docs/heatmap-plan.md new file mode 100644 index 0000000..b90f05f --- /dev/null +++ b/docs/heatmap-plan.md @@ -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 浅色格、同样可点击(柱状图语义,见「五、每周视图」) \ No newline at end of file