- Terracotta (light) and Carbon (dark) themes with toggle - Inknut Antiqua headings, DM Sans body - Dot grid background on EntityGraph and GraphBackground - Theme-adaptive graph colors on EntityGraph - Art Nouveau chat styling (borders, underlines, blockquote quotes) - Bullet point styles in chat prose - Task goal in header, rename Overview→Tasks, New Task labels - Logo uses var(--primary) for theme awareness
556 lines
20 KiB
Svelte
556 lines
20 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte'
|
|
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
|
|
import { fetchGraph, fetchEntityTypes, type GraphView, type Entity, type Health } from '$lib/api'
|
|
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
|
import { typeToCategory, type Category } from '$lib/categories'
|
|
import { Skeleton } from '$lib/components/ui/skeleton'
|
|
|
|
export interface GraphInfo {
|
|
allNodeTypes: string[]
|
|
allRelTypes: string[]
|
|
relColors: Map<string, string>
|
|
visibleCount: number
|
|
truncated: boolean
|
|
zoomPct: number
|
|
}
|
|
|
|
let {
|
|
category,
|
|
selectedSlug = null,
|
|
onSelect,
|
|
root = $bindable(''),
|
|
depth,
|
|
search,
|
|
reloadToken,
|
|
resetToken,
|
|
activeNodeTypes = $bindable(new Set<string>()),
|
|
activeRelTypes = $bindable(new Set<string>()),
|
|
info = $bindable<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
|
|
}: {
|
|
category: Category
|
|
selectedSlug?: string | null
|
|
onSelect: (slug: string | null) => void
|
|
root?: string
|
|
depth: number
|
|
search: string
|
|
// Bumped by the parent toolbar to request a data reload / view reset —
|
|
// these controls live in the shared page toolbar (not squeezed inside
|
|
// this resizable pane), so they can't call load()/resetView() directly.
|
|
reloadToken: number
|
|
resetToken: number
|
|
activeNodeTypes?: Set<string>
|
|
activeRelTypes?: Set<string>
|
|
info?: GraphInfo
|
|
} = $props()
|
|
|
|
interface Node extends Entity {
|
|
x?: number
|
|
y?: number
|
|
vx?: number
|
|
vy?: number
|
|
fx?: number | null
|
|
fy?: number | null
|
|
degree: number
|
|
}
|
|
interface Link {
|
|
source: string | Node
|
|
target: string | Node
|
|
type: string
|
|
}
|
|
|
|
let graph = $state<GraphView | null>(null)
|
|
let loading = $state(true)
|
|
let nodes = $state<Node[]>([])
|
|
let links = $state<Link[]>([])
|
|
let sim: Simulation<Node, Link> | null = null
|
|
|
|
// type → browsing category, so the graph can be scoped client-side (the
|
|
// graph endpoint itself has no category/domain param). Value is undefined
|
|
// for types deliberately excluded from every category (e.g. execution/
|
|
// check/task — see categories.ts); the key is still present so inCategory
|
|
// can tell "excluded on purpose" apart from "not in the ontology at all."
|
|
let typeCategory = $state<Map<string, Category | undefined>>(new Map())
|
|
|
|
let hoveredId = $state<string | null>(null)
|
|
|
|
// viewport transform: translate(x, y) scale(k)
|
|
let view = $state({ x: 0, y: 0, k: 1 })
|
|
let svgEl = $state<SVGSVGElement | null>(null)
|
|
|
|
const width = 1200
|
|
const height = 800
|
|
|
|
const healthColor: Record<Health, string> = {
|
|
healthy: '#3fb950',
|
|
degraded: '#d29922',
|
|
down: '#f85149',
|
|
unknown: '#8b949e'
|
|
}
|
|
|
|
const relPalette = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#39c5cf', '#f0883e', '#db61a2']
|
|
const relColorByType = $derived.by(() => {
|
|
const map = new Map<string, string>()
|
|
const types = Array.from(new Set(links.map((l) => l.type))).sort()
|
|
types.forEach((t, i) => map.set(t, relPalette[i % relPalette.length]))
|
|
return map
|
|
})
|
|
|
|
function relColor(type: string): string {
|
|
return relColorByType.get(type) ?? '#30363d'
|
|
}
|
|
|
|
function markerId(type: string): string {
|
|
return 'arrow-' + type.replace(/[^a-z0-9]/gi, '_')
|
|
}
|
|
|
|
function endpoint(end: string | Node): Node | undefined {
|
|
return typeof end === 'object' ? end : nodes.find((n) => n.id === end)
|
|
}
|
|
function endpointId(end: string | Node): string {
|
|
return typeof end === 'object' ? end.id : end
|
|
}
|
|
|
|
// Node belongs to the active category? Types the ontology never returned
|
|
// at all fall back to visible (so a missing entry never blanks the
|
|
// graph); types the ontology returned but categories.ts deliberately
|
|
// excludes (key present, value undefined) do not.
|
|
function inCategory(type: string): boolean {
|
|
if (!typeCategory.has(type)) return true
|
|
return typeCategory.get(type) === category
|
|
}
|
|
|
|
// Brand-new nodes (no `prev`) get x/y left undefined, and d3-force's
|
|
// default init spreads those via a spiral centered on the ORIGIN — not
|
|
// (width/2, height/2) — while the x/y centering forces below are
|
|
// deliberately weak (0.04, so they don't fight the link/collide layout).
|
|
// Together that meant the cluster could settle noticeably off-origin
|
|
// instead of centered. Fixed by explicitly fitting the viewport to the
|
|
// node bounding box once the simulation settles, rather than relying on
|
|
// the force balance to land on center by itself.
|
|
function fitToView() {
|
|
const placed = nodes.filter((n) => n.x != null && n.y != null)
|
|
if (!placed.length) return
|
|
const xs = placed.map((n) => n.x as number)
|
|
const ys = placed.map((n) => n.y as number)
|
|
const minX = Math.min(...xs)
|
|
const maxX = Math.max(...xs)
|
|
const minY = Math.min(...ys)
|
|
const maxY = Math.max(...ys)
|
|
const pad = 70
|
|
const bw = Math.max(maxX - minX, 1)
|
|
const bh = Math.max(maxY - minY, 1)
|
|
const k = Math.min((width - pad * 2) / bw, (height - pad * 2) / bh, 2.5)
|
|
const cx = (minX + maxX) / 2
|
|
const cy = (minY + maxY) / 2
|
|
view = { k, x: width / 2 - cx * k, y: height / 2 - cy * k }
|
|
}
|
|
|
|
// fit=false for passive background reloads (live entity/relationship
|
|
// events) — those shouldn't yank the view out from under someone
|
|
// actively panning/zooming. Fresh loads (mount, root/depth change,
|
|
// reset, re-root) default to fit=true.
|
|
async function load(fit = true) {
|
|
loading = true
|
|
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
|
|
loading = false
|
|
if (!graph) return
|
|
|
|
const byId = new Map(nodes.map((n) => [n.id, n]))
|
|
const degree = new Map<string, number>()
|
|
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
|
|
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)
|
|
}
|
|
|
|
nodes = graph.nodes.map((n) => {
|
|
const prev = byId.get(n.id)
|
|
return { ...n, x: prev?.x, y: prev?.y, degree: degree.get(n.id) ?? 0 }
|
|
})
|
|
links = graph.edges.map((e) => ({
|
|
source: idBySlug.get(e.source) ?? e.source,
|
|
target: idBySlug.get(e.target) ?? e.target,
|
|
type: e.type
|
|
}))
|
|
|
|
// Default the node/edge-type toggles to the types present in the active category.
|
|
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
|
|
activeRelTypes = new Set(links.map((l) => l.type))
|
|
|
|
sim?.stop()
|
|
sim = forceSimulation(nodes)
|
|
.force('link', forceLink<Node, Link>(links).id((n) => n.id).distance(70).strength(0.6))
|
|
.force('charge', forceManyBody().strength(-240).distanceMax(400))
|
|
.force('center', forceCenter(width / 2, height / 2))
|
|
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 8))
|
|
.force('x', forceX(width / 2).strength(0.04))
|
|
.force('y', forceY(height / 2).strength(0.04))
|
|
.velocityDecay(0.32)
|
|
.alphaDecay(0.035)
|
|
.on('tick', () => {
|
|
nodes = [...nodes]
|
|
})
|
|
.on('end', () => {
|
|
if (fit) fitToView()
|
|
})
|
|
}
|
|
|
|
onMount(() => {
|
|
fetchEntityTypes().then((types) => {
|
|
typeCategory = new Map(types.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
|
|
// Re-derive the active node types now that category membership is known.
|
|
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
|
|
})
|
|
load()
|
|
const unsubscribe = subscribeEvents()
|
|
return () => {
|
|
unsubscribe()
|
|
sim?.stop()
|
|
}
|
|
})
|
|
|
|
onDestroy(() => sim?.stop())
|
|
|
|
// When the category perspective changes, reset the node-type toggles to it.
|
|
$effect(() => {
|
|
category
|
|
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
|
|
})
|
|
|
|
$effect(() => {
|
|
const ev = $liveEvents[0]
|
|
if (!ev) return
|
|
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
|
|
load(false)
|
|
}
|
|
})
|
|
|
|
// Toolbar-driven reload/reset — mirrors the old onchange={load} behavior:
|
|
// typing freely doesn't refetch, only a committed change (Enter/blur in the
|
|
// parent's inputs, or the Reset button) bumps the token.
|
|
let lastReloadToken = $state(0)
|
|
$effect(() => {
|
|
if (reloadToken !== lastReloadToken) {
|
|
lastReloadToken = reloadToken
|
|
load()
|
|
}
|
|
})
|
|
|
|
let lastResetToken = $state(0)
|
|
$effect(() => {
|
|
if (resetToken !== lastResetToken) {
|
|
lastResetToken = resetToken
|
|
view = { x: 0, y: 0, k: 1 }
|
|
load()
|
|
}
|
|
})
|
|
|
|
function selectNode(node: Node) {
|
|
onSelect(node.slug)
|
|
}
|
|
|
|
function rerootTo(node: Node) {
|
|
root = node.slug
|
|
load()
|
|
}
|
|
|
|
function nodeColor(node: Node): string {
|
|
const h = graph?.health?.[node.id]
|
|
return h ? healthColor[h] : '#58a6ff'
|
|
}
|
|
|
|
function nodeRadius(node: Node): number {
|
|
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
|
|
}
|
|
|
|
// Only offer node-type toggles that live in the active category.
|
|
const allNodeTypes = $derived(Array.from(new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))).sort())
|
|
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
|
|
|
|
// Publish status/legend info up to the parent toolbar.
|
|
$effect(() => {
|
|
info = {
|
|
allNodeTypes,
|
|
allRelTypes,
|
|
relColors: relColorByType,
|
|
visibleCount: visibleNodeIds.size,
|
|
truncated: !!graph?.truncated,
|
|
zoomPct: Math.round(view.k * 100)
|
|
}
|
|
})
|
|
|
|
const matchedIds = $derived.by(() => {
|
|
if (!search.trim()) return null
|
|
const q = search.trim().toLowerCase()
|
|
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
|
|
})
|
|
|
|
// Focus = in the active category AND its node-type toggle is on — these
|
|
// are what the category tab is "about."
|
|
const focusNodeIds = $derived(new Set(nodes.filter((n) => inCategory(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id)))
|
|
|
|
// Real infra relationships mostly cross category lines (a service sits on
|
|
// a network, uses storage, runs on an lxc — different categories under
|
|
// this taxonomy). Hard-hiding any edge whose other end isn't in-category
|
|
// left focus nodes looking like disconnected dots. Rooted views (the user
|
|
// is exploring out from one entity) pull in 1-hop neighbors of any
|
|
// category, dimmed, so the edges — and what they connect to — stay
|
|
// visible. Unscoped "browse the whole category" views (no root) skip
|
|
// this: with ~50 focus nodes that touch nearly everything, 1-hop
|
|
// expansion floods in most of the graph (measured: 417 of 479 total
|
|
// entities for an unrooted Fleet view) — worse than the isolated-dot
|
|
// problem it was meant to fix. There, same-category-only edges stay.
|
|
const neighborNodeIds = $derived.by(() => {
|
|
const neighbors = new Set<string>()
|
|
if (!root.trim()) return neighbors
|
|
for (const l of links) {
|
|
if (!activeRelTypes.has(l.type)) continue
|
|
const s = endpointId(l.source)
|
|
const t = endpointId(l.target)
|
|
if (focusNodeIds.has(s) && !focusNodeIds.has(t)) neighbors.add(t)
|
|
else if (focusNodeIds.has(t) && !focusNodeIds.has(s)) neighbors.add(s)
|
|
}
|
|
return neighbors
|
|
})
|
|
|
|
const visibleNodeIds = $derived(new Set([...focusNodeIds, ...neighborNodeIds]))
|
|
|
|
const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null)
|
|
|
|
const adjacency = $derived.by(() => {
|
|
const adj = new Map<string, Set<string>>()
|
|
for (const l of links) {
|
|
const s = endpointId(l.source)
|
|
const t = endpointId(l.target)
|
|
if (!adj.has(s)) adj.set(s, new Set())
|
|
if (!adj.has(t)) adj.set(t, new Set())
|
|
adj.get(s)!.add(t)
|
|
adj.get(t)!.add(s)
|
|
}
|
|
return adj
|
|
})
|
|
|
|
const focusIds = $derived.by(() => {
|
|
const focus = hoveredId ?? selectedId
|
|
if (!focus) return null
|
|
const set = new Set<string>([focus])
|
|
for (const n of adjacency.get(focus) ?? []) set.add(n)
|
|
return set
|
|
})
|
|
|
|
function nodeOpacity(node: Node): number {
|
|
const base = focusNodeIds.has(node.id) ? 1 : 0.4
|
|
if (matchedIds !== null) return matchedIds.has(node.id) ? base : 0.1
|
|
if (focusIds !== null) return focusIds.has(node.id) ? 1 : Math.min(base, 0.15)
|
|
return base
|
|
}
|
|
|
|
function linkVisualState(link: Link): { opacity: number; emphasized: boolean } {
|
|
const s = endpointId(link.source)
|
|
const t = endpointId(link.target)
|
|
const focus = hoveredId ?? selectedId
|
|
if (focus && (s === focus || t === focus)) return { opacity: 0.95, emphasized: true }
|
|
if (focusIds !== null || matchedIds !== null) return { opacity: 0.08, emphasized: false }
|
|
return { opacity: 0.45, emphasized: false }
|
|
}
|
|
|
|
// ─── pan / zoom / drag ───────────────────────────────────────────────
|
|
|
|
function toViewBox(clientX: number, clientY: number): { x: number; y: number } {
|
|
const rect = svgEl!.getBoundingClientRect()
|
|
return {
|
|
x: ((clientX - rect.left) / rect.width) * width,
|
|
y: ((clientY - rect.top) / rect.height) * height
|
|
}
|
|
}
|
|
|
|
function toWorld(clientX: number, clientY: number): { x: number; y: number } {
|
|
const p = toViewBox(clientX, clientY)
|
|
return { x: (p.x - view.x) / view.k, y: (p.y - view.y) / view.k }
|
|
}
|
|
|
|
function onWheel(e: WheelEvent) {
|
|
e.preventDefault()
|
|
const factor = e.deltaY < 0 ? 1.18 : 1 / 1.18
|
|
const k = Math.min(6, Math.max(0.25, view.k * factor))
|
|
const p = toViewBox(e.clientX, e.clientY)
|
|
const wx = (p.x - view.x) / view.k
|
|
const wy = (p.y - view.y) / view.k
|
|
view = { k, x: p.x - wx * k, y: p.y - wy * k }
|
|
}
|
|
|
|
let panState = $state<{ startX: number; startY: number; viewX: number; viewY: number; moved: boolean } | null>(null)
|
|
let dragState: { node: Node; moved: boolean } | null = null
|
|
|
|
function onBackgroundPointerDown(e: PointerEvent) {
|
|
if (dragState) return
|
|
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
|
const p = toViewBox(e.clientX, e.clientY)
|
|
panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y, moved: false }
|
|
}
|
|
|
|
function onNodePointerDown(e: PointerEvent, node: Node) {
|
|
e.stopPropagation()
|
|
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
|
dragState = { node, moved: false }
|
|
sim?.alphaTarget(0.25).restart()
|
|
}
|
|
|
|
function onPointerMove(e: PointerEvent) {
|
|
if (dragState) {
|
|
const w = toWorld(e.clientX, e.clientY)
|
|
dragState.node.fx = w.x
|
|
dragState.node.fy = w.y
|
|
dragState.moved = true
|
|
return
|
|
}
|
|
if (panState) {
|
|
const p = toViewBox(e.clientX, e.clientY)
|
|
const dx = p.x - panState.startX
|
|
const dy = p.y - panState.startY
|
|
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) panState.moved = true
|
|
view = { ...view, x: panState.viewX + dx, y: panState.viewY + dy }
|
|
}
|
|
}
|
|
|
|
function onPointerUp(e: PointerEvent) {
|
|
if (dragState) {
|
|
const { node, moved } = dragState
|
|
node.fx = null
|
|
node.fy = null
|
|
sim?.alphaTarget(0)
|
|
dragState = null
|
|
if (!moved) selectNode(node)
|
|
return
|
|
}
|
|
if (panState && !panState.moved) {
|
|
// Plain click on empty background (not a drag-pan) — clear selection.
|
|
onSelect(null)
|
|
}
|
|
panState = null
|
|
}
|
|
</script>
|
|
|
|
{#if loading && !nodes.length}
|
|
<Skeleton class="h-full min-h-0" />
|
|
{:else}
|
|
<div class="relative h-full min-h-0 overflow-hidden rounded-lg border">
|
|
<svg
|
|
bind:this={svgEl}
|
|
viewBox="0 0 {width} {height}"
|
|
preserveAspectRatio="xMidYMid slice"
|
|
class="h-full w-full touch-none {panState ? 'cursor-grabbing' : 'cursor-grab'}"
|
|
role="application"
|
|
aria-label="Entity graph"
|
|
onwheel={onWheel}
|
|
onpointerdown={onBackgroundPointerDown}
|
|
onpointermove={onPointerMove}
|
|
onpointerup={onPointerUp}
|
|
onpointercancel={onPointerUp}
|
|
>
|
|
<defs>
|
|
<pattern id="dot-grid" width="12" height="12" patternUnits="userSpaceOnUse">
|
|
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
|
|
</pattern>
|
|
{#each allRelTypes as type}
|
|
<marker id={markerId(type)} viewBox="0 -4 8 8" refX="8" refY="0" markerWidth="7" markerHeight="7" orient="auto">
|
|
<path d="M0,-3.5L8,0L0,3.5" fill={relColor(type)} />
|
|
</marker>
|
|
{/each}
|
|
</defs>
|
|
<rect x="0" y="0" width={width} height={height} fill="url(#dot-grid)" />
|
|
<g transform="translate({view.x},{view.y}) scale({view.k})">
|
|
<g>
|
|
{#each links as link}
|
|
{@const s = endpoint(link.source)}
|
|
{@const t = endpoint(link.target)}
|
|
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null && activeRelTypes.has(link.type) && visibleNodeIds.has(s.id) && visibleNodeIds.has(t.id)}
|
|
{@const vs = linkVisualState(link)}
|
|
{@const dx = t.x - s.x}
|
|
{@const dy = t.y - s.y}
|
|
{@const len = Math.max(Math.hypot(dx, dy), 1)}
|
|
{@const tr = nodeRadius(t) + 3}
|
|
{@const ex = t.x - (dx / len) * tr}
|
|
{@const ey = t.y - (dy / len) * tr}
|
|
<line
|
|
x1={s.x}
|
|
y1={s.y}
|
|
x2={ex}
|
|
y2={ey}
|
|
stroke={relColor(link.type)}
|
|
stroke-width={vs.emphasized ? 2 : 1.2}
|
|
opacity={vs.opacity}
|
|
marker-end="url(#{markerId(link.type)})"
|
|
>
|
|
<title>{link.type}</title>
|
|
</line>
|
|
{#if vs.emphasized && view.k >= 0.7}
|
|
<text
|
|
x={(s.x + ex) / 2}
|
|
y={(s.y + ey) / 2 - 4}
|
|
text-anchor="middle"
|
|
font-size={10 / view.k}
|
|
fill={relColor(link.type)}
|
|
opacity="0.95"
|
|
paint-order="stroke"
|
|
stroke="var(--background)"
|
|
stroke-width={3 / view.k}
|
|
>
|
|
{link.type}
|
|
</text>
|
|
{/if}
|
|
{/if}
|
|
{/each}
|
|
</g>
|
|
<g>
|
|
{#each nodes as node (node.id)}
|
|
{#if node.x != null && node.y != null && visibleNodeIds.has(node.id)}
|
|
{@const r = nodeRadius(node)}
|
|
{@const op = nodeOpacity(node)}
|
|
{@const isFocus = hoveredId === node.id || selectedId === node.id}
|
|
{@const isMatch = matchedIds !== null && matchedIds.has(node.id)}
|
|
<g
|
|
transform="translate({node.x},{node.y})"
|
|
opacity={op}
|
|
class="cursor-pointer"
|
|
role="button"
|
|
tabindex="0"
|
|
onpointerdown={(e) => onNodePointerDown(e, node)}
|
|
onpointerenter={() => (hoveredId = node.id)}
|
|
onpointerleave={() => (hoveredId = null)}
|
|
onkeydown={(e) => e.key === 'Enter' && selectNode(node)}
|
|
ondblclick={() => rerootTo(node)}
|
|
>
|
|
{#if isFocus || isMatch}
|
|
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
|
{/if}
|
|
<circle r={r} fill={nodeColor(node)} stroke={isFocus || isMatch ? 'var(--foreground)' : 'var(--background)'} stroke-width={isFocus || isMatch ? 2 : 1.25} />
|
|
{#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null}
|
|
<text
|
|
y={r + 12}
|
|
text-anchor="middle"
|
|
font-size={isFocus ? 12 / view.k : 10 / Math.max(view.k, 1)}
|
|
fill={isFocus ? 'var(--foreground)' : 'var(--muted-foreground)'}
|
|
paint-order="stroke"
|
|
stroke="var(--background)"
|
|
stroke-width={3 / view.k}
|
|
class="pointer-events-none select-none"
|
|
>
|
|
{node.slug}
|
|
</text>
|
|
{/if}
|
|
</g>
|
|
{/if}
|
|
{/each}
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
<div class="pointer-events-none absolute bottom-2 left-2 rounded bg-background/80 px-2 py-1 text-[10px] text-muted-foreground">
|
|
scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root
|
|
</div>
|
|
</div>
|
|
{/if}
|