feat(heatmap): 年份选择器 + 每周视图柱状图 + 对抗审查修复
- 修复 4 个 heatmap 提交的编译错误与对抗审查问题:浮层锚定与跨年日期、 Crossfade 快照、时区口径统一(useUtc8)、万级格式化溢出、周视图周日对齐 - 新增年份选择器:自然年语义、默认今年、三种视图跟随、下拉菜单、 图例固定不随热力图滚动、打开默认滚到最右、0 token 天可点击查看 - 每周视图改为离散柱状图:列=自然周,高度按 token 排名比例映射 2~7 格 (不同量级周高度可区分),0 用量周显示 Level 0 空白格(整列 7 格全绘制) - 选中周整列主题色描边强调,浮层日期跨年带年份前缀 - 文档 docs/heatmap-plan.md 补充年份选择器/图例与滚动/柱状图章节
This commit is contained in:
parent
77cb664590
commit
962d8f1215
1
.gitignore
vendored
1
.gitignore
vendored
@ -21,3 +21,4 @@ release.jks
|
|||||||
*.jks
|
*.jks
|
||||||
tmp_cc/
|
tmp_cc/
|
||||||
goal.md
|
goal.md
|
||||||
|
/reference/
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -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}日"
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 分位数颜色等级计算 ──────────────────────────────
|
// ── 分位数颜色等级计算 ──────────────────────────────
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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
124
docs/heatmap-plan.md
Normal 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 浅色格、同样可点击(柱状图语义,见「五、每周视图」)
|
||||||
Loading…
Reference in New Issue
Block a user