import { useEffect, useMemo, useRef, useState } from "react"; import { useHive } from "../store"; import { fmtCost, fmtNum } from "../lib/format"; import { smooth } from "../lib/agents"; import { cumulativeSeries, delegationsFromEvents, rateSeries } from "../lib/series"; import type { Delegation } from "../api"; import type { HiveEvent } from "../types"; interface Pt { t: number; tok: number; cost: number; } // The chart plots either the cumulative totals or the per-minute RATE (E2). The // Overview chart is labeled "last 60 min · cost/min · tokens/min", so it uses // mode="rate" and the labels now match what is plotted. The Cost tab uses the // cumulative view (running totals over the session). Both draw from the single // shared aggregation (lib/series) over typed delegation DELTAS (Phase 3.1) — no // longer the truncated raw-event window. function buildSeries(rows: Delegation[], mode: "cumulative" | "rate", now: number): Pt[] { if (mode === "rate") { return rateSeries(rows, 60, now).map((p) => ({ t: p.t, tok: p.tokPerMin, cost: p.costPerMin })); } return cumulativeSeries(rows).map((p) => ({ t: p.t, tok: p.tok, cost: p.cost })); } const PAD = { l: 56, r: 56, t: 16, b: 30 }; function niceMax(v: number): number { if (v <= 0) return 1; const exp = Math.floor(Math.log10(v)); const base = Math.pow(10, exp); const f = v / base; const nice = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10; return nice * base; } function fmtTime(t: number): string { return new Date(t).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); } export default function CostTokensChart({ mode = "cumulative", events }: { mode?: "cumulative" | "rate"; events?: HiveEvent[] }) { const scopedDelegations = useHive((s) => s.scopedDelegations); const now = useHive((s) => s.now); // Live: the store's typed delegation deltas for the scoped sessions. Replay // (K5): an explicit event slice is passed — reconstruct its deltas client-side // (there are no server rows for a historical cursor). Live SSE never touches // the replay slice. const source = useMemo( () => (events ? delegationsFromEvents(events) : scopedDelegations), [events, scopedDelegations], ); // For the rate view, bucket against the live clock so the 60-min window slides. const series = useMemo(() => buildSeries(source, mode, now || Date.now()), [source, mode, now]); const [hover, setHover] = useState<{ i: number; px: number } | null>(null); const [size, setSize] = useState({ w: 760, h: 240 }); const svgRef = useRef(null); const hostRef = useRef(null); useEffect(() => { if (!hostRef.current || !("ResizeObserver" in window)) return; const ro = new ResizeObserver((entries) => { const r = entries[0]?.contentRect; if (r && r.width > 0 && r.height > 0) setSize({ w: Math.round(r.width), h: Math.round(r.height) }); }); ro.observe(hostRef.current); return () => ro.disconnect(); }, []); const geom = useMemo(() => { const pts = series; const { w: W, h: H } = size; const PLOT_W = W - PAD.l - PAD.r; const PLOT_H = H - PAD.t - PAD.b; if (pts.length < 2 || PLOT_W <= 0 || PLOT_H <= 0) return null; const t0 = pts[0].t, t1 = pts[pts.length - 1].t || t0 + 1; const maxTok = niceMax(Math.max(1, ...pts.map((p) => p.tok))); const maxCost = niceMax(Math.max(0.01, ...pts.map((p) => p.cost))); const x = (t: number) => PAD.l + ((t - t0) / (t1 - t0 || 1)) * PLOT_W; const yT = (v: number) => PAD.t + PLOT_H - (v / maxTok) * PLOT_H; const yC = (v: number) => PAD.t + PLOT_H - (v / maxCost) * PLOT_H; // Quadratic-bezier midpoint smoothing (ported `smooth()`) for calm lines. const line = (acc: (p: Pt) => number) => smooth(pts.map((p) => [x(p.t), acc(p)] as [number, number])); const area = (acc: (p: Pt) => number) => line(acc) + ` L ${x(t1).toFixed(1)} ${PAD.t + PLOT_H} L ${x(t0).toFixed(1)} ${PAD.t + PLOT_H} Z`; const ticks = [0, 0.25, 0.5, 0.75, 1]; return { pts, W, H, PLOT_H, t0, t1, maxTok, maxCost, x, yT, yC, line, area, ticks }; }, [series, size]); function onMove(e: React.PointerEvent) { const g = geom; if (!g || !svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); const vx = ((e.clientX - rect.left) / rect.width) * g.W; let best = 0, bestD = Infinity; g.pts.forEach((p, i) => { const d = Math.abs(g.x(p.t) - vx); if (d < bestD) { bestD = d; best = i; } }); setHover({ i: best, px: g.x(g.pts[best].t) }); } const hp = geom && hover ? geom.pts[hover.i] : null; const plotBottom = geom ? PAD.t + geom.PLOT_H : 0; return (
{!geom ?
Not enough activity to chart yet.
: ( <> setHover(null)}> {geom.ticks.map((f) => { const y = plotBottom - f * geom.PLOT_H; return ( {fmtNum(geom.maxTok * f)} {fmtCost(geom.maxCost * f)} ); })} {[0, 0.5, 1].map((f) => { const t = geom.t0 + (geom.t1 - geom.t0) * f; const x = PAD.l + f * (geom.W - PAD.l - PAD.r); return {fmtTime(t)}; })} {/* tokens: run-colored line + 10% fill; cost: brand line, no fill */} geom.yT(p.tok))} fill="url(#gTok)" /> geom.yT(p.tok))} /> geom.yC(p.cost))} /> {hp && hover && ( <> )} {hp && hover && (
{fmtTime(hp.t)}
{mode === "rate" ? "tok/min" : "tokens"}{fmtNum(hp.tok)}
{mode === "rate" ? "cost/min" : "cost"}{fmtCost(hp.cost)}
)} )}
); }