cf27b13880
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
353 lines
15 KiB
TypeScript
353 lines
15 KiB
TypeScript
/**
|
||
* Geometry math for the cost center charts: pure functions, no React / no
|
||
* JSX, easy to unit test (see test/usage-charts.test.ts). The two "last 30
|
||
* days" charts (the daily Token bar's three-segment stack, the daily cost
|
||
* line + area) share one coordinate system — canvas width, padding, the
|
||
* x()/y() mapping, SVG paths, x-axis label indices. The stacked bar's
|
||
* 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), 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 —
|
||
* a scaled-down "bar width" would be a fake pixel discounted by the
|
||
* container's width (640 units squeezed into a half-width cell becomes
|
||
* ~495px, a 0.77 factor), while requirements like "at least 25px wide" must
|
||
* land on **real display pixels**. So the canvas width is supplied by the caller after measuring the container.
|
||
*/
|
||
|
||
/** Canvas height and padding (carried over from the original TrendChart constants; width is now measured from the container, see the file header). */
|
||
export const CHART_H = 200;
|
||
export const PAD_L = 46;
|
||
export const PAD_R = 8;
|
||
export const PAD_T = 10;
|
||
export const PAD_B = 22;
|
||
|
||
/** The daily Token chart's three buckets (bottom-to-top stacking order is output → cacheWrite → cacheRead). */
|
||
export type TokenBucketKey = "cacheRead" | "cacheWrite" | "output";
|
||
|
||
/** A chart's coordinate system: canvas width w, data point count n, y-axis upper bound max, and x()/y() mapping "index / value" to canvas coordinates. */
|
||
export interface ChartGeom {
|
||
n: number;
|
||
max: number;
|
||
/** Total canvas width (= viewBox width = CSS pixel width). */
|
||
w: number;
|
||
innerW: number;
|
||
innerH: number;
|
||
step: number;
|
||
x: (i: number) => number;
|
||
y: (v: number) => number;
|
||
}
|
||
|
||
/**
|
||
* Build the coordinate system: x takes each cell's midpoint, y runs
|
||
* top-to-bottom with max as the full height. w is the canvas width (pixels).
|
||
* When `max <= 0` (no data / all zero), y always takes the baseline —
|
||
* callers already guarantee max > 0, but this is an exported public pure
|
||
* function, and without this guard a single 0 would turn the entire chart's coordinates into NaN / Infinity.
|
||
*/
|
||
export function makeGeom(n: number, max: number, w: number): ChartGeom {
|
||
const innerW = Math.max(0, w - PAD_L - PAD_R);
|
||
const innerH = CHART_H - PAD_T - PAD_B;
|
||
const step = n > 0 ? innerW / n : innerW;
|
||
return {
|
||
n,
|
||
max,
|
||
w,
|
||
innerW,
|
||
innerH,
|
||
step,
|
||
x: (i) => PAD_L + step * i + step / 2,
|
||
y: (v) => PAD_T + innerH * (1 - (max > 0 ? v / max : 0)),
|
||
};
|
||
}
|
||
|
||
/** Line path: `M x0,y0 L x1,y1 …` (identical to the original TrendChart's cost line). */
|
||
export function linePath(geom: ChartGeom, values: number[]): string {
|
||
return values.map((v, i) => `${i === 0 ? "M" : "L"}${geom.x(i)},${geom.y(v)}`).join(" ");
|
||
}
|
||
|
||
/** Area path: the line drops vertically to the baseline (y=0) at the end, then closes back along the baseline to the start; used by the cost line's fill layer. */
|
||
export function areaPath(geom: ChartGeom, values: number[]): string {
|
||
const n = values.length;
|
||
if (n === 0) return "";
|
||
const baseY = geom.y(0);
|
||
const parts: string[] = [];
|
||
for (let i = 0; i < n; i++)
|
||
parts.push(`${i === 0 ? "M" : "L"}${geom.x(i)},${geom.y(values[i]!)}`);
|
||
parts.push(`L${geom.x(n - 1)},${baseY}`);
|
||
parts.push(`L${geom.x(0)},${baseY}`);
|
||
parts.push("Z");
|
||
return parts.join(" ");
|
||
}
|
||
|
||
/** Sparse x-axis label indices: first, middle, last (labeling every point would blur together when cells are narrow and there are many points). */
|
||
export function sparseLabelIdx(n: number): number[] {
|
||
if (n <= 0) return [];
|
||
if (n === 1) return [0];
|
||
if (n === 2) return [0, 1];
|
||
return [0, Math.floor((n - 1) / 2), n - 1];
|
||
}
|
||
|
||
/** Horizontal space a single date label (`MM-DD`, fontSize 9) takes up: roughly 28px of text width plus breathing room. */
|
||
const LABEL_MIN_PX = 40;
|
||
|
||
/**
|
||
* Adaptive x-axis label indices: label more of them when each cell is wide
|
||
* enough (the stride = the number of cells needed to fit the next label).
|
||
* The Token bar chart's cells are each ≥ 2×25px, so in practice every day
|
||
* gets labeled; when cells are narrow it automatically skips a few cells between labels so they don't blur together.
|
||
*/
|
||
export function autoLabelIdx(n: number, step: number): number[] {
|
||
if (n <= 0) return [];
|
||
const stride = step > 0 ? Math.max(1, Math.ceil(LABEL_MIN_PX / step)) : n;
|
||
const idx: number[] = [];
|
||
for (let i = 0; i < n; i += stride) idx.push(i);
|
||
return idx;
|
||
}
|
||
|
||
/** Request success rate: no requests (total=0) is treated as 1 (matches the old bar's convention, avoiding 0/0). */
|
||
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 ——
|
||
|
||
/**
|
||
* Bar width (**real CSS pixels**, since 1 canvas unit = 1 pixel): **a fixed
|
||
* value, not a minimum** — it used to be implemented as "no less than 25px",
|
||
* which made bars stretch to fill the container when there were few points
|
||
* (3 daily points could balloon to ~180px), defeating the intent of "25px
|
||
* bar width". Now it's always 25px: scroll when it doesn't fit, and give the extra space to bar spacing when it does.
|
||
*/
|
||
export const BAR_W = 25;
|
||
|
||
/**
|
||
* Minimum height of the per-segment hover hit band (canvas units = pixels,
|
||
* innerH=168): in real data, output is often under 1% of the day's total
|
||
* (sub-pixel height), and if the hit area equaled the visual rectangle it
|
||
* would be un-hoverable — highlighting down to "every segment" is this
|
||
* chart's core requirement. Widening the bar (≥25px) doesn't help the
|
||
* vertical dimension either: a sub-pixel value stays sub-pixel, so this
|
||
* floor must be kept.
|
||
* (The hit band's **width** is a separate matter: it spans the full cell horizontally, see TokenBarChart's hitLayer.)
|
||
*/
|
||
export const MIN_HIT_H = 8;
|
||
|
||
/** The Token bar chart's horizontal layout: bar width (always BAR_W), total canvas width, and whether the content overflows the container (needing horizontal scroll). */
|
||
export interface TokenBarLayout {
|
||
/** Bar width (CSS pixels): always BAR_W. */
|
||
barW: number;
|
||
/** Total canvas width (CSS pixels): fills the container, or overflows it per "bar + equal spacing". */
|
||
chartW: number;
|
||
/** Canvas is wider than the container: the caller needs horizontal scrolling to see it all. */
|
||
scroll: boolean;
|
||
}
|
||
|
||
/**
|
||
* Token bar chart horizontal layout: **bar width is always BAR_W (25 real
|
||
* pixels), bar spacing ≥ bar width**.
|
||
* - Many points (n×2×25px doesn't fit): each cell is exactly 2× the bar
|
||
* width, and the canvas overflows the container in real pixels →
|
||
* the container scrolls horizontally (no scaling, no squeezing);
|
||
* - Few points (fits): the canvas fills the container, and **all the extra
|
||
* space goes to bar spacing** — bars no longer stretch (the old
|
||
* implementation treated 25px as a floor, letting 3 daily points' bars
|
||
* balloon to ~180px), they just stand farther apart.
|
||
*/
|
||
export function tokenBarLayout(containerW: number, n: number): TokenBarLayout {
|
||
const innerW = Math.max(0, containerW - PAD_L - PAD_R);
|
||
const needed = 2 * BAR_W * n; // inner width needed to lay out n bars (bar + equal spacing)
|
||
if (needed <= innerW) return { barW: BAR_W, chartW: containerW, scroll: false };
|
||
return { barW: BAR_W, chartW: PAD_L + needed + PAD_R, scroll: true };
|
||
}
|
||
|
||
/** One segment within a bar: the visual rectangle is drawn strictly to value, the hit band is computed separately (small segments are raised to be hoverable). */
|
||
export interface BarSegment {
|
||
key: TokenBucketKey;
|
||
value: number;
|
||
/** Visual rectangle: segments sit flush against each other, total height = the day's total (no visual floor, no inflating the bar's height). */
|
||
y: number;
|
||
h: number;
|
||
/** Hit band: fills the whole bar bottom-to-top with no overlap, small segments raised to minHit. */
|
||
hitY: number;
|
||
hitH: number;
|
||
}
|
||
|
||
/**
|
||
* Hit-band height allocation (water-filling): segments below minHit are
|
||
* raised to minHit, the rest share the remaining space proportionally to
|
||
* their visual height; when the whole bar is shorter than k*minHit it
|
||
* degrades to an even split (nobody can squeeze anybody else out).
|
||
* Guarantee: the segment heights sum to total (the hit band fills the whole bar with no overlap).
|
||
*/
|
||
function hitHeights(heights: number[], total: number, minHit: number): number[] {
|
||
const k = heights.length;
|
||
if (k === 0) return [];
|
||
if (total <= k * minHit) return heights.map(() => total / k);
|
||
const small = new Set<number>();
|
||
// Each round adds at most one segment to small; total > k*minHit guarantees not every segment gets added (some segment must end up with > minHit).
|
||
for (;;) {
|
||
const rest = total - small.size * minHit;
|
||
const bigSum = heights.reduce((s, h, i) => (small.has(i) ? s : s + h), 0);
|
||
const scaled = (i: number) =>
|
||
bigSum > 0 ? (heights[i]! / bigSum) * rest : rest / (k - small.size);
|
||
const next = heights.findIndex((_, i) => !small.has(i) && scaled(i) < minHit);
|
||
if (next < 0) return heights.map((_, i) => (small.has(i) ? minHit : scaled(i)));
|
||
small.add(next);
|
||
}
|
||
}
|
||
|
||
/** Stacking order: bottom-to-top output → cacheWrite → cacheRead (matches TOKEN_COLORS' shading, darkest at the bottom). */
|
||
const STACK_ORDER: readonly TokenBucketKey[] = ["output", "cacheWrite", "cacheRead"];
|
||
|
||
/**
|
||
* A bar's three-segment stack: bottom-to-top output → cacheWrite →
|
||
* cacheRead, a zero-value bucket produces no segment (not drawn, and shouldn't be hoverable). The visual rectangle is drawn strictly to value; see hitHeights for the hit band.
|
||
*/
|
||
export function barSegments(
|
||
geom: ChartGeom,
|
||
p: { cacheRead: number; cacheWrite: number; output: number },
|
||
): BarSegment[] {
|
||
const stack = STACK_ORDER.map((key) => ({ key, value: p[key] })).filter((b) => b.value > 0);
|
||
if (stack.length === 0) return [];
|
||
|
||
// Visual rectangles: the top edge is taken from the cumulative value, so segments sit flush against each other.
|
||
const rects: Array<{ y: number; h: number }> = [];
|
||
let cum = 0;
|
||
for (const b of stack) {
|
||
const bottom = geom.y(cum);
|
||
cum += b.value;
|
||
const top = geom.y(cum);
|
||
rects.push({ y: top, h: bottom - top });
|
||
}
|
||
|
||
const base = geom.y(0);
|
||
const hits = hitHeights(
|
||
rects.map((r) => r.h),
|
||
base - geom.y(cum),
|
||
MIN_HIT_H,
|
||
);
|
||
let hitBottom = base;
|
||
return stack.map((b, i) => {
|
||
const hitH = hits[i]!;
|
||
const seg: BarSegment = {
|
||
key: b.key,
|
||
value: b.value,
|
||
y: rects[i]!.y,
|
||
h: rects[i]!.h,
|
||
hitY: hitBottom - hitH,
|
||
hitH,
|
||
};
|
||
hitBottom -= hitH;
|
||
return seg;
|
||
});
|
||
}
|
||
|
||
// —— Each Agent's call count: pie chart ——
|
||
|
||
const TAU = Math.PI * 2;
|
||
/** Path coordinates keep 2 decimal places: the path string stays short and readable, and is easy to assert on in unit tests. */
|
||
const rnd = (v: number): number => Math.round(v * 100) / 100;
|
||
|
||
/** Take a point in polar coordinates: angle is measured from 12 o'clock, clockwise-positive (SVG's y-axis points down). */
|
||
function polar(cx: number, cy: number, r: number, angle: number): [number, number] {
|
||
const a = angle - Math.PI / 2;
|
||
return [rnd(cx + r * Math.cos(a)), rnd(cy + r * Math.sin(a))];
|
||
}
|
||
|
||
/** A single pie slice. */
|
||
export interface PieSlice {
|
||
/** Index within the passed-in values (the caller uses this to look up name and color). */
|
||
index: number;
|
||
value: number;
|
||
/** Fraction of the total [0,1]. */
|
||
frac: number;
|
||
/** Start/end angle (radians, clockwise from 12 o'clock). */
|
||
start: number;
|
||
end: number;
|
||
/** The slice's path. */
|
||
path: string;
|
||
}
|
||
|
||
/**
|
||
* Slice path: `M center L start A radius … end Z`; sweep=1 means clockwise,
|
||
* large-arc=1 when spanning more than a semicircle.
|
||
* At 100% the start and end points coincide and the A command degrades into
|
||
* "draws nothing" — split into two semicircular arcs to get a full circle.
|
||
*/
|
||
function slicePath(cx: number, cy: number, r: number, start: number, end: number): string {
|
||
if (end - start >= TAU - 1e-9) {
|
||
const [tx, ty] = polar(cx, cy, r, 0);
|
||
const [bx, by] = polar(cx, cy, r, Math.PI);
|
||
return `M${tx},${ty} A${r},${r} 0 1 1 ${bx},${by} A${r},${r} 0 1 1 ${tx},${ty} Z`;
|
||
}
|
||
const [x0, y0] = polar(cx, cy, r, start);
|
||
const [x1, y1] = polar(cx, cy, r, end);
|
||
const large = end - start > Math.PI ? 1 : 0;
|
||
return `M${rnd(cx)},${rnd(cy)} L${x0},${y0} A${r},${r} 0 ${large} 1 ${x1},${y1} Z`;
|
||
}
|
||
|
||
/**
|
||
* Pie slices: laid out clockwise from 12 o'clock in the order passed in,
|
||
* each slice's angle = that value's share of the total.
|
||
* Non-positive values produce no slice (a 0-degree arc is a degenerate
|
||
* path); when the total ≤ 0, returns empty (the caller falls back to an empty state).
|
||
*/
|
||
export function pieSlices(values: number[], cx: number, cy: number, r: number): PieSlice[] {
|
||
const total = values.reduce((s, v) => s + Math.max(0, v), 0);
|
||
if (total <= 0) return [];
|
||
const slices: PieSlice[] = [];
|
||
let start = 0;
|
||
values.forEach((value, index) => {
|
||
if (value <= 0) return;
|
||
const frac = value / total;
|
||
const end = start + frac * TAU;
|
||
slices.push({ index, value, frac, start, end, path: slicePath(cx, cy, r, start, end) });
|
||
start = end;
|
||
});
|
||
return slices;
|
||
}
|