feat(usage): render token trends as stacked bars with an adaptive time axis

This commit is contained in:
lzt404 committed 2026-09-18 18:17:31 +08:00
1 parent 81597fcf91
commit ba247159da
7 files changed
+259 -337

No files matched your search

+1 -1
View File
@@ -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 時間ごとに集計',
+1 -1
View File
@@ -456,7 +456,7 @@ export const en: Record<MessageKey, string> = {
'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',
+1 -1
View File
@@ -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 小时聚合',
+70 -166
View File
@@ -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
<strong>{t('usage.trend.title')}</strong>
</div>
</div>
{overview.timeline.length ? <UsageTrend points={overview.timeline} range={range} /> : <UsageEmpty />}
<UsageTrend points={overview.timeline} range={range} />
</section>
<section className="panel usage-health-panel">
<div className="usage-section-heading">
@@ -938,6 +938,8 @@ function UsageTrend({
const { t, locale } = useI18n();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [hiddenModels, setHiddenModels] = useState<string[]>([]);
const plotRef = useRef<HTMLDivElement>(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 <UsageEmpty />;
@@ -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({
<div className="usage-trend-chart">
<div className="usage-trend-y-axis" aria-hidden="true">
{[...chart.yTicks].reverse().map((tick, index) => (
<span key={`y-${tick}-${index}`}>{compactNumber(tick)}</span>
{chart.yTicks.map((tick) => (
<span key={tick} style={{ top: `${((chart.baseY - (tick / chart.maxTokens) * chart.UH) / 154) * 100}%` }}>
{compactNumber(tick)}
</span>
))}
</div>
<div
ref={plotRef}
className="usage-trend-plot"
tabIndex={0}
role="region"
@@ -1160,30 +1124,6 @@ function UsageTrend({
preserveAspectRatio="none"
className="usage-trend-svg"
>
<defs>
{visibleModels.map((model) => (
<linearGradient
key={`grad-${model.key}`}
id={`trend-grad-${model.key.replace(/[^a-zA-Z0-9_-]+/g, '-')}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={model.color}
stopOpacity={0.28}
/>
<stop
offset="100%"
stopColor={model.color}
stopOpacity={0.08}
/>
</linearGradient>
))}
</defs>
{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 (
<g key={layer.model.key} className="usage-trend-layer">
<path
d={layer.areaPath}
fill={`url(#trend-grad-${layer.model.key.replace(/[^a-zA-Z0-9_-]+/g, '-')})`}
className="usage-trend-area"
{chart.bars.map((bar, index) => (
<g key={series.points[index].hour} className={`usage-trend-bar${index === hoveredIndex ? ' is-active' : ''}`}>
{bar.layers.map((layer) => (
<rect
key={layer.key}
x={bar.x}
y={layer.y}
width={bar.width}
height={layer.height}
fill={layer.color}
/>
<path
d={layer.linePath}
fill="none"
stroke={layer.model.color}
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
className="usage-trend-line"
/>
</g>
);
})}
{chart.totalLinePath ? (
<path
d={chart.totalLinePath}
fill="none"
stroke="var(--theme-3f6f98)"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
className="usage-trend-total-line"
/>
) : null}
))}
</g>
))}
{active && hoveredIndex != null ? (
<g className="usage-trend-active-mark">
@@ -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 (
<circle
key={layer.key}
cx={activeViewboxX}
cy={dotY}
r="3.5"
fill="var(--theme-fffdf8)"
stroke={model?.color ?? '#3b82f6'}
strokeWidth="2"
vectorEffect="non-scaling-stroke"
className="usage-trend-dot"
/>
);
})}
</g>
) : null}
</svg>
@@ -1299,18 +1201,20 @@ function UsageTrend({
</div>
<div className="usage-trend-x-axis" aria-hidden="true">
{chart.labelIndexes.map((idx) => {
const point = series.points[idx];
if (!point) return null;
const left = count <= 1 ? 50 : (idx / (count - 1)) * 100;
const posClass = idx === 0 ? 'is-start' : idx === count - 1 ? 'is-end' : 'is-mid';
{chart.timeTicks.map((date, index) => {
const left = trendTimePosition(date, chart.start, chart.end) * 100;
const posClass = index === 0 ? 'is-start' : index === chart.timeTicks.length - 1 ? 'is-end' : 'is-mid';
return (
<span
key={`axis-${point.hour}-${idx}`}
key={date.getTime()}
className={posClass}
style={{ left: `${left}%` }}
title={date.toLocaleString(locale)}
>
{formatTrendAxisLabel(point, series.bucket, locale, { compactSameDay: chart.compactSameDay })}
{formatTrendAxisLabel({ start: date }, series.bucket, locale, {
compactSameDay: chart.compactSameDay,
showTime: chart.showAxisTime,
})}
</span>
);
})}
+75 -121
View File
@@ -50,9 +50,6 @@ export type PreparedTrendSeries = {
models: PreparedTrendModel[];
};
export type ChartPoint = { x: number; y: number };
const MAX_POINTS = 96;
const MAX_VISIBLE_MODELS = 6;
const OTHER_MODEL_KEY = '__other__';
const HOUR_MS = 60 * 60 * 1000;
@@ -183,33 +180,14 @@ export function endOfBucket(start: Date, bucket: TrendBucket): Date {
return addBucket(start, bucket);
}
function estimateBucketCount(start: Date, end: Date, bucket: TrendBucket): number {
if (end.getTime() <= start.getTime()) return 1;
if (bucket === '30m') {
return Math.max(1, Math.ceil((end.getTime() - start.getTime()) / (HOUR_MS / 2)));
}
if (bucket === 'hour') {
return Math.max(1, Math.ceil((end.getTime() - start.getTime()) / HOUR_MS));
}
if (bucket === '3h') {
return Math.max(1, Math.ceil((end.getTime() - start.getTime()) / (3 * HOUR_MS)));
}
let count = 0;
let cursor = startOfBucket(start, bucket);
const limit = startOfBucket(end, bucket);
while (cursor.getTime() <= limit.getTime() && count <= MAX_POINTS + 2) {
count += 1;
cursor = addBucket(cursor, bucket);
}
return Math.max(1, count);
}
const BUCKET_ORDER: TrendBucket[] = ['30m', 'hour', '3h', 'day', 'week', 'month', 'year'];
export function chooseTrendBucket(start: Date, end: Date): TrendBucket {
for (const bucket of BUCKET_ORDER) {
if (estimateBucketCount(start, end, bucket) <= MAX_POINTS) return bucket;
}
const hours = Math.max(0, end.getTime() - start.getTime()) / HOUR_MS;
if (hours <= 6) return '30m';
if (hours <= 48) return 'hour';
if (hours <= 72) return '3h';
if (hours <= 24 * 90) return 'day';
if (hours <= 24 * 366 * 2) return 'week';
if (hours <= 24 * 366 * 10) return 'month';
return 'year';
}
@@ -276,16 +254,38 @@ export function trendAxisTicks(max: number, targetCount = 5): number[] {
return ticks;
}
export function selectTrendAxisLabels(count: number, maxLabels = 8): number[] {
if (count <= 0) return [];
if (count <= maxLabels) return Array.from({ length: count }, (_, index) => index);
const last = count - 1;
const indexes = new Set<number>([0, last]);
const inner = maxLabels - 2;
for (let step = 1; step <= inner; step += 1) {
indexes.add(Math.round((last * step) / (inner + 1)));
export function trendTimePosition(date: Date, start: Date, end: Date): number {
const span = end.getTime() - start.getTime();
if (span <= 0) return 0;
return Math.max(0, Math.min(1, (date.getTime() - start.getTime()) / span));
}
export function trendTimeAxisTicks(start: Date, end: Date, width: number, labelWidth = 88): Date[] {
const span = end.getTime() - start.getTime();
if (span <= 0) return [start];
// Choose a readable time interval from the selected duration and available space.
const minStep = span * labelWidth / Math.max(labelWidth, width);
const steps = [1 / 60, 5 / 60, 15 / 60, 0.5, 1, 2, 3, 6, 12, 24, 48, 72, 168, 336, 720, 2160, 4380, 8760];
const step = steps.map((hours) => hours * HOUR_MS).find((value) => value >= minStep)
?? niceCeiling(minStep / (8760 * HOUR_MS)) * 8760 * HOUR_MS;
const ticks = [start];
const monthStep = step >= 720 * HOUR_MS ? Math.max(1, Math.round(step / (730 * HOUR_MS))) : 0;
let cursor = monthStep ? startOfBucket(start, 'month') : start;
while (true) {
cursor = cloneDate(cursor);
if (monthStep) cursor.setMonth(cursor.getMonth() + monthStep);
else cursor.setTime(cursor.getTime() + step);
if (cursor.getTime() > end.getTime() - minStep) break;
if (cursor.getTime() >= start.getTime() + minStep) ticks.push(cursor);
}
return [...indexes].sort((left, right) => left - right);
ticks.push(end);
return ticks;
}
export function findTrendPointIndex(points: PreparedTrendPoint[], time: Date): number {
if (!points.length) return -1;
const index = points.findIndex((point) => time.getTime() < point.end.getTime());
return index < 0 ? points.length - 1 : index;
}
function sameCalendarDay(left: Date, right: Date): boolean {
@@ -313,12 +313,12 @@ function formatYear(date: Date, locale: string): string {
}
export function formatTrendAxisLabel(
point: PreparedTrendPoint,
point: Pick<PreparedTrendPoint, 'start'>,
bucket: TrendBucket,
locale: string,
options?: { compactSameDay?: boolean },
options?: { compactSameDay?: boolean; showTime?: boolean },
): string {
if (bucket === '30m' || bucket === 'hour' || bucket === '3h') {
if (options?.showTime || bucket === '30m' || bucket === 'hour' || bucket === '3h') {
if (options?.compactSameDay) return formatTime(point.start, locale);
return `${formatMonthDay(point.start, locale)} ${formatTime(point.start, locale)}`;
}
@@ -329,15 +329,17 @@ export function formatTrendAxisLabel(
}
export function formatTrendRangeLabel(
point: PreparedTrendPoint,
point: Pick<PreparedTrendPoint, 'start' | 'end'>,
locale: string,
bucket: TrendBucket = 'hour',
): string {
const start = point.start;
const end = new Date(Math.max(start.getTime(), point.end.getTime() - 1));
if (bucket === 'year') return formatYear(start, locale);
if (bucket === 'month') return formatYearMonth(start, locale);
if (bucket === 'day') {
const end = point.end;
const completeBucket = start.getTime() === startOfBucket(start, bucket).getTime()
&& end.getTime() === endOfBucket(start, bucket).getTime();
if (bucket === 'year' && completeBucket) return formatYear(start, locale);
if (bucket === 'month' && completeBucket) return formatYearMonth(start, locale);
if (bucket === 'day' && completeBucket) {
const withYear = start.getFullYear() !== new Date().getFullYear();
return new Intl.DateTimeFormat(locale, {
year: withYear ? 'numeric' : undefined,
@@ -345,14 +347,15 @@ export function formatTrendRangeLabel(
day: 'numeric',
}).format(start);
}
if (bucket === 'week') {
const withYear = start.getFullYear() !== end.getFullYear();
if (bucket === 'week' && completeBucket) {
const lastDay = new Date(end.getTime() - 1);
const withYear = start.getFullYear() !== lastDay.getFullYear();
const formatter = new Intl.DateTimeFormat(locale, {
year: withYear ? 'numeric' : undefined,
month: 'numeric',
day: 'numeric',
});
return `${formatter.format(start)}-${formatter.format(end)}`;
return `${formatter.format(start)}-${formatter.format(lastDay)}`;
}
const sameDay = sameCalendarDay(start, end);
const dayPart = formatMonthDay(start, locale);
@@ -371,10 +374,16 @@ export function buildUsageTrendSeries(
now = new Date(),
): PreparedTrendSeries {
const labels = new Map<string, string>();
const rangeStart = parseRangeDate(range?.start);
const rangeEnd = parseRangeDate(range?.end);
const parsed = points
.map((point) => {
const start = parseLocalHourKey(point.hour);
if (!start) return null;
// The backend filters requests before grouping them into half-hour slots.
// Keep the partial slot overlapping the start, and the inclusive end slot.
if (rangeStart && addBucket(start, '30m').getTime() <= rangeStart.getTime()) return null;
if (rangeEnd && start.getTime() > rangeEnd.getTime()) return null;
const models: Record<string, number> = {};
for (const model of point.models ?? []) {
const key = (model.key || model.label || '').trim() || 'unknown';
@@ -409,19 +418,17 @@ export function buildUsageTrendSeries(
}
}
if (parsed.length === 0) {
if (parsed.length === 0 && !rangeStart) {
return { bucket: '30m', points: [], totals, peak: null, models: [] };
}
const rangeStart = parseRangeDate(range?.start);
const rangeEnd = parseRangeDate(range?.end);
const first = parsed[0].start;
const last = parsed[parsed.length - 1].start;
const spanStart = rangeStart
? new Date(Math.min(rangeStart.getTime(), first.getTime()))
: first;
const spanEndSource = rangeEnd ?? (rangeStart ? now : new Date(last.getTime() + HOUR_MS / 2));
const spanEnd = new Date(Math.max(spanEndSource.getTime(), last.getTime() + HOUR_MS / 2));
const first = parsed[0]?.start ?? rangeStart!;
const last = parsed[parsed.length - 1]?.start ?? first;
const spanStart = rangeStart ?? first;
const spanEnd = rangeEnd ?? (rangeStart ? now : addBucket(last, '30m'));
if (spanEnd.getTime() <= spanStart.getTime()) {
return { bucket: '30m', points: [], totals: emptyTotals(), peak: null, models: [] };
}
const bucket = chooseTrendBucket(spanStart, spanEnd);
const seriesStart = startOfBucket(spanStart, bucket);
@@ -429,16 +436,15 @@ export function buildUsageTrendSeries(
const buckets = new Map<number, PreparedTrendPoint>();
let cursor = cloneDate(seriesStart);
let guard = 0;
while (cursor.getTime() <= seriesEnd.getTime() && guard <= 400) {
while (cursor.getTime() <= seriesEnd.getTime()) {
buckets.set(cursor.getTime(), emptyPoint(cloneDate(cursor), bucket));
cursor = addBucket(cursor, bucket);
guard += 1;
}
for (const point of parsed) {
const key = startOfBucket(point.start, bucket).getTime();
const current = buckets.get(key) ?? emptyPoint(startOfBucket(point.start, bucket), bucket);
const time = new Date(Math.max(spanStart.getTime(), Math.min(point.start.getTime(), spanEnd.getTime() - 1)));
const key = startOfBucket(time, bucket).getTime();
const current = buckets.get(key)!;
current.requests += point.requests;
current.success += point.success;
current.failure += point.failure;
@@ -450,7 +456,11 @@ export function buildUsageTrendSeries(
buckets.set(key, current);
}
const series = [...buckets.values()].sort((left, right) => left.start.getTime() - right.start.getTime());
const series = [...buckets.values()].map((point) => ({
...point,
start: new Date(Math.max(point.start.getTime(), spanStart.getTime())),
end: new Date(Math.min(point.end.getTime(), spanEnd.getTime())),
}));
const ranked = [...modelTotals.entries()]
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
const visible = ranked.slice(0, MAX_VISIBLE_MODELS);
@@ -496,60 +506,4 @@ export function stackModelTokens(
});
}
function monotoneTangents(points: ChartPoint[]): number[] {
const count = points.length;
const delta = Array.from({ length: Math.max(0, count - 1) }, () => 0);
const tangent = Array.from({ length: count }, () => 0);
for (let index = 0; index < count - 1; index += 1) {
const dx = points[index + 1].x - points[index].x;
delta[index] = dx === 0 ? 0 : (points[index + 1].y - points[index].y) / dx;
}
if (count > 0) tangent[0] = delta[0] ?? 0;
if (count > 1) tangent[count - 1] = delta[count - 2] ?? 0;
for (let index = 1; index < count - 1; index += 1) {
tangent[index] = delta[index - 1] * delta[index] <= 0 ? 0 : (delta[index - 1] + delta[index]) / 2;
}
for (let index = 0; index < count - 1; index += 1) {
if (delta[index] === 0) {
tangent[index] = 0;
tangent[index + 1] = 0;
continue;
}
const alpha = tangent[index] / delta[index];
const beta = tangent[index + 1] / delta[index];
const square = alpha * alpha + beta * beta;
if (square > 9) {
const scale = 3 / Math.sqrt(square);
tangent[index] = scale * alpha * delta[index];
tangent[index + 1] = scale * beta * delta[index];
}
}
return tangent;
}
export function smoothLinePath(points: ChartPoint[]): string {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
if (points.length === 2) {
return `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)} L ${points[1].x.toFixed(2)} ${points[1].y.toFixed(2)}`;
}
const tangent = monotoneTangents(points);
let path = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
for (let index = 0; index < points.length - 1; index += 1) {
const start = points[index];
const end = points[index + 1];
const dx = (end.x - start.x) / 3;
path += ` C ${(start.x + dx).toFixed(2)} ${(start.y + tangent[index] * dx).toFixed(2)} ${(end.x - dx).toFixed(2)} ${(end.y - tangent[index + 1] * dx).toFixed(2)} ${end.x.toFixed(2)} ${end.y.toFixed(2)}`;
}
return path;
}
export function smoothAreaPath(top: ChartPoint[], bottom: ChartPoint[]): string {
if (!top.length || top.length !== bottom.length) return '';
const topPath = smoothLinePath(top);
const bottomPath = smoothLinePath([...bottom].reverse());
if (!topPath || !bottomPath) return '';
return `${topPath} ${bottomPath.replace(/^M /, 'L ')} Z`;
}
export const OTHER_TREND_MODEL_KEY = OTHER_MODEL_KEY;
+12 -18
View File
@@ -8313,13 +8313,9 @@ textarea:focus-visible {
}
.usage-trend-y-axis {
position: relative;
grid-column: 1;
grid-row: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
padding: 2px 0;
color: var(--theme-8a847c);
font-size: 10px;
font-variant-numeric: tabular-nums;
@@ -8327,6 +8323,12 @@ textarea:focus-visible {
user-select: none;
}
.usage-trend-y-axis span {
position: absolute;
right: 0;
transform: translateY(-50%);
}
.usage-trend-plot {
position: relative;
grid-column: 2;
@@ -8363,17 +8365,13 @@ textarea:focus-visible {
vector-effect: non-scaling-stroke;
}
.usage-trend-area {
transition: opacity 0.2s ease;
.usage-trend-bar {
opacity: 0.8;
transition: opacity 0.15s ease;
}
.usage-trend-line {
vector-effect: non-scaling-stroke;
}
.usage-trend-total-line {
opacity: 0.5;
vector-effect: non-scaling-stroke;
.usage-trend-bar.is-active {
opacity: 1;
}
.usage-trend-cursor-line {
@@ -8384,10 +8382,6 @@ textarea:focus-visible {
vector-effect: non-scaling-stroke;
}
.usage-trend-dot {
vector-effect: non-scaling-stroke;
}
.usage-trend-x-axis {
position: relative;
grid-column: 2;
+99 -29
View File
@@ -3,17 +3,17 @@ import {
OTHER_TREND_MODEL_KEY,
buildUsageTrendSeries,
chooseTrendBucket,
findTrendPointIndex,
formatLocalHourKey,
formatTrendAxisLabel,
formatTrendRangeLabel,
niceCeiling,
parseLocalHourKey,
selectTrendAxisLabels,
smoothAreaPath,
smoothLinePath,
stackModelTokens,
startOfBucket,
trendAxisTicks,
trendTimeAxisTicks,
trendTimePosition,
type UsageTimelinePoint,
} from '../src/services/usageTrend';
@@ -47,14 +47,15 @@ describe('usage trend helpers', () => {
test('chooses coarser buckets as the range grows', () => {
const hourStart = new Date(2026, 8, 14, 12, 0, 0);
expect(chooseTrendBucket(hourStart, new Date(2026, 8, 15, 12, 0, 0))).toBe('30m');
expect(chooseTrendBucket(new Date(2026, 8, 1, 0, 0, 0), new Date(2026, 8, 4, 0, 0, 0))).toBe('hour');
expect(chooseTrendBucket(new Date(2026, 8, 1, 0, 0, 0), new Date(2026, 8, 8, 0, 0, 0))).toBe('3h');
expect(chooseTrendBucket(hourStart, new Date(2026, 8, 14, 16, 0, 0))).toBe('30m');
expect(chooseTrendBucket(hourStart, new Date(2026, 8, 15, 12, 0, 0))).toBe('hour');
expect(chooseTrendBucket(new Date(2026, 8, 1, 0, 0, 0), new Date(2026, 8, 4, 0, 0, 0))).toBe('3h');
expect(chooseTrendBucket(new Date(2026, 8, 1, 0, 0, 0), new Date(2026, 8, 8, 0, 0, 0))).toBe('day');
expect(chooseTrendBucket(new Date(2026, 8, 1, 0, 0, 0), new Date(2026, 8, 16, 0, 0, 0))).toBe('day');
expect(chooseTrendBucket(new Date(2026, 8, 1, 0, 0, 0), new Date(2026, 9, 1, 0, 0, 0))).toBe('day');
expect(chooseTrendBucket(new Date(2025, 0, 1, 0, 0, 0), new Date(2026, 6, 1, 0, 0, 0))).toBe('week');
expect(chooseTrendBucket(new Date(2022, 0, 1, 0, 0, 0), new Date(2026, 0, 1, 0, 0, 0))).toBe('month');
expect(chooseTrendBucket(new Date(2018, 0, 1, 0, 0, 0), new Date(2026, 0, 1, 0, 0, 0))).toBe('year');
expect(chooseTrendBucket(new Date(2010, 0, 1, 0, 0, 0), new Date(2026, 0, 1, 0, 0, 0))).toBe('year');
});
test('fills idle hours inside the selected range', () => {
@@ -111,7 +112,7 @@ describe('usage trend helpers', () => {
test('aggregates sparse hours into 3-hour buckets', () => {
const start = new Date(2026, 8, 1, 0, 0, 0);
const end = new Date(2026, 8, 8, 0, 0, 0);
const end = new Date(2026, 8, 4, 0, 0, 0);
const series = buildUsageTrendSeries(
[
point('2026-09-01-01', 1, 5),
@@ -124,7 +125,7 @@ describe('usage trend helpers', () => {
const first = series.points[0];
expect(startOfBucket(first.start, '3h').getHours()).toBe(0);
expect(first).toMatchObject({ requests: 4, tokens: 12, failure: 1, success: 3 });
expect(series.points.length).toBeGreaterThan(40);
expect(series.points.length).toBe(24);
});
test('uses daily buckets and fills idle days for a 30-day range', () => {
@@ -154,6 +155,94 @@ describe('usage trend helpers', () => {
expect(monday.getDate()).toBe(29);
});
test.each([
[4, '30m', 8],
[24, 'hour', 24],
[7 * 24, 'day', 7],
[30 * 24, 'day', 30],
] as const)('divides a %i-hour range by time, including empty slots', (hours, bucket, count) => {
const start = new Date(2026, 8, 1);
const end = new Date(start.getTime() + hours * 3_600_000);
const series = buildUsageTrendSeries([], { start: start.toISOString(), end: end.toISOString() });
expect(series.bucket).toBe(bucket);
expect(series.points).toHaveLength(count);
expect(series.points[0].start).toEqual(start);
expect(series.points.at(-1)?.end).toEqual(end);
expect(series.points.every((item) => item.tokens === 0)).toBe(true);
});
test('clips partial boundary slots without dropping prefiltered tokens', () => {
const start = new Date(2026, 8, 14, 10, 17);
const end = new Date(2026, 8, 14, 14, 17);
const series = buildUsageTrendSeries([
point('2026-09-14-09-30', 1, 100),
point('2026-09-14-10-00', 1, 10),
point('2026-09-14-14-00', 2, 20),
point('2026-09-14-14-30', 1, 200),
], { start: start.toISOString(), end: end.toISOString() });
expect(series.points[0].start).toEqual(start);
expect(series.points.at(-1)?.end).toEqual(end);
expect(series.points[0].tokens).toBe(10);
expect(series.points.at(-1)?.tokens).toBe(20);
expect(series.totals.tokens).toBe(30);
expect(series.points.reduce((sum, item) => sum + item.tokens, 0)).toBe(30);
expect(trendTimePosition(series.points[0].end, start, end)).toBeCloseTo(13 / 240);
expect(findTrendPointIndex(series.points, new Date(2026, 8, 14, 10, 29))).toBe(0);
expect(findTrendPointIndex(series.points, new Date(2026, 8, 14, 10, 30))).toBe(1);
expect(findTrendPointIndex(series.points, end)).toBe(series.points.length - 1);
expect(formatTrendRangeLabel(series.points[0], 'zh-CN', '30m')).toContain('10:17-10:30');
});
test('keeps an inclusive end request in the final bar without extending the range', () => {
const start = new Date(2026, 8, 14, 10);
const end = new Date(2026, 8, 14, 14);
const series = buildUsageTrendSeries([point('2026-09-14-14-00', 1, 50)], {
start: start.toISOString(), end: end.toISOString(),
});
expect(series.points).toHaveLength(8);
expect(series.points.at(-1)?.end).toEqual(end);
expect(series.points.at(-1)?.tokens).toBe(50);
});
test('uses the selected open range and handles reversed ranges', () => {
const start = new Date(2026, 8, 14, 10, 17);
const now = new Date(2026, 8, 14, 14, 17);
const series = buildUsageTrendSeries([point('2026-09-14-11-00', 1, 10)], { start: start.toISOString() }, now);
expect(series.points[0].start).toEqual(start);
expect(series.points.at(-1)?.end).toEqual(now);
expect(buildUsageTrendSeries([], { start: now.toISOString(), end: start.toISOString() }).points).toEqual([]);
expect(buildUsageTrendSeries([]).points).toEqual([]);
});
test('scales months by their elapsed duration instead of equal point indexes', () => {
const start = new Date(2023, 0, 1);
const end = new Date(2026, 0, 1);
const series = buildUsageTrendSeries([], { start: start.toISOString(), end: end.toISOString() });
expect(series.bucket).toBe('month');
const widths = series.points.slice(0, 2).map((item) =>
trendTimePosition(item.end, start, end) - trendTimePosition(item.start, start, end),
);
expect(widths[0] / widths[1]).toBeCloseTo(31 / 28);
});
test('adapts time ticks to duration and width while retaining exact endpoints', () => {
const start = new Date(2026, 8, 14, 10, 17);
const end = new Date(2026, 8, 14, 14, 17);
const wide = trendTimeAxisTicks(start, end, 1000);
const narrow = trendTimeAxisTicks(start, end, 320);
expect(wide[0]).toEqual(start);
expect(wide.at(-1)).toEqual(end);
expect(wide.length).toBeGreaterThan(narrow.length);
const interval = wide[1].getTime() - wide[0].getTime();
for (let index = 2; index < wide.length; index++) {
expect(wide[index].getTime() - wide[index - 1].getTime()).toBe(interval);
}
expect(trendTimePosition(wide[1], start, end)).toBeCloseTo(interval / (end.getTime() - start.getTime()));
expect(trendTimeAxisTicks(start, start, 800)).toEqual([start]);
const months = trendTimeAxisTicks(new Date(2023, 0, 1), new Date(2026, 0, 1), 1000);
expect(months.every((date) => date.getDate() === 1)).toBe(true);
});
test('stacks model tokens and groups overflow models', () => {
const start = new Date(2026, 8, 14, 10, 0, 0);
const end = new Date(2026, 8, 14, 12, 0, 0);
@@ -196,31 +285,11 @@ describe('usage trend helpers', () => {
expect(hidden[hidden.length - 1].y1).toBe(series.points[0].tokens - 80);
});
test('builds a monotone cubic path that stays inside the value range', () => {
const line = smoothLinePath([
{ x: 0, y: 10 },
{ x: 10, y: 0 },
{ x: 20, y: 10 },
]);
expect(line).toContain('C');
const numbers = [...line.matchAll(/-?\d+(?:\.\d+)?/g)].map((match) => Number(match[0]));
const ys = numbers.filter((_, index) => index % 2 === 1);
expect(Math.min(...ys)).toBeGreaterThanOrEqual(0);
expect(Math.max(...ys)).toBeLessThanOrEqual(10);
expect(smoothAreaPath(
[{ x: 0, y: 4 }, { x: 10, y: 8 }],
[{ x: 0, y: 10 }, { x: 10, y: 10 }],
)).toContain('Z');
});
test('builds readable axis ticks and labels', () => {
expect(niceCeiling(0)).toBe(1);
expect(niceCeiling(12)).toBe(20);
expect(trendAxisTicks(20)).toEqual([0, 5, 10, 15, 20]);
expect(trendAxisTicks(5)).toEqual([0, 1, 2, 3, 4, 5]);
expect(selectTrendAxisLabels(24, 8)[0]).toBe(0);
expect(selectTrendAxisLabels(24, 8).at(-1)).toBe(23);
expect(selectTrendAxisLabels(24, 8).length).toBeGreaterThanOrEqual(7);
const hourPoint = {
...point('2026-09-14-15', 1, 1),
@@ -228,6 +297,7 @@ describe('usage trend helpers', () => {
end: new Date(2026, 8, 14, 16, 0, 0),
};
expect(formatTrendAxisLabel(hourPoint, 'hour', 'en', { compactSameDay: true })).toMatch(/15:00|3:00/);
expect(formatTrendAxisLabel(hourPoint, 'day', 'en', { showTime: true })).toMatch(/15:00|3:00/);
const rangeLabel = formatTrendRangeLabel(hourPoint, 'en', 'hour');
expect(rangeLabel).toMatch(/9\/14|14\/9/);
expect(rangeLabel).toMatch(/15:00|3:00/);