From f35980ba85446fbec576ed8b6bca707dfe5761ea Mon Sep 17 00:00:00 2001 From: h Date: Wed, 2 Sep 2026 04:35:01 +0200 Subject: [PATCH] feat(ui): graph on d3-force with steady labels, quieter quota text, flat rail --- ui/bun.lock | 12 ++ ui/package.json | 2 + ui/src/lib/rail/rail.svelte | 3 - ui/src/lib/shell/force.ts | 185 ++++++++++++++-------------- ui/src/lib/shell/graph.svelte | 126 ++++++++++++------- ui/src/lib/shell/instruments.svelte | 8 +- ui/src/routes/usage/+page.svelte | 27 ++-- 7 files changed, 206 insertions(+), 157 deletions(-) diff --git a/ui/bun.lock b/ui/bun.lock index b8fb42d..0fb1b25 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -5,6 +5,7 @@ "": { "name": "ui", "dependencies": { + "d3-force": "^3.0.0", "dompurify": "^3.4.14", "marked": "^18.0.11", }, @@ -19,6 +20,7 @@ "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.3.0", + "@types/d3-force": "^3.0.10", "@types/node": "^26.4.0", "bits-ui": "^2.19.0", "clsx": "^2.1.1", @@ -186,6 +188,8 @@ "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="], @@ -226,6 +230,14 @@ "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], diff --git a/ui/package.json b/ui/package.json index ded077d..98acfd8 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,6 +14,7 @@ "preview": "vite preview" }, "dependencies": { + "d3-force": "^3.0.0", "dompurify": "^3.4.14", "marked": "^18.0.11" }, @@ -28,6 +29,7 @@ "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.3.0", + "@types/d3-force": "^3.0.10", "@types/node": "^26.4.0", "bits-ui": "^2.19.0", "clsx": "^2.1.1", diff --git a/ui/src/lib/rail/rail.svelte b/ui/src/lib/rail/rail.svelte index 3d251f0..0a1d5a0 100644 --- a/ui/src/lib/rail/rail.svelte +++ b/ui/src/lib/rail/rail.svelte @@ -272,7 +272,6 @@ {/if} {#each group.branches as row (row.id)} { + source: Body; + target: Body; } -const RING_RADIUS = [0, 92, 168, 224]; -const LINK_STRENGTH = 0.06; -const LINK_REST = 70; -const CHARGE = 1600; -const CHARGE_REACH = 320; -const RADIAL = 0.05; -const HUB_HOLD = 0.4; -const DECAY = 0.55; -const ALPHA_EASE = 0.025; -const BREATH = 0.35; -const MIN_D2 = 36; +const RING_RADIUS = [0, 96, 176, 236]; +const CHARGE = -150; +const CHARGE_REACH = 340; +const LINK_REST = 62; +const LINK_RING_GAP = 14; +const LINK_STRENGTH = 0.45; +const COLLIDE_HUB = 24; +const COLLIDE = 13; +const RADIAL_HUB = 1; +const RADIAL = 0.07; +const CENTER = 0.02; +const ALPHA_DECAY = 0.03; +const VELOCITY_DECAY = 0.45; +const BREATH_TARGET = 0.012; +const SETTLED = 0.004; +const JITTER = 0.06; const TAU = Math.PI * 2; const HASH_MOD = 1_000_003; const HASH_BASE = 31; @@ -45,14 +59,43 @@ export function ringRadius(ring: number): number { export class Force { bodies: Body[] = []; - alpha = 1; - alphaTarget = 0; - private links: Link[] = []; + private readonly sim: Simulation; + private readonly linkForce = forceLink([]) + .id((body) => body.id) + .distance( + (link) => + LINK_REST + + Math.abs(link.source.ring - link.target.ring) * LINK_RING_GAP + ) + .strength(LINK_STRENGTH); private readonly index = new Map(); private clock = 0; - // Keep what is already placed, seat newcomers on their ring near a - // neighbour when they have one, forget what is gone. + constructor() { + this.sim = forceSimulation([]) + .stop() + .alphaDecay(ALPHA_DECAY) + .velocityDecay(VELOCITY_DECAY) + .force( + "charge", + forceManyBody().strength(CHARGE).distanceMax(CHARGE_REACH) + ) + .force("link", this.linkForce) + .force( + "collide", + forceCollide((body) => (body.ring === 0 ? COLLIDE_HUB : COLLIDE)) + ) + .force( + "radial", + forceRadial((body) => ringRadius(body.ring), 0, 0).strength( + (body) => (body.ring === 0 ? RADIAL_HUB : RADIAL) + ) + ) + .force("center", forceCenter(0, 0).strength(CENTER)); + } + + // Keep what is placed, seat newcomers on their ring beside a neighbour + // when they have one, forget what is gone. sync( nodes: { id: string; ring: number }[], edges: { from: string; to: string }[] @@ -69,11 +112,8 @@ export class Force { const angle = (hash(node.id) % 360) * (TAU / 360); const r = ringRadius(node.ring); const body: Body = { - fixed: false, id: node.id, ring: node.ring, - vx: 0, - vy: 0, x: Math.cos(angle) * r, y: Math.sin(angle) * r, }; @@ -88,26 +128,28 @@ export class Force { this.bodies = nodes .map((node) => this.index.get(node.id)) .filter((body): body is Body => body !== undefined); - this.links = []; + const links: Link[] = []; for (const edge of edges) { - const a = this.index.get(edge.from); - const b = this.index.get(edge.to); - if (a && b && a !== b) { - this.links.push({ a, b }); + const source = this.index.get(edge.from); + const target = this.index.get(edge.to); + if (source && target && source !== target) { + links.push({ source, target }); } } for (const body of fresh) { - const link = this.links.find((l) => l.a === body || l.b === body); + const link = links.find((l) => l.source === body || l.target === body); if (link) { - const other = link.a === body ? link.b : link.a; + const other = link.source === body ? link.target : link.source; const r = ringRadius(body.ring); const angle = Math.atan2(other.y, other.x) + (hash(body.id) % 7) * 0.09; body.x = Math.cos(angle) * r; body.y = Math.sin(angle) * r; } } + this.sim.nodes(this.bodies); + this.linkForce.links(links); if (fresh.length > 0) { - this.reheat(0.6); + this.reheat(0.7); } } @@ -115,75 +157,34 @@ export class Force { return this.index.get(id); } + get alpha(): number { + return this.sim.alpha(); + } + reheat(to = 0.4): void { - this.alpha = Math.max(this.alpha, to); + this.sim.alpha(Math.max(this.sim.alpha(), to)); } get settled(): boolean { - return this.alpha < 0.004 && this.alphaTarget === 0; + return this.sim.alpha() < SETTLED; } - // One tick; ``breathe`` adds the slow drift of a graph nobody is touching. + // One tick; ``breathe`` keeps a faint target so the graph never freezes. step(breathe: boolean): void { this.clock += 1 / 60; - const { alpha, bodies } = this; - for (const link of this.links) { - const dx = link.b.x - link.a.x; - const dy = link.b.y - link.a.y; - const d = Math.max(Math.sqrt(dx * dx + dy * dy), 1); - const rest = LINK_REST + Math.abs(link.a.ring - link.b.ring) * 10; - const f = ((d - rest) / d) * LINK_STRENGTH * alpha; - link.a.vx += dx * f; - link.a.vy += dy * f; - link.b.vx -= dx * f; - link.b.vy -= dy * f; - } - for (let i = 0; i < bodies.length; i += 1) { - const a = bodies[i]; - for (let j = i + 1; j < bodies.length; j += 1) { - const b = bodies[j]; - const dx = b.x - a.x; - const dy = b.y - a.y; - const d2 = Math.max(dx * dx + dy * dy, MIN_D2); - if (d2 > CHARGE_REACH * CHARGE_REACH) { + this.sim.alphaTarget(breathe ? BREATH_TARGET : 0); + if (breathe) { + for (const body of this.bodies) { + if (body.fx !== null && body.fx !== undefined) { continue; } - const f = (CHARGE / d2) * alpha; - const d = Math.sqrt(d2); - const fx = (dx / d) * f; - const fy = (dy / d) * f; - a.vx -= fx; - a.vy -= fy; - b.vx += fx; - b.vy += fy; - } - } - for (const body of bodies) { - if (body.fixed) { - body.vx = 0; - body.vy = 0; - continue; - } - if (body.ring === 0) { - body.vx -= body.x * HUB_HOLD * alpha; - body.vy -= body.y * HUB_HOLD * alpha; - } else { - const r = Math.max(Math.sqrt(body.x * body.x + body.y * body.y), 1); - const pull = ((ringRadius(body.ring) - r) / r) * RADIAL * alpha; - body.vx += body.x * pull; - body.vy += body.y * pull; - } - if (breathe) { const seed = hash(body.id) % 11; - body.vx += Math.sin(this.clock * 0.6 + seed) * BREATH * 0.02; - body.vy += Math.cos(this.clock * 0.5 + seed * 1.3) * BREATH * 0.02; + body.vx = (body.vx ?? 0) + Math.sin(this.clock * 0.6 + seed) * JITTER; + body.vy = + (body.vy ?? 0) + Math.cos(this.clock * 0.5 + seed * 1.3) * JITTER; } - body.vx *= DECAY; - body.vy *= DECAY; - body.x += body.vx; - body.y += body.vy; } - this.alpha += (this.alphaTarget - this.alpha) * ALPHA_EASE; + this.sim.tick(); } bounds(): { x: number; y: number; w: number; h: number } { diff --git a/ui/src/lib/shell/graph.svelte b/ui/src/lib/shell/graph.svelte index 4443e9c..ac7f184 100644 --- a/ui/src/lib/shell/graph.svelte +++ b/ui/src/lib/shell/graph.svelte @@ -39,17 +39,22 @@ class?: string; } = $props(); - const LABEL_MAX = 22; - const LABEL_MAX_NEAR = 40; + const LABEL_MAX = 24; + const LABEL_MAX_NEAR = 48; const ZOOM_MIN = 0.25; const ZOOM_MAX = 4; const ZOOM_STEP = 0.0016; const FIT_PAD = 0.86; const FIT_MAX = 1.5; const CLICK_SLOP = 4; - const LABEL_ZOOM = 1.35; + const LABEL_RING1_MAX = 14; + const LABEL_RING1_ZOOM = 1.2; + const LABEL_RING2_ZOOM = 2; const SETTLE_FRAMES = 90; const FONT = 11; + // Small box: the hub, its ring, and a few of what lies one link away. + const SMALL_RING1_FILES = 16; + const SMALL_GHOSTS = 12; const force = new Force(); let frame = $state(0); @@ -76,20 +81,42 @@ } let drag: Drag | null = null; - const byId = $derived(new Map(nodes.map((node) => [node.id, node]))); + const shown = $derived.by((): GraphNode[] => { + if (expanded) { + return nodes; + } + let files = 0; + let ghosts = 0; + return nodes.filter((node) => { + if (node.kind !== "file") { + return true; + } + if (node.state === "ghost") { + ghosts += 1; + return ghosts <= SMALL_GHOSTS; + } + files += 1; + return files <= SMALL_RING1_FILES; + }); + }); + const byId = $derived(new Map(shown.map((node) => [node.id, node]))); + const shownEdges = $derived( + edges.filter((edge) => byId.has(edge.from) && byId.has(edge.to)) + ); const neighbours = $derived.by(() => { const map = new Map>(); - for (const edge of edges) { + for (const edge of shownEdges) { map.set(edge.from, (map.get(edge.from) ?? new Set()).add(edge.to)); map.set(edge.to, (map.get(edge.to) ?? new Set()).add(edge.from)); } return map; }); + const ringOne = $derived(shown.filter((node) => node.ring === 1).length); $effect(() => { force.sync( - nodes.map((n) => ({ id: n.id, ring: n.ring })), - edges + shown.map((n) => ({ id: n.id, ring: n.ring })), + shownEdges ); settling = SETTLE_FRAMES; untrack(() => { @@ -102,7 +129,7 @@ let handle = 0; const tick = () => { const breathe = !(reduced || drag); - if (!(force.settled && !breathe) || settling > 0) { + if (breathe || !force.settled || settling > 0) { force.step(breathe && force.settled); frame += 1; } @@ -118,11 +145,15 @@ return () => cancelAnimationFrame(handle); }); - const placed = $derived.by(() => { + // Positions as plain numbers, refreshed every frame: the bodies mutate in + // place, and nothing in the template would notice otherwise. + const pos = $derived.by(() => { if (frame < 0) { - return []; + return new Map(); } - return force.bodies.map((body) => ({ body, node: byId.get(body.id) })); + return new Map( + force.bodies.map((body) => [body.id, { x: body.x, y: body.y }]) + ); }); function fit() { @@ -183,7 +214,8 @@ startY: event.clientY, }; if (body) { - body.fixed = true; + body.fx = body.x; + body.fy = body.y; force.reheat(0.3); settling = 0; } @@ -201,10 +233,9 @@ } if (drag.body) { const at = toGraph(event); - drag.body.x = at.x; - drag.body.y = at.y; + drag.body.fx = at.x; + drag.body.fy = at.y; force.reheat(0.25); - frame += 1; } else if (drag.moved) { tx = drag.startTx + dx; ty = drag.startTy + dy; @@ -218,7 +249,8 @@ const { body, moved } = drag; drag = null; if (body) { - body.fixed = false; + body.fx = null; + body.fy = null; if (!moved) { onOpen?.(body.id); } @@ -288,11 +320,19 @@ ); } + // Labels stay one screen size whatever the zoom; the ring decides + // whether a node earns one when nothing is hovered. function labelled(node: GraphNode): boolean { if (hovered) { return near(node.id); } - return node.ring <= 1 || k >= LABEL_ZOOM; + if (node.ring === 0) { + return true; + } + if (node.ring === 1) { + return ringOne <= LABEL_RING1_MAX || k >= LABEL_RING1_ZOOM; + } + return k >= LABEL_RING2_ZOOM; } function dimmed(id: string): boolean { @@ -343,14 +383,14 @@ - {#each edges as edge (edge.from + edge.to)} - {@const a = force.body(edge.from)} - {@const b = force.body(edge.to)} - {#if a && b && frame >= 0} + {#each shownEdges as edge (edge.from + edge.to)} + {@const a = pos.get(edge.from)} + {@const b = pos.get(edge.to)} + {#if a && b} {/if} {/each} - {#each placed as item (item.body.id)} - {#if item.node} - {@const node = item.node} + {#each shown as node (node.id)} + {@const at = pos.get(node.id)} + {#if at} {@const r = radius(node)} {@const faded = dimmed(node.id)} onContext(event, node)} - onpointerdown={(event) => onDown(event, item.body)} + onpointerdown={(event) => onDown(event, force.body(node.id) ?? null)} onpointerenter={() => { hovered = node.id; }} @@ -384,22 +424,12 @@ style="opacity: {faded ? 0.25 : 1}; transition: opacity 150ms" > {#if node.state === "running"} - + {/if} + - {clip(node.label, near(node.id) ? LABEL_MAX_NEAR : LABEL_MAX)} @@ -455,6 +485,14 @@ {/if} + {#if !expanded && shown.length < nodes.length} + + {nodes.length - shown.length} + more when expanded + + {/if} {#if nodes.length === 0}

- + {fmtTokens( w.gateway.input + w.gateway.output + w.gateway.cache_creation )} - through here · {fmtMoney(w.gateway.cost_usd)} · seen - {fmtRelative(w.ts, now)} + · {fmtMoney(w.gateway.cost_usd)} {/if} diff --git a/ui/src/routes/usage/+page.svelte b/ui/src/routes/usage/+page.svelte index 76754e3..4342c3d 100644 --- a/ui/src/routes/usage/+page.svelte +++ b/ui/src/routes/usage/+page.svelte @@ -142,9 +142,11 @@ {#if bound && w.last_known} - {limitWord(w.status)} - now · the figure is from - {fmtRelative(w.last_known.ts, now)} + reported {fmtRelative(w.last_known.ts, now)} · + {limitWord( + w.status + )} + now {/if} {:else} @@ -156,27 +158,22 @@ > {limitWord(w.status)} - - no figure · seen {fmtRelative(w.ts, now)} - {/if} - + {fmtCountdown(w.resets_at, now)} - · through here since - {fmtTime(w.gateway.since).replace(SECONDS, "")}: + · {fmtTokens( w.gateway.input + w.gateway.output + w.gateway.cache_creation )} - tokens · {fmtMoney(w.gateway.cost_usd)} + · {fmtMoney(w.gateway.cost_usd)} since + {fmtTime(w.gateway.since).replace(SECONDS, "")} {/each} -

- The API puts a number on a window only when it nears the limit; the - rest of the time the status word is all it says. Between reports the - gateway's own count is the lower bound. -

{/if}