diff --git a/packages/web/src/features/traces/trace-file-view.tsx b/packages/web/src/features/traces/trace-file-view.tsx index a2cdff9..a72eec1 100644 --- a/packages/web/src/features/traces/trace-file-view.tsx +++ b/packages/web/src/features/traces/trace-file-view.tsx @@ -34,6 +34,7 @@ import * as api from "../../api/endpoints"; import { ApiError } from "../../api/client"; import { S } from "../../lib/strings"; import { + cacheHitRate, computeTps, formatMoney, formatPercent, @@ -103,11 +104,8 @@ function SummaryRow({ label, value }: { label: string; value: string }) { /** This round's input = cache hit (cacheRead) + cache miss (cacheWrite). */ const inputOf = (b: Buckets): number => b.cacheRead + b.cacheWrite; -/** Cache hit rate = cache hit ÷ this round's input; undefined when input is 0 → null (shown as `—`). */ -const hitRateOf = (b: Buckets): number | null => { - const input = inputOf(b); - return input > 0 ? b.cacheRead / input : null; -}; +/** Cache hit rate of this round's input: the shared formula (lib/format.ts cacheHitRate, also used by the Cost center's bubble); input 0 → null (shown as `—`). */ +const hitRateOf = (b: Buckets): number | null => cacheHitRate(b.cacheRead, b.cacheWrite); /** Shared style for stat rows (icon + value, tabular figures). */ const CHIP_CLASS = diff --git a/packages/web/src/features/usage/chart-geom.ts b/packages/web/src/features/usage/chart-geom.ts index 6a3914f..da0bd89 100644 --- a/packages/web/src/features/usage/chart-geom.ts +++ b/packages/web/src/features/usage/chart-geom.ts @@ -7,7 +7,8 @@ * horizontal layout (fixed 25px bar width, spacing ≥ bar width) is computed * by tokenBarLayout, and per-segment geometry (including per-segment hit * bands) is produced by barSegments; there's also pie-slice geometry (each - * Agent's call count) and success-rate normalization. See chart-svg.tsx for the render skeleton. + * Agent's call count), success-rate normalization, and hover-bubble + * placement (pointer lower-right, flipping at the edges). See chart-svg.tsx for the render skeleton. * * **Canvas width = the container's measured pixel width (1 canvas unit = 1 * CSS pixel)**: the SVG no longer stretches/scales via a fixed viewBox — @@ -112,6 +113,46 @@ export function successRate(completed: number, total: number): number { return total > 0 ? completed / total : 1; } +// —— Hover bubble placement (shared by both daily charts) —— + +/** Gap between the pointer and the bubble's near corner: close enough to read as attached, far enough that the bubble never sits under the pointer. */ +export const BUBBLE_OFFSET = 12; + +/** The window the bubble must stay inside, in canvas coordinates: the scroll container's currently visible region (left/right move with horizontal scroll; the top is always 0). */ +export interface BubbleView { + left: number; + right: number; + bottom: number; +} + +/** + * Hover bubble placement: the preferred spot is the pointer's lower-right + * (pointer + BUBBLE_OFFSET on both axes). Near an edge it **flips** to the + * pointer's other side (right edge → lower-left, bottom edge → upper-right, + * corner → upper-left): flipping keeps the bubble out from under the + * pointer, where pure clamping would slide it back over the hovered mark. + * The final clamp only guards the degenerate case (a window narrower than + * the bubble on both sides of the pointer): the bubble then covers the + * pointer, and a window narrower than the bubble itself still clips on the + * right — unreachable at real card widths; the clamp just keeps the failure graceful. + */ +export function bubblePosition( + px: number, + py: number, + bubbleW: number, + bubbleH: number, + view: BubbleView, +): { left: number; top: number } { + let left = px + BUBBLE_OFFSET; + if (left + bubbleW > view.right) left = px - BUBBLE_OFFSET - bubbleW; + let top = py + BUBBLE_OFFSET; + if (top + bubbleH > view.bottom) top = py - BUBBLE_OFFSET - bubbleH; + return { + left: Math.max(view.left, Math.min(left, view.right - bubbleW)), + top: Math.max(0, Math.min(top, view.bottom - bubbleH)), + }; +} + // —— Daily Token: bar + three-segment stack —— /** diff --git a/packages/web/src/features/usage/chart-svg.tsx b/packages/web/src/features/usage/chart-svg.tsx index 0c6abfd..3b5dde6 100644 --- a/packages/web/src/features/usage/chart-svg.tsx +++ b/packages/web/src/features/usage/chart-svg.tsx @@ -3,7 +3,8 @@ * original TrendChart, reused by both the daily Token stacked bar and the * daily cost line): 4 horizontal grid lines + y-axis ticks, x-axis dates, a * hover vertical indicator line + a transparent hit area + a value bubble - * that follows the cursor. "Data marks" (line / area / bars) are drawn by + * that follows the cursor at its lower-right (flipping to the other side of + * the pointer near the edges, see chart-geom's bubblePosition). "Data marks" (line / area / bars) are drawn by * the caller as children in the same x()/y() coordinate system; * see chart-geom.ts for the coordinate math. * @@ -23,11 +24,23 @@ * already indicates the x position — an extra vertical line would just be * noise, so the bar chart passes hoverLine={false} to turn it off. */ -import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react"; -import { CHART_H, PAD_L, PAD_R, PAD_T, sparseLabelIdx, type ChartGeom } from "./chart-geom"; - -/** Upper bound on bubble width: clamps the bubble back inside the canvas near the right edge, so it doesn't spuriously trigger extra horizontal scroll. */ -const BUBBLE_W = 160; +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, + type RefObject, +} from "react"; +import { + bubblePosition, + CHART_H, + PAD_L, + PAD_R, + PAD_T, + sparseLabelIdx, + type ChartGeom, +} from "./chart-geom"; /** * Measure the available width inside the chart card (CSS pixels, rounded @@ -103,18 +116,77 @@ export function ChartFrame({ el.scrollLeft = el.scrollWidth - el.clientWidth; }, [scrollToEnd, w, dates.length]); + // The bubble follows the pointer **imperatively** (lower-right, flipping + // at the edges — see chart-geom's bubblePosition): mousemove only records + // the client position and schedules a single rAF, whose callback batches + // the layout reads (svg origin, the scroll container's visible window, + // the bubble's real size) and then writes style.left/top directly. No + // React state is involved, so a 60–120Hz pointer neither re-renders the + // few-hundred-element svg nor forces multiple sync layouts per event. + // The bubble mounts hidden and is revealed by its first placement (on + // entry, mouseenter fires before the first mousemove has sampled the pointer). + const svgRef = useRef(null); + const bubbleRef = useRef(null); + const mouseRef = useRef<{ cx: number; cy: number } | null>(null); + const rafRef = useRef(0); + const placeBubble = () => { + const el = bubbleRef.current; + const svg = svgRef.current; + const sc = scrollRef.current; + const m = mouseRef.current; + if (!el || !svg || !sc || !m) return; + const r = svg.getBoundingClientRect(); + // Pointer in canvas coordinates (the svg's top-left is the content + // origin; bubble and content scroll together, so content pixels are + // enough), bounded by the *visible* window rather than the full canvas — + // that is what keeps the bubble on screen when the Token bar canvas is scrolled. + const pos = bubblePosition(m.cx - r.left, m.cy - r.top, el.offsetWidth, el.offsetHeight, { + left: sc.scrollLeft, + right: sc.scrollLeft + sc.clientWidth, + bottom: CHART_H, + }); + el.style.left = `${pos.left}px`; + el.style.top = `${pos.top}px`; + el.style.visibility = "visible"; + }; + const schedulePlace = () => { + if (rafRef.current) return; + rafRef.current = requestAnimationFrame(() => { + rafRef.current = 0; + placeBubble(); + }); + }; + useEffect(() => () => cancelAnimationFrame(rafRef.current), []); + + // Re-place synchronously (before paint) when the bubble's content changes: + // its size changes as hover moves between days/buckets, which can change + // the flip decision. Keyed on the hover anchor and the bubble renderer's + // identity (the parent re-creates the callback whenever its own hover + // state — e.g. the bar chart's segment key — changes) instead of running on every render. + useLayoutEffect(placeBubble, [bubble, hover]); + return ( // Horizontal scroll when the canvas is wider than the container (bar // width has a pixel floor, so 30 days won't fit in a half-width panel); - // the bubble is this container's absolutely-positioned child element and scrolls along with the content, so anchoring it to the column by pixels is enough. + // the bubble is this container's absolutely-positioned child element and scrolls along with the content, so anchoring it to the pointer in content pixels is enough.
onHover(null)} + onMouseLeave={() => { + onHover(null); + // Drop the pointer sample: on re-entry the bubble stays hidden until the fresh position is known, rather than flashing at the stale one. + mouseRef.current = null; + }} + // Only record the client position and coalesce into one rAF: all layout reads happen inside the rAF callback (see placeBubble). + onMouseMove={(e) => { + mouseRef.current = { cx: e.clientX, cy: e.clientY }; + schedulePlace(); + }} > {/* Grid lines and y-axis ticks (recessive gray) */} {gridLevels.map((v, i) => ( @@ -190,11 +262,15 @@ export function ChartFrame({ {bubble && hover !== null && dates[hover] && ( diff --git a/packages/web/src/features/usage/usage-charts.tsx b/packages/web/src/features/usage/usage-charts.tsx index 632a540..c32b0c5 100644 --- a/packages/web/src/features/usage/usage-charts.tsx +++ b/packages/web/src/features/usage/usage-charts.tsx @@ -25,7 +25,7 @@ import type { } from "@prismshadow/penguin-server/api"; import { catalogEntryFor, providerInfo } from "@prismshadow/penguin-core/model-catalog"; import { S } from "../../lib/strings"; -import { humanizeTokens } from "../../lib/format"; +import { cacheHitRate, formatPercent, humanizeTokens } from "../../lib/format"; import { TOKEN_COLORS } from "../../lib/token-colors"; import { categoryColor } from "../../lib/category-colors"; import { @@ -254,7 +254,8 @@ interface SegHover { * be un-hoverable; widening the bar doesn't help the vertical dimension * either). Hitting a segment highlights only that segment and fades out * everything else; the bubble reports only that segment's date/bucket - * name/Token count. When legend is passed in (legend hover), it highlights all segments of the matching bucket. + * name/Token count (the cacheRead segment adds that day's cache hit rate, + * cacheRead / (cacheRead + cacheWrite)). When legend is passed in (legend hover), it highlights all segments of the matching bucket. * No hover vertical line is drawn (hoverLine={false}): the bar itself already indicates the x position. */ export function TokenBarChart({ @@ -304,12 +305,22 @@ export function TokenBarChart({ const p = trend[i]!; const key = hover?.key; if (!key) return null; + // The cacheRead bubble additionally reports that day's cache hit + // rate, via the formula/format/label shared with the Trace page + // (lib/format.ts cacheHitRate + formatPercent, S.traces.hitRate), + // so the metric reads identically everywhere; null (denominator 0) omits the line instead of showing 0/0. + const hitRate = key === "cacheRead" ? cacheHitRate(p.cacheRead, p.cacheWrite) : null; return ( <>

{p.date}

{bucketLabel(key)} {humanizeTokens(p[key])}

+ {hitRate !== null && ( +

+ {S.traces.hitRate} {formatPercent(hitRate)} +

+ )} ); }} diff --git a/packages/web/src/lib/format.ts b/packages/web/src/lib/format.ts index e53970d..7e7814d 100644 --- a/packages/web/src/lib/format.ts +++ b/packages/web/src/lib/format.ts @@ -72,11 +72,22 @@ export function formatTps(tps: number | null | undefined): string { return `${v} tok/s`; } +/** + * Cache hit rate = cacheRead ÷ (cacheRead + cacheWrite): the share of cached + * input actually served from cache; null when the denominator is 0 (no cache + * activity — the rate is undefined, callers omit the stat or let + * formatPercent render `—`). The single formula shared by the Trace page's + * turn/global summaries and the Cost center's cacheRead bubble, so the two pages can never drift apart. + */ +export function cacheHitRate(cacheRead: number, cacheWrite: number): number | null { + const total = cacheRead + cacheWrite; + return total > 0 ? cacheRead / total : null; +} + /** * Ratio display (rounded to a whole percent): `0.714` → `71%`; null * (denominator is 0, undefined) or a non-finite value → `—`. - * Used by the Trace page's **cache hit rate** (cache hits ÷ this turn's - * input) and similar cases. + * Used with cacheHitRate above (Trace page and the Cost center's cacheRead bubble) and similar cases. */ export function formatPercent(ratio: number | null | undefined): string { if (ratio == null || !Number.isFinite(ratio)) return "—"; diff --git a/packages/web/test/format.test.ts b/packages/web/test/format.test.ts index 34b83cb..98bb964 100644 --- a/packages/web/test/format.test.ts +++ b/packages/web/test/format.test.ts @@ -3,6 +3,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + cacheHitRate, computeTps, formatBytes, formatDateTime, @@ -93,6 +94,26 @@ describe("formatPercent", () => { }); }); +describe("cacheHitRate (shared by the Trace summaries and the Cost center's cacheRead bubble)", () => { + it("cacheRead ÷ (cacheRead + cacheWrite)", () => { + expect(cacheHitRate(50, 50)).toBe(0.5); + expect(cacheHitRate(75, 25)).toBe(0.75); + expect(cacheHitRate(100, 0)).toBe(1); + expect(cacheHitRate(0, 100)).toBe(0); // all writes, no hits: 0 is a real value, not the guard + }); + + it("renders via formatPercent as a whole percent", () => { + expect(formatPercent(cacheHitRate(1, 2))).toBe("33%"); // 33.3… rounds down + expect(formatPercent(cacheHitRate(2, 1))).toBe("67%"); // 66.6… rounds up + expect(formatPercent(cacheHitRate(999, 1))).toBe("100%"); // 99.9 rounds to 100% at whole-percent precision + }); + + it("denominator 0 (no cache activity) yields null: the bubble omits the line, formatPercent shows —", () => { + expect(cacheHitRate(0, 0)).toBeNull(); + expect(formatPercent(cacheHitRate(0, 0))).toBe("—"); + }); +}); + describe("formatBytes", () => { it("byte abbreviation", () => { expect(formatBytes(812)).toBe("812B"); diff --git a/packages/web/test/usage-charts.test.ts b/packages/web/test/usage-charts.test.ts index efde79f..6c26073 100644 --- a/packages/web/test/usage-charts.test.ts +++ b/packages/web/test/usage-charts.test.ts @@ -2,8 +2,10 @@ * Cost Center chart pure-function unit tests (chart-geom.ts): coordinate mapping, SVG path * assembly, Token bar chart horizontal layout (fixed 25px bar width / spacing ≥ bar width / * whether it scrolls horizontally), stacked-bar segment geometry and per-segment hit bands, - * pie slice arcs, success rate. Component interaction isn't covered here (vitest runs in a - * node environment, no DOM). + * pie slice arcs, success rate, hover-bubble placement (pointer lower-right, flipping at + * the edges; the cache hit rate shown in the cacheRead bubble is lib/format's shared + * cacheHitRate, tested in format.test.ts). Component interaction isn't covered here + * (vitest runs in a node environment, no DOM). * * Canvas width is "measured container pixels" (1 canvas unit = 1 CSS pixel), so each case * passes an explicit width; 640 was the original fixed canvas width, and reusing it as the @@ -18,10 +20,13 @@ import { sparseLabelIdx, autoLabelIdx, successRate, + bubblePosition, tokenBarLayout, barSegments, pieSlices, BAR_W, + BUBBLE_OFFSET, + CHART_H, MIN_HIT_H, PAD_L, PAD_R, @@ -250,3 +255,61 @@ describe("successRate", () => { expect(successRate(0, 0)).toBe(1); }); }); + +describe("bubblePosition", () => { + // A 640px canvas that fits its card, not scrolled: the visible window is the whole canvas. + const view = { left: 0, right: 640, bottom: CHART_H }; + const BW = 160; + const BH = 48; + + it("default: the bubble hangs at the pointer's lower-right, offset on both axes", () => { + expect(bubblePosition(100, 50, BW, BH, view)).toEqual({ + left: 100 + BUBBLE_OFFSET, + top: 50 + BUBBLE_OFFSET, + }); + }); + + it("right edge: flips to the pointer's lower-left (clamping would slide it back under the pointer)", () => { + const pos = bubblePosition(600, 50, BW, BH, view); + expect(pos).toEqual({ left: 600 - BUBBLE_OFFSET - BW, top: 50 + BUBBLE_OFFSET }); + expect(pos.left + BW).toBeLessThanOrEqual(view.right); // fully inside the window + expect(pos.left + BW).toBeLessThanOrEqual(600 - BUBBLE_OFFSET); // and clear of the pointer + }); + + it("bottom edge: flips above the pointer", () => { + expect(bubblePosition(100, 190, BW, BH, view)).toEqual({ + left: 100 + BUBBLE_OFFSET, + top: 190 - BUBBLE_OFFSET - BH, + }); + }); + + it("bottom-right corner: flips on both axes to the pointer's upper-left", () => { + expect(bubblePosition(630, 195, BW, BH, view)).toEqual({ + left: 630 - BUBBLE_OFFSET - BW, + top: 195 - BUBBLE_OFFSET - BH, + }); + }); + + it("an exact fit against the edge does not flip", () => { + const px = view.right - BUBBLE_OFFSET - BW; // left + BW lands exactly on view.right + expect(bubblePosition(px, 50, BW, BH, view).left).toBe(px + BUBBLE_OFFSET); + }); + + it("scrolled Token bar canvas: flips against the *visible* window, not the full canvas", () => { + // 1554px canvas in a 495px card scrolled to the far right: visible [1059, 1554]. + const v = { left: 1059, right: 1554, bottom: CHART_H }; + // Mid-window: normal lower-right placement (canvas coordinates, not window-relative). + expect(bubblePosition(1100, 50, BW, BH, v)).toEqual({ left: 1112, top: 62 }); + // Near the visible right edge: 1512+160 would clip at 1554 → flip left. + expect(bubblePosition(1500, 50, BW, BH, v).left).toBe(1500 - BUBBLE_OFFSET - BW); + // Near the visible left edge the lower-right placement already fits: no shove. + expect(bubblePosition(1065, 50, BW, BH, v).left).toBe(1065 + BUBBLE_OFFSET); + }); + + it("degenerate guard: when neither side of the pointer fits, clamp to the window (covering the pointer is then unavoidable)", () => { + const v = { left: 0, right: 200, bottom: CHART_H }; + const pos = bubblePosition(100, 100, 180, BH, v); // wider than either side of the pointer, still narrower than the window + expect(pos.left).toBe(0); // flip target would be negative → clamped to the window's left edge + expect(pos.left + 180).toBeLessThanOrEqual(v.right); // a bubble wider than the whole window would still clip on the right — unreachable at real card widths + }); +});