Files
beaver-gateway/ui/src/lib/shell/graph.svelte
T

236 lines
5.7 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte";
import { clip } from "$lib/format";
import { cn } from "$lib/utils";
export interface GraphNode {
href?: string;
id: string;
kind: string;
label: string;
// 0 = hub, 1 = first ring, 2 = second ring, 3 = ghost
ring: number;
state: "running" | "waiting" | "quiet" | "closed" | "ghost";
}
export interface GraphEdge {
from: string;
to: string;
}
let {
nodes,
edges,
onOpen,
class: className = "",
}: {
nodes: GraphNode[];
edges: GraphEdge[];
onOpen?: (id: string) => void;
class?: string;
} = $props();
const SIZE = 320;
const CENTER = SIZE / 2;
const RING = [0, 74, 118, 148];
const DRIFT = 3.5;
const LABEL_MAX = 18;
const TAU = Math.PI * 2;
const HASH_MOD = 1_000_003;
const HASH_BASE = 31;
let time = $state(0);
let reduced = false;
onMount(() => {
reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) {
return;
}
let frame = 0;
const start = performance.now();
const tick = (ts: number) => {
time = (ts - start) / 1000;
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
});
function hash(id: string): number {
let h = 0;
for (const ch of id) {
h = (h * HASH_BASE + ch.charCodeAt(0)) % HASH_MOD;
}
return h;
}
interface Placed extends GraphNode {
x: number;
y: number;
}
function place(
node: GraphNode,
slot: number,
total: number,
t: number
): Placed {
const ring = Math.min(node.ring, RING.length - 1);
const dist = RING[ring];
const seed = hash(node.id);
const angle = (slot / total) * TAU + (seed % 100) / 100 + node.ring * 0.7;
const wobble = reduced ? 0 : Math.sin(t * 0.35 + (seed % 7)) * DRIFT;
const wobble2 = reduced ? 0 : Math.cos(t * 0.27 + (seed % 5)) * DRIFT;
if (node.ring === 0) {
return { ...node, x: CENTER + wobble * 0.3, y: CENTER + wobble2 * 0.3 };
}
return {
...node,
x: CENTER + Math.cos(angle) * dist + wobble,
y: CENTER + Math.sin(angle) * dist + wobble2,
};
}
const placed = $derived.by((): Placed[] => {
const byRing = new Map<number, GraphNode[]>();
for (const node of nodes) {
const list = byRing.get(node.ring) ?? [];
list.push(node);
byRing.set(node.ring, list);
}
const out: Placed[] = [];
for (const list of byRing.values()) {
let slot = 0;
for (const node of list) {
out.push(place(node, slot, list.length, time));
slot += 1;
}
}
return out;
});
const byId = $derived(new Map(placed.map((node) => [node.id, node])));
const RADIUS: Record<string, number> = {
closed: 3.5,
ghost: 2.5,
quiet: 4.5,
running: 6.5,
waiting: 6,
};
const FILL: Record<string, string> = {
branch: "var(--color-kind-branch)",
deep: "var(--color-kind-deep)",
file: "var(--foreground)",
fork: "var(--color-kind-fork)",
job: "var(--color-kind-job)",
master: "var(--color-kind-master)",
};
function radius(node: Placed): number {
const base = RADIUS[node.state] ?? 4;
return node.ring === 0 ? base + 4 : base;
}
function strokeOf(node: Placed): string {
if (node.state === "ghost") {
return "var(--muted-foreground)";
}
return node.state === "waiting" ? "var(--attention)" : "none";
}
function fillOf(node: Placed): string {
return node.state === "ghost"
? "none"
: (FILL[node.kind] ?? "var(--foreground)");
}
function textOf(node: Placed): string {
return node.state === "ghost" || node.state === "closed"
? "var(--muted-foreground)"
: "var(--foreground)";
}
function ghostEdge(edge: GraphEdge): boolean {
return (
byId.get(edge.from)?.state === "ghost" ||
byId.get(edge.to)?.state === "ghost"
);
}
</script>
{#snippet dot(node: Placed)}
{@const r = radius(node)}
{#if node.state === "running"}
<circle cx={node.x} cy={node.y} fill="url(#graph-glow)" r={r * 4} />
{/if}
<circle
cx={node.x}
cy={node.y}
fill={fillOf(node)}
fill-opacity={node.state === "closed" ? 0.35 : 1}
{r}
stroke={strokeOf(node)}
stroke-opacity={node.state === "ghost" ? 0.6 : 1}
stroke-width={node.state === "waiting" ? 2.5 : 1}
/>
<text
dominant-baseline="hanging"
fill={textOf(node)}
font-size="10"
font-weight={node.ring === 0 ? 600 : 400}
text-anchor="middle"
x={node.x}
y={node.y + r + 4}
>
{clip(node.label, LABEL_MAX)}
</text>
{/snippet}
<svg
aria-label="what the agent is touching"
class={cn("h-auto w-full select-none", className)}
role="img"
viewBox="0 0 {SIZE} {SIZE}"
>
<defs>
<radialGradient id="graph-glow">
<stop offset="0%" stop-color="var(--signal)" stop-opacity="0.35" />
<stop offset="100%" stop-color="var(--signal)" stop-opacity="0" />
</radialGradient>
</defs>
{#each edges as edge (edge.from + edge.to)}
{@const a = byId.get(edge.from)}
{@const b = byId.get(edge.to)}
{#if a && b}
<line
stroke="var(--foreground)"
stroke-dasharray={ghostEdge(edge) ? "2 4" : undefined}
stroke-opacity={ghostEdge(edge) ? 0.18 : 0.14}
stroke-width="1"
x1={a.x}
x2={b.x}
y1={a.y}
y2={b.y}
/>
{/if}
{/each}
{#each placed as node (node.id)}
{#if node.href}
<a
class="cursor-pointer"
href={node.href}
onclick={(event) => {
event.preventDefault();
onOpen?.(node.id);
}}
>
{@render dot(node)}
</a>
{:else}
<g>{@render dot(node)}</g>
{/if}
{/each}
</svg>