Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 37 additions & 29 deletions apps/web/src/components/usage/UsageProviderChart.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
import type { UsageProviderKind } from "@t3tools/contracts";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useLayoutEffect, useRef, useState } from "react";

import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";
import {
Expand Down Expand Up @@ -223,6 +223,21 @@ 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<UsageProviderKind>,
) {
"use memo";
const byPeriod = new Map<string, DailyTotals | HourlyTotals>(
totals.map((entry) => ["hourStart" in entry ? entry.hourStart : entry.day, entry]),
);
return buildChart(periods, byPeriod, metric, providers, loadingProviders);
}

export function UsageProviderChart({
providers,
loadingProviders = NONE_LOADING,
Expand All @@ -236,29 +251,25 @@ export function UsageProviderChart({
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 { columns, scale, paths, stepX } = useChart(
periods,
resolution === "hour" ? hourly : daily,
metric,
providers,
loadingProviders,
);
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 { columns, scale, paths, stepX } = useMemo(
() => buildChart(periods, byPeriod, metric, providers, loadingProviders),
[byPeriod, loadingProviders, metric, periods, providers],
);
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 = useCallback(() => {
const positionTooltip = () => {
const plot = plotRef.current;
const tooltip = tooltipRef.current;
const hoverPosition = hoverPositionRef.current;
Expand All @@ -281,7 +292,7 @@ export function UsageProviderChart({
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;
Expand All @@ -294,22 +305,19 @@ export function UsageProviderChart({
return observeResize([plot, tooltip], positionTooltip);
}, [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 handleMove = (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)));
};

const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];
const hoveredColumn = hoverIndex === null ? undefined : columns[hoverIndex];
Expand Down
Loading