import type { UsageProviderKind } from "@t3tools/contracts";import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";import { formatDayShort, formatHourShort, formatRelativeHourShort, formatTokens, formatUsd,} from "@t3tools/shared/usageFormat";import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; const VIEW_WIDTH = 960;const VIEW_HEIGHT = 260;const TICK_COUNT = 4;const PLOT_TOP = 8; export type UsageChartMetric = "tokens" | "cost"; interface UsageProviderChartProps { readonly providers: readonly UsageProviderKind[]; readonly days: readonly string[]; readonly daily: readonly DailyTotals[]; readonly hours: readonly string[]; readonly hourly: readonly HourlyTotals[]; readonly metric: UsageChartMetric; readonly referenceTime: string | undefined; readonly resolution: "day" | "hour"; readonly timeZone: string;} /** One day's per-provider values, shared by the paths and the hover readout. */export interface DayColumn { readonly bands: readonly { readonly provider: UsageProviderKind; readonly value: number; }[]; readonly total: number;} interface Point { readonly x: number; readonly y: number;} function valueFor( totals: DailyTotals | HourlyTotals | undefined, provider: UsageProviderKind, metric: UsageChartMetric,): number { const entry = totals?.byProvider.get(provider); if (entry === undefined) return 0; return metric === "tokens" ? entry.totalTokens : entry.costUsd;} export function buildPeriodColumns( periods: readonly string[], byPeriod: ReadonlyMap<string, DailyTotals | HourlyTotals>, metric: UsageChartMetric,): readonly DayColumn[] { return periods.map((period) => { const entry = byPeriod.get(period); const bands = PROVIDER_ORDER.map((provider) => ({ provider, value: valueFor(entry, provider, metric), })); return { bands, total: bands.reduce((sum, band) => sum + band.value, 0) }; });} /** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */function monotoneTangents(points: readonly Point[]): readonly number[] { const count = points.length; if (count < 2) return [0]; const slopes: number[] = []; for (let index = 0; index < count - 1; index += 1) { const dx = (points[index + 1]?.x ?? 0) - (points[index]?.x ?? 0); const dy = (points[index + 1]?.y ?? 0) - (points[index]?.y ?? 0); slopes.push(dx === 0 ? 0 : dy / dx); } const tangents: number[] = Array.from({ length: count }, () => 0); tangents[0] = slopes[0] ?? 0; tangents[count - 1] = slopes[count - 2] ?? 0; for (let index = 1; index < count - 1; index += 1) { const previous = slopes[index - 1] ?? 0; const next = slopes[index] ?? 0; tangents[index] = previous * next <= 0 ? 0 : (previous + next) / 2; } for (let index = 0; index < count - 1; index += 1) { const slope = slopes[index] ?? 0; if (slope === 0) { tangents[index] = 0; tangents[index + 1] = 0; continue; } const a = (tangents[index] ?? 0) / slope; const b = (tangents[index + 1] ?? 0) / slope; const magnitude = a * a + b * b; if (magnitude > 9) { const scale = 3 / Math.sqrt(magnitude); tangents[index] = scale * a * slope; tangents[index + 1] = scale * b * slope; } } return tangents;} interface CurveSegment { readonly from: Point; readonly c1: Point; readonly c2: Point; readonly to: Point;} function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { if (points.length < 2) return []; const tangents = monotoneTangents(points); const segments: CurveSegment[] = []; for (let index = 0; index < points.length - 1; index += 1) { const from = points[index]; const to = points[index + 1]; if (from === undefined || to === undefined) continue; const dx = to.x - from.x; segments.push({ from, c1: { x: from.x + dx / 3, y: from.y + ((tangents[index] ?? 0) * dx) / 3 }, c2: { x: to.x - dx / 3, y: to.y - ((tangents[index + 1] ?? 0) * dx) / 3 }, to, }); } return segments;} function curvePath(segments: readonly CurveSegment[]): string { const first = segments[0]; if (first === undefined) return ""; let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; for (const segment of segments) { 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)}`; } return path;} /** * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the * peak. * * Rounding the maximum *up* is the point: stopping at the last step below the * peak leaves the tallest day drawn past the top of the plot, where it is * clipped. */export function niceScale(peak: number, count: number): { max: number; ticks: readonly number[] } { if (peak <= 0) return { max: 0, ticks: [0] }; const rawStep = peak / count; const magnitude = 10 ** Math.floor(Math.log10(rawStep)); const normalized = rawStep / magnitude; const step = (normalized > 5 ? 10 : normalized > 2 ? 5 : normalized > 1 ? 2 : 1) * magnitude; const max = Math.ceil(peak / step) * step; const ticks: number[] = []; for (let value = 0; value <= max + step * 1e-6; value += step) ticks.push(value); return { max, ticks };} export function UsageProviderChart({ providers, days, daily, hours, hourly, metric, referenceTime, resolution, timeZone,}: UsageProviderChartProps) { const periods = resolution === "hour" ? hours : days; const byPeriod = useMemo( () => resolution === "hour" ? new Map(hourly.map((entry) => [entry.hourStart, entry])) : new Map(daily.map((entry) => [entry.day, entry])), [daily, hourly, resolution], ); const [hoverIndex, setHoverIndex] = useState<number | null>(null); const plotRef = useRef<HTMLDivElement | null>(null); const tooltipRef = useRef<HTMLDivElement | null>(null); const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); const { paths, ticks, stepX, toY, series } = useMemo(() => { if (periods.length === 0) { return { paths: [], series: [] as readonly DayColumn[], stepX: 0, ticks: [0] as readonly number[], toY: () => VIEW_HEIGHT, }; } const columns = buildPeriodColumns(periods, byPeriod, metric); // The scale tops out at the largest single provider-period, not the sum: // layered series each measure from zero, so a combined peak would leave // the plot permanently half empty. const peak = columns.reduce( (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), 0, ); const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1); // Leave room above the top gridline so the constant-width stroke is not // clipped when a series reaches the peak. const toY = (value: number) => max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); const built = providers.map((provider) => { const providerIndex = PROVIDER_ORDER.indexOf(provider); const line = curvePath( smoothCurve( columns.map((column, periodIndex) => ({ x: periodIndex * step, y: toY(column.bands[providerIndex]?.value ?? 0), })), ), ); return { provider, total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), area: line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`, line, }; }); // Paint the heavier series first so the lighter one is not buried. return { paths: built.toSorted((a, b) => b.total - a.total), series: columns, stepX: step, ticks: tickValues, toY, }; }, [byPeriod, metric, periods, providers]); const format = metric === "tokens" ? formatTokens : formatUsd; const positionTooltip = useCallback(() => { const plot = plotRef.current; const tooltip = tooltipRef.current; const hoverPosition = hoverPositionRef.current; if (plot === null || tooltip === null || hoverPosition === null) return; const gap = 12; const tooltipWidth = tooltip.offsetWidth; const tooltipHeight = tooltip.offsetHeight; const plotWidth = plot.clientWidth; const plotHeight = plot.clientHeight; const preferredLeft = hoverPosition.x + gap + tooltipWidth <= plotWidth ? hoverPosition.x + gap : hoverPosition.x - gap - tooltipWidth; const preferredTop = hoverPosition.y + gap + tooltipHeight <= plotHeight ? hoverPosition.y + gap : hoverPosition.y - gap - tooltipHeight; const left = Math.min(Math.max(0, preferredLeft), Math.max(0, plotWidth - tooltipWidth)); const top = Math.min(Math.max(0, preferredTop), Math.max(0, plotHeight - tooltipHeight)); plot.style.setProperty("--usage-tooltip-left", `${left}px`); plot.style.setProperty("--usage-tooltip-top", `${top}px`); }, []); useLayoutEffect(() => { if (hoverIndex === null) return; positionTooltip(); const plot = plotRef.current; const tooltip = tooltipRef.current; if (plot === null || tooltip === null || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(positionTooltip); observer.observe(plot); observer.observe(tooltip); return () => observer.disconnect(); }, [hoverIndex, positionTooltip]); const handleMove = useCallback( (event: React.MouseEvent<HTMLDivElement>) => { const plot = plotRef.current; if (plot === null || periods.length === 0) return; const bounds = plot.getBoundingClientRect(); if (bounds.width === 0) return; const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left)); const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top)); const fraction = localX / bounds.width; const index = Math.round(fraction * (periods.length - 1)); hoverPositionRef.current = { x: localX, y: localY }; positionTooltip(); setHoverIndex(Math.min(periods.length - 1, Math.max(0, index))); }, [periods.length, positionTooltip], ); const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; const formatPeriod = (period: string) => resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); const formatTooltipPeriod = (period: string) => resolution === "hour" && referenceTime !== undefined ? formatRelativeHourShort(period, referenceTime, timeZone) : formatPeriod(period); return ( <div className="flex flex-col gap-1"> <div className="flex gap-2"> {/* Axis labels sit outside the plot so they stay aligned to gridlines. */} <div className="relative h-56 w-14 shrink-0"> {ticks.map((tick) => ( <span key={tick} className="absolute right-0 -translate-y-1/2 text-[10px] text-muted-foreground tabular-nums" style={{ top: `${(toY(tick) / VIEW_HEIGHT) * 100}%` }} > {tick === 0 ? "0" : format(tick)} </span> ))} </div> <div ref={plotRef} className="relative h-56 flex-1" onMouseMove={handleMove} onMouseLeave={() => { hoverPositionRef.current = null; setHoverIndex(null); }} > <svg className="h-full w-full" viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`} preserveAspectRatio="none" role="img" aria-label={`${resolution === "hour" ? "Hourly" : "Daily"} ${metric === "tokens" ? "processed tokens" : "cost"} by provider`} > {ticks.map((tick) => { const y = toY(tick); return ( <line key={tick} x1={0} x2={VIEW_WIDTH} y1={y} y2={y} stroke="currentColor" strokeWidth={1} className="text-border" vectorEffect="non-scaling-stroke" /> ); })} {/* Fills first, then every stroke, so no series covers another's line. */} {paths.map(({ provider, area }) => ( <path key={provider} d={area} fill={PROVIDER_PRESENTATION[provider].color} fillOpacity={0.12} /> ))} {paths.map(({ provider, line }) => ( <path key={provider} d={line} fill="none" stroke={PROVIDER_PRESENTATION[provider].color} strokeWidth={2} vectorEffect="non-scaling-stroke" /> ))} {hoverIndex === null ? null : ( <line x1={hoverIndex * stepX} x2={hoverIndex * stepX} y1={PLOT_TOP} y2={VIEW_HEIGHT} stroke="currentColor" strokeWidth={1} className="text-muted-foreground" vectorEffect="non-scaling-stroke" /> )} </svg> {hoveredPeriod === undefined ? null : ( <div ref={tooltipRef} 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" style={{ left: "var(--usage-tooltip-left, 0px)", top: "var(--usage-tooltip-top, 0px)", }} > <div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div> {providers.map((provider) => { const { label, mark: Mark } = PROVIDER_PRESENTATION[provider]; return ( <div key={provider} className="flex items-center justify-between gap-3"> <span className="flex items-center gap-1.5 text-muted-foreground"> <Mark className="size-3 shrink-0" aria-hidden /> {label} </span> <span className="text-foreground tabular-nums"> {format( hoveredColumn?.bands.find((band) => band.provider === provider)?.value ?? 0, )} </span> </div> ); })} <div className="mt-1 flex items-center justify-between gap-3 border-t border-border pt-1"> <span className="text-muted-foreground">Total</span> <span className="text-foreground tabular-nums"> {format(hoveredColumn?.total ?? 0)} </span> </div> </div> )} </div> </div> <div className="flex justify-between pl-16 text-[10px] text-muted-foreground uppercase"> <span>{periods[0] === undefined ? "" : formatPeriod(periods[0])}</span> <span> {periods[Math.floor(periods.length / 2)] === undefined ? "" : formatPeriod(periods[Math.floor(periods.length / 2)] ?? "")} </span> <span> {periods[periods.length - 1] === undefined ? "" : formatPeriod(periods[periods.length - 1] ?? "")} </span> </div> </div> );}
import type { UsageProviderKind } from "@t3tools/contracts";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";
import {
formatDayShort,
formatHourShort,
formatRelativeHourShort,
formatTokens,
formatUsd,
} from "@t3tools/shared/usageFormat";
import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders";
const VIEW_WIDTH = 960;
const VIEW_HEIGHT = 260;
const TICK_COUNT = 4;
const PLOT_TOP = 8;
export type UsageChartMetric = "tokens" | "cost";
interface UsageProviderChartProps {
readonly providers: readonly UsageProviderKind[];
readonly days: readonly string[];
readonly daily: readonly DailyTotals[];
readonly hours: readonly string[];
readonly hourly: readonly HourlyTotals[];
readonly metric: UsageChartMetric;
readonly referenceTime: string | undefined;
readonly resolution: "day" | "hour";
readonly timeZone: string;
}
/** One day's per-provider values, shared by the paths and the hover readout. */
export interface DayColumn {
readonly bands: readonly {
readonly provider: UsageProviderKind;
readonly value: number;
}[];
readonly total: number;
}
interface Point {
readonly x: number;
readonly y: number;
}
function valueFor(
totals: DailyTotals | HourlyTotals | undefined,
provider: UsageProviderKind,
metric: UsageChartMetric,
): number {
const entry = totals?.byProvider.get(provider);
if (entry === undefined) return 0;
return metric === "tokens" ? entry.totalTokens : entry.costUsd;
}
export function buildPeriodColumns(
periods: readonly string[],
byPeriod: ReadonlyMap<string, DailyTotals | HourlyTotals>,
metric: UsageChartMetric,
): readonly DayColumn[] {
return periods.map((period) => {
const entry = byPeriod.get(period);
const bands = PROVIDER_ORDER.map((provider) => ({
provider,
value: valueFor(entry, provider, metric),
}));
return { bands, total: bands.reduce((sum, band) => sum + band.value, 0) };
});
}
/** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */
function monotoneTangents(points: readonly Point[]): readonly number[] {
const count = points.length;
if (count < 2) return [0];
const slopes: number[] = [];
for (let index = 0; index < count - 1; index += 1) {
const dx = (points[index + 1]?.x ?? 0) - (points[index]?.x ?? 0);
const dy = (points[index + 1]?.y ?? 0) - (points[index]?.y ?? 0);
slopes.push(dx === 0 ? 0 : dy / dx);
}
const tangents: number[] = Array.from({ length: count }, () => 0);
tangents[0] = slopes[0] ?? 0;
tangents[count - 1] = slopes[count - 2] ?? 0;
for (let index = 1; index < count - 1; index += 1) {
const previous = slopes[index - 1] ?? 0;
const next = slopes[index] ?? 0;
tangents[index] = previous * next <= 0 ? 0 : (previous + next) / 2;
}
for (let index = 0; index < count - 1; index += 1) {
const slope = slopes[index] ?? 0;
if (slope === 0) {
tangents[index] = 0;
tangents[index + 1] = 0;
continue;
}
const a = (tangents[index] ?? 0) / slope;
const b = (tangents[index + 1] ?? 0) / slope;
const magnitude = a * a + b * b;
if (magnitude > 9) {
const scale = 3 / Math.sqrt(magnitude);
tangents[index] = scale * a * slope;
tangents[index + 1] = scale * b * slope;
}
}
return tangents;
}
interface CurveSegment {
readonly from: Point;
readonly c1: Point;
readonly c2: Point;
readonly to: Point;
}
function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
if (points.length < 2) return [];
const tangents = monotoneTangents(points);
const segments: CurveSegment[] = [];
for (let index = 0; index < points.length - 1; index += 1) {
const from = points[index];
const to = points[index + 1];
if (from === undefined || to === undefined) continue;
const dx = to.x - from.x;
segments.push({
from,
c1: { x: from.x + dx / 3, y: from.y + ((tangents[index] ?? 0) * dx) / 3 },
c2: { x: to.x - dx / 3, y: to.y - ((tangents[index + 1] ?? 0) * dx) / 3 },
to,
});
}
return segments;
}
function curvePath(segments: readonly CurveSegment[]): string {
const first = segments[0];
if (first === undefined) return "";
let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`;
for (const segment of segments) {
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)}`;
}
return path;
}
/**
* Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the
* peak.
*
* Rounding the maximum *up* is the point: stopping at the last step below the
* peak leaves the tallest day drawn past the top of the plot, where it is
* clipped.
*/
export function niceScale(peak: number, count: number): { max: number; ticks: readonly number[] } {
if (peak <= 0) return { max: 0, ticks: [0] };
const rawStep = peak / count;
const magnitude = 10 ** Math.floor(Math.log10(rawStep));
const normalized = rawStep / magnitude;
const step = (normalized > 5 ? 10 : normalized > 2 ? 5 : normalized > 1 ? 2 : 1) * magnitude;
const max = Math.ceil(peak / step) * step;
const ticks: number[] = [];
for (let value = 0; value <= max + step * 1e-6; value += step) ticks.push(value);
return { max, ticks };
}
export function UsageProviderChart({
providers,
days,
daily,
hours,
hourly,
metric,
referenceTime,
resolution,
timeZone,
}: UsageProviderChartProps) {
const periods = resolution === "hour" ? hours : days;
const byPeriod = useMemo(
() =>
resolution === "hour"
? new Map(hourly.map((entry) => [entry.hourStart, entry]))
: new Map(daily.map((entry) => [entry.day, entry])),
[daily, hourly, resolution],
);
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const plotRef = useRef<HTMLDivElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const hoverPositionRef = useRef<{ x: number; y: number } | null>(null);
const { paths, ticks, stepX, toY, series } = useMemo(() => {
if (periods.length === 0) {
return {
paths: [],
series: [] as readonly DayColumn[],
stepX: 0,
ticks: [0] as readonly number[],
toY: () => VIEW_HEIGHT,
};
}
const columns = buildPeriodColumns(periods, byPeriod, metric);
// The scale tops out at the largest single provider-period, not the sum:
// layered series each measure from zero, so a combined peak would leave
// the plot permanently half empty.
const peak = columns.reduce(
(max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),
0,
);
const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);
const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1);
// Leave room above the top gridline so the constant-width stroke is not
// clipped when a series reaches the peak.
const toY = (value: number) =>
max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
const built = providers.map((provider) => {
const providerIndex = PROVIDER_ORDER.indexOf(provider);
const line = curvePath(
smoothCurve(
columns.map((column, periodIndex) => ({
x: periodIndex * step,
y: toY(column.bands[providerIndex]?.value ?? 0),
})),
),
);
return {
provider,
total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0),
area: line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`,
line,
};
});
// Paint the heavier series first so the lighter one is not buried.
return {
paths: built.toSorted((a, b) => b.total - a.total),
series: columns,
stepX: step,
ticks: tickValues,
toY,
};
}, [byPeriod, metric, periods, providers]);
const format = metric === "tokens" ? formatTokens : formatUsd;
const positionTooltip = useCallback(() => {
const plot = plotRef.current;
const tooltip = tooltipRef.current;
const hoverPosition = hoverPositionRef.current;
if (plot === null || tooltip === null || hoverPosition === null) return;
const gap = 12;
const tooltipWidth = tooltip.offsetWidth;
const tooltipHeight = tooltip.offsetHeight;
const plotWidth = plot.clientWidth;
const plotHeight = plot.clientHeight;
const preferredLeft =
hoverPosition.x + gap + tooltipWidth <= plotWidth
? hoverPosition.x + gap
: hoverPosition.x - gap - tooltipWidth;
const preferredTop =
hoverPosition.y + gap + tooltipHeight <= plotHeight
? hoverPosition.y + gap
: hoverPosition.y - gap - tooltipHeight;
const left = Math.min(Math.max(0, preferredLeft), Math.max(0, plotWidth - tooltipWidth));
const top = Math.min(Math.max(0, preferredTop), Math.max(0, plotHeight - tooltipHeight));
plot.style.setProperty("--usage-tooltip-left", `${left}px`);
plot.style.setProperty("--usage-tooltip-top", `${top}px`);
}, []);
useLayoutEffect(() => {
if (hoverIndex === null) return;
positionTooltip();
const plot = plotRef.current;
const tooltip = tooltipRef.current;
if (plot === null || tooltip === null || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(positionTooltip);
observer.observe(plot);
observer.observe(tooltip);
return () => observer.disconnect();
}, [hoverIndex, positionTooltip]);
const handleMove = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const plot = plotRef.current;
if (plot === null || periods.length === 0) return;
const bounds = plot.getBoundingClientRect();
if (bounds.width === 0) return;
const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left));
const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top));
const fraction = localX / bounds.width;
const index = Math.round(fraction * (periods.length - 1));
hoverPositionRef.current = { x: localX, y: localY };
positionTooltip();
setHoverIndex(Math.min(periods.length - 1, Math.max(0, index)));
},
[periods.length, positionTooltip],
);
const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];
const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];
const formatPeriod = (period: string) =>
resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);
const formatTooltipPeriod = (period: string) =>
resolution === "hour" && referenceTime !== undefined
? formatRelativeHourShort(period, referenceTime, timeZone)
: formatPeriod(period);
return (
<div className="flex flex-col gap-1">
<div className="flex gap-2">
{/* Axis labels sit outside the plot so they stay aligned to gridlines. */}
<div className="relative h-56 w-14 shrink-0">
{ticks.map((tick) => (
<span
key={tick}
className="absolute right-0 -translate-y-1/2 text-[10px] text-muted-foreground tabular-nums"
style={{ top: `${(toY(tick) / VIEW_HEIGHT) * 100}%` }}
>
{tick === 0 ? "0" : format(tick)}
</span>
))}
</div>
<div
ref={plotRef}
className="relative h-56 flex-1"
onMouseMove={handleMove}
onMouseLeave={() => {
hoverPositionRef.current = null;
setHoverIndex(null);
}}
>
<svg
className="h-full w-full"
viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
preserveAspectRatio="none"
role="img"
aria-label={`${resolution === "hour" ? "Hourly" : "Daily"} ${metric === "tokens" ? "processed tokens" : "cost"} by provider`}
>
{ticks.map((tick) => {
const y = toY(tick);
return (
<line
key={tick}
x1={0}
x2={VIEW_WIDTH}
y1={y}
y2={y}
stroke="currentColor"
strokeWidth={1}
className="text-border"
vectorEffect="non-scaling-stroke"
/>
);
})}
{/* Fills first, then every stroke, so no series covers another's line. */}
{paths.map(({ provider, area }) => (
<path
key={provider}
d={area}
fill={PROVIDER_PRESENTATION[provider].color}
fillOpacity={0.12}
/>
))}
{paths.map(({ provider, line }) => (
<path
key={provider}
d={line}
fill="none"
stroke={PROVIDER_PRESENTATION[provider].color}
strokeWidth={2}
vectorEffect="non-scaling-stroke"
/>
))}
{hoverIndex === null ? null : (
<line
x1={hoverIndex * stepX}
x2={hoverIndex * stepX}
y1={PLOT_TOP}
y2={VIEW_HEIGHT}
stroke="currentColor"
strokeWidth={1}
className="text-muted-foreground"
vectorEffect="non-scaling-stroke"
/>
)}
</svg>
{hoveredPeriod === undefined ? null : (
<div
ref={tooltipRef}
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"
style={{
left: "var(--usage-tooltip-left, 0px)",
top: "var(--usage-tooltip-top, 0px)",
}}
>
<div className="mb-1 text-muted-foreground">{formatTooltipPeriod(hoveredPeriod)}</div>
{providers.map((provider) => {
const { label, mark: Mark } = PROVIDER_PRESENTATION[provider];
return (
<div key={provider} className="flex items-center justify-between gap-3">
<span className="flex items-center gap-1.5 text-muted-foreground">
<Mark className="size-3 shrink-0" aria-hidden />
{label}
</span>
<span className="text-foreground tabular-nums">
{format(
hoveredColumn?.bands.find((band) => band.provider === provider)?.value ?? 0,
)}
</span>
</div>
);
})}
<div className="mt-1 flex items-center justify-between gap-3 border-t border-border pt-1">
<span className="text-muted-foreground">Total</span>
<span className="text-foreground tabular-nums">
{format(hoveredColumn?.total ?? 0)}
</span>
</div>
</div>
)}
</div>
</div>
<div className="flex justify-between pl-16 text-[10px] text-muted-foreground uppercase">
<span>{periods[0] === undefined ? "" : formatPeriod(periods[0])}</span>
<span>
{periods[Math.floor(periods.length / 2)] === undefined
? ""
: formatPeriod(periods[Math.floor(periods.length / 2)] ?? "")}
</span>
<span>
{periods[periods.length - 1] === undefined
? ""
: formatPeriod(periods[periods.length - 1] ?? "")}
</span>
</div>
</div>
);
}