feat(ui): graph on d3-force with steady labels, quieter quota text, flat rail
This commit is contained in:
+12
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "ui",
|
"name": "ui",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"dompurify": "^3.4.14",
|
"dompurify": "^3.4.14",
|
||||||
"marked": "^18.0.11",
|
"marked": "^18.0.11",
|
||||||
},
|
},
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/node": "^26.4.0",
|
"@types/node": "^26.4.0",
|
||||||
"bits-ui": "^2.19.0",
|
"bits-ui": "^2.19.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -186,6 +188,8 @@
|
|||||||
|
|
||||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||||
|
|
||||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"dompurify": "^3.4.14",
|
"dompurify": "^3.4.14",
|
||||||
"marked": "^18.0.11"
|
"marked": "^18.0.11"
|
||||||
},
|
},
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/node": "^26.4.0",
|
"@types/node": "^26.4.0",
|
||||||
"bits-ui": "^2.19.0",
|
"bits-ui": "^2.19.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -272,7 +272,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each group.branches as row (row.id)}
|
{#each group.branches as row (row.id)}
|
||||||
<Strip
|
<Strip
|
||||||
class="strip-child"
|
|
||||||
{client}
|
{client}
|
||||||
current={row.id === selected}
|
current={row.id === selected}
|
||||||
detail={row.last_item?.text ?? ""}
|
detail={row.last_item?.text ?? ""}
|
||||||
@@ -297,7 +296,6 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{#each [...group.forks, ...group.others] as row (row.id)}
|
{#each [...group.forks, ...group.others] as row (row.id)}
|
||||||
<Strip
|
<Strip
|
||||||
class="strip-child"
|
|
||||||
{client}
|
{client}
|
||||||
current={row.id === selected}
|
current={row.id === selected}
|
||||||
detail="{row.kind} · {row.status}"
|
detail="{row.kind} · {row.status}"
|
||||||
@@ -327,7 +325,6 @@
|
|||||||
{#if shownJobs}
|
{#if shownJobs}
|
||||||
{#each group.jobs as row (row.id)}
|
{#each group.jobs as row (row.id)}
|
||||||
<Strip
|
<Strip
|
||||||
class="strip-child"
|
|
||||||
{client}
|
{client}
|
||||||
current={row.id === selected}
|
current={row.id === selected}
|
||||||
detail={row.last_item?.text ?? ""}
|
detail={row.last_item?.text ?? ""}
|
||||||
|
|||||||
+93
-92
@@ -1,32 +1,46 @@
|
|||||||
// A small force layout: rings keep the structure legible, springs and
|
import {
|
||||||
// repulsion keep neighbours apart, a breath keeps it alive between touches.
|
forceCenter,
|
||||||
|
forceCollide,
|
||||||
|
forceLink,
|
||||||
|
forceManyBody,
|
||||||
|
forceRadial,
|
||||||
|
forceSimulation,
|
||||||
|
type Simulation,
|
||||||
|
type SimulationLinkDatum,
|
||||||
|
type SimulationNodeDatum,
|
||||||
|
} from "d3-force";
|
||||||
|
|
||||||
export interface Body {
|
// d3-force under a small API: rings keep the picture legible, collision
|
||||||
fixed: boolean;
|
// keeps dots apart, a faint alpha target keeps the graph breathing.
|
||||||
|
|
||||||
|
export interface Body extends SimulationNodeDatum {
|
||||||
id: string;
|
id: string;
|
||||||
ring: number;
|
ring: number;
|
||||||
vx: number;
|
|
||||||
vy: number;
|
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Link {
|
interface Link extends SimulationLinkDatum<Body> {
|
||||||
a: Body;
|
source: Body;
|
||||||
b: Body;
|
target: Body;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RING_RADIUS = [0, 92, 168, 224];
|
const RING_RADIUS = [0, 96, 176, 236];
|
||||||
const LINK_STRENGTH = 0.06;
|
const CHARGE = -150;
|
||||||
const LINK_REST = 70;
|
const CHARGE_REACH = 340;
|
||||||
const CHARGE = 1600;
|
const LINK_REST = 62;
|
||||||
const CHARGE_REACH = 320;
|
const LINK_RING_GAP = 14;
|
||||||
const RADIAL = 0.05;
|
const LINK_STRENGTH = 0.45;
|
||||||
const HUB_HOLD = 0.4;
|
const COLLIDE_HUB = 24;
|
||||||
const DECAY = 0.55;
|
const COLLIDE = 13;
|
||||||
const ALPHA_EASE = 0.025;
|
const RADIAL_HUB = 1;
|
||||||
const BREATH = 0.35;
|
const RADIAL = 0.07;
|
||||||
const MIN_D2 = 36;
|
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 TAU = Math.PI * 2;
|
||||||
const HASH_MOD = 1_000_003;
|
const HASH_MOD = 1_000_003;
|
||||||
const HASH_BASE = 31;
|
const HASH_BASE = 31;
|
||||||
@@ -45,14 +59,43 @@ export function ringRadius(ring: number): number {
|
|||||||
|
|
||||||
export class Force {
|
export class Force {
|
||||||
bodies: Body[] = [];
|
bodies: Body[] = [];
|
||||||
alpha = 1;
|
private readonly sim: Simulation<Body, Link>;
|
||||||
alphaTarget = 0;
|
private readonly linkForce = forceLink<Body, Link>([])
|
||||||
private links: Link[] = [];
|
.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<string, Body>();
|
private readonly index = new Map<string, Body>();
|
||||||
private clock = 0;
|
private clock = 0;
|
||||||
|
|
||||||
// Keep what is already placed, seat newcomers on their ring near a
|
constructor() {
|
||||||
// neighbour when they have one, forget what is gone.
|
this.sim = forceSimulation<Body>([])
|
||||||
|
.stop()
|
||||||
|
.alphaDecay(ALPHA_DECAY)
|
||||||
|
.velocityDecay(VELOCITY_DECAY)
|
||||||
|
.force(
|
||||||
|
"charge",
|
||||||
|
forceManyBody<Body>().strength(CHARGE).distanceMax(CHARGE_REACH)
|
||||||
|
)
|
||||||
|
.force("link", this.linkForce)
|
||||||
|
.force(
|
||||||
|
"collide",
|
||||||
|
forceCollide<Body>((body) => (body.ring === 0 ? COLLIDE_HUB : COLLIDE))
|
||||||
|
)
|
||||||
|
.force(
|
||||||
|
"radial",
|
||||||
|
forceRadial<Body>((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(
|
sync(
|
||||||
nodes: { id: string; ring: number }[],
|
nodes: { id: string; ring: number }[],
|
||||||
edges: { from: string; to: string }[]
|
edges: { from: string; to: string }[]
|
||||||
@@ -69,11 +112,8 @@ export class Force {
|
|||||||
const angle = (hash(node.id) % 360) * (TAU / 360);
|
const angle = (hash(node.id) % 360) * (TAU / 360);
|
||||||
const r = ringRadius(node.ring);
|
const r = ringRadius(node.ring);
|
||||||
const body: Body = {
|
const body: Body = {
|
||||||
fixed: false,
|
|
||||||
id: node.id,
|
id: node.id,
|
||||||
ring: node.ring,
|
ring: node.ring,
|
||||||
vx: 0,
|
|
||||||
vy: 0,
|
|
||||||
x: Math.cos(angle) * r,
|
x: Math.cos(angle) * r,
|
||||||
y: Math.sin(angle) * r,
|
y: Math.sin(angle) * r,
|
||||||
};
|
};
|
||||||
@@ -88,26 +128,28 @@ export class Force {
|
|||||||
this.bodies = nodes
|
this.bodies = nodes
|
||||||
.map((node) => this.index.get(node.id))
|
.map((node) => this.index.get(node.id))
|
||||||
.filter((body): body is Body => body !== undefined);
|
.filter((body): body is Body => body !== undefined);
|
||||||
this.links = [];
|
const links: Link[] = [];
|
||||||
for (const edge of edges) {
|
for (const edge of edges) {
|
||||||
const a = this.index.get(edge.from);
|
const source = this.index.get(edge.from);
|
||||||
const b = this.index.get(edge.to);
|
const target = this.index.get(edge.to);
|
||||||
if (a && b && a !== b) {
|
if (source && target && source !== target) {
|
||||||
this.links.push({ a, b });
|
links.push({ source, target });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const body of fresh) {
|
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) {
|
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 r = ringRadius(body.ring);
|
||||||
const angle = Math.atan2(other.y, other.x) + (hash(body.id) % 7) * 0.09;
|
const angle = Math.atan2(other.y, other.x) + (hash(body.id) % 7) * 0.09;
|
||||||
body.x = Math.cos(angle) * r;
|
body.x = Math.cos(angle) * r;
|
||||||
body.y = Math.sin(angle) * r;
|
body.y = Math.sin(angle) * r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.sim.nodes(this.bodies);
|
||||||
|
this.linkForce.links(links);
|
||||||
if (fresh.length > 0) {
|
if (fresh.length > 0) {
|
||||||
this.reheat(0.6);
|
this.reheat(0.7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,75 +157,34 @@ export class Force {
|
|||||||
return this.index.get(id);
|
return this.index.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get alpha(): number {
|
||||||
|
return this.sim.alpha();
|
||||||
|
}
|
||||||
|
|
||||||
reheat(to = 0.4): void {
|
reheat(to = 0.4): void {
|
||||||
this.alpha = Math.max(this.alpha, to);
|
this.sim.alpha(Math.max(this.sim.alpha(), to));
|
||||||
}
|
}
|
||||||
|
|
||||||
get settled(): boolean {
|
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 {
|
step(breathe: boolean): void {
|
||||||
this.clock += 1 / 60;
|
this.clock += 1 / 60;
|
||||||
const { alpha, bodies } = this;
|
this.sim.alphaTarget(breathe ? BREATH_TARGET : 0);
|
||||||
for (const link of this.links) {
|
if (breathe) {
|
||||||
const dx = link.b.x - link.a.x;
|
for (const body of this.bodies) {
|
||||||
const dy = link.b.y - link.a.y;
|
if (body.fx !== null && body.fx !== undefined) {
|
||||||
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) {
|
|
||||||
continue;
|
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;
|
const seed = hash(body.id) % 11;
|
||||||
body.vx += Math.sin(this.clock * 0.6 + seed) * BREATH * 0.02;
|
body.vx = (body.vx ?? 0) + Math.sin(this.clock * 0.6 + seed) * JITTER;
|
||||||
body.vy += Math.cos(this.clock * 0.5 + seed * 1.3) * BREATH * 0.02;
|
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 } {
|
bounds(): { x: number; y: number; w: number; h: number } {
|
||||||
|
|||||||
@@ -39,17 +39,22 @@
|
|||||||
class?: string;
|
class?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const LABEL_MAX = 22;
|
const LABEL_MAX = 24;
|
||||||
const LABEL_MAX_NEAR = 40;
|
const LABEL_MAX_NEAR = 48;
|
||||||
const ZOOM_MIN = 0.25;
|
const ZOOM_MIN = 0.25;
|
||||||
const ZOOM_MAX = 4;
|
const ZOOM_MAX = 4;
|
||||||
const ZOOM_STEP = 0.0016;
|
const ZOOM_STEP = 0.0016;
|
||||||
const FIT_PAD = 0.86;
|
const FIT_PAD = 0.86;
|
||||||
const FIT_MAX = 1.5;
|
const FIT_MAX = 1.5;
|
||||||
const CLICK_SLOP = 4;
|
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 SETTLE_FRAMES = 90;
|
||||||
const FONT = 11;
|
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();
|
const force = new Force();
|
||||||
let frame = $state(0);
|
let frame = $state(0);
|
||||||
@@ -76,20 +81,42 @@
|
|||||||
}
|
}
|
||||||
let drag: Drag | null = null;
|
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 neighbours = $derived.by(() => {
|
||||||
const map = new Map<string, Set<string>>();
|
const map = new Map<string, Set<string>>();
|
||||||
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.from, (map.get(edge.from) ?? new Set()).add(edge.to));
|
||||||
map.set(edge.to, (map.get(edge.to) ?? new Set()).add(edge.from));
|
map.set(edge.to, (map.get(edge.to) ?? new Set()).add(edge.from));
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
const ringOne = $derived(shown.filter((node) => node.ring === 1).length);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
force.sync(
|
force.sync(
|
||||||
nodes.map((n) => ({ id: n.id, ring: n.ring })),
|
shown.map((n) => ({ id: n.id, ring: n.ring })),
|
||||||
edges
|
shownEdges
|
||||||
);
|
);
|
||||||
settling = SETTLE_FRAMES;
|
settling = SETTLE_FRAMES;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
@@ -102,7 +129,7 @@
|
|||||||
let handle = 0;
|
let handle = 0;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const breathe = !(reduced || drag);
|
const breathe = !(reduced || drag);
|
||||||
if (!(force.settled && !breathe) || settling > 0) {
|
if (breathe || !force.settled || settling > 0) {
|
||||||
force.step(breathe && force.settled);
|
force.step(breathe && force.settled);
|
||||||
frame += 1;
|
frame += 1;
|
||||||
}
|
}
|
||||||
@@ -118,11 +145,15 @@
|
|||||||
return () => cancelAnimationFrame(handle);
|
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) {
|
if (frame < 0) {
|
||||||
return [];
|
return new Map<string, { x: number; y: number }>();
|
||||||
}
|
}
|
||||||
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() {
|
function fit() {
|
||||||
@@ -183,7 +214,8 @@
|
|||||||
startY: event.clientY,
|
startY: event.clientY,
|
||||||
};
|
};
|
||||||
if (body) {
|
if (body) {
|
||||||
body.fixed = true;
|
body.fx = body.x;
|
||||||
|
body.fy = body.y;
|
||||||
force.reheat(0.3);
|
force.reheat(0.3);
|
||||||
settling = 0;
|
settling = 0;
|
||||||
}
|
}
|
||||||
@@ -201,10 +233,9 @@
|
|||||||
}
|
}
|
||||||
if (drag.body) {
|
if (drag.body) {
|
||||||
const at = toGraph(event);
|
const at = toGraph(event);
|
||||||
drag.body.x = at.x;
|
drag.body.fx = at.x;
|
||||||
drag.body.y = at.y;
|
drag.body.fy = at.y;
|
||||||
force.reheat(0.25);
|
force.reheat(0.25);
|
||||||
frame += 1;
|
|
||||||
} else if (drag.moved) {
|
} else if (drag.moved) {
|
||||||
tx = drag.startTx + dx;
|
tx = drag.startTx + dx;
|
||||||
ty = drag.startTy + dy;
|
ty = drag.startTy + dy;
|
||||||
@@ -218,7 +249,8 @@
|
|||||||
const { body, moved } = drag;
|
const { body, moved } = drag;
|
||||||
drag = null;
|
drag = null;
|
||||||
if (body) {
|
if (body) {
|
||||||
body.fixed = false;
|
body.fx = null;
|
||||||
|
body.fy = null;
|
||||||
if (!moved) {
|
if (!moved) {
|
||||||
onOpen?.(body.id);
|
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 {
|
function labelled(node: GraphNode): boolean {
|
||||||
if (hovered) {
|
if (hovered) {
|
||||||
return near(node.id);
|
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 {
|
function dimmed(id: string): boolean {
|
||||||
@@ -343,14 +383,14 @@
|
|||||||
</radialGradient>
|
</radialGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<g transform="translate({width / 2 + tx} {height / 2 + ty}) scale({k})">
|
<g transform="translate({width / 2 + tx} {height / 2 + ty}) scale({k})">
|
||||||
{#each edges as edge (edge.from + edge.to)}
|
{#each shownEdges as edge (edge.from + edge.to)}
|
||||||
{@const a = force.body(edge.from)}
|
{@const a = pos.get(edge.from)}
|
||||||
{@const b = force.body(edge.to)}
|
{@const b = pos.get(edge.to)}
|
||||||
{#if a && b && frame >= 0}
|
{#if a && b}
|
||||||
<line
|
<line
|
||||||
stroke="var(--foreground)"
|
stroke="var(--foreground)"
|
||||||
stroke-dasharray={ghostEdge(edge) ? "2 4" : undefined}
|
stroke-dasharray={ghostEdge(edge) ? "2 4" : undefined}
|
||||||
stroke-opacity={edgeDimmed(edge) ? 0.05 : 0.16}
|
stroke-opacity={edgeDimmed(edge) ? 0.05 : 0.18}
|
||||||
stroke-width={1 / Math.sqrt(k)}
|
stroke-width={1 / Math.sqrt(k)}
|
||||||
x1={a.x}
|
x1={a.x}
|
||||||
x2={b.x}
|
x2={b.x}
|
||||||
@@ -359,9 +399,9 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{#each placed as item (item.body.id)}
|
{#each shown as node (node.id)}
|
||||||
{#if item.node}
|
{@const at = pos.get(node.id)}
|
||||||
{@const node = item.node}
|
{#if at}
|
||||||
{@const r = radius(node)}
|
{@const r = radius(node)}
|
||||||
{@const faded = dimmed(node.id)}
|
{@const faded = dimmed(node.id)}
|
||||||
<a
|
<a
|
||||||
@@ -374,7 +414,7 @@
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
oncontextmenu={(event) => onContext(event, node)}
|
oncontextmenu={(event) => onContext(event, node)}
|
||||||
onpointerdown={(event) => onDown(event, item.body)}
|
onpointerdown={(event) => onDown(event, force.body(node.id) ?? null)}
|
||||||
onpointerenter={() => {
|
onpointerenter={() => {
|
||||||
hovered = node.id;
|
hovered = node.id;
|
||||||
}}
|
}}
|
||||||
@@ -384,22 +424,12 @@
|
|||||||
style="opacity: {faded ? 0.25 : 1}; transition: opacity 150ms"
|
style="opacity: {faded ? 0.25 : 1}; transition: opacity 150ms"
|
||||||
>
|
>
|
||||||
{#if node.state === "running"}
|
{#if node.state === "running"}
|
||||||
<circle
|
<circle cx={at.x} cy={at.y} fill="url(#graph-glow)" r={r * 4} />
|
||||||
cx={item.body.x}
|
|
||||||
cy={item.body.y}
|
|
||||||
fill="url(#graph-glow)"
|
|
||||||
r={r * 4}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<circle cx={at.x} cy={at.y} fill="transparent" r={r + 8} />
|
||||||
<circle
|
<circle
|
||||||
cx={item.body.x}
|
cx={at.x}
|
||||||
cy={item.body.y}
|
cy={at.y}
|
||||||
fill="transparent"
|
|
||||||
r={r + 8}
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx={item.body.x}
|
|
||||||
cy={item.body.y}
|
|
||||||
fill={fillOf(node)}
|
fill={fillOf(node)}
|
||||||
fill-opacity={node.state === "closed" ? 0.4 : 1}
|
fill-opacity={node.state === "closed" ? 0.4 : 1}
|
||||||
{r}
|
{r}
|
||||||
@@ -413,15 +443,15 @@
|
|||||||
fill={node.state === "ghost" || node.state === "closed"
|
fill={node.state === "ghost" || node.state === "closed"
|
||||||
? "var(--muted-foreground)"
|
? "var(--muted-foreground)"
|
||||||
: "var(--foreground)"}
|
: "var(--foreground)"}
|
||||||
font-size={FONT}
|
font-size={FONT / k}
|
||||||
font-weight={node.ring === 0 || hovered === node.id ? 600 : 400}
|
font-weight={node.ring === 0 || hovered === node.id ? 600 : 400}
|
||||||
paint-order="stroke"
|
paint-order="stroke"
|
||||||
stroke="var(--background)"
|
stroke="var(--background)"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="3"
|
stroke-width={3 / k}
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
x={item.body.x}
|
x={at.x}
|
||||||
y={item.body.y + r + 3}
|
y={at.y + r + 3 / k}
|
||||||
>
|
>
|
||||||
{clip(node.label, near(node.id) ? LABEL_MAX_NEAR : LABEL_MAX)}
|
{clip(node.label, near(node.id) ? LABEL_MAX_NEAR : LABEL_MAX)}
|
||||||
</text>
|
</text>
|
||||||
@@ -455,6 +485,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{#if !expanded && shown.length < nodes.length}
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute bottom-2 left-3 text-[11px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{nodes.length - shown.length}
|
||||||
|
more when expanded
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{#if nodes.length === 0}
|
{#if nodes.length === 0}
|
||||||
<p
|
<p
|
||||||
class="pointer-events-none absolute inset-0 flex items-center justify-center text-muted-foreground text-xs"
|
class="pointer-events-none absolute inset-0 flex items-center justify-center text-muted-foreground text-xs"
|
||||||
|
|||||||
@@ -111,12 +111,14 @@
|
|||||||
{fmtCountdown(w.resets_at, now).replace("resets ", "")}
|
{fmtCountdown(w.resets_at, now).replace("resets ", "")}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="tabular text-muted-foreground text-xs">
|
<span
|
||||||
|
class="tabular text-muted-foreground text-xs"
|
||||||
|
title="through the gateway this window · last report {fmtRelative(w.ts, now)}"
|
||||||
|
>
|
||||||
{fmtTokens(
|
{fmtTokens(
|
||||||
w.gateway.input + w.gateway.output + w.gateway.cache_creation
|
w.gateway.input + w.gateway.output + w.gateway.cache_creation
|
||||||
)}
|
)}
|
||||||
through here · {fmtMoney(w.gateway.cost_usd)} · seen
|
· {fmtMoney(w.gateway.cost_usd)}
|
||||||
{fmtRelative(w.ts, now)}
|
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -142,9 +142,11 @@
|
|||||||
</span>
|
</span>
|
||||||
{#if bound && w.last_known}
|
{#if bound && w.last_known}
|
||||||
<span class="text-muted-foreground text-xs">
|
<span class="text-muted-foreground text-xs">
|
||||||
{limitWord(w.status)}
|
reported {fmtRelative(w.last_known.ts, now)} ·
|
||||||
now · the figure is from
|
{limitWord(
|
||||||
{fmtRelative(w.last_known.ts, now)}
|
w.status
|
||||||
|
)}
|
||||||
|
now
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -156,27 +158,22 @@
|
|||||||
>
|
>
|
||||||
{limitWord(w.status)}
|
{limitWord(w.status)}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-muted-foreground text-xs">
|
|
||||||
no figure · seen {fmtRelative(w.ts, now)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
<span class="tabular text-muted-foreground text-xs">
|
<span
|
||||||
|
class="tabular text-muted-foreground text-xs"
|
||||||
|
title="The API sends a figure only near the limit; until then the status word is all it says. Last report {fmtRelative(w.ts, now)}."
|
||||||
|
>
|
||||||
{fmtCountdown(w.resets_at, now)}
|
{fmtCountdown(w.resets_at, now)}
|
||||||
· through here since
|
·
|
||||||
{fmtTime(w.gateway.since).replace(SECONDS, "")}:
|
|
||||||
{fmtTokens(
|
{fmtTokens(
|
||||||
w.gateway.input + w.gateway.output + w.gateway.cache_creation
|
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, "")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<p class="doc text-muted-foreground text-xs">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user