1import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
2import type { UsageProviderKind } from "@t3tools/contracts";
3import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
4
5import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";
6import {
7 formatDayShort,
8 formatHourShort,
9 formatRelativeHourShort,
10 formatTokens,
11 formatUsd,
12} from "@t3tools/shared/usageFormat";
13import { cn } from "~/lib/utils";
14import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders";
15import { observeResize } from "~/lib/observeResize";
16
17const VIEW_WIDTH = 960;
18const VIEW_HEIGHT = 260;
19const TICK_COUNT = 4;
20const PLOT_TOP = 8;
21const NONE_LOADING: ReadonlySet<UsageProviderKind> = new Set();
22
23export type UsageChartMetric = "tokens" | "cost";
24
25interface UsageProviderChartProps {
26 readonly providers: readonly UsageProviderKind[];
27 /** Providers whose figures are still coming in: their lines are muted. */
28 readonly loadingProviders?: ReadonlySet<UsageProviderKind>;
29 readonly days: readonly string[];
30 readonly daily: readonly DailyTotals[];
31 readonly hours: readonly string[];
32 readonly hourly: readonly HourlyTotals[];
33 readonly metric: UsageChartMetric;
34 readonly referenceTime: string | undefined;
35 readonly resolution: "day" | "hour";
36 readonly timeZone: string;
37}
38
39/** One day's per-provider values, shared by the paths and the hover readout. */
40export interface DayColumn {
41 readonly bands: readonly {
42 readonly provider: UsageProviderKind;
43 readonly value: number;
44 }[];
45 readonly total: number;
46}
47
48interface Point {
49 readonly x: number;
50 readonly y: number;
51}
52
53function valueFor(
54 totals: DailyTotals | HourlyTotals | undefined,
55 provider: UsageProviderKind,
56 metric: UsageChartMetric,
57): number {
58 const entry = totals?.byProvider.get(provider);
59 if (entry === undefined) return 0;
60 return metric === "tokens" ? entry.totalTokens : entry.costUsd;
61}
62
63export function buildPeriodColumns(
64 periods: readonly string[],
65 byPeriod: ReadonlyMap<string, DailyTotals | HourlyTotals>,
66 metric: UsageChartMetric,
67): readonly DayColumn[] {
68 return periods.map((period) => {
69 const entry = byPeriod.get(period);
70 const bands = PROVIDER_ORDER.map((provider) => ({
71 provider,
72 value: valueFor(entry, provider, metric),
73 }));
74 return { bands, total: bands.reduce((sum, band) => sum + band.value, 0) };
75 });
76}
77
78/** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */
79function monotoneTangents(points: readonly Point[]): readonly number[] {
80 const count = points.length;
81 if (count < 2) return [0];
82
83 const slopes: number[] = [];
84 for (let index = 0; index < count - 1; index += 1) {
85 const dx = (points[index + 1]?.x ?? 0) - (points[index]?.x ?? 0);
86 const dy = (points[index + 1]?.y ?? 0) - (points[index]?.y ?? 0);
87 slopes.push(dx === 0 ? 0 : dy / dx);
88 }
89
90 const tangents: number[] = Array.from({ length: count }, () => 0);
91 tangents[0] = slopes[0] ?? 0;
92 tangents[count - 1] = slopes[count - 2] ?? 0;
93 for (let index = 1; index < count - 1; index += 1) {
94 const previous = slopes[index - 1] ?? 0;
95 const next = slopes[index] ?? 0;
96 tangents[index] = previous * next <= 0 ? 0 : (previous + next) / 2;
97 }
98
99 for (let index = 0; index < count - 1; index += 1) {
100 const slope = slopes[index] ?? 0;
101 if (slope === 0) {
102 tangents[index] = 0;
103 tangents[index + 1] = 0;
104 continue;
105 }
106 const a = (tangents[index] ?? 0) / slope;
107 const b = (tangents[index + 1] ?? 0) / slope;
108 const magnitude = a * a + b * b;
109 if (magnitude > 9) {
110 const scale = 3 / Math.sqrt(magnitude);
111 tangents[index] = scale * a * slope;
112 tangents[index + 1] = scale * b * slope;
113 }
114 }
115
116 return tangents;
117}
118
119interface CurveSegment {
120 readonly from: Point;
121 readonly c1: Point;
122 readonly c2: Point;
123 readonly to: Point;
124}
125
126function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
127 if (points.length < 2) return [];
128 const tangents = monotoneTangents(points);
129 const segments: CurveSegment[] = [];
130
131 for (let index = 0; index < points.length - 1; index += 1) {
132 const from = points[index];
133 const to = points[index + 1];
134 if (from === undefined || to === undefined) continue;
135 const dx = to.x - from.x;
136 segments.push({
137 from,
138 c1: { x: from.x + dx / 3, y: from.y + ((tangents[index] ?? 0) * dx) / 3 },
139 c2: { x: to.x - dx / 3, y: to.y - ((tangents[index + 1] ?? 0) * dx) / 3 },
140 to,
141 });
142 }
143 return segments;
144}
145
146function curvePath(segments: readonly CurveSegment[]): string {
147 const first = segments[0];
148 if (first === undefined) return "";
149 let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`;
150 for (const segment of segments) {
151 path += ` C${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.to.x.toFixed(2)},${segment.to.y.toFixed(2)}`;
152 }
153 return path;
154}
155
156function areaPath(line: string) {
157 return line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`;
158}
159
160/**
161 * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the
162 * peak.
163 *
164 * Rounding the maximum *up* is the point: stopping at the last step below the
165 * peak leaves the tallest day drawn past the top of the plot, where it is
166 * clipped.
167 */
168export function niceScale(peak: number, count: number): { max: number; ticks: readonly number[] } {
169 if (peak <= 0) return { max: 0, ticks: [0] };
170
171 const rawStep = peak / count;
172 const magnitude = 10 ** Math.floor(Math.log10(rawStep));
173 const normalized = rawStep / magnitude;
174 const step = (normalized > 5 ? 10 : normalized > 2 ? 5 : normalized > 1 ? 2 : 1) * magnitude;
175
176 const max = Math.ceil(peak / step) * step;
177 const ticks: number[] = [];
178 for (let value = 0; value <= max + step * 1e-6; value += step) ticks.push(value);
179 return { max, ticks };
180}
181
182const PLACEHOLDER_TICKS = Array.from({ length: TICK_COUNT + 1 }, (_, index) => index);
183
184/**
185 * Scales to the largest single provider-period. With nothing to show yet while
186 * providers load, unlabeled placeholder gridlines hold their usual spacing so
187 * nothing shifts on arrival.
188 */
189export function chartScale(
190 columns: readonly DayColumn[],
191 loadingProviders: ReadonlySet<UsageProviderKind>,
192) {
193 // Not the sum: layered series each measure from zero, so a combined peak
194 // would leave the plot permanently half empty.
195 const peak = columns.reduce(
196 (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),
197 0,
198 );
199 return peak === 0 && loadingProviders.size > 0
200 ? { max: TICK_COUNT, ticks: PLACEHOLDER_TICKS, labeled: false }
201 : { ...niceScale(peak, TICK_COUNT), labeled: true };
202}
203
204// Leave room above the top gridline so the constant-width stroke is not
205// clipped when a series reaches the peak.
206function valueToY(value: number, max: number) {
207 return max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
208}
209
210/** Per-provider paths in paint order, heaviest first. */
211function buildChart(
212 periods: readonly string[],
213 byPeriod: ReadonlyMap<string, DailyTotals | HourlyTotals>,
214 metric: UsageChartMetric,
215 providers: readonly UsageProviderKind[],
216 loadingProviders: ReadonlySet<UsageProviderKind>,
217) {
218 const columns = buildPeriodColumns(periods, byPeriod, metric);
219 const scale = chartScale(columns, loadingProviders);
220 const stepX = periods.length < 2 ? 0 : VIEW_WIDTH / (periods.length - 1);
221 const paths = providers.map((provider) => {
222 const slot = PROVIDER_ORDER.indexOf(provider);
223 const line = curvePath(
224 smoothCurve(
225 columns.map((column, periodIndex) => ({
226 x: periodIndex * stepX,
227 y: valueToY(column.bands[slot]?.value ?? 0, scale.max),
228 })),
229 ),
230 );
231 return {
232 provider,
233 loading: loadingProviders.has(provider),
234 total: columns.reduce((sum, column) => sum + (column.bands[slot]?.value ?? 0), 0),
235 line,
236 area: areaPath(line),
237 };
238 });
239
240 // Paint the heavier series first so the lighter one is not buried.
241 return { columns, scale, stepX, paths: paths.toSorted((a, b) => b.total - a.total) };
242}
243
244export function UsageProviderChart({
245 providers,
246 loadingProviders = NONE_LOADING,
247 days,
248 daily,
249 hours,
250 hourly,
251 metric,
252 referenceTime,
253 resolution,
254 timeZone,
255}: UsageProviderChartProps) {
256 const periods = resolution === "hour" ? hours : days;
257 const byPeriod = useMemo(
258 () =>
259 resolution === "hour"
260 ? new Map(hourly.map((entry) => [entry.hourStart, entry]))
261 : new Map(daily.map((entry) => [entry.day, entry])),
262 [daily, hourly, resolution],
263 );
264 const [hoverIndex, setHoverIndex] = useState<number | null>(null);
265 const plotRef = useRef<HTMLDivElement | null>(null);
266 const tooltipRef = useRef<HTMLDivElement | null>(null);
267 const hoverPositionRef = useRef<{ x: number; y: number } | null>(null);
268
269 const { columns, scale, paths, stepX } = useMemo(
270 () => buildChart(periods, byPeriod, metric, providers, loadingProviders),
271 [byPeriod, loadingProviders, metric, periods, providers],
272 );
273 const toY = (value: number) => valueToY(value, scale.max);
274 // The delay keeps a quick answer from flashing, as with the page's figures.
275 const seriesClassName = (loading: boolean) =>
276 cn("transition-opacity", loading && "opacity-40 delay-150");
277
278 const format = metric === "tokens" ? formatTokens : formatUsd;
279
280 const positionTooltip = useCallback(() => {
281 const plot = plotRef.current;
282 const tooltip = tooltipRef.current;
283 const hoverPosition = hoverPositionRef.current;
284 if (plot === null || tooltip === null || hoverPosition === null) return;
285
286 const gap = 12;
287 const tooltipWidth = tooltip.offsetWidth;
288 const tooltipHeight = tooltip.offsetHeight;
289 const plotWidth = plot.clientWidth;
290 const plotHeight = plot.clientHeight;
291 const preferredLeft =
292 hoverPosition.x + gap + tooltipWidth <= plotWidth
293 ? hoverPosition.x + gap
294 : hoverPosition.x - gap - tooltipWidth;
295 const preferredTop =
296 hoverPosition.y + gap + tooltipHeight <= plotHeight
297 ? hoverPosition.y + gap
298 : hoverPosition.y - gap - tooltipHeight;
299 const left = Math.min(Math.max(0, preferredLeft), Math.max(0, plotWidth - tooltipWidth));
300 const top = Math.min(Math.max(0, preferredTop), Math.max(0, plotHeight - tooltipHeight));
301 plot.style.setProperty("--usage-tooltip-left", `${left}px`);
302 plot.style.setProperty("--usage-tooltip-top", `${top}px`);
303 }, []);
304
305 useLayoutEffect(() => {
306 if (hoverIndex === null) return;
307 positionTooltip();
308
309 const plot = plotRef.current;
310 const tooltip = tooltipRef.current;
311 if (plot === null || tooltip === null) return;
312
313 return observeResize([plot, tooltip], positionTooltip);
314 }, [hoverIndex, positionTooltip]);
315
316 const handleMove = useCallback(
317 (event: React.MouseEvent<HTMLDivElement>) => {
318 const plot = plotRef.current;
319 if (plot === null || periods.length === 0) return;
320 const bounds = plot.getBoundingClientRect();
321 if (bounds.width === 0) return;
322 const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left));
323 const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top));
324 const fraction = localX / bounds.width;
325 const index = Math.round(fraction * (periods.length - 1));
326 hoverPositionRef.current = { x: localX, y: localY };
327 positionTooltip();
328 setHoverIndex(Math.min(periods.length - 1, Math.max(0, index)));
329 },
330 [periods.length, positionTooltip],
331 );
332
333 const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];
334 const hoveredColumn = hoverIndex === null ? undefined : columns[hoverIndex];
335 const partial = providers.some((provider) => loadingProviders.has(provider));
336 const formatPeriod = (period: string) =>
337 resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);
338 const formatTooltipPeriod = (period: string) =>
339 resolution === "hour" && referenceTime !== undefined
340 ? formatRelativeHourShort(period, referenceTime, timeZone)
341 : formatPeriod(period);
342
343 return (
344 <div className="flex flex-col gap-1">
345 <div className="flex gap-2">
346 {/* Axis labels sit outside the plot so they stay aligned to gridlines. */}
347 <div className="relative h-56 w-14 shrink-0">
348 {scale.labeled
349 ? scale.ticks.map((tick) => (
350 <span
351 key={tick}
352 className="absolute right-0 -translate-y-1/2 text-3xs text-muted-foreground tabular-nums"
353 style={{ top: `${(toY(tick) / VIEW_HEIGHT) * 100}%` }}
354 >
355 {tick === 0 ? "0" : format(tick)}
356 </span>
357 ))
358 : null}
359 </div>
360
361 <div
362 ref={plotRef}
363 className="relative h-56 flex-1"
364 onMouseMove={handleMove}
365 onMouseLeave={() => {
366 hoverPositionRef.current = null;
367 setHoverIndex(null);
368 }}
369 >
370 <svg
371 className="h-full w-full"
372 viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
373 preserveAspectRatio="none"
374 role="img"
375 aria-label={`${resolution === "hour" ? "Hourly" : "Daily"} ${metric === "tokens" ? "processed tokens" : "cost"} by provider`}
376 >
377 {scale.ticks.map((tick) => {
378 const y = toY(tick);
379 return (
380 <line
381 key={tick}
382 x1={0}
383 x2={VIEW_WIDTH}
384 y1={y}
385 y2={y}
386 stroke="currentColor"
387 strokeWidth={1}
388 className="text-border"
389 vectorEffect="non-scaling-stroke"
390 />
391 );
392 })}
393
394 {/* Fills first, then every stroke, so no series covers another's line. */}
395 {paths.map(({ provider, loading, area }) => (
396 <path
397 key={provider}
398 d={area}
399 className={seriesClassName(loading)}
400 fill={PROVIDER_PRESENTATION[provider].color}
401 fillOpacity={0.12}
402 />
403 ))}
404 {paths.map(({ provider, loading, line }) => (
405 <path
406 key={provider}
407 d={line}
408 className={seriesClassName(loading)}
409 fill="none"
410 stroke={PROVIDER_PRESENTATION[provider].color}
411 strokeWidth={2}
412 vectorEffect="non-scaling-stroke"
413 />
414 ))}
415
416 {hoverIndex === null ? null : (
417 <line
418 x1={hoverIndex * stepX}
419 x2={hoverIndex * stepX}
420 y1={PLOT_TOP}
421 y2={VIEW_HEIGHT}
422 stroke="currentColor"
423 strokeWidth={1}
424 className="text-muted-foreground"
425 vectorEffect="non-scaling-stroke"
426 />
427 )}
428 </svg>
429
430 {hoveredPeriod === undefined ? null : (
431 <div
432 ref={tooltipRef}
433 className="surface-glass pointer-events-none absolute z-10 min-w-36 max-w-full rounded-xl border border-border/50 px-2.5 py-2 text-xs shadow-lg"
434 style={{
435 left: "var(--usage-tooltip-left, 0px)",
436 top: "var(--usage-tooltip-top, 0px)",
437 }}
438 >
439 <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div>
440 {providers.map((provider) => {
441 const { label, driverKind } = PROVIDER_PRESENTATION[provider];
442 return (
443 <div key={provider} className="flex items-center justify-between gap-3">
444 <span className="flex items-center gap-1.5 text-muted-foreground">
445 <ProviderInstanceIcon
446 driverKind={driverKind}
447 displayName={label}
448 iconClassName="size-3"
449 />
450 {label}
451 </span>
452 <span
453 className={cn(
454 "tabular-nums",
455 loadingProviders.has(provider)
456 ? "text-muted-foreground"
457 : "text-foreground",
458 )}
459 >
460 {format(
461 hoveredColumn?.bands.find((band) => band.provider === provider)?.value ?? 0,
462 )}
463 </span>
464 </div>
465 );
466 })}
467 <div className="mt-1 flex items-center justify-between gap-3 border-t border-border pt-1">
468 <span className="text-muted-foreground">Total</span>
469 <span
470 className={cn(
471 "tabular-nums",
472 partial ? "text-muted-foreground" : "text-foreground",
473 )}
474 >
475 {format(hoveredColumn?.total ?? 0)}
476 </span>
477 </div>
478 </div>
479 )}
480 </div>
481 </div>
482
483 <div className="flex justify-between pl-16 text-3xs text-muted-foreground uppercase">
484 <span>{periods[0] === undefined ? "" : formatPeriod(periods[0])}</span>
485 <span>
486 {periods[Math.floor(periods.length / 2)] === undefined
487 ? ""
488 : formatPeriod(periods[Math.floor(periods.length / 2)] ?? "")}
489 </span>
490 <span>
491 {periods[periods.length - 1] === undefined
492 ? ""
493 : formatPeriod(periods[periods.length - 1] ?? "")}
494 </span>
495 </div>
496 </div>
497 );
498}