fix(web): usage chart tooltip at the pointer's lower-right + cache hit rate in the Cache Read bubble (#37)

Co-authored-by: Alice <alice@prismshadow.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-23 00:27:54 +08:00
committed by GitHub
parent cabbab1c16
commit cf27b13880
7 changed files with 246 additions and 25 deletions
+42 -1
View File
@@ -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 ——
/**
+89 -13
View File
@@ -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<SVGSVGElement>(null);
const bubbleRef = useRef<HTMLDivElement>(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.
<div ref={scrollRef} className="relative overflow-x-auto">
<svg
ref={svgRef}
viewBox={`0 0 ${w} ${CHART_H}`}
width={w}
height={CHART_H}
className="text-gray-600 dark:text-gray-400"
role="img"
onMouseLeave={() => 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] && (
<div
className="pointer-events-none absolute top-0 rounded border border-gray-200 bg-white px-2 py-1 text-xs shadow-sm dark:border-gray-700 dark:bg-gray-900"
// Anchored near that column's left edge, clamped back inside the
// canvas (1 unit = 1 pixel, positioned directly in pixels; the
// left edge can't be negative, since the scroll container would clip off the part that sticks out).
style={{ left: `${Math.max(0, Math.min(x(hover) - 30, w - BUBBLE_W))}px` }}
ref={bubbleRef}
className="pointer-events-none absolute rounded border border-gray-200 bg-white px-2 py-1 text-xs whitespace-nowrap shadow-sm dark:border-gray-700 dark:bg-gray-900"
// Mounted hidden at the origin; placeBubble moves it to the
// pointer's lower-right (flipping near the right/bottom edges, see
// bubblePosition) and reveals it. The declared style below never
// changes between renders, so React leaves the imperative
// left/top/visibility writes alone (nowrap keeps the measured
// width the true content width, independent of where the bubble lands).
style={{ left: 0, top: 0, visibility: "hidden" }}
>
{bubble(hover)}
</div>
@@ -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 className="text-gray-400">{p.date}</p>
<p className="font-mono">
{bucketLabel(key)} {humanizeTokens(p[key])}
</p>
{hitRate !== null && (
<p className="font-mono">
{S.traces.hitRate} {formatPercent(hitRate)}
</p>
)}
</>
);
}}