From bf030d10fc0cd3972e8969d7764ae4d763b56940 Mon Sep 17 00:00:00 2001 From: Amamiya23 Date: Sat, 25 Apr 2026 19:34:21 +0800 Subject: [PATCH] Improve token type distribution readability --- src/components/usage/TokenBreakdownChart.tsx | 105 +++++++++++++++---- src/pages/UsagePage.tsx | 2 + 2 files changed, 86 insertions(+), 21 deletions(-) diff --git a/src/components/usage/TokenBreakdownChart.tsx b/src/components/usage/TokenBreakdownChart.tsx index 7b1084a..05498ac 100644 --- a/src/components/usage/TokenBreakdownChart.tsx +++ b/src/components/usage/TokenBreakdownChart.tsx @@ -1,6 +1,7 @@ import { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Line } from 'react-chartjs-2'; +import type { ChartData, ChartOptions } from 'chart.js'; +import { Bar } from 'react-chartjs-2'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { @@ -12,15 +13,21 @@ import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConf import type { UsagePayload } from './hooks/useUsageData'; import styles from '@/pages/UsagePage.module.scss'; -const TOKEN_COLORS: Record = { - input: { border: '#8b8680', bg: 'rgba(139, 134, 128, 0.25)' }, - output: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.25)' }, - cached: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.25)' }, - reasoning: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.25)' } +const TOKEN_COLORS: Record = { + input: '#8CC21F', + output: '#FA6450', + cached: '#F5ED58', + reasoning: '#00ABA5' }; const CATEGORIES: TokenCategory[] = ['input', 'output', 'cached', 'reasoning']; +function formatTokens(num: number): string { + if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M'; + if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K'; + return num.toString(); +} + export interface TokenBreakdownChartProps { usage: UsagePayload | null; loading: boolean; @@ -51,38 +58,94 @@ export function TokenBreakdownChart({ reasoning: t('usage_stats.reasoning_tokens') }; - const data = { + const categoryTotals: Record = { + input: 0, + output: 0, + cached: 0, + reasoning: 0 + }; + + CATEGORIES.forEach((cat) => { + categoryTotals[cat] = series.dataByCategory[cat].reduce( + (sum, val) => sum + (Number(val) || 0), + 0, + ); + }); + + const data: ChartData<'bar'> = { labels: series.labels, datasets: CATEGORIES.map((cat) => ({ label: categoryLabels[cat], + categoryId: cat, data: series.dataByCategory[cat], - borderColor: TOKEN_COLORS[cat].border, - backgroundColor: TOKEN_COLORS[cat].bg, - pointBackgroundColor: TOKEN_COLORS[cat].border, - pointBorderColor: TOKEN_COLORS[cat].border, - fill: true, - tension: 0.35 + backgroundColor: TOKEN_COLORS[cat], + borderColor: isDark ? '#0f172a' : '#ffffff', + borderWidth: 1, + borderSkipped: false, + grouped: false, + // Higher order is drawn earlier by Chart.js, so larger series stay behind smaller ones. + order: categoryTotals[cat], + categoryPercentage: 0.78, + barPercentage: 0.82 })) }; - const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile }); - const options = { + const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile }) as ChartOptions<'bar'>; + const options: ChartOptions<'bar'> = { ...baseOptions, scales: { ...baseOptions.scales, y: { ...baseOptions.scales?.y, - stacked: true + stacked: false }, x: { ...baseOptions.scales?.x, - stacked: true + stacked: false + } + }, + plugins: { + ...baseOptions.plugins, + tooltip: { + ...baseOptions.plugins?.tooltip, + itemSort: (a, b) => a.datasetIndex - b.datasetIndex, + callbacks: { + ...baseOptions.plugins?.tooltip?.callbacks, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + label: function (context: any) { + const val = Number(context.raw) || 0; + const cat = context.dataset.categoryId; + let text = `${context.dataset.label}: ${formatTokens(val)}`; + + if (cat === 'cached') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inputDs = context.chart.data.datasets.find((ds: any) => ds.categoryId === 'input'); + const inputVal = inputDs ? (Number(inputDs.data[context.dataIndex]) || 0) : 0; + if (inputVal > 0) { + const perc = ((val / inputVal) * 100).toFixed(2); + text += ` (${perc}%)`; + } + } + return text; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + footer: function (tooltipItems: any[]) { + let total = 0; + const dataIndex = tooltipItems[0].dataIndex; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tooltipItems[0].chart.data.datasets.forEach((ds: any) => { + total += Number(ds.data[dataIndex]) || 0; + }); + return `Total: ${formatTokens(total)}`; + } + } } } }; return { chartData: data, chartOptions: options }; }, [usage, period, isDark, isMobile, hourWindowHours, t]); + const labels = chartData.labels ?? []; return ( {loading ? (
{t('common.loading')}
- ) : chartData.labels.length > 0 ? ( + ) : labels.length > 0 ? (
{chartData.datasets.map((dataset, index) => ( @@ -117,7 +180,7 @@ export function TokenBreakdownChart({ className={styles.legendItem} title={dataset.label} > - + {dataset.label}
))} @@ -128,11 +191,11 @@ export function TokenBreakdownChart({ className={styles.chartCanvas} style={ period === 'hour' - ? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) } + ? { minWidth: getHourChartMinWidth(labels.length, isMobile) } : undefined } > - +
diff --git a/src/pages/UsagePage.tsx b/src/pages/UsagePage.tsx index 0ee7f79..a64bef8 100644 --- a/src/pages/UsagePage.tsx +++ b/src/pages/UsagePage.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Chart as ChartJS, + BarElement, CategoryScale, LinearScale, PointElement, @@ -46,6 +47,7 @@ import styles from './UsagePage.module.scss'; // Register Chart.js components ChartJS.register( + BarElement, CategoryScale, LinearScale, PointElement,