feat(lens): live dot field timeline and full-screen traces view (#44390)

* feat(lens): add dot field layout and live status helpers for the traces timeline

* test(lens): cover dot field layout, agent colors and live status

* feat(lens): draw the traces timeline as a live dot field

* feat(lens): add the sweep animation for the live traces timeline

* feat(lens): put the lens tabs in a compact header and fill the screen with traces
This commit is contained in:
ishaan-berri 2026-10-03 10:56:48 -07:00 • committed by GitHub
parent ad8babae33
commit f0abe1bea1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 423 additions and 66 deletions

View file

@ -473,3 +473,12 @@
[data-slot="dialog-content"][data-nested-dialog-open] {
visibility: hidden;
}
@keyframes lens-sweep {
from {
left: -6rem;
}
to {
left: 100%;
}
}

View file

@ -69,29 +69,31 @@ function LensContent({
const openDemo = onDemo ? () => onDemo(activeTab) : undefined;
return (
<LensPreviewTarget.Provider value={previewTarget}>
<main className="flex w-full min-w-0 flex-1 flex-col gap-5 p-6 md:p-8">
<div className="flex min-h-9 flex-wrap items-center justify-between gap-3">
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Aperture aria-hidden="true" className="size-7" strokeWidth={1.75} />
Lens
</h1>
<div ref={setPreviewTarget} />
</div>
<main className="flex min-h-full w-full min-w-0 flex-1 flex-col gap-2 px-3 pt-2 pb-3">
{demo && <DemoNotice onExit={onExit} />}
<Tabs
value={activeTab}
onValueChange={(value) => (demo ? setDemoTab(value as Tab) : void setTab(value as Tab))}
className="min-h-0 flex-1 gap-4"
className="min-h-0 flex-1 gap-2"
>
<TabsList variant="line" aria-label="Lens" className="w-full justify-start gap-6 border-b px-0">
<TabsTrigger value="traces" className="flex-none px-0">
Traces
</TabsTrigger>
<TabsTrigger value="investigations" className="flex-none px-0">
Investigations
</TabsTrigger>
</TabsList>
<TabsContent value="traces" keepMounted className="min-h-0">
<div className="flex min-h-8 flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h1 className="flex items-center gap-1.5 text-sm font-semibold tracking-tight">
<Aperture aria-hidden="true" className="size-4" strokeWidth={2} />
Lens
</h1>
<TabsList aria-label="Lens" className="h-8">
<TabsTrigger value="traces" className="px-3">
Traces
</TabsTrigger>
<TabsTrigger value="investigations" className="px-3">
Investigations
</TabsTrigger>
</TabsList>
</div>
<div ref={setPreviewTarget} />
</div>
<TabsContent value="traces" keepMounted className="flex min-h-0 flex-col">
<AgentTracesPage
accessToken={accessToken}
isActive={activeTab === "traces"}

View file

@ -5,7 +5,9 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/cva.config";
import { FIELD_COLS, FIELD_ROWS, agentDotColor, agoLabel, columnDots, isReceiving } from "./lensField";
import type { TraceSummary } from "./traceTypes";
import { traceAgentNames } from "./traceUtils";
const BUCKETS = 60;
const TICKS = 6;
@ -24,6 +26,7 @@ export interface Bucket {
endMs: number;
runs: number;
failed: number;
agents: readonly string[];
}
/** Run counts per equal-width time bucket across the window; runs outside it are dropped. */
@ -32,6 +35,7 @@ export function bucketRuns(runs: readonly TraceSummary[], range: TimeWindow, buc
const placed = runs.map((run) => ({
index: Math.floor((moment(run.start_time).valueOf() - range.startMs) / width),
failed: run.error_count > 0,
agent: traceAgentNames(run)[0] ?? "",
}));
return Array.from({ length: buckets }, (_, i) => {
const hits = placed.filter((p) => p.index === i);
@ -40,6 +44,7 @@ export function bucketRuns(runs: readonly TraceSummary[], range: TimeWindow, buc
endMs: range.startMs + (i + 1) * width,
runs: hits.length,
failed: hits.filter((p) => p.failed).length,
agents: hits.filter((p) => !p.failed).map((p) => p.agent),
};
});
}
@ -73,44 +78,202 @@ interface TracesTimelineProps {
onSelect: (selection: TimeWindow | null) => void;
}
function BucketBar({
bucket,
max,
dimmed,
hovered,
}: {
bucket: Bucket;
const FAILED_RED = "#e5484d";
const DOT_PITCH = 6;
const FIELD_HEIGHT = FIELD_ROWS * DOT_PITCH;
function fieldDotColor(lit: boolean, dot: ReturnType<typeof columnDots>[number], grid: string): string {
if (!lit) return grid;
if (dot.kind === "failed") return FAILED_RED;
return dot.kind === "run" ? dot.color : grid;
}
interface FieldFrame {
buckets: readonly Bucket[];
max: number;
dimmed: boolean;
hovered: boolean;
band: Band | null;
hover: number | null;
progress: number;
}
function dotRadius(lit: boolean, hovered: boolean): number {
if (!lit) return 0.9;
return hovered ? 2.4 : 1.9;
}
function drawField(canvas: HTMLCanvasElement, { buckets, max, band, hover, progress }: FieldFrame) {
const context = canvas.getContext("2d");
const width = canvas.clientWidth;
if (!context || width === 0) return;
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(FIELD_HEIGHT * ratio);
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, width, FIELD_HEIGHT);
const grid = document.documentElement.classList.contains("dark") ? "rgba(255,255,255,0.08)" : "rgba(15,23,42,0.08)";
const columnWidth = width / buckets.length;
const pitch = columnWidth / FIELD_COLS;
buckets.forEach((bucket, i) => {
const dots = columnDots(bucket, max, i * 7919 + bucket.runs);
const visible = Math.ceil(dots.length * progress);
const dimmed = band !== null && (i < band.lo || i > band.hi);
dots.forEach((dot, index) => {
const lit = dot.kind !== "grid" && index < visible;
context.globalAlpha = lit && dimmed ? 0.15 : 1;
context.fillStyle = fieldDotColor(lit, dot, grid);
context.beginPath();
context.arc(
i * columnWidth + pitch * ((index % FIELD_COLS) + 0.5),
FIELD_HEIGHT - DOT_PITCH * (Math.floor(index / FIELD_COLS) + 0.5),
dotRadius(lit, hover === i),
0,
Math.PI * 2,
);
context.fill();
});
});
context.globalAlpha = 1;
}
function useRiseIn(): number {
const [progress, setProgress] = useState(0);
useEffect(() => {
if (typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setProgress(1);
return;
}
const start = performance.now();
let frame = requestAnimationFrame(function rise(now) {
const t = Math.min(1, (now - start) / 700);
setProgress(1 - (1 - t) ** 3);
if (t < 1) frame = requestAnimationFrame(rise);
});
return () => cancelAnimationFrame(frame);
}, []);
return progress;
}
function DotField({
buckets,
max,
band,
hover,
}: {
buckets: readonly Bucket[];
max: number;
band: Band | null;
hover: number | null;
}) {
const canvas = useRef<HTMLCanvasElement>(null);
const progress = useRiseIn();
useEffect(() => {
const node = canvas.current;
if (!node) return;
const frame: FieldFrame = { buckets, max, band, hover, progress };
const paint = () => drawField(node, frame);
paint();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(paint);
observer.observe(node);
return () => observer.disconnect();
}, [buckets, max, band, hover, progress]);
return (
<div
className="pointer-events-none flex h-full flex-1 flex-col justify-end px-px"
data-testid="timeline-bucket"
data-runs={bucket.runs}
>
{bucket.runs > 0 && (
<div
className={cn(
"mx-auto flex w-full max-w-[10px] flex-col overflow-hidden rounded-t-[2px]",
dimmed && "bg-muted-foreground/25",
!dimmed && (hovered ? "bg-info" : "bg-info/65"),
)}
style={{ height: `${Math.max(6, (bucket.runs / max) * 100)}%` }}
>
{bucket.failed > 0 && (
<div
className="w-full shrink-0 bg-destructive"
style={{ height: `${Math.max(2, Math.min(25, (bucket.failed / bucket.runs) * 100))}%` }}
/>
)}
</div>
<canvas
ref={canvas}
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-0 w-full"
style={{ height: FIELD_HEIGHT }}
/>
);
}
function BucketBar({ bucket }: { bucket: Bucket }) {
return <div className="pointer-events-none h-full flex-1" data-testid="timeline-bucket" data-runs={bucket.runs} />;
}
function useNow(intervalMs: number): number {
const [now, setNow] = useState(Date.now);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), intervalMs);
return () => window.clearInterval(timer);
}, [intervalMs]);
return now;
}
function LivePulse({ live }: { live: boolean }) {
return (
<span aria-hidden="true" className="relative flex size-2">
{live && (
<span className="absolute inline-flex size-full rounded-full bg-[#3b5bfd] opacity-60 motion-safe:animate-ping" />
)}
<span
className={cn("relative inline-flex size-2 rounded-full", live ? "bg-[#3b5bfd]" : "bg-muted-foreground/50")}
/>
</span>
);
}
function latestStart(runs: readonly TraceSummary[]): number | null {
return runs.reduce<number | null>((latest, run) => {
const t = moment(run.start_time).valueOf();
return latest === null || t > latest ? t : latest;
}, null);
}
function FieldHeader({ runs, range }: { runs: readonly TraceSummary[]; range: TimeWindow }) {
const now = useNow(1000);
const agents = Array.from(new Set(runs.flatMap(traceAgentNames)));
const failed = runs.filter((run) => run.error_count > 0).length;
const lastMs = latestStart(runs);
const live = isReceiving(lastMs, now);
return (
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-1 font-mono text-[11px] leading-none">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground tabular-nums">
<span className="flex items-center gap-2" role="status" data-testid="traces-last-seen">
<LivePulse live={live} />
<span className={cn("font-semibold", live ? "text-[#0011b3] dark:text-[#8b9bff]" : "text-foreground")}>
{live ? "receiving" : "idle"}
</span>
{lastMs !== null && <span>last trace {agoLabel(lastMs, now)}</span>}
</span>
<span>
<span className="font-semibold text-foreground">{runs.length.toLocaleString()}</span>{" "}
{runs.length === 1 ? "run" : "runs"} from{" "}
<span className="font-semibold text-foreground">{agents.length}</span>{" "}
{agents.length === 1 ? "agent" : "agents"}
{failed > 0 && <span className="text-[#e5484d]"> · {failed} failed</span>}
</span>
</div>
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 text-muted-foreground">
{agents.slice(0, 6).map((agent) => (
<span key={agent} className="flex items-center gap-1.5">
<span className="size-[6px] rounded-full" style={{ backgroundColor: agentDotColor(agent) }} />
{agent}
</span>
))}
<span className="rounded border border-border bg-muted/50 px-1.5 py-px">
Total {formatSpan(range.endMs - range.startMs)}
</span>
</div>
</div>
);
}
function NowEdge() {
return (
<>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 w-24 bg-gradient-to-r from-transparent via-[#3b5bfd]/[0.08] to-transparent motion-safe:animate-[lens-sweep_6s_linear_infinite] motion-reduce:hidden"
/>
<div aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0">
<span className="absolute inset-y-0 right-0 w-px bg-[#3b5bfd]/50" />
<span className="absolute -top-0.5 right-0 size-1.5 translate-x-1/2 rounded-full bg-[#3b5bfd] motion-safe:animate-pulse" />
</div>
</>
);
}
function BucketTooltip({ bucket, index }: { bucket: Bucket; index: number }) {
return (
<div
@ -125,7 +288,7 @@ function BucketTooltip({ bucket, index }: { bucket: Bucket; index: number }) {
{bucket.runs} {bucket.runs === 1 ? "run" : "runs"}
{bucket.failed > 0 && `, ${bucket.failed} failed`}
</div>
<div className="text-info">drag to zoom</div>
<div className="text-[#0011b3] dark:text-[#8b9bff]">drag to zoom</div>
</div>
);
}
@ -185,29 +348,31 @@ function SelectionBracket({
const leftFrac = band.lo / BUCKETS;
const rightFrac = (band.hi + 1) / BUCKETS;
const widthPx = (rightFrac - leftFrac) * stripWidth;
const handle = "absolute inset-y-0 w-[3px] cursor-ew-resize bg-info";
const handle = "absolute inset-y-0 w-[2px] cursor-ew-resize bg-[#0011b3] dark:bg-[#8b9bff]";
const tick =
"before:absolute before:top-0 before:h-[2px] before:w-2 before:bg-inherit after:absolute after:bottom-0 after:h-[2px] after:w-2 after:bg-inherit";
const edgeLabel =
"pointer-events-none absolute -top-4 font-mono text-[10px] whitespace-nowrap text-info tabular-nums";
"pointer-events-none absolute -bottom-5 font-mono text-[10px] whitespace-nowrap text-[#0011b3] tabular-nums dark:text-[#8b9bff]";
return (
<>
<div
className="absolute inset-y-0 cursor-grab rounded-[2px] border border-info bg-info/10 active:cursor-grabbing"
className="absolute inset-y-0 cursor-grab bg-[#0011b3]/[0.05] active:cursor-grabbing dark:bg-[#8b9bff]/[0.08]"
style={{ left: pct(leftFrac), width: pct(rightFrac - leftFrac) }}
data-testid="timeline-selection"
onPointerDown={onHandleDown("move")}
>
<span
className={cn(handle, "-left-px")}
className={cn(handle, tick, "-left-px before:left-0 after:left-0")}
data-testid="timeline-handle-lo"
onPointerDown={onHandleDown("resize-lo")}
/>
<span
className={cn(handle, "-right-px")}
className={cn(handle, tick, "-right-px before:right-0 after:right-0")}
data-testid="timeline-handle-hi"
onPointerDown={onHandleDown("resize-hi")}
/>
{widthPx >= MIN_DURATION_LABEL_PX && (
<span className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 text-center font-mono text-[11px] font-medium text-info">
<span className="pointer-events-none absolute inset-x-0 top-2 text-center font-mono text-[11px] font-semibold text-[#0011b3] dark:text-[#8b9bff]">
{formatSpan(window.endMs - window.startMs)}
</span>
)}
@ -312,7 +477,6 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
onSelect(null);
};
const isDimmed = (i: number): boolean => band !== null && (i < band.lo || i > band.hi);
const labelFormat = edgeFormat(range);
return (
@ -322,16 +486,13 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
tabIndex={0}
onKeyDown={onKeyDown}
>
<div className="mb-4 flex items-center">
<span className="rounded border border-border bg-muted/50 px-1.5 py-px font-mono text-[10px] text-muted-foreground">
Total {formatSpan(range.endMs - range.startMs)}
</span>
</div>
<FieldHeader runs={runs} range={range} />
<div
ref={areaRef}
role="presentation"
data-testid="timeline-area"
className="relative flex h-14 cursor-crosshair touch-none items-end border-b border-border"
className="relative mt-2 flex cursor-crosshair touch-none items-end"
style={{ height: FIELD_HEIGHT }}
onPointerDown={(e) => begin("select", e)}
onPointerMove={onMove}
onPointerUp={onUp}
@ -340,13 +501,15 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
>
{hover !== null && !drag && (
<div
className="pointer-events-none absolute inset-y-0 w-px bg-info"
className="pointer-events-none absolute inset-y-0 w-px bg-[#0011b3]/50 dark:bg-[#8b9bff]/50"
style={{ left: pct((hover + 0.5) / BUCKETS) }}
data-testid="timeline-cursor"
/>
)}
{buckets.map((b, i) => (
<BucketBar key={b.startMs} bucket={b} max={max} dimmed={isDimmed(i)} hovered={hover === i} />
<DotField buckets={buckets} max={max} band={band} hover={hover} />
<NowEdge />
{buckets.map((b) => (
<BucketBar key={b.startMs} bucket={b} />
))}
{band && (
<SelectionBracket
@ -358,7 +521,9 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
/>
)}
</div>
<TickAxis range={range} />
<div className="mt-1">
<TickAxis range={range} />
</div>
{hover !== null && !drag && <BucketTooltip bucket={buckets[hover]} index={hover} />}
</div>
);

View file

@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
AGENT_DOT_COLORS,
FIELD_COLS,
FIELD_ROWS,
UNNAMED_AGENT_COLOR,
agoLabel,
isReceiving,
agentDotColor,
columnDots,
columnTop,
litDots,
} from "./lensField";
const capacity = FIELD_ROWS * FIELD_COLS;
const bucket = (runs: number, failed = 0, agents: string[] = []) => ({ runs, failed, agents });
const count = (dots: ReturnType<typeof columnDots>, kind: string) => dots.filter((dot) => dot.kind === kind).length;
describe("litDots", () => {
it("lights nothing for an empty bucket and at least one full row for any runs", () => {
expect(litDots(0, 10)).toBe(0);
expect(litDots(1, 1000)).toBe(FIELD_COLS);
});
it("fills the busiest bucket and keeps quieter buckets visible with square-root scaling", () => {
expect(litDots(4, 4)).toBe(capacity);
expect(litDots(1, 4)).toBe(capacity / 2);
});
});
describe("columnDots", () => {
it("always returns one dot per slot in the column", () => {
expect(columnDots(bucket(0), 4, 1)).toHaveLength(capacity);
expect(count(columnDots(bucket(0), 4, 1), "grid")).toBe(capacity);
});
it("puts failures above the successful runs", () => {
const dots = columnDots(bucket(4, 1, ["a", "a", "a"]), 4, 3);
const lastRun = dots.findLastIndex((dot) => dot.kind === "run");
const firstFailed = dots.findIndex((dot) => dot.kind === "failed");
expect(firstFailed).toBeGreaterThan(lastRun);
expect(count(dots, "failed")).toBeGreaterThan(0);
});
it("colors successful runs by agent in bands sized by each agent's share", () => {
const other = ["beta", "gamma", "delta", "omega", "zeta"].find(
(name) => agentDotColor(name) !== agentDotColor("alpha"),
);
if (!other) throw new Error("palette maps every candidate to one color");
const dots = columnDots(bucket(4, 0, ["alpha", "alpha", "alpha", other]), 4, 5);
const colors = dots.flatMap((dot) => (dot.kind === "run" ? [dot.color] : []));
const alpha = colors.filter((color) => color === agentDotColor("alpha")).length;
const beta = colors.filter((color) => color === agentDotColor(other)).length;
expect(alpha).toBeGreaterThan(beta * 2);
expect(beta).toBeGreaterThan(0);
});
it("keeps the bottom row solid so even a single run reads as a mark", () => {
const dots = columnDots(bucket(1, 0, ["a"]), 1000, 9);
expect(dots.slice(0, FIELD_COLS).every((dot) => dot.kind === "run")).toBe(true);
});
it("is deterministic for the same bucket and seed so the field does not flicker on re-render", () => {
expect(columnDots(bucket(3, 1, ["a", "b"]), 4, 11)).toEqual(columnDots(bucket(3, 1, ["a", "b"]), 4, 11));
});
});
describe("agentDotColor", () => {
it("gives each agent a stable color from the palette", () => {
expect(agentDotColor("claude-code")).toBe(agentDotColor("claude-code"));
expect(AGENT_DOT_COLORS).toContain(agentDotColor("claude-code"));
});
it("keeps runs with no agent name neutral so they never look like a named agent", () => {
expect(agentDotColor("")).toBe(UNNAMED_AGENT_COLOR);
expect(AGENT_DOT_COLORS).not.toContain(agentDotColor(""));
});
});
describe("columnTop", () => {
it("reaches the top of the field only for the busiest bucket", () => {
expect(columnTop(4, 4)).toBe(1);
expect(columnTop(0, 4)).toBe(0);
expect(columnTop(1, 4)).toBeGreaterThan(0);
expect(columnTop(1, 4)).toBeLessThan(columnTop(2, 4));
});
});
describe("agoLabel", () => {
const now = Date.UTC(2026, 9, 3, 12, 0, 0);
it("rounds down to the largest whole unit", () => {
expect(agoLabel(now - 2_000, now)).toBe("just now");
expect(agoLabel(now - 45_000, now)).toBe("45s ago");
expect(agoLabel(now - 12 * 60_000 - 59_000, now)).toBe("12m ago");
expect(agoLabel(now - 3 * 3_600_000, now)).toBe("3h ago");
expect(agoLabel(now - 2 * 86_400_000, now)).toBe("2d ago");
});
it("treats a timestamp slightly in the future as just now instead of negative", () => {
expect(agoLabel(now + 10_000, now)).toBe("just now");
});
});
describe("isReceiving", () => {
const now = Date.UTC(2026, 9, 3, 12, 0, 0);
it("is live only when a trace landed in the last five minutes", () => {
expect(isReceiving(now - 4 * 60_000, now)).toBe(true);
expect(isReceiving(now - 6 * 60_000, now)).toBe(false);
expect(isReceiving(null, now)).toBe(false);
});
});

View file

@ -0,0 +1,67 @@
export const AGENT_DOT_COLORS = ["#0011b3", "#3b5bfd", "#7c93ff", "#22b3e8"] as const;
export const FIELD_ROWS = 12;
export const FIELD_COLS = 2;
export type FieldDot = { kind: "run"; color: string } | { kind: "failed" } | { kind: "grid" };
export interface FieldBucket {
runs: number;
failed: number;
agents: readonly string[];
}
const hash = (seed: number, value: number): number => Math.imul(seed ^ Math.imul(value, 0x9e3779b1), 0x85ebca6b) >>> 0;
export const UNNAMED_AGENT_COLOR = "#94a3b8";
export function agentDotColor(agent: string): string {
if (!agent) return UNNAMED_AGENT_COLOR;
const code = Array.from(agent).reduce((total, char) => hash(total, char.charCodeAt(0)), 7);
return AGENT_DOT_COLORS[code % AGENT_DOT_COLORS.length];
}
/** Lit dot count for a bucket: square-root scaled against the busiest bucket so sparse traffic still reads. */
export function litDots(runs: number, max: number, capacity = FIELD_ROWS * FIELD_COLS): number {
if (runs === 0 || max === 0) return 0;
return Math.max(FIELD_COLS, Math.round(Math.sqrt(runs / max) * capacity));
}
/**
* Bottom-up dots for one time bucket. Successful runs fill from the bottom in agent-colored bands sized by
* each agent's share; failures sit on top. A stable sprinkle of gaps (seeded by bucket) keeps the field organic.
*/
export function columnDots(bucket: FieldBucket, max: number, seed: number): readonly FieldDot[] {
const capacity = FIELD_ROWS * FIELD_COLS;
const lit = litDots(bucket.runs, max, capacity);
const failed =
bucket.failed === 0 ? 0 : Math.min(lit, Math.max(FIELD_COLS, Math.round((bucket.failed / bucket.runs) * lit)));
const ok = lit - failed;
const agents = bucket.agents.length ? bucket.agents : [""];
return Array.from({ length: capacity }, (_, index): FieldDot => {
if (index >= lit) return { kind: "grid" };
const gap = index >= FIELD_COLS && hash(seed + 1, index) % 100 < 12;
if (gap) return { kind: "grid" };
if (index >= ok) return { kind: "failed" };
return { kind: "run", color: agentDotColor(agents[Math.floor((index / ok) * agents.length)]) };
});
}
/** Top edge of a bucket's lit dots as a 0..1 fraction of the field height, for anchoring annotation leaders. */
export function columnTop(runs: number, max: number): number {
return Math.ceil(litDots(runs, max) / FIELD_COLS) / FIELD_ROWS;
}
export function agoLabel(thenMs: number, nowMs: number): string {
const seconds = Math.max(0, Math.floor((nowMs - thenMs) / 1000));
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
export const RECEIVING_WINDOW_MS = 5 * 60 * 1000;
export function isReceiving(lastMs: number | null, nowMs: number): boolean {
return lastMs !== null && nowMs - lastMs < RECEIVING_WINDOW_MS;
}