fix(views): improve daily token usage chart readability (#641)

* fix(views): improve daily token usage chart readability

- Fix Y-axis showing scrambled/truncated tick labels by computing
  explicit nice ticks and using compact number formatting (100M not 100.0M)
- Simplify token categories from 4 (Input/Output/Cache Read/Cache Write)
  to 3 (Input/Output/Cached) — cache write merged into input
- Replace noisy stacked area chart with clean single-area total trend,
  with a custom tooltip showing per-category breakdown and total
- Increase Y-axis width to prevent label clipping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(views): handle floating point edge case in formatTokens

Use modulo + threshold instead of Number.isInteger to avoid floating
point precision issues (e.g. 2.5M * 4 = 10.000000000000004).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(views): keep 4 token categories consistent between chart tooltip and summary cards

Revert the 3-category simplification (Cached/Input/Output) back to the
original 4 categories (Input/Output/Cache Read/Cache Write) so the chart
tooltip matches the summary cards on the same page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
LinYushen
2026-04-10 16:20:03 +08:00
committed by GitHub
parent 28b9bf85ee
commit 63ca8d7d89
2 changed files with 112 additions and 53 deletions

View File

@@ -1,34 +1,118 @@
import { useMemo } from "react";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
} from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
type ChartConfig,
} from "@multica/ui/components/ui/chart";
import type { DailyTokenData } from "../../utils";
import { formatTokens } from "../../utils";
const tokenChartConfig = {
input: { label: "Input", color: "hsl(var(--chart-1))" },
output: { label: "Output", color: "hsl(var(--chart-2))" },
cacheRead: { label: "Cache Read", color: "hsl(var(--chart-3))" },
cacheWrite: { label: "Cache Write", color: "hsl(var(--chart-4))" },
total: { label: "Total", color: "hsl(var(--chart-1))" },
} satisfies ChartConfig;
type DailyTokenRow = DailyTokenData & { total: number };
function computeNiceTicks(data: DailyTokenRow[], tickCount = 5): number[] {
const maxTotal = data.reduce(
(max, d) => (d.total > max ? d.total : max),
0,
);
if (maxTotal === 0) return [0];
const rawStep = maxTotal / (tickCount - 1);
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
const niceSteps = [1, 2, 2.5, 5, 10];
const niceStep =
magnitude * (niceSteps.find((s) => s * magnitude >= rawStep) ?? 10);
const ticks: number[] = [];
for (let i = 0; i < tickCount; i++) {
ticks.push(niceStep * i);
}
if ((ticks[ticks.length - 1] ?? 0) < maxTotal) {
ticks.push(niceStep * tickCount);
}
return ticks;
}
function TokenTooltipContent({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{ payload: DailyTokenRow }>;
label?: string;
}) {
if (!active || !payload?.length) return null;
const row = payload[0]!.payload;
const items = [
{ label: "Input", value: row.input },
{ label: "Output", value: row.output },
{ label: "Cache Read", value: row.cacheRead },
{ label: "Cache Write", value: row.cacheWrite },
];
return (
<div className="rounded-lg border border-border/50 bg-background px-3 py-2 text-xs shadow-xl">
<div className="font-medium mb-1.5">{label}</div>
<div className="grid gap-1">
{items.map((item) => (
<div
key={item.label}
className="flex items-center justify-between gap-6"
>
<span className="text-muted-foreground">{item.label}</span>
<span className="font-mono font-medium tabular-nums">
{formatTokens(item.value)}
</span>
</div>
))}
<div className="flex items-center justify-between gap-6 border-t pt-1 mt-0.5 font-medium">
<span>Total</span>
<span className="font-mono tabular-nums">
{formatTokens(row.total)}
</span>
</div>
</div>
</div>
);
}
export function DailyTokenChart({ data }: { data: DailyTokenData[] }) {
const chartData = useMemo<DailyTokenRow[]>(
() =>
data.map((d) => ({
...d,
total: d.input + d.output + d.cacheRead + d.cacheWrite,
})),
[data],
);
const ticks = useMemo(() => computeNiceTicks(chartData), [chartData]);
const yMax = ticks[ticks.length - 1] ?? 0;
return (
<div className="rounded-lg border p-4">
<h4 className="text-xs font-medium text-muted-foreground mb-3">Daily Token Usage</h4>
<ChartContainer config={tokenChartConfig} className="aspect-[2.5/1] w-full">
<AreaChart data={data} margin={{ left: 0, right: 0, top: 4, bottom: 0 }}>
<h4 className="text-xs font-medium text-muted-foreground mb-3">
Daily Token Usage
</h4>
<ChartContainer
config={tokenChartConfig}
className="aspect-[2.5/1] w-full"
>
<AreaChart
data={chartData}
margin={{ left: 0, right: 0, top: 4, bottom: 0 }}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
@@ -38,53 +122,22 @@ export function DailyTokenChart({ data }: { data: DailyTokenData[] }) {
interval="preserveStartEnd"
/>
<YAxis
type="number"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(v: number) => formatTokens(v)}
width={50}
width={65}
domain={[0, yMax]}
ticks={ticks}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) =>
typeof value === "number" ? formatTokens(value) : String(value)
}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
<Tooltip content={<TokenTooltipContent />} />
<Area
type="monotone"
dataKey="input"
stackId="1"
stroke="var(--color-input)"
fill="var(--color-input)"
fillOpacity={0.4}
/>
<Area
type="monotone"
dataKey="output"
stackId="1"
stroke="var(--color-output)"
fill="var(--color-output)"
fillOpacity={0.4}
/>
<Area
type="monotone"
dataKey="cacheRead"
stackId="1"
stroke="var(--color-cacheRead)"
fill="var(--color-cacheRead)"
fillOpacity={0.4}
/>
<Area
type="monotone"
dataKey="cacheWrite"
stackId="1"
stroke="var(--color-cacheWrite)"
fill="var(--color-cacheWrite)"
fillOpacity={0.4}
dataKey="total"
stroke="var(--color-total)"
fill="var(--color-total)"
fillOpacity={0.3}
/>
</AreaChart>
</ChartContainer>

View File

@@ -14,8 +14,14 @@ export function formatLastSeen(lastSeenAt: string | null): string {
}
export function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
if (n >= 1_000_000) {
const m = n / 1_000_000;
return m % 1 < 0.05 ? `${Math.round(m)}M` : `${m.toFixed(1)}M`;
}
if (n >= 1_000) {
const k = n / 1_000;
return k % 1 < 0.05 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`;
}
return n.toLocaleString();
}