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:
7 files changed
+246
-25
No files matched your search
@@ -34,6 +34,7 @@ import * as api from "../../api/endpoints";
|
|||||||
import { ApiError } from "../../api/client";
|
import { ApiError } from "../../api/client";
|
||||||
import { S } from "../../lib/strings";
|
import { S } from "../../lib/strings";
|
||||||
import {
|
import {
|
||||||
|
cacheHitRate,
|
||||||
computeTps,
|
computeTps,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
formatPercent,
|
formatPercent,
|
||||||
@@ -103,11 +104,8 @@ function SummaryRow({ label, value }: { label: string; value: string }) {
|
|||||||
/** This round's input = cache hit (cacheRead) + cache miss (cacheWrite). */
|
/** This round's input = cache hit (cacheRead) + cache miss (cacheWrite). */
|
||||||
const inputOf = (b: Buckets): number => b.cacheRead + b.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 `—`). */
|
/** 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 => {
|
const hitRateOf = (b: Buckets): number | null => cacheHitRate(b.cacheRead, b.cacheWrite);
|
||||||
const input = inputOf(b);
|
|
||||||
return input > 0 ? b.cacheRead / input : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Shared style for stat rows (icon + value, tabular figures). */
|
/** Shared style for stat rows (icon + value, tabular figures). */
|
||||||
const CHIP_CLASS =
|
const CHIP_CLASS =
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
* horizontal layout (fixed 25px bar width, spacing ≥ bar width) is computed
|
* horizontal layout (fixed 25px bar width, spacing ≥ bar width) is computed
|
||||||
* by tokenBarLayout, and per-segment geometry (including per-segment hit
|
* by tokenBarLayout, and per-segment geometry (including per-segment hit
|
||||||
* bands) is produced by barSegments; there's also pie-slice geometry (each
|
* 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
|
* **Canvas width = the container's measured pixel width (1 canvas unit = 1
|
||||||
* CSS pixel)**: the SVG no longer stretches/scales via a fixed viewBox —
|
* 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;
|
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 ——
|
// —— Daily Token: bar + three-segment stack ——
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* original TrendChart, reused by both the daily Token stacked bar and the
|
* 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
|
* 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
|
* 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;
|
* the caller as children in the same x()/y() coordinate system;
|
||||||
* see chart-geom.ts for the coordinate math.
|
* see chart-geom.ts for the coordinate math.
|
||||||
*
|
*
|
||||||
@@ -23,11 +24,23 @@
|
|||||||
* already indicates the x position — an extra vertical line would just be
|
* already indicates the x position — an extra vertical line would just be
|
||||||
* noise, so the bar chart passes hoverLine={false} to turn it off.
|
* noise, so the bar chart passes hoverLine={false} to turn it off.
|
||||||
*/
|
*/
|
||||||
import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
import {
|
||||||
import { CHART_H, PAD_L, PAD_R, PAD_T, sparseLabelIdx, type ChartGeom } from "./chart-geom";
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
/** 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. */
|
useRef,
|
||||||
const BUBBLE_W = 160;
|
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
|
* Measure the available width inside the chart card (CSS pixels, rounded
|
||||||
@@ -103,18 +116,77 @@ export function ChartFrame({
|
|||||||
el.scrollLeft = el.scrollWidth - el.clientWidth;
|
el.scrollLeft = el.scrollWidth - el.clientWidth;
|
||||||
}, [scrollToEnd, w, dates.length]);
|
}, [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 (
|
return (
|
||||||
// Horizontal scroll when the canvas is wider than the container (bar
|
// 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);
|
// 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">
|
<div ref={scrollRef} className="relative overflow-x-auto">
|
||||||
<svg
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
viewBox={`0 0 ${w} ${CHART_H}`}
|
viewBox={`0 0 ${w} ${CHART_H}`}
|
||||||
width={w}
|
width={w}
|
||||||
height={CHART_H}
|
height={CHART_H}
|
||||||
className="text-gray-600 dark:text-gray-400"
|
className="text-gray-600 dark:text-gray-400"
|
||||||
role="img"
|
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) */}
|
{/* Grid lines and y-axis ticks (recessive gray) */}
|
||||||
{gridLevels.map((v, i) => (
|
{gridLevels.map((v, i) => (
|
||||||
@@ -190,11 +262,15 @@ export function ChartFrame({
|
|||||||
|
|
||||||
{bubble && hover !== null && dates[hover] && (
|
{bubble && hover !== null && dates[hover] && (
|
||||||
<div
|
<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"
|
ref={bubbleRef}
|
||||||
// Anchored near that column's left edge, clamped back inside the
|
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"
|
||||||
// canvas (1 unit = 1 pixel, positioned directly in pixels; the
|
// Mounted hidden at the origin; placeBubble moves it to the
|
||||||
// left edge can't be negative, since the scroll container would clip off the part that sticks out).
|
// pointer's lower-right (flipping near the right/bottom edges, see
|
||||||
style={{ left: `${Math.max(0, Math.min(x(hover) - 30, w - BUBBLE_W))}px` }}
|
// 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)}
|
{bubble(hover)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import type {
|
|||||||
} from "@prismshadow/penguin-server/api";
|
} from "@prismshadow/penguin-server/api";
|
||||||
import { catalogEntryFor, providerInfo } from "@prismshadow/penguin-core/model-catalog";
|
import { catalogEntryFor, providerInfo } from "@prismshadow/penguin-core/model-catalog";
|
||||||
import { S } from "../../lib/strings";
|
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 { TOKEN_COLORS } from "../../lib/token-colors";
|
||||||
import { categoryColor } from "../../lib/category-colors";
|
import { categoryColor } from "../../lib/category-colors";
|
||||||
import {
|
import {
|
||||||
@@ -254,7 +254,8 @@ interface SegHover {
|
|||||||
* be un-hoverable; widening the bar doesn't help the vertical dimension
|
* be un-hoverable; widening the bar doesn't help the vertical dimension
|
||||||
* either). Hitting a segment highlights only that segment and fades out
|
* either). Hitting a segment highlights only that segment and fades out
|
||||||
* everything else; the bubble reports only that segment's date/bucket
|
* 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.
|
* No hover vertical line is drawn (hoverLine={false}): the bar itself already indicates the x position.
|
||||||
*/
|
*/
|
||||||
export function TokenBarChart({
|
export function TokenBarChart({
|
||||||
@@ -304,12 +305,22 @@ export function TokenBarChart({
|
|||||||
const p = trend[i]!;
|
const p = trend[i]!;
|
||||||
const key = hover?.key;
|
const key = hover?.key;
|
||||||
if (!key) return null;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="text-gray-400">{p.date}</p>
|
<p className="text-gray-400">{p.date}</p>
|
||||||
<p className="font-mono">
|
<p className="font-mono">
|
||||||
{bucketLabel(key)} {humanizeTokens(p[key])}
|
{bucketLabel(key)} {humanizeTokens(p[key])}
|
||||||
</p>
|
</p>
|
||||||
|
{hitRate !== null && (
|
||||||
|
<p className="font-mono">
|
||||||
|
{S.traces.hitRate} {formatPercent(hitRate)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -72,11 +72,22 @@ export function formatTps(tps: number | null | undefined): string {
|
|||||||
return `${v} tok/s`;
|
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
|
* Ratio display (rounded to a whole percent): `0.714` → `71%`; null
|
||||||
* (denominator is 0, undefined) or a non-finite value → `—`.
|
* (denominator is 0, undefined) or a non-finite value → `—`.
|
||||||
* Used by the Trace page's **cache hit rate** (cache hits ÷ this turn's
|
* Used with cacheHitRate above (Trace page and the Cost center's cacheRead bubble) and similar cases.
|
||||||
* input) and similar cases.
|
|
||||||
*/
|
*/
|
||||||
export function formatPercent(ratio: number | null | undefined): string {
|
export function formatPercent(ratio: number | null | undefined): string {
|
||||||
if (ratio == null || !Number.isFinite(ratio)) return "—";
|
if (ratio == null || !Number.isFinite(ratio)) return "—";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
cacheHitRate,
|
||||||
computeTps,
|
computeTps,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatDateTime,
|
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", () => {
|
describe("formatBytes", () => {
|
||||||
it("byte abbreviation", () => {
|
it("byte abbreviation", () => {
|
||||||
expect(formatBytes(812)).toBe("812B");
|
expect(formatBytes(812)).toBe("812B");
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
* Cost Center chart pure-function unit tests (chart-geom.ts): coordinate mapping, SVG path
|
* 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 /
|
* 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,
|
* 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
|
* pie slice arcs, success rate, hover-bubble placement (pointer lower-right, flipping at
|
||||||
* node environment, no DOM).
|
* 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
|
* 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
|
* passes an explicit width; 640 was the original fixed canvas width, and reusing it as the
|
||||||
@@ -18,10 +20,13 @@ import {
|
|||||||
sparseLabelIdx,
|
sparseLabelIdx,
|
||||||
autoLabelIdx,
|
autoLabelIdx,
|
||||||
successRate,
|
successRate,
|
||||||
|
bubblePosition,
|
||||||
tokenBarLayout,
|
tokenBarLayout,
|
||||||
barSegments,
|
barSegments,
|
||||||
pieSlices,
|
pieSlices,
|
||||||
BAR_W,
|
BAR_W,
|
||||||
|
BUBBLE_OFFSET,
|
||||||
|
CHART_H,
|
||||||
MIN_HIT_H,
|
MIN_HIT_H,
|
||||||
PAD_L,
|
PAD_L,
|
||||||
PAD_R,
|
PAD_R,
|
||||||
@@ -250,3 +255,61 @@ describe("successRate", () => {
|
|||||||
expect(successRate(0, 0)).toBe(1);
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user