11import type { UsageProviderKind } from "@t3tools/contracts" ;
2- import { useCallback , useMemo , useRef , useState } from "react" ;
2+ import { useCallback , useLayoutEffect , useMemo , useRef , useState } from "react" ;
33
44import type { DailyTotals , HourlyTotals } from "@t3tools/shared/usageMerge" ;
55import {
@@ -68,13 +68,7 @@ function buildPeriodColumns(
6868 } ) ;
6969}
7070
71- /**
72- * Monotone cubic tangents (Fritsch-Carlson).
73- *
74- * Plain cubic smoothing overshoots on spiky daily data and would dip the area
75- * below zero between points, which reads as negative spend. This variant is
76- * shape-preserving, so a smoothed series never leaves the range of its samples.
77- */
71+ /** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */
7872function monotoneTangents ( points : readonly Point [ ] ) : readonly number [ ] {
7973 const count = points . length ;
8074 if ( count < 2 ) return [ 0 ] ;
@@ -115,15 +109,13 @@ function monotoneTangents(points: readonly Point[]): readonly number[] {
115109 return tangents ;
116110}
117111
118- /** One cubic segment of a smoothed boundary. */
119112interface CurveSegment {
120113 readonly from : Point ;
121114 readonly c1 : Point ;
122115 readonly c2 : Point ;
123116 readonly to : Point ;
124117}
125118
126- /** Smoothed polyline through `points`, as explicit cubic control points. */
127119function smoothCurve ( points : readonly Point [ ] ) : readonly CurveSegment [ ] {
128120 if ( points . length < 2 ) return [ ] ;
129121 const tangents = monotoneTangents ( points ) ;
@@ -144,10 +136,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
144136 return segments ;
145137}
146138
147- function curvePath ( segments : readonly CurveSegment [ ] , startCommand : "M" | "L" ) : string {
139+ function curvePath ( segments : readonly CurveSegment [ ] ) : string {
148140 const first = segments [ 0 ] ;
149141 if ( first === undefined ) return "" ;
150- let path = `${ startCommand } ${ first . from . x . toFixed ( 2 ) } ,${ first . from . y . toFixed ( 2 ) } ` ;
142+ let path = `M ${ first . from . x . toFixed ( 2 ) } ,${ first . from . y . toFixed ( 2 ) } ` ;
151143 for ( const segment of segments ) {
152144 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 ) } ` ;
153145 }
@@ -179,10 +171,8 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re
179171/**
180172 * Turns the merged daily totals into one column per day.
181173 *
182- * Values are absolute, not cumulative: the series are layered from a shared
183- * zero baseline rather than stacked. A stacked chart puts whichever provider is
184- * drawn last permanently above the other, which reads as "that one is bigger"
185- * even on days where it is not.
174+ * Values are absolute, not cumulative: each provider is drawn from the same
175+ * zero baseline so the chart never implies that one provider is always larger.
186176 *
187177 * The chart paths and the hover readout both consume this, so the number under
188178 * the cursor is by construction the number that was plotted rather than a
@@ -216,42 +206,39 @@ export function UsageProviderChart({
216206 ) ;
217207 const [ hoverIndex , setHoverIndex ] = useState < number | null > ( null ) ;
218208 const plotRef = useRef < HTMLDivElement | null > ( null ) ;
209+ const tooltipRef = useRef < HTMLDivElement | null > ( null ) ;
210+ const hoverPositionRef = useRef < { x : number ; y : number } | null > ( null ) ;
219211
220- const { paths, ticks , stepX, toY , series } = useMemo ( ( ) => {
212+ const { paths, series , stepX, ticks , toY } = useMemo ( ( ) => {
221213 if ( periods . length === 0 ) {
222214 return {
223215 paths : [ ] ,
224- ticks : [ 0 ] as readonly number [ ] ,
216+ series : [ ] as readonly DayColumn [ ] ,
225217 stepX : 0 ,
218+ ticks : [ 0 ] as readonly number [ ] ,
226219 toY : ( ) => VIEW_HEIGHT ,
227- series : [ ] as readonly DayColumn [ ] ,
228220 } ;
229221 }
230222
231223 const columns = buildPeriodColumns ( periods , byPeriod , metric ) ;
232-
233- // The scale tops out at the largest single provider-day, not the largest
234- // sum: layered series each measure from zero, so a combined peak would
235- // leave the plot permanently half empty.
236224 const peak = columns . reduce (
237225 ( max , column ) => column . bands . reduce ( ( inner , band ) => Math . max ( inner , band . value ) , max ) ,
238226 0 ,
239227 ) ;
240228 const { max, ticks : tickValues } = niceScale ( peak , TICK_COUNT ) ;
241229 const step = periods . length === 1 ? 0 : VIEW_WIDTH / ( periods . length - 1 ) ;
242- // Reserve a sliver above the top gridline so the series stroke, which is
243- // drawn at constant screen width, is not shaved off at a peak.
244230 const toY = ( value : number ) =>
245231 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - ( value / max ) * ( VIEW_HEIGHT - PLOT_TOP ) ;
246232
247233 const built = PROVIDER_ORDER . map ( ( provider , providerIndex ) => {
248- const curve = smoothCurve (
249- columns . map ( ( column , dayIndex ) => ( {
250- x : dayIndex * step ,
251- y : toY ( column . bands [ providerIndex ] ?. value ?? 0 ) ,
252- } ) ) ,
234+ const line = curvePath (
235+ smoothCurve (
236+ columns . map ( ( column , periodIndex ) => ( {
237+ x : periodIndex * step ,
238+ y : toY ( column . bands [ providerIndex ] ?. value ?? 0 ) ,
239+ } ) ) ,
240+ ) ,
253241 ) ;
254- const line = curvePath ( curve , "M" ) ;
255242 return {
256243 provider,
257244 total : columns . reduce ( ( sum , column ) => sum + ( column . bands [ providerIndex ] ?. value ?? 0 ) , 0 ) ,
@@ -260,30 +247,75 @@ export function UsageProviderChart({
260247 } ;
261248 } ) ;
262249
263- // Paint the heavier series first so the lighter one is never buried under
264- // it. The fills are faint enough that the order barely shows, but the
265- // strokes are drawn in a second pass regardless, so neither can be hidden.
266- const ordered = [ ...built ] . sort ( ( a , b ) => b . total - a . total ) ;
267-
268- return { paths : ordered , ticks : tickValues , stepX : step , toY, series : columns } ;
250+ return {
251+ paths : built . toSorted ( ( a , b ) => b . total - a . total ) ,
252+ series : columns ,
253+ stepX : step ,
254+ ticks : tickValues ,
255+ toY,
256+ } ;
269257 } , [ byPeriod , metric , periods ] ) ;
270258
271259 const format = metric === "tokens" ? formatTokens : formatUsd ;
272260
261+ const positionTooltip = useCallback ( ( ) => {
262+ const plot = plotRef . current ;
263+ const tooltip = tooltipRef . current ;
264+ const hoverPosition = hoverPositionRef . current ;
265+ if ( plot === null || tooltip === null || hoverPosition === null ) return ;
266+
267+ const gap = 12 ;
268+ const tooltipWidth = tooltip . offsetWidth ;
269+ const tooltipHeight = tooltip . offsetHeight ;
270+ const plotWidth = plot . clientWidth ;
271+ const plotHeight = plot . clientHeight ;
272+ const preferredLeft =
273+ hoverPosition . x + gap + tooltipWidth <= plotWidth
274+ ? hoverPosition . x + gap
275+ : hoverPosition . x - gap - tooltipWidth ;
276+ const preferredTop =
277+ hoverPosition . y + gap + tooltipHeight <= plotHeight
278+ ? hoverPosition . y + gap
279+ : hoverPosition . y - gap - tooltipHeight ;
280+ const left = Math . min ( Math . max ( 0 , preferredLeft ) , Math . max ( 0 , plotWidth - tooltipWidth ) ) ;
281+ const top = Math . min ( Math . max ( 0 , preferredTop ) , Math . max ( 0 , plotHeight - tooltipHeight ) ) ;
282+ plot . style . setProperty ( "--usage-tooltip-left" , `${ left } px` ) ;
283+ plot . style . setProperty ( "--usage-tooltip-top" , `${ top } px` ) ;
284+ } , [ ] ) ;
285+
286+ useLayoutEffect ( ( ) => {
287+ if ( hoverIndex === null ) return ;
288+ positionTooltip ( ) ;
289+
290+ const plot = plotRef . current ;
291+ const tooltip = tooltipRef . current ;
292+ if ( plot === null || tooltip === null || typeof ResizeObserver === "undefined" ) return ;
293+
294+ const observer = new ResizeObserver ( positionTooltip ) ;
295+ observer . observe ( plot ) ;
296+ observer . observe ( tooltip ) ;
297+ return ( ) => observer . disconnect ( ) ;
298+ } , [ hoverIndex , positionTooltip ] ) ;
299+
273300 const handleMove = useCallback (
274301 ( event : React . MouseEvent < HTMLDivElement > ) => {
275- const bounds = plotRef . current ?. getBoundingClientRect ( ) ;
276- if ( bounds === undefined || bounds . width === 0 || periods . length === 0 ) return ;
277- const fraction = ( event . clientX - bounds . left ) / bounds . width ;
302+ const plot = plotRef . current ;
303+ if ( plot === null || periods . length === 0 ) return ;
304+ const bounds = plot . getBoundingClientRect ( ) ;
305+ if ( bounds . width === 0 ) return ;
306+ const localX = Math . min ( bounds . width , Math . max ( 0 , event . clientX - bounds . left ) ) ;
307+ const localY = Math . min ( bounds . height , Math . max ( 0 , event . clientY - bounds . top ) ) ;
308+ const fraction = localX / bounds . width ;
278309 const index = Math . round ( fraction * ( periods . length - 1 ) ) ;
310+ hoverPositionRef . current = { x : localX , y : localY } ;
311+ positionTooltip ( ) ;
279312 setHoverIndex ( Math . min ( periods . length - 1 , Math . max ( 0 , index ) ) ) ;
280313 } ,
281- [ periods . length ] ,
314+ [ periods . length , positionTooltip ] ,
282315 ) ;
283316
284317 const hoveredPeriod = hoverIndex === null ? undefined : periods [ hoverIndex ] ;
285318 const hoveredColumn = hoverIndex === null ? undefined : series [ hoverIndex ] ;
286- const hoverLeft = periods . length <= 1 ? 0 : ( ( hoverIndex ?? 0 ) / ( periods . length - 1 ) ) * 100 ;
287319 const formatPeriod = ( period : string ) =>
288320 resolution === "hour" ? formatHourShort ( period , timeZone ) : formatDayShort ( period ) ;
289321 const formatTooltipPeriod = ( period : string ) =>
@@ -311,7 +343,10 @@ export function UsageProviderChart({
311343 ref = { plotRef }
312344 className = "relative h-56 flex-1"
313345 onMouseMove = { handleMove }
314- onMouseLeave = { ( ) => setHoverIndex ( null ) }
346+ onMouseLeave = { ( ) => {
347+ hoverPositionRef . current = null ;
348+ setHoverIndex ( null ) ;
349+ } }
315350 >
316351 < svg
317352 className = "h-full w-full"
@@ -337,7 +372,6 @@ export function UsageProviderChart({
337372 ) ;
338373 } ) }
339374
340- { /* Fills first, then every stroke, so no series covers another's line. */ }
341375 { paths . map ( ( { provider, area } ) => (
342376 < path
343377 key = { provider }
@@ -373,10 +407,11 @@ export function UsageProviderChart({
373407
374408 { hoveredPeriod === undefined ? null : (
375409 < div
376- className = "pointer-events-none absolute top-0 z-10 min-w-36 border border-border bg-background/95 px-2 py-1.5 text-xs"
410+ ref = { tooltipRef }
411+ 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"
377412 style = { {
378- left : ` ${ hoverLeft } %` ,
379- transform : hoverLeft > 60 ? "translateX(-100%)" : "translateX(0 )",
413+ left : "var(--usage-tooltip-left, 0px)" ,
414+ top : "var(--usage-tooltip-top, 0px )",
380415 } }
381416 >
382417 < div className = "mb-1 text-muted-foreground" > { formatTooltipPeriod ( hoveredPeriod ) } </ div >
@@ -423,21 +458,3 @@ export function UsageProviderChart({
423458 </ div >
424459 ) ;
425460}
426-
427- export function UsageChartLegend ( ) {
428- return (
429- < div className = "flex items-center gap-4" >
430- { PROVIDER_ORDER . map ( ( provider ) => {
431- // Brand marks keep monochrome providers identifiable even when their
432- // chart series use distinct colors.
433- const { label, mark : Mark } = PROVIDER_PRESENTATION [ provider ] ;
434- return (
435- < span key = { provider } className = "flex items-center gap-1.5 text-xs text-muted-foreground" >
436- < Mark className = "size-3.5 shrink-0" aria-hidden />
437- { label }
438- </ span >
439- ) ;
440- } ) }
441- </ div >
442- ) ;
443- }
0 commit comments