Quadratic-bezier edges instead of straight lines, and drop the auto-refit-on-load that caused a jarring zoom/pan snap once the force simulation settled. Also namespace each graph's dot-grid pattern id with a per-instance uuid — multiple SessionGraph instances can now be mounted at once (one per open task window), and duplicate SVG ids silently blanked out every graph's background but the first. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
267 lines
9.2 KiB
Svelte
267 lines
9.2 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte'
|
|
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, type Simulation } from 'd3-force'
|
|
import { fetchGraph, type Health } from '$lib/api'
|
|
import { getTheme } from '$lib/stores/theme.svelte'
|
|
|
|
// Ambient, non-interactive knowledge-graph backdrop. Purely decorative: the
|
|
// host places this behind the page with pointer-events:none, so it never
|
|
// steals clicks. The "alive" feeling comes entirely from the camera (slow
|
|
// autonomous drift + mouse parallax + per-node depth), NOT from a live force
|
|
// sim — we warm the layout up once, freeze it, then just pan a static field.
|
|
|
|
interface SimNode {
|
|
id: string
|
|
slug: string
|
|
degree: number
|
|
z: number // depth in [0,1] for parallax
|
|
x?: number
|
|
y?: number
|
|
fx?: number | null
|
|
fy?: number | null
|
|
}
|
|
interface SimLink {
|
|
source: string | SimNode
|
|
target: string | SimNode
|
|
}
|
|
|
|
let host = $state<HTMLDivElement | null>(null)
|
|
let canvas = $state<HTMLCanvasElement | null>(null)
|
|
|
|
let nodes: SimNode[] = []
|
|
let links: SimLink[] = []
|
|
let health: Record<string, Health> = {}
|
|
|
|
// World bounds the layout is centered in; camera pans within.
|
|
const WORLD = 1400
|
|
const MAX_NODES = 260
|
|
|
|
const healthColor: Record<Health, string> = {
|
|
healthy: '#3fb950',
|
|
degraded: '#d29922',
|
|
down: '#f85149',
|
|
unknown: '#8b949e'
|
|
}
|
|
|
|
function nodeRadius(n: SimNode): number {
|
|
return 3 + Math.min(Math.sqrt(n.degree) * 1.4, 7)
|
|
}
|
|
|
|
async function loadGraph() {
|
|
const graph = await fetchGraph({ depth: 3, includeStatus: true })
|
|
if (!graph) return
|
|
health = graph.health ?? {}
|
|
|
|
// degree by id, edges reference slugs
|
|
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
|
|
const degree = new Map<string, number>()
|
|
for (const e of graph.edges) {
|
|
const s = idBySlug.get(e.source) ?? e.source
|
|
const t = idBySlug.get(e.target) ?? e.target
|
|
degree.set(s, (degree.get(s) ?? 0) + 1)
|
|
degree.set(t, (degree.get(t) ?? 0) + 1)
|
|
}
|
|
|
|
let all: SimNode[] = graph.nodes.map((n) => ({
|
|
id: n.id,
|
|
slug: n.slug,
|
|
degree: degree.get(n.id) ?? 0,
|
|
z: Math.random()
|
|
}))
|
|
// Cap to the most-connected nodes so large graphs stay cheap.
|
|
if (all.length > MAX_NODES) {
|
|
all = [...all].sort((a, b) => b.degree - a.degree).slice(0, MAX_NODES)
|
|
}
|
|
const keep = new Set(all.map((n) => n.id))
|
|
nodes = all
|
|
links = graph.edges
|
|
.map((e) => ({ source: idBySlug.get(e.source) ?? e.source, target: idBySlug.get(e.target) ?? e.target }))
|
|
.filter((l) => keep.has(l.source as string) && keep.has(l.target as string))
|
|
|
|
warmUpLayout()
|
|
}
|
|
|
|
// Run the sim to a settled state without rendering each tick, then freeze.
|
|
function warmUpLayout() {
|
|
const sim: Simulation<SimNode, SimLink> = forceSimulation(nodes)
|
|
.force('link', forceLink<SimNode, SimLink>(links).id((n) => n.id).distance(60).strength(0.5))
|
|
.force('charge', forceManyBody().strength(-140).distanceMax(360))
|
|
.force('center', forceCenter(0, 0))
|
|
.force('collide', forceCollide<SimNode>((n) => nodeRadius(n) + 6))
|
|
.stop()
|
|
const ticks = Math.min(400, Math.max(120, nodes.length * 2))
|
|
for (let i = 0; i < ticks; i++) sim.tick()
|
|
sim.stop()
|
|
}
|
|
|
|
// ─── camera + render loop ───────────────────────────────────────────────
|
|
|
|
let cam = { x: 0, y: 0 } // eased mouse-parallax offset
|
|
let targetCam = { x: 0, y: 0 }
|
|
let timer: ReturnType<typeof setTimeout> | 0 = 0
|
|
let dpr = 1
|
|
let w = 0
|
|
let h = 0
|
|
let dotCanvas: HTMLCanvasElement | null = null
|
|
let lastDotDark: boolean | null = null
|
|
|
|
function drawDots(dark: boolean) {
|
|
if (!dotCanvas) {
|
|
dotCanvas = document.createElement('canvas')
|
|
}
|
|
dotCanvas.width = Math.round(w * dpr)
|
|
dotCanvas.height = Math.round(h * dpr)
|
|
const dctx = dotCanvas.getContext('2d')!
|
|
dctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
dctx.clearRect(0, 0, w, h)
|
|
dctx.fillStyle = dark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)'
|
|
const spacing = 12
|
|
for (let x = spacing; x < w; x += spacing) {
|
|
for (let y = spacing; y < h; y += spacing) {
|
|
dctx.beginPath()
|
|
dctx.arc(x, y, 0.7, 0, Math.PI * 2)
|
|
dctx.fill()
|
|
}
|
|
}
|
|
}
|
|
|
|
function onPointerMove(e: PointerEvent) {
|
|
if (!host) return
|
|
const rect = host.getBoundingClientRect()
|
|
const nx = (e.clientX - rect.left) / rect.width - 0.5 // -0.5..0.5
|
|
const ny = (e.clientY - rect.top) / rect.height - 0.5
|
|
targetCam = { x: -nx * 90, y: -ny * 90 } // small parallax nudge
|
|
}
|
|
|
|
function resize() {
|
|
if (!host || !canvas) return
|
|
dpr = Math.min(window.devicePixelRatio || 1, 2)
|
|
w = host.clientWidth
|
|
h = host.clientHeight
|
|
canvas.width = Math.round(w * dpr)
|
|
canvas.height = Math.round(h * dpr)
|
|
dotCanvas = null // force redraw on next frame
|
|
}
|
|
|
|
function colorForNode(n: SimNode): string {
|
|
return healthColor[health[n.id] ?? 'unknown']
|
|
}
|
|
|
|
// Driven by setTimeout rather than requestAnimationFrame: some embedding
|
|
// contexts (iframed previews, backgrounded-but-visible panes) report
|
|
// document.hidden = true and browsers fully suspend rAF callbacks there,
|
|
// which would freeze this canvas forever. setTimeout keeps ticking
|
|
// regardless, and ~30fps is plenty for a slow ambient drift.
|
|
function draw(t: number) {
|
|
timer = setTimeout(() => draw(performance.now()), 33)
|
|
if (!canvas) return
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) return
|
|
|
|
// ease parallax toward target
|
|
cam.x += (targetCam.x - cam.x) * 0.05
|
|
cam.y += (targetCam.y - cam.y) * 0.05
|
|
|
|
// autonomous drift (Lissajous pan + breathing zoom)
|
|
const ts = t / 1000
|
|
const driftX = Math.sin(ts * 0.05) * 70 + Math.sin(ts * 0.017) * 40
|
|
const driftY = Math.cos(ts * 0.043) * 60 + Math.sin(ts * 0.023) * 30
|
|
const zoom = 0.82 + Math.sin(ts * 0.03) * 0.03
|
|
|
|
const dark = getTheme() !== 'light'
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
ctx.clearRect(0, 0, w, h)
|
|
|
|
if (lastDotDark !== dark) { dotCanvas = null; lastDotDark = dark }
|
|
if (!dotCanvas) drawDots(dark)
|
|
ctx.drawImage(dotCanvas!, 0, 0)
|
|
|
|
const cx = w / 2
|
|
const cy = h / 2
|
|
|
|
// project a world point to screen, applying per-depth parallax
|
|
function project(px: number, py: number, z: number) {
|
|
const par = 0.5 + z // nearer nodes (higher z) move more
|
|
const ox = (driftX + cam.x) * par
|
|
const oy = (driftY + cam.y) * par
|
|
return { x: cx + (px + ox) * zoom, y: cy + (py + oy) * zoom }
|
|
}
|
|
|
|
// edges
|
|
ctx.lineWidth = 1
|
|
ctx.strokeStyle = dark ? 'rgba(140,175,230,0.28)' : 'rgba(60,90,140,0.22)'
|
|
ctx.beginPath()
|
|
for (const l of links) {
|
|
const s = l.source as SimNode
|
|
const tg = l.target as SimNode
|
|
if (s.x == null || tg.x == null) continue
|
|
const z = (s.z + tg.z) / 2
|
|
const a = project(s.x, s.y!, z)
|
|
const b = project(tg.x, tg.y!, z)
|
|
const dx = b.x - a.x
|
|
const dy = b.y - a.y
|
|
const len = Math.max(Math.hypot(dx, dy), 1)
|
|
const curve = Math.min(len * 0.15, 40)
|
|
const mx = (a.x + b.x) / 2 - (dy / len) * curve
|
|
const my = (a.y + b.y) / 2 + (dx / len) * curve
|
|
ctx.moveTo(a.x, a.y)
|
|
ctx.quadraticCurveTo(mx, my, b.x, b.y)
|
|
}
|
|
ctx.stroke()
|
|
|
|
// nodes (glow via radial gradient, cheap enough at this count)
|
|
for (const n of nodes) {
|
|
if (n.x == null || n.y == null) continue
|
|
const p = project(n.x, n.y, n.z)
|
|
const r = nodeRadius(n) * zoom * (0.7 + n.z * 0.6)
|
|
const col = colorForNode(n)
|
|
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 3.2)
|
|
glow.addColorStop(0, hexA(col, dark ? 0.45 : 0.32))
|
|
glow.addColorStop(1, hexA(col, 0))
|
|
ctx.fillStyle = glow
|
|
ctx.beginPath()
|
|
ctx.arc(p.x, p.y, r * 3.2, 0, Math.PI * 2)
|
|
ctx.fill()
|
|
ctx.fillStyle = hexA(col, dark ? 0.7 : 0.55)
|
|
ctx.beginPath()
|
|
ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
|
|
ctx.fill()
|
|
}
|
|
|
|
// legibility scrim: dim only the center band where the UI sits, taper to
|
|
// ~nothing at the edges so the graph (and its connections) stay visible
|
|
// in the margins instead of being crushed everywhere equally.
|
|
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
|
|
const base = dark ? '13,17,23' : '255,255,255'
|
|
scrim.addColorStop(0, `rgba(${base},0.68)`)
|
|
scrim.addColorStop(0.45, `rgba(${base},0.32)`)
|
|
scrim.addColorStop(1, `rgba(${base},0.02)`)
|
|
ctx.fillStyle = scrim
|
|
ctx.fillRect(0, 0, w, h)
|
|
}
|
|
|
|
// "#rrggbb" + alpha -> rgba()
|
|
function hexA(hex: string, a: number): string {
|
|
const n = parseInt(hex.slice(1), 16)
|
|
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`
|
|
}
|
|
|
|
onMount(() => {
|
|
loadGraph()
|
|
resize()
|
|
const ro = new ResizeObserver(resize)
|
|
if (host) ro.observe(host)
|
|
window.addEventListener('pointermove', onPointerMove)
|
|
timer = setTimeout(() => draw(performance.now()), 33)
|
|
return () => {
|
|
clearTimeout(timer)
|
|
ro.disconnect()
|
|
window.removeEventListener('pointermove', onPointerMove)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<div bind:this={host} class="pointer-events-none absolute inset-0 overflow-hidden">
|
|
<canvas bind:this={canvas} class="h-full w-full"></canvas>
|
|
</div>
|