diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 6b2d130..ef2070b 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -468,7 +468,7 @@ export const jaOverrides = { 'usage.token.reasoning': '推論', 'usage.token.cacheRead': 'キャッシュ読み取り', 'usage.token.cacheCreation': 'キャッシュ作成', - 'usage.trend.aria': 'Token の推移図。マウスまたは矢印キーで各時間帯とモデルの内訳を確認できます。', + 'usage.trend.aria': 'Token の積み上げ棒グラフ。マウスまたは矢印キーで各時間帯とモデルの内訳を確認できます。', 'usage.trend.bucket.30m': '30 分ごとに表示', 'usage.trend.bucket.hour': '1 時間ごとに表示', 'usage.trend.bucket.3h': '3 時間ごとに集計', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 1cf891d..6fbb7b1 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -456,7 +456,7 @@ export const en: Record = { 'usage.token.reasoning': 'Reasoning', 'usage.token.cacheRead': 'Cache Read', 'usage.token.cacheCreation': 'Cache Creation', - 'usage.trend.aria': 'Token trend chart. Hover or use arrow keys to inspect each bucket and model.', + 'usage.trend.aria': 'Stacked token bar chart. Hover or use arrow keys to inspect each time interval and model.', 'usage.trend.bucket.30m': 'Shown every 30 minutes', 'usage.trend.bucket.hour': 'Shown by hour', 'usage.trend.bucket.3h': 'Grouped every 3 hours', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index a387e4e..45613d2 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -455,7 +455,7 @@ export const zhCN = { 'usage.token.reasoning': '思考', 'usage.token.cacheRead': '缓存读取', 'usage.token.cacheCreation': '缓存创建', - 'usage.trend.aria': 'Token 趋势图,可悬停或用方向键查看各时段和模型明细', + 'usage.trend.aria': 'Token 堆叠柱形图,可悬停或用方向键查看各时段和模型明细', 'usage.trend.bucket.30m': '按 30 分钟展示', 'usage.trend.bucket.hour': '按小时展示', 'usage.trend.bucket.3h': '按 3 小时聚合', diff --git a/src/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx index 95f3adb..ed25b6a 100644 --- a/src/pages/UsageRecordsPage.tsx +++ b/src/pages/UsageRecordsPage.tsx @@ -28,13 +28,13 @@ import { formatUsageNumber } from '../services/usageNumber'; import { OTHER_TREND_MODEL_KEY, buildUsageTrendSeries, + findTrendPointIndex, formatTrendAxisLabel, formatTrendRangeLabel, niceCeiling, - selectTrendAxisLabels, trendAxisTicks, - smoothAreaPath, - smoothLinePath, + trendTimeAxisTicks, + trendTimePosition, stackModelTokens, type UsageTimelinePoint, } from '../services/usageTrend'; @@ -882,7 +882,7 @@ function OverviewView({ overview, range }: { overview: UsageOverview; range?: Pi {t('usage.trend.title')} - {overview.timeline.length ? : } +
@@ -938,6 +938,8 @@ function UsageTrend({ const { t, locale } = useI18n(); const [hoveredIndex, setHoveredIndex] = useState(null); const [hiddenModels, setHiddenModels] = useState([]); + const plotRef = useRef(null); + const [plotWidth, setPlotWidth] = useState(800); const series = useMemo( () => buildUsageTrendSeries(points, range), @@ -945,11 +947,6 @@ function UsageTrend({ ); const hiddenKeys = useMemo(() => new Set(hiddenModels), [hiddenModels]); - const visibleModels = useMemo( - () => series.models.filter((model) => !hiddenKeys.has(model.key)), - [hiddenKeys, series.models], - ); - useEffect(() => { setHoveredIndex(null); setHiddenModels((current) => current.filter((key) => series.models.some((model) => model.key === key))); @@ -957,24 +954,17 @@ function UsageTrend({ const count = series.points.length; - const chart = useMemo(() => { - if (count === 0) { - return { - maxTokens: 1, - stacked: [], - areaLayers: [], - totalLinePath: '', - yTicks: [0, 0.25, 0.5, 0.75, 1], - labelIndexes: [], - compactSameDay: false, - baseY: 146, - PT: 8, - UH: 138, - }; - } + useEffect(() => { + const plot = plotRef.current; + if (!plot) return; + const observer = new ResizeObserver(([entry]) => setPlotWidth(entry.contentRect.width)); + observer.observe(plot); + return () => observer.disconnect(); + }, [count > 0]); + const chart = useMemo(() => { const stacked = series.points.map((point) => stackModelTokens(point, series.models, hiddenKeys)); - const maxTokens = niceCeiling(Math.max(1, ...stacked.map((layers) => layers[layers.length - 1]?.y1 ?? 0))); + const maxTokens = niceCeiling(stacked.reduce((max, layers) => Math.max(max, layers[layers.length - 1]?.y1 ?? 0), 1)); const VIEWBOX_W = 1000; const PT = 8; @@ -983,74 +973,44 @@ function UsageTrend({ const baseY = PT + UH; const calcY = (val: number) => (maxTokens > 0 ? baseY - (val / maxTokens) * UH : baseY); - const calcX = (idx: number) => (count <= 1 ? VIEWBOX_W / 2 : (idx / (count - 1)) * VIEWBOX_W); - - const areaLayers = visibleModels.map((model) => { - let topPoints: { x: number; y: number }[]; - let bottomPoints: { x: number; y: number }[]; - - if (count <= 1) { - const y1 = calcY(stacked[0]?.find((l) => l.key === model.key)?.y1 ?? 0); - const y0 = calcY(stacked[0]?.find((l) => l.key === model.key)?.y0 ?? 0); - topPoints = [{ x: 0, y: y1 }, { x: VIEWBOX_W, y: y1 }]; - bottomPoints = [{ x: 0, y: y0 }, { x: VIEWBOX_W, y: y0 }]; - } else { - topPoints = series.points.map((_, idx) => ({ - x: calcX(idx), - y: calcY(stacked[idx]?.find((l) => l.key === model.key)?.y1 ?? 0), - })); - bottomPoints = series.points.map((_, idx) => ({ - x: calcX(idx), - y: calcY(stacked[idx]?.find((l) => l.key === model.key)?.y0 ?? 0), - })); - } - - const areaPath = smoothAreaPath(topPoints, bottomPoints); - const linePath = smoothLinePath(topPoints); - const hasTokens = stacked.some((layers) => (layers.find((l) => l.key === model.key)?.tokens ?? 0) > 0); - + const start = series.points[0]?.start ?? new Date(0); + const end = series.points[count - 1]?.end ?? start; + const bars = series.points.map((point, index) => { + const left = trendTimePosition(point.start, start, end) * VIEWBOX_W; + const right = trendTimePosition(point.end, start, end) * VIEWBOX_W; + const gap = Math.min((right - left) * 0.2, 6); return { - model, - areaPath, - linePath, - hasTokens, + x: left + gap / 2, + width: right - left - gap, + center: (left + right) / 2, + layers: stacked[index].filter((layer) => layer.tokens > 0).map((layer) => ({ + ...layer, + y: calcY(layer.y1), + height: (layer.tokens / maxTokens) * UH, + color: series.models.find((model) => model.key === layer.key)?.color, + })), }; }); - - let totalPoints: { x: number; y: number }[]; - if (count <= 1) { - const topY = calcY(stacked[0]?.[stacked[0]?.length - 1]?.y1 ?? 0); - totalPoints = [{ x: 0, y: topY }, { x: VIEWBOX_W, y: topY }]; - } else { - totalPoints = series.points.map((_, idx) => ({ - x: calcX(idx), - y: calcY(stacked[idx]?.[stacked[idx]?.length - 1]?.y1 ?? 0), - })); - } - const totalLinePath = smoothLinePath(totalPoints); const yTicks = trendAxisTicks(maxTokens); - - const labelIndexes = selectTrendAxisLabels( - count, - count > 48 ? 5 : count > 24 ? 6 : 7, - ); - const compactSameDay = - count > 1 && - series.points[0].start.toDateString() === series.points[count - 1].start.toDateString(); + const compactSameDay = start.toDateString() === end.toDateString(); + const timeTicks = trendTimeAxisTicks(start, end, plotWidth, compactSameDay ? 64 : 112); + const showAxisTime = timeTicks.length > 1 && timeTicks[1].getTime() - timeTicks[0].getTime() < 24 * 60 * 60 * 1000; return { maxTokens, stacked, - areaLayers, - totalLinePath, + bars, + start, + end, yTicks, - labelIndexes, + timeTicks, compactSameDay, + showAxisTime, baseY, PT, UH, }; - }, [count, hiddenKeys, series, visibleModels]); + }, [count, hiddenKeys, series, plotWidth]); if (count === 0) { return ; @@ -1065,7 +1025,8 @@ function UsageTrend({ const rect = e.currentTarget.getBoundingClientRect(); if (rect.width <= 0 || count === 0) return; const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - const idx = Math.round(ratio * (count - 1)); + const time = new Date(chart.start.getTime() + ratio * (chart.end.getTime() - chart.start.getTime())); + const idx = findTrendPointIndex(series.points, time); setHoveredIndex(idx); }; @@ -1094,8 +1055,8 @@ function UsageTrend({ const active = hoveredIndex != null && hoveredIndex >= 0 && hoveredIndex < count ? series.points[hoveredIndex] : null; const activeStacked = hoveredIndex != null && chart.stacked[hoveredIndex] ? chart.stacked[hoveredIndex] : []; - const activePercent = count <= 1 ? 50 : ((hoveredIndex ?? 0) / (count - 1)) * 100; - const activeViewboxX = count <= 1 ? 500 : ((hoveredIndex ?? 0) / (count - 1)) * 1000; + const activeViewboxX = chart.bars[hoveredIndex ?? 0]?.center ?? 0; + const activePercent = activeViewboxX / 10; const activeLayers = [...activeStacked] .filter((l) => l.tokens > 0) .sort((a, b) => b.tokens - a.tokens); @@ -1141,12 +1102,15 @@ function UsageTrend({
- - {visibleModels.map((model) => ( - - - - - ))} - - {chart.yTicks.map((tick) => { const y = chart.baseY - (tick / chart.maxTokens) * chart.UH; const isBase = tick === 0; @@ -1199,41 +1139,20 @@ function UsageTrend({ ); })} - {chart.areaLayers.map((layer) => { - if (!layer.hasTokens || !layer.areaPath) return null; - return ( - - ( + + {bar.layers.map((layer) => ( + - - - ); - })} - - {chart.totalLinePath ? ( - - ) : null} + ))} + + ))} {active && hoveredIndex != null ? ( @@ -1244,23 +1163,6 @@ function UsageTrend({ y2={chart.baseY} className="usage-trend-cursor-line" /> - {activeLayers.map((layer) => { - const model = series.models.find((m) => m.key === layer.key); - const dotY = chart.baseY - (layer.y1 / chart.maxTokens) * chart.UH; - return ( - - ); - })} ) : null} @@ -1299,18 +1201,20 @@ function UsageTrend({