diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 4166eda94cc9..459312658c32 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -223,52 +223,91 @@ function buildChart( return { columns, scale, stepX, paths: paths.toSorted((a, b) => b.total - a.total) }; } -// Rebuilding the chart walks every period and provider; keep it cached across hover renders. -function useChart( - periods: readonly string[], - totals: readonly DailyTotals[] | readonly HourlyTotals[], - metric: UsageChartMetric, - providers: readonly UsageProviderKind[], - loadingProviders: ReadonlySet, -) { - "use memo"; - const byPeriod = new Map( - totals.map((entry) => ["hourStart" in entry ? entry.hourStart : entry.day, entry]), +function UsageChartTooltip({ + tooltipRef, + label, + column, + providers, + loadingProviders, + format, +}: { + readonly tooltipRef: React.Ref; + readonly label: string; + readonly column: DayColumn | undefined; + readonly providers: readonly UsageProviderKind[]; + readonly loadingProviders: ReadonlySet; + readonly format: (value: number) => string; +}) { + const partial = providers.some((provider) => loadingProviders.has(provider)); + return ( +
+
{label}
+ {providers.map((provider) => { + const { label: providerLabel, driverKind } = PROVIDER_PRESENTATION[provider]; + return ( +
+ + + {providerLabel} + + + {format(column?.bands.find((band) => band.provider === provider)?.value ?? 0)} + +
+ ); + })} +
+ Total + + {format(column?.total ?? 0)} + +
+
); - return buildChart(periods, byPeriod, metric, providers, loadingProviders); } -export function UsageProviderChart({ +function UsageChartPlot({ + periods, + columns, + stepX, providers, - loadingProviders = NONE_LOADING, - days, - daily, - hours, - hourly, - metric, - referenceTime, - resolution, - timeZone, -}: UsageProviderChartProps) { - const periods = resolution === "hour" ? hours : days; - const { columns, scale, paths, stepX } = useChart( - periods, - resolution === "hour" ? hourly : daily, - metric, - providers, - loadingProviders, - ); + loadingProviders, + format, + formatTooltipPeriod, + ariaLabel, + children, +}: { + readonly periods: readonly string[]; + readonly columns: readonly DayColumn[]; + readonly stepX: number; + readonly providers: readonly UsageProviderKind[]; + readonly loadingProviders: ReadonlySet; + readonly format: (value: number) => string; + readonly formatTooltipPeriod: (period: string) => string; + readonly ariaLabel: string; + readonly children: React.ReactNode; +}) { const [hoverIndex, setHoverIndex] = useState(null); const plotRef = useRef(null); const tooltipRef = useRef(null); const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); - const toY = (value: number) => valueToY(value, scale.max); - const seriesClassName = (loading: boolean) => - cn("transition-opacity", loading && "opacity-40 delay-150"); - - const format = metric === "tokens" ? formatTokens : formatUsd; - const positionTooltip = () => { const plot = plotRef.current; const tooltip = tooltipRef.current; @@ -320,8 +359,82 @@ export function UsageProviderChart({ }; const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; - const hoveredColumn = hoverIndex === null ? undefined : columns[hoverIndex]; - const partial = providers.some((provider) => loadingProviders.has(provider)); + + return ( +
{ + hoverPositionRef.current = null; + setHoverIndex(null); + }} + > + + {children} + {hoverIndex === null ? null : ( + + )} + + + {hoverIndex === null || hoveredPeriod === undefined ? null : ( + + )} +
+ ); +} + +export function UsageProviderChart({ + providers, + loadingProviders = NONE_LOADING, + days, + daily, + hours, + hourly, + metric, + referenceTime, + resolution, + timeZone, +}: UsageProviderChartProps) { + const periods = resolution === "hour" ? hours : days; + const totals: readonly (DailyTotals | HourlyTotals)[] = resolution === "hour" ? hourly : daily; + const byPeriod = new Map( + totals.map((entry) => ["hourStart" in entry ? entry.hourStart : entry.day, entry]), + ); + const { columns, scale, paths, stepX } = buildChart( + periods, + byPeriod, + metric, + providers, + loadingProviders, + ); + + const toY = (value: number) => valueToY(value, scale.max); + const seriesClassName = (loading: boolean) => + cn("transition-opacity", loading && "opacity-40 delay-150"); + const format = metric === "tokens" ? formatTokens : formatUsd; const formatPeriod = (period: string) => resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); const formatTooltipPeriod = (period: string) => @@ -346,125 +459,54 @@ export function UsageProviderChart({ : null} -
{ - hoverPositionRef.current = null; - setHoverIndex(null); - }} + - - {scale.ticks.map((tick) => { - const y = toY(tick); - return ( - - ); - })} - - {paths.map(({ provider, loading, area }) => ( - - ))} - {paths.map(({ provider, loading, line }) => ( - - ))} - - {hoverIndex === null ? null : ( + {scale.ticks.map((tick) => { + const y = toY(tick); + return ( - )} - - - {hoveredPeriod === undefined ? null : ( -
-
{formatTooltipPeriod(hoveredPeriod)}
- {providers.map((provider) => { - const { label, driverKind } = PROVIDER_PRESENTATION[provider]; - return ( -
- - - {label} - - - {format( - hoveredColumn?.bands.find((band) => band.provider === provider)?.value ?? 0, - )} - -
- ); - })} -
- Total - - {format(hoveredColumn?.total ?? 0)} - -
-
- )} -
+ ); + })} + + {paths.map(({ provider, loading, area }) => ( + + ))} + {paths.map(({ provider, loading, line }) => ( + + ))} +