236 lines
5.7 KiB
Svelte
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>
|