feat(ui,api): strip board redesign - island, rail by day, context view, server search, vault graph
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user