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;
|
||||
|
||||
+99
-29
@@ -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/);
|
||||
|
||||
Reference in new issue
Block a user