feat(usage): render token trends as stacked bars with an adaptive time axis
This commit is contained in:
1 parent
81597fcf91
commit
ba247159da
7 files changed
+259
-337
No files matched your search
+1
-1
@@ -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 時間ごとに集計',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user