mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user