@reddb-io/ui 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/LICENSE +17 -0
  2. package/bin/red-ui.js +259 -0
  3. package/build/CNAME +1 -0
  4. package/build/_app/env.js +1 -0
  5. package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
  6. package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
  7. package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
  8. package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
  9. package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  10. package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  11. package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  12. package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  13. package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  14. package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  15. package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  16. package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
  17. package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
  18. package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
  19. package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
  20. package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
  21. package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
  22. package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
  23. package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
  24. package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
  25. package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
  26. package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
  27. package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
  28. package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
  29. package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
  30. package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
  31. package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
  32. package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
  33. package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
  34. package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
  35. package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
  36. package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
  37. package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
  38. package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
  39. package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
  40. package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
  41. package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
  42. package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
  43. package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
  44. package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
  45. package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
  46. package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
  47. package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
  48. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  49. package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
  50. package/build/_app/version.json +1 -0
  51. package/build/favicon.png +0 -0
  52. package/build/index.html +59 -0
  53. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
  54. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
  55. package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
  56. package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
  57. package/dist/embed/core-QuRYvMt8.js +118 -0
  58. package/dist/embed/core-QuRYvMt8.js.map +1 -0
  59. package/dist/embed/index-B6ZQhCsu.js +577 -0
  60. package/dist/embed/index-B6ZQhCsu.js.map +1 -0
  61. package/dist/embed/index-client-ItCEU64C.js +3087 -0
  62. package/dist/embed/index-client-ItCEU64C.js.map +1 -0
  63. package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
  64. package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
  65. package/dist/embed/index.js +11 -0
  66. package/dist/embed/index.js.map +1 -0
  67. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
  68. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
  69. package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
  70. package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
  71. package/dist/embed/ui.css +1 -0
  72. package/package.json +101 -0
  73. package/src/app.css +238 -0
  74. package/src/lib/ActivityIndicator.svelte +100 -0
  75. package/src/lib/ClusterNode.svelte +119 -0
  76. package/src/lib/ClusterView.svelte +541 -0
  77. package/src/lib/CollectionHistory.svelte +266 -0
  78. package/src/lib/CollectionsView.svelte +14 -0
  79. package/src/lib/CommandPalette.svelte +237 -0
  80. package/src/lib/ConnectDropdown.svelte +222 -0
  81. package/src/lib/EmptyState.svelte +27 -0
  82. package/src/lib/LiveChanges.svelte +195 -0
  83. package/src/lib/MasterPasswordDialog.svelte +168 -0
  84. package/src/lib/PageHeader.svelte +32 -0
  85. package/src/lib/PendingChangesPanel.svelte +153 -0
  86. package/src/lib/QueryEditor.svelte +122 -0
  87. package/src/lib/ResultsPane.svelte +747 -0
  88. package/src/lib/SchemaTree.svelte +193 -0
  89. package/src/lib/SecurityView.svelte +255 -0
  90. package/src/lib/ShortcutOverlay.svelte +104 -0
  91. package/src/lib/Splash.svelte +46 -0
  92. package/src/lib/StatusBar.svelte +163 -0
  93. package/src/lib/SubNav.svelte +62 -0
  94. package/src/lib/Topbar.svelte +119 -0
  95. package/src/lib/Workspace.svelte +247 -0
  96. package/src/lib/activity.svelte.ts +91 -0
  97. package/src/lib/build-info.ts +41 -0
  98. package/src/lib/capability.ts +221 -0
  99. package/src/lib/cn.ts +6 -0
  100. package/src/lib/collection-catalog.ts +116 -0
  101. package/src/lib/collection-pages.ts +33 -0
  102. package/src/lib/connection-history.ts +301 -0
  103. package/src/lib/connections.svelte.ts +493 -0
  104. package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
  105. package/src/lib/embed/index.ts +234 -0
  106. package/src/lib/graph-loader/contract.ts +336 -0
  107. package/src/lib/graph-loader/drilldown.ts +334 -0
  108. package/src/lib/graph-loader/index.ts +13 -0
  109. package/src/lib/index.ts +114 -0
  110. package/src/lib/pending-changes.svelte.ts +149 -0
  111. package/src/lib/query-tabs.svelte.ts +130 -0
  112. package/src/lib/reddb/boot-params.ts +254 -0
  113. package/src/lib/reddb/cdc-stream-client.ts +234 -0
  114. package/src/lib/reddb/client.ts +698 -0
  115. package/src/lib/reddb/connection-bootstrap.ts +260 -0
  116. package/src/lib/reddb/connection-provider.contract.ts +72 -0
  117. package/src/lib/reddb/connection-provider.ts +72 -0
  118. package/src/lib/reddb/index.ts +14 -0
  119. package/src/lib/reddb/injected-client-provider.ts +76 -0
  120. package/src/lib/reddb/local-url-provider.ts +245 -0
  121. package/src/lib/reddb/secure-store.ts +41 -0
  122. package/src/lib/reddb/server-capabilities.ts +71 -0
  123. package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
  124. package/src/lib/reddb/transports.ts +89 -0
  125. package/src/lib/reddb/types.ts +32 -0
  126. package/src/lib/reddb/uri.ts +54 -0
  127. package/src/lib/reddb/web-encrypted-store.ts +201 -0
  128. package/src/lib/renderers/DiffRenderer.svelte +184 -0
  129. package/src/lib/renderers/DocumentRenderer.svelte +134 -0
  130. package/src/lib/renderers/GraphRenderer.svelte +1328 -0
  131. package/src/lib/renderers/HypertableRenderer.svelte +253 -0
  132. package/src/lib/renderers/JsonRenderer.svelte +30 -0
  133. package/src/lib/renderers/KvRenderer.svelte +150 -0
  134. package/src/lib/renderers/QueueRenderer.svelte +286 -0
  135. package/src/lib/renderers/StatsRenderer.svelte +123 -0
  136. package/src/lib/renderers/TableRenderer.svelte +440 -0
  137. package/src/lib/renderers/VectorRenderer.svelte +388 -0
  138. package/src/lib/renderers/diff-render.ts +122 -0
  139. package/src/lib/renderers/diff.ts +9 -0
  140. package/src/lib/renderers/document-render.ts +64 -0
  141. package/src/lib/renderers/document.ts +9 -0
  142. package/src/lib/renderers/graph-render.ts +631 -0
  143. package/src/lib/renderers/graph-sigma.ts +395 -0
  144. package/src/lib/renderers/graph.ts +9 -0
  145. package/src/lib/renderers/hypertable-render.ts +320 -0
  146. package/src/lib/renderers/hypertable.ts +9 -0
  147. package/src/lib/renderers/index.ts +42 -0
  148. package/src/lib/renderers/json-render.ts +89 -0
  149. package/src/lib/renderers/json.ts +10 -0
  150. package/src/lib/renderers/kv-render.ts +193 -0
  151. package/src/lib/renderers/kv.ts +9 -0
  152. package/src/lib/renderers/queue-render.ts +197 -0
  153. package/src/lib/renderers/queue.ts +9 -0
  154. package/src/lib/renderers/registry.ts +68 -0
  155. package/src/lib/renderers/stats-render.ts +73 -0
  156. package/src/lib/renderers/stats.ts +9 -0
  157. package/src/lib/renderers/table-render.ts +156 -0
  158. package/src/lib/renderers/table.ts +11 -0
  159. package/src/lib/renderers/types.ts +33 -0
  160. package/src/lib/renderers/vector-render.ts +154 -0
  161. package/src/lib/renderers/vector.ts +9 -0
  162. package/src/lib/router.svelte.ts +163 -0
  163. package/src/lib/secureStore.svelte.ts +148 -0
  164. package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
  165. package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
  166. package/src/lib/shortcuts.ts +22 -0
  167. package/src/lib/surface.ts +80 -0
  168. package/src/lib/tabs.svelte.ts +115 -0
  169. package/src/lib/theme.svelte.ts +63 -0
@@ -0,0 +1,1328 @@
1
+ <script lang="ts">
2
+ import type { QueryResult } from '#reddb'
3
+ import { onDestroy, onMount } from 'svelte'
4
+ import { colorForCommunity, compareGraphNodesByCentrality, extractGraph, graphNodeCentrality, graphNodeDetail, graphNodeIncomingSizeScales, graphNodeIsOrphan, runGraphLayout, type GraphNode, type GraphEdge, type GraphNodeRelation } from './graph-render'
5
+ import {
6
+ buildSigmaGraph,
7
+ colorForType,
8
+ nodeVisualRadius,
9
+ reduceSigmaEdge,
10
+ reduceSigmaNode,
11
+ FOCUS_EDGE_COLOR,
12
+ FOCUS_NODE_STROKE_COLOR,
13
+ type SigmaReducerState,
14
+ type SigmaThemeColors,
15
+ } from './graph-sigma'
16
+ import { collectionPageHref } from '$lib/collection-pages'
17
+ import { connection } from '$lib/connections.svelte'
18
+ import { activity } from '$lib/activity.svelte'
19
+ import { Network, Table2, Search, X, Info, Maximize2, Play, Code2, RotateCw, Loader2, Route, AlertTriangle, ZoomIn, ZoomOut } from 'lucide-svelte'
20
+
21
+ interface Props {
22
+ result: QueryResult
23
+ collection?: string
24
+ subpage?: string
25
+ showSystem?: boolean
26
+ }
27
+
28
+ interface FallbackSvgNode extends GraphNode {
29
+ x: number
30
+ y: number
31
+ r: number
32
+ color: string
33
+ }
34
+
35
+ interface FallbackSvgEdge extends GraphEdge {
36
+ x1: number
37
+ y1: number
38
+ x2: number
39
+ y2: number
40
+ }
41
+
42
+ let { result, collection, subpage }: Props = $props()
43
+
44
+ // ─── data ───────────────────────────────────────────────────────────────
45
+ let overrideResult = $state<QueryResult | null>(null)
46
+ const activeResult = $derived(overrideResult ?? result)
47
+ const graph = $derived(extractGraph(activeResult))
48
+ const nodeCentrality = $derived(graphNodeCentrality(graph.nodes, graph.edges))
49
+ const nodeSizeScales = $derived(graphNodeIncomingSizeScales(graph.nodes, nodeCentrality))
50
+ const orphanNodeIds = $derived.by(() => {
51
+ return new Set(
52
+ graph.nodes
53
+ .filter((node) => graphNodeIsOrphan(node, nodeCentrality))
54
+ .map((node) => node.id),
55
+ )
56
+ })
57
+
58
+ // ─── view state ─────────────────────────────────────────────────────────
59
+ let viewMode = $state<'canvas' | 'svg' | 'table'>('canvas')
60
+ let canvasLimit = $state(25000)
61
+ let labelFilter = $state('')
62
+ let selectedNode = $state<GraphNode | null>(null)
63
+ let selectedEdge = $state<GraphEdge | null>(null)
64
+ let canvasError = $state<string | null>(null)
65
+ let renderBudget = $state(350)
66
+ let svgZoom = $state(1)
67
+
68
+ const selectedNodeDetail = $derived(
69
+ selectedNode
70
+ ? graphNodeDetail(selectedNode, graph.nodes, graph.edges, nodeCentrality)
71
+ : null,
72
+ )
73
+
74
+ $effect(() => {
75
+ if (subpage === 'svg' && viewMode !== 'svg') viewMode = 'svg'
76
+ else if (subpage === 'graph' && viewMode !== 'canvas') viewMode = 'canvas'
77
+ })
78
+ let hoveredFocusNodeId = $state<string | null>(null)
79
+ let pinnedFocusNodeId = $state<string | null>(null)
80
+
81
+ // ─── query toolbar state ────────────────────────────────────────────────
82
+ let queryOpen = $state(false)
83
+ let queryText = $state('')
84
+ let querying = $state(false)
85
+ let queryError = $state<string | null>(null)
86
+ let pathFrom = $state('')
87
+ let pathTo = $state('')
88
+
89
+ async function runQuery(q: string) {
90
+ const client = connection.client
91
+ if (!client || !q.trim()) return
92
+ querying = true
93
+ queryError = null
94
+ try {
95
+ const r = await activity.track(
96
+ `${collection ?? 'graph'} · query (${q.slice(0, 40).replace(/\s+/g, ' ')}…)`,
97
+ () => client.query(q),
98
+ )
99
+ if (!r.ok) {
100
+ queryError = r.error ?? 'query failed'
101
+ return
102
+ }
103
+ overrideResult = r
104
+ selectedNode = null
105
+ selectedEdge = null
106
+ clearGraphFocus()
107
+ } catch (e) {
108
+ queryError = (e as Error).message
109
+ } finally {
110
+ querying = false
111
+ }
112
+ }
113
+
114
+ function resetToOriginal() {
115
+ overrideResult = null
116
+ queryText = ''
117
+ queryError = null
118
+ selectedNode = null
119
+ selectedEdge = null
120
+ clearGraphFocus()
121
+ }
122
+
123
+ function quickNeighborhood() {
124
+ if (!selectedNode) return
125
+ queryText = `GRAPH NEIGHBORHOOD '${selectedNode.label}' DEPTH 1`
126
+ queryOpen = true
127
+ runQuery(queryText)
128
+ }
129
+
130
+ function quickCentrality() {
131
+ queryText = `GRAPH CENTRALITY LIMIT 50`
132
+ queryOpen = true
133
+ runQuery(queryText)
134
+ }
135
+
136
+ function useSelectedForPath(which: 'from' | 'to') {
137
+ if (!selectedNode) return
138
+ if (which === 'from') pathFrom = selectedNode.label
139
+ else pathTo = selectedNode.label
140
+ queryOpen = true
141
+ }
142
+
143
+ function quickShortestPath() {
144
+ if (!pathFrom.trim() || !pathTo.trim()) return
145
+ queryText = `GRAPH SHORTEST_PATH '${pathFrom.trim()}' TO '${pathTo.trim()}'`
146
+ queryOpen = true
147
+ runQuery(queryText)
148
+ }
149
+
150
+ // ─── node-type filter ───────────────────────────────────────────────────
151
+ const availableTypes = $derived.by(() => {
152
+ const counts = new Map<string, number>()
153
+ for (const n of graph.nodes) {
154
+ const t = String(n.data.node_type ?? 'node')
155
+ counts.set(t, (counts.get(t) ?? 0) + 1)
156
+ }
157
+ return [...counts.entries()].sort((a, b) => b[1] - a[1])
158
+ })
159
+
160
+ let hiddenTypes = $state<Set<string>>(new Set())
161
+
162
+ const filteredNodes = $derived.by(() => {
163
+ const q = labelFilter.trim().toLowerCase()
164
+ return graph.nodes.filter((n) => {
165
+ const type = String(n.data.node_type ?? 'node')
166
+ if (hiddenTypes.has(type)) return false
167
+ if (!q) return true
168
+ return n.label.toLowerCase().includes(q) || n.id.toLowerCase().includes(q)
169
+ }).sort((a, b) => compareGraphNodesByCentrality(a, b, nodeCentrality))
170
+ })
171
+
172
+ const renderTarget = $derived(Math.min(filteredNodes.length, canvasLimit))
173
+ const drawNodes = $derived(filteredNodes.slice(0, Math.min(renderTarget, renderBudget)))
174
+ const drawNodeIds = $derived(new Set(drawNodes.map((n) => n.id)))
175
+ const drawEdges = $derived(
176
+ graph.edges.filter((e) => drawNodeIds.has(e.source) && drawNodeIds.has(e.target)),
177
+ )
178
+
179
+ const rendering = $derived(drawNodes.length < renderTarget)
180
+ const truncated = $derived(filteredNodes.length > canvasLimit)
181
+
182
+ $effect(() => {
183
+ const total = renderTarget
184
+ const batchSize = viewMode === 'canvas' ? 1000 : 350
185
+ const initial = Math.min(total, batchSize)
186
+ renderBudget = initial
187
+ if (initial >= total) return
188
+
189
+ let cancelled = false
190
+ let raf = 0
191
+ let current = initial
192
+ const step = () => {
193
+ if (cancelled) return
194
+ current = Math.min(total, current + batchSize)
195
+ renderBudget = current
196
+ if (current < total) raf = requestAnimationFrame(step)
197
+ }
198
+ raf = requestAnimationFrame(step)
199
+ return () => {
200
+ cancelled = true
201
+ cancelAnimationFrame(raf)
202
+ }
203
+ })
204
+
205
+ function toggleType(t: string) {
206
+ const next = new Set(hiddenTypes)
207
+ if (next.has(t)) next.delete(t)
208
+ else next.add(t)
209
+ hiddenTypes = next
210
+ }
211
+
212
+ // Color/size contract (community fill + type stroke) lives in graph-sigma.ts
213
+ // so the WebGL adapter and the SVG fallback share one source of truth.
214
+ const focusEdgeColor = FOCUS_EDGE_COLOR
215
+ const focusNodeStrokeColor = FOCUS_NODE_STROKE_COLOR
216
+
217
+ // ─── sigma (WebGL) renderer for the canvas view ─────────────────────────
218
+ // sigma touches WebGL globals at import time, so it is loaded lazily in the
219
+ // browser only. Everything DOM-free lives in ./graph-sigma (and is tested).
220
+ //
221
+ // Ceiling: WebGL renders the node/edge geometry without blocking the main
222
+ // thread well past the previous 2D-canvas limit — the `limit` select goes to
223
+ // 50k and 25k draws smoothly. The remaining cost is the community-aware
224
+ // layout (Louvain → ForceAtlas2 in graph-render.ts), which is CPU-bound and
225
+ // unchanged by this migration; the progressive `renderBudget` reveal keeps it
226
+ // off the critical path. Above ~50k, raise the limit but expect the *layout*
227
+ // (not the render) to dominate frame time.
228
+ let sigmaContainer: HTMLDivElement | undefined = $state()
229
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
230
+ let sigmaInstance: any = null
231
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
232
+ let sigmaModules: { Sigma: any; NodeBorderProgram: any } | null = null
233
+ let cameraRatio = $state(1)
234
+ let pulseProgress = $state(0)
235
+ let pulseRaf = 0
236
+ let graphThemeVersion = $state(0)
237
+
238
+ function prefersReducedMotion(): boolean {
239
+ return (
240
+ typeof window !== 'undefined' &&
241
+ typeof window.matchMedia === 'function' &&
242
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
243
+ )
244
+ }
245
+
246
+ onDestroy(() => {
247
+ cancelAnimationFrame(pulseRaf)
248
+ if (sigmaInstance) {
249
+ sigmaInstance.kill()
250
+ sigmaInstance = null
251
+ }
252
+ })
253
+
254
+ onMount(() => {
255
+ const observer = new MutationObserver(() => {
256
+ graphThemeVersion += 1
257
+ })
258
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
259
+ return () => observer.disconnect()
260
+ })
261
+
262
+ function clearSelection() {
263
+ selectedNode = null
264
+ selectedEdge = null
265
+ }
266
+
267
+ function clearGraphFocus() {
268
+ hoveredFocusNodeId = null
269
+ pinnedFocusNodeId = null
270
+ }
271
+
272
+ function nodeById(id: string): GraphNode | null {
273
+ return graph.nodes.find((n) => n.id === id) ?? null
274
+ }
275
+
276
+ function selectRelationNode(relation: GraphNodeRelation) {
277
+ const node = nodeById(relation.nodeId)
278
+ if (!node) return
279
+ pinFocusNode(node)
280
+ selectNode(node)
281
+ }
282
+
283
+ function selectNode(node: GraphNode) {
284
+ selectedEdge = null
285
+ selectedNode = node
286
+ }
287
+
288
+ function pinFocusNode(node: GraphNode) {
289
+ pinnedFocusNodeId = node.id
290
+ hoveredFocusNodeId = node.id
291
+ }
292
+
293
+ function clickGraphNode(node: GraphNode) {
294
+ if (pinnedFocusNodeId) {
295
+ clearGraphFocus()
296
+ clearSelection()
297
+ return
298
+ }
299
+ pinFocusNode(node)
300
+ selectNode(node)
301
+ }
302
+
303
+ function selectEdge(edge: GraphEdge) {
304
+ selectedNode = null
305
+ selectedEdge = edge
306
+ }
307
+
308
+ function isPromotedNodeProperty(key: string): boolean {
309
+ return [
310
+ 'id',
311
+ 'rid',
312
+ 'label',
313
+ 'name',
314
+ 'title',
315
+ 'description',
316
+ 'summary',
317
+ 'content',
318
+ 'exports',
319
+ 'orphan',
320
+ 'is_orphan',
321
+ 'isOrphan',
322
+ 'dead_code',
323
+ 'deadCode',
324
+ ].includes(key)
325
+ }
326
+
327
+ function onSelectableKey(e: KeyboardEvent, fn: () => void) {
328
+ if (e.key !== 'Enter' && e.key !== ' ') return
329
+ e.preventDefault()
330
+ fn()
331
+ }
332
+
333
+ function clampZoom(value: number): number {
334
+ return Math.max(0.5, Math.min(8, value))
335
+ }
336
+
337
+ // Zoom percentage shown in the toolbar: sigma camera ratio for canvas,
338
+ // the SVG scale factor otherwise (ratio 1 = 100%, zooming in lowers ratio).
339
+ const zoomPercent = $derived(
340
+ viewMode === 'canvas'
341
+ ? Math.round(100 / (cameraRatio || 1))
342
+ : Math.round(svgZoom * 100),
343
+ )
344
+
345
+ function zoomSvg(direction: 'in' | 'out') {
346
+ if (viewMode === 'canvas') {
347
+ const camera = sigmaInstance?.getCamera()
348
+ if (!camera) return
349
+ if (direction === 'in') camera.animatedZoom(1.4)
350
+ else camera.animatedUnzoom(1.4)
351
+ return
352
+ }
353
+ const factor = direction === 'in' ? 1.25 : 0.8
354
+ svgZoom = clampZoom(svgZoom * factor)
355
+ }
356
+
357
+ function resetSvgZoom() {
358
+ if (viewMode === 'canvas') {
359
+ sigmaInstance?.getCamera().animatedReset()
360
+ return
361
+ }
362
+ svgZoom = 1
363
+ }
364
+
365
+ function onSvgWheel(e: WheelEvent) {
366
+ // Canvas mode lets sigma's built-in camera handle the wheel; this only
367
+ // drives the SVG fallback's scale transform.
368
+ e.preventDefault()
369
+ const factor = e.deltaY < 0 ? 1.25 : 0.8
370
+ svgZoom = clampZoom(svgZoom * factor)
371
+ }
372
+
373
+ const topEdgeLabels = $derived.by(() => {
374
+ const counts = new Map<string, number>()
375
+ for (const e of graph.edges) {
376
+ if (!e.label) continue
377
+ counts.set(e.label, (counts.get(e.label) ?? 0) + 1)
378
+ }
379
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4)
380
+ })
381
+
382
+ // Community-aware positions: Louvain → ForceAtlas2. Clusters group
383
+ // spatially, hubs gravitate to community centres, edge crossings drop.
384
+ // See packages/ui/src/lib/renderers/graph-render.ts → runGraphLayout.
385
+ const layout = $derived(runGraphLayout(drawNodes, drawEdges))
386
+
387
+ const fallbackSvgNodes = $derived.by<FallbackSvgNode[]>(() => {
388
+ return drawNodes.map((node) => {
389
+ const type = String(node.data.node_type ?? 'node')
390
+ const incomingScale = nodeSizeScales.get(node.id) ?? 1
391
+ const pos = layout.get(node.id)
392
+ // Community tint as primary color so clusters read at a glance;
393
+ // type still drives the radius/stroke contract.
394
+ const color = pos ? colorForCommunity(pos.community) : colorForType(type)
395
+ return {
396
+ ...node,
397
+ x: pos?.x ?? 50,
398
+ y: pos?.y ?? 50,
399
+ r: nodeVisualRadius(type, incomingScale),
400
+ color,
401
+ }
402
+ })
403
+ })
404
+
405
+ const fallbackSvgNodeById = $derived(new Map(fallbackSvgNodes.map((n) => [n.id, n])))
406
+ const fallbackSvgEdges = $derived.by<FallbackSvgEdge[]>(() => {
407
+ const out: FallbackSvgEdge[] = []
408
+ for (const edge of drawEdges) {
409
+ const source = fallbackSvgNodeById.get(edge.source)
410
+ const target = fallbackSvgNodeById.get(edge.target)
411
+ if (!source || !target) continue
412
+ out.push({
413
+ ...edge,
414
+ x1: source.x,
415
+ y1: source.y,
416
+ x2: target.x,
417
+ y2: target.y,
418
+ })
419
+ }
420
+ return out
421
+ })
422
+
423
+ const activeFocusNodeId = $derived(pinnedFocusNodeId ?? hoveredFocusNodeId)
424
+
425
+ const focusEdgeIds = $derived.by(() => {
426
+ const ids = new Set<string>()
427
+ if (!activeFocusNodeId) return ids
428
+ for (const edge of fallbackSvgEdges) {
429
+ if (edge.source === activeFocusNodeId || edge.target === activeFocusNodeId) ids.add(edge.id)
430
+ }
431
+ return ids
432
+ })
433
+
434
+ const focusNodeIds = $derived.by(() => {
435
+ const ids = new Set<string>()
436
+ if (!activeFocusNodeId) return ids
437
+ ids.add(activeFocusNodeId)
438
+ for (const edge of fallbackSvgEdges) {
439
+ if (edge.source === activeFocusNodeId) ids.add(edge.target)
440
+ if (edge.target === activeFocusNodeId) ids.add(edge.source)
441
+ }
442
+ return ids
443
+ })
444
+
445
+ // Focus pulse — drives pulseProgress, read by the sigma node reducer.
446
+ $effect(() => {
447
+ const focusId = activeFocusNodeId
448
+ if (!focusId) {
449
+ pulseProgress = 0
450
+ return
451
+ }
452
+ if (!fallbackSvgNodeById.has(focusId)) {
453
+ clearGraphFocus()
454
+ return
455
+ }
456
+ startFocusPulse()
457
+ })
458
+
459
+ function startFocusPulse() {
460
+ cancelAnimationFrame(pulseRaf)
461
+ // prefers-reduced-motion: skip the grow-and-settle pulse entirely.
462
+ if (prefersReducedMotion()) {
463
+ pulseProgress = 0
464
+ return
465
+ }
466
+ const start = performance.now()
467
+ pulseProgress = 1
468
+ const tick = () => {
469
+ const p = Math.max(0, 1 - (performance.now() - start) / 170)
470
+ pulseProgress = p
471
+ if (p > 0) pulseRaf = requestAnimationFrame(tick)
472
+ }
473
+ pulseRaf = requestAnimationFrame(tick)
474
+ }
475
+
476
+ const graphTransform = $derived(`translate(50 50) scale(${svgZoom}) translate(-50 -50)`)
477
+
478
+ function cssColor(name: string, fallback: string): string {
479
+ const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
480
+ return value || fallback
481
+ }
482
+
483
+ // Theme-aware palette handed to the sigma reducers (dark tokens drive it).
484
+ function sigmaThemeColors(): SigmaThemeColors {
485
+ const dark = document.documentElement.dataset.theme !== 'light'
486
+ return {
487
+ background: cssColor('--color-bg-0', dark ? '#050607' : '#ffffff'),
488
+ edge: cssColor('--color-line-3', dark ? '#3a424d' : '#c9cbd1'),
489
+ focusEdge: focusEdgeColor,
490
+ focusNodeStroke: focusNodeStrokeColor,
491
+ selectedNodeStroke: dark ? '#0a0a0b' : '#18181b',
492
+ nodeStroke: dark ? '#ffffff' : '#27272a',
493
+ warn: cssColor('--color-warn', dark ? '#fbbf24' : '#b45309'),
494
+ }
495
+ }
496
+
497
+ // Live state the sigma reducers close over; rebuilt cheaply per refresh.
498
+ function sigmaReducerState(): SigmaReducerState {
499
+ return {
500
+ focusId: activeFocusNodeId,
501
+ focus: { nodes: focusNodeIds, edges: focusEdgeIds },
502
+ selectedNodeId: selectedNode?.id ?? null,
503
+ pulse: pulseProgress,
504
+ colors: sigmaThemeColors(),
505
+ }
506
+ }
507
+
508
+ function buildCurrentSigmaGraph() {
509
+ return buildSigmaGraph({
510
+ nodes: drawNodes,
511
+ edges: drawEdges,
512
+ layout,
513
+ sizeScales: nodeSizeScales,
514
+ orphanIds: orphanNodeIds,
515
+ dark: document.documentElement.dataset.theme !== 'light',
516
+ edgeColor: sigmaThemeColors().edge,
517
+ })
518
+ }
519
+
520
+ function edgeById(id: string): GraphEdge | null {
521
+ return drawEdges.find((e) => e.id === id) ?? null
522
+ }
523
+
524
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
525
+ function wireSigmaEvents(instance: any) {
526
+ instance.on('clickNode', ({ node }: { node: string }) => {
527
+ const gn = nodeById(node)
528
+ if (gn) clickGraphNode(gn)
529
+ })
530
+ instance.on('enterNode', ({ node }: { node: string }) => {
531
+ if (!pinnedFocusNodeId) hoveredFocusNodeId = node
532
+ if (sigmaContainer) sigmaContainer.style.cursor = 'pointer'
533
+ })
534
+ instance.on('leaveNode', () => {
535
+ if (!pinnedFocusNodeId) hoveredFocusNodeId = null
536
+ if (sigmaContainer) sigmaContainer.style.cursor = ''
537
+ })
538
+ instance.on('clickEdge', ({ edge }: { edge: string }) => {
539
+ const ge = edgeById(edge)
540
+ if (ge) {
541
+ clearGraphFocus()
542
+ selectEdge(ge)
543
+ }
544
+ })
545
+ instance.on('clickStage', () => {
546
+ clearGraphFocus()
547
+ clearSelection()
548
+ })
549
+ const camera = instance.getCamera()
550
+ camera.on('updated', () => {
551
+ cameraRatio = camera.ratio
552
+ })
553
+ }
554
+
555
+ // Create / tear down the sigma (WebGL) instance with the canvas view.
556
+ $effect(() => {
557
+ if (viewMode !== 'canvas') return
558
+ const container = sigmaContainer
559
+ if (!container) return
560
+ let killed = false
561
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
562
+ let instance: any = null
563
+ void (async () => {
564
+ try {
565
+ if (!sigmaModules) {
566
+ const [sig, nb] = await Promise.all([
567
+ import('sigma'),
568
+ import('@sigma/node-border'),
569
+ ])
570
+ sigmaModules = { Sigma: sig.default, NodeBorderProgram: nb.NodeBorderProgram }
571
+ }
572
+ if (killed || !sigmaContainer) return
573
+ const { Sigma, NodeBorderProgram } = sigmaModules
574
+ instance = new Sigma(buildCurrentSigmaGraph(), container, {
575
+ allowInvalidContainer: true,
576
+ defaultNodeType: 'border',
577
+ nodeProgramClasses: { border: NodeBorderProgram },
578
+ defaultEdgeType: 'line',
579
+ renderEdgeLabels: true,
580
+ labelFont: '"JetBrains Mono Variable", monospace',
581
+ edgeLabelFont: '"JetBrains Mono Variable", monospace',
582
+ labelSize: 12,
583
+ edgeLabelSize: 10,
584
+ labelWeight: '700',
585
+ labelColor: { color: cssColor('--color-fg-0', '#ffffff') },
586
+ edgeLabelColor: { color: '#f6d36b' },
587
+ labelRenderedSizeThreshold: Infinity,
588
+ zIndex: true,
589
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
590
+ nodeReducer: (node: string, data: any) =>
591
+ reduceSigmaNode(node, data, sigmaReducerState()),
592
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
593
+ edgeReducer: (edge: string, data: any) =>
594
+ reduceSigmaEdge(edge, data, sigmaReducerState()),
595
+ })
596
+ wireSigmaEvents(instance)
597
+ sigmaInstance = instance
598
+ cameraRatio = instance.getCamera().ratio
599
+ canvasError = null
600
+ } catch (e) {
601
+ canvasError = (e as Error).message
602
+ }
603
+ })()
604
+ return () => {
605
+ killed = true
606
+ if (instance) instance.kill()
607
+ if (sigmaInstance === instance) sigmaInstance = null
608
+ }
609
+ })
610
+
611
+ // Swap the graph in place when the draw set, layout, or theme changes.
612
+ $effect(() => {
613
+ void drawNodes
614
+ void drawEdges
615
+ void layout
616
+ void nodeSizeScales
617
+ void orphanNodeIds
618
+ void graphThemeVersion
619
+ const instance = sigmaInstance
620
+ if (!instance || viewMode !== 'canvas') return
621
+ // Label colours are static settings (reducers can't set them), so refresh
622
+ // them here to stay theme-aware on a dark/light token swap.
623
+ instance.setSetting('labelColor', { color: cssColor('--color-fg-0', '#ffffff') })
624
+ instance.setGraph(buildCurrentSigmaGraph())
625
+ instance.refresh()
626
+ })
627
+
628
+ // Re-run reducers (no re-index) when focus / selection / pulse changes.
629
+ $effect(() => {
630
+ void activeFocusNodeId
631
+ void focusNodeIds
632
+ void focusEdgeIds
633
+ void selectedNode
634
+ void pulseProgress
635
+ const instance = sigmaInstance
636
+ if (!instance || viewMode !== 'canvas') return
637
+ instance.refresh({ skipIndexation: true })
638
+ })
639
+
640
+ function onSvgNodePointerEnter(node: GraphNode) {
641
+ if (!pinnedFocusNodeId) hoveredFocusNodeId = node.id
642
+ }
643
+
644
+ function onSvgNodePointerLeave(node: GraphNode) {
645
+ if (!pinnedFocusNodeId && hoveredFocusNodeId === node.id) hoveredFocusNodeId = null
646
+ }
647
+
648
+ function svgNodeRadius(node: FallbackSvgNode): number {
649
+ if (!activeFocusNodeId) return node.r * 1.14
650
+ if (activeFocusNodeId === node.id) return node.r * 2.24
651
+ if (focusNodeIds.has(node.id)) return node.r * 1.6
652
+ return node.r * 0.92
653
+ }
654
+
655
+ function svgNodeOpacity(node: FallbackSvgNode): number {
656
+ if (!activeFocusNodeId) return 1
657
+ return focusNodeIds.has(node.id) ? 1 : 0.12
658
+ }
659
+
660
+ function showSvgLabel(node: FallbackSvgNode): boolean {
661
+ if (!activeFocusNodeId) return false
662
+ if (activeFocusNodeId === node.id) return true
663
+ return focusNodeIds.size <= 36 && focusNodeIds.has(node.id)
664
+ }
665
+
666
+ function showSvgEdgeLabel(edge: FallbackSvgEdge): boolean {
667
+ return Boolean(activeFocusNodeId && focusEdgeIds.has(edge.id) && edge.label)
668
+ }
669
+ </script>
670
+
671
+ <div class="flex h-full flex-col text-fg-1">
672
+ {#if graph.nodes.length === 0 && graph.edges.length === 0}
673
+ <div class="flex-1 grid place-items-center text-fg-3 text-[12px] font-mono p-6">
674
+ No graph shape in this result.
675
+ </div>
676
+ {:else}
677
+ <!-- Toolbar -->
678
+ <div class="border-b border-line-1 px-3 py-1.5 flex items-center gap-2 text-[11px] font-mono bg-bg-1/40">
679
+ <div class="inline-flex border border-line-2 rounded overflow-hidden">
680
+ <a
681
+ href={collection ? collectionPageHref(collection, 'graph') : undefined}
682
+ class={[
683
+ 'inline-flex items-center gap-1 px-2 py-1 text-[11px] cursor-pointer transition-colors',
684
+ viewMode === 'canvas' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0',
685
+ ].join(' ')}
686
+ onclick={() => { if (!collection) viewMode = 'canvas' }}
687
+ aria-current={viewMode === 'canvas' ? 'page' : undefined}
688
+ >
689
+ <Network class="size-3" />
690
+ Canvas
691
+ </a>
692
+ <a
693
+ href={collection ? collectionPageHref(collection, 'svg') : undefined}
694
+ class={[
695
+ 'inline-flex items-center gap-1 px-2 py-1 text-[11px] cursor-pointer transition-colors border-l border-line-2',
696
+ viewMode === 'svg' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0',
697
+ ].join(' ')}
698
+ onclick={() => { if (!collection) viewMode = 'svg' }}
699
+ aria-current={viewMode === 'svg' ? 'page' : undefined}
700
+ >
701
+ <Network class="size-3" />
702
+ SVG
703
+ </a>
704
+ <a
705
+ href={collection ? collectionPageHref(collection, 'table') : undefined}
706
+ class={[
707
+ 'inline-flex items-center gap-1 px-2 py-1 text-[11px] cursor-pointer transition-colors border-l border-line-2',
708
+ viewMode === 'table' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0',
709
+ ].join(' ')}
710
+ onclick={() => { if (!collection) viewMode = 'table' }}
711
+ aria-current={viewMode === 'table' ? 'page' : undefined}
712
+ >
713
+ <Table2 class="size-3" />
714
+ Table
715
+ </a>
716
+ </div>
717
+
718
+ {#if viewMode !== 'table'}
719
+ <div class="inline-flex items-center gap-1.5 border border-line-1 rounded px-2 py-1 bg-bg-0">
720
+ <Search class="size-3 text-fg-3" />
721
+ <input
722
+ type="text"
723
+ bind:value={labelFilter}
724
+ placeholder="filter by label…"
725
+ class="bg-transparent text-fg-1 text-[11px] outline-none w-[160px] placeholder:text-fg-3"
726
+ />
727
+ {#if labelFilter}
728
+ <button type="button" class="text-fg-3 hover:text-fg-1 cursor-pointer" onclick={() => (labelFilter = '')} aria-label="Clear filter">
729
+ <X class="size-3" />
730
+ </button>
731
+ {/if}
732
+ </div>
733
+
734
+ <label class="inline-flex items-center gap-1.5 text-fg-3">
735
+ <Maximize2 class="size-3" />
736
+ <span>limit:</span>
737
+ <select
738
+ class="bg-bg-1 text-fg-1 border border-line-1 rounded px-1 py-0.5"
739
+ value={canvasLimit}
740
+ onchange={(e) => (canvasLimit = Number((e.currentTarget as HTMLSelectElement).value))}
741
+ >
742
+ <option value={500}>500</option>
743
+ <option value={1000}>1k</option>
744
+ <option value={2000}>2k</option>
745
+ <option value={5000}>5k</option>
746
+ <option value={10000}>10k</option>
747
+ <option value={25000}>25k</option>
748
+ <option value={50000}>50k</option>
749
+ </select>
750
+ </label>
751
+ {/if}
752
+
753
+ {#if viewMode !== 'table'}
754
+ <div class="inline-flex items-center overflow-hidden rounded border border-line-1 bg-bg-0">
755
+ <button
756
+ type="button"
757
+ class="inline-flex h-6 w-7 items-center justify-center text-fg-3 hover:bg-bg-1 hover:text-fg-0 disabled:opacity-40"
758
+ onclick={() => zoomSvg('out')}
759
+ disabled={viewMode === 'svg' && svgZoom <= 0.5}
760
+ title="Zoom out"
761
+ aria-label="Zoom out"
762
+ >
763
+ <ZoomOut class="size-3.5" />
764
+ </button>
765
+ <button
766
+ type="button"
767
+ class="inline-flex h-6 min-w-12 items-center justify-center border-x border-line-1 px-2 text-[10px] text-fg-3 hover:bg-bg-1 hover:text-fg-0"
768
+ onclick={resetSvgZoom}
769
+ title="Fit graph"
770
+ aria-label="Fit graph"
771
+ >
772
+ {zoomPercent}%
773
+ </button>
774
+ <button
775
+ type="button"
776
+ class="inline-flex h-6 w-7 items-center justify-center text-fg-3 hover:bg-bg-1 hover:text-fg-0 disabled:opacity-40"
777
+ onclick={() => zoomSvg('in')}
778
+ disabled={viewMode === 'svg' && svgZoom >= 8}
779
+ title="Zoom in"
780
+ aria-label="Zoom in"
781
+ >
782
+ <ZoomIn class="size-3.5" />
783
+ </button>
784
+ </div>
785
+ {/if}
786
+
787
+ <button
788
+ type="button"
789
+ class={[
790
+ 'inline-flex items-center gap-1 h-6 px-2 rounded border cursor-pointer transition-colors',
791
+ queryOpen ? 'border-accent/40 bg-accent/10 text-accent' : 'border-line-1 text-fg-3 hover:text-fg-1 hover:border-line-2',
792
+ ].join(' ')}
793
+ onclick={() => (queryOpen = !queryOpen)}
794
+ aria-pressed={queryOpen}
795
+ title="Run a graph query (GRAPH NEIGHBORHOOD / SHORTEST_PATH / MATCH …)"
796
+ >
797
+ <Code2 class="size-3" />
798
+ query
799
+ </button>
800
+
801
+ {#if overrideResult}
802
+ <button
803
+ type="button"
804
+ class="inline-flex items-center gap-1 h-6 px-2 rounded border border-line-1 text-fg-3 hover:text-fg-1 cursor-pointer"
805
+ onclick={resetToOriginal}
806
+ title="Discard graph query and show the original collection result"
807
+ >
808
+ <RotateCw class="size-3" />
809
+ reset
810
+ </button>
811
+ {/if}
812
+
813
+ <div class="ml-auto flex items-center gap-3 text-fg-3">
814
+ <span>{graph.nodes.length.toLocaleString()} nodes · {graph.edges.length.toLocaleString()} edges</span>
815
+ {#if viewMode !== 'table' && rendering}
816
+ <span class="text-accent">rendering {drawNodes.length.toLocaleString()} / {renderTarget.toLocaleString()}</span>
817
+ {/if}
818
+ {#if viewMode !== 'table' && truncated}
819
+ <span class="text-warn" title="More nodes match — raise the limit or narrow the filter to see them">
820
+ drawing {drawNodes.length.toLocaleString()}
821
+ </span>
822
+ {/if}
823
+ {#if collection}<span class="text-fg-2">{collection}</span>{/if}
824
+ </div>
825
+ </div>
826
+
827
+ {#if queryOpen}
828
+ <div class="border-b border-line-1 px-3 py-2 bg-bg-1/20 text-[11px] font-mono">
829
+ <div class="flex items-center gap-2 mb-1.5 text-fg-3">
830
+ <span>quick:</span>
831
+ <button
832
+ type="button"
833
+ class="px-1.5 py-0.5 rounded border border-line-1 hover:border-line-2 hover:text-fg-1 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
834
+ disabled={!selectedNode}
835
+ onclick={quickNeighborhood}
836
+ title={selectedNode ? `neighborhood of ${selectedNode.label}` : 'select a node first'}
837
+ >
838
+ neighborhood of selected
839
+ </button>
840
+ <button
841
+ type="button"
842
+ class="px-1.5 py-0.5 rounded border border-line-1 hover:border-line-2 hover:text-fg-1 cursor-pointer"
843
+ onclick={quickCentrality}
844
+ >
845
+ top centrality
846
+ </button>
847
+ <button
848
+ type="button"
849
+ class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-line-1 hover:border-line-2 hover:text-fg-1 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
850
+ disabled={!pathFrom.trim() || !pathTo.trim()}
851
+ onclick={quickShortestPath}
852
+ title="Run shortest path in reddb"
853
+ >
854
+ <Route class="size-3" />
855
+ shortest path
856
+ </button>
857
+ </div>
858
+ <div class="mb-1.5 grid grid-cols-[1fr_1fr_auto_auto] gap-2">
859
+ <input
860
+ type="text"
861
+ bind:value={pathFrom}
862
+ placeholder="from label…"
863
+ class="h-6 rounded border border-line-1 bg-bg-0 px-2 text-[11px] text-fg-1 outline-none placeholder:text-fg-3 focus:border-accent"
864
+ />
865
+ <input
866
+ type="text"
867
+ bind:value={pathTo}
868
+ placeholder="to label…"
869
+ class="h-6 rounded border border-line-1 bg-bg-0 px-2 text-[11px] text-fg-1 outline-none placeholder:text-fg-3 focus:border-accent"
870
+ />
871
+ <button
872
+ type="button"
873
+ class="rounded border border-line-1 px-1.5 text-fg-3 hover:border-line-2 hover:text-fg-1 disabled:opacity-40 disabled:cursor-not-allowed"
874
+ disabled={!selectedNode}
875
+ onclick={() => useSelectedForPath('from')}
876
+ title={selectedNode ? `Use ${selectedNode.label} as source` : 'select a node first'}
877
+ >
878
+ use as from
879
+ </button>
880
+ <button
881
+ type="button"
882
+ class="rounded border border-line-1 px-1.5 text-fg-3 hover:border-line-2 hover:text-fg-1 disabled:opacity-40 disabled:cursor-not-allowed"
883
+ disabled={!selectedNode}
884
+ onclick={() => useSelectedForPath('to')}
885
+ title={selectedNode ? `Use ${selectedNode.label} as target` : 'select a node first'}
886
+ >
887
+ use as to
888
+ </button>
889
+ </div>
890
+ <div class="flex items-stretch gap-2">
891
+ <textarea
892
+ bind:value={queryText}
893
+ placeholder={collection ? `GRAPH NEIGHBORHOOD '<label>' DEPTH 2\nGRAPH SHORTEST_PATH '<from>' TO '<to>'\nMATCH (n)-[r]->(m) RETURN n, r, m LIMIT 100` : 'GRAPH NEIGHBORHOOD … / GRAPH SHORTEST_PATH … / MATCH …'}
894
+ spellcheck="false"
895
+ rows="3"
896
+ class="flex-1 bg-bg-0 text-fg-1 border border-line-1 rounded px-2 py-1 text-[12px] font-mono outline-none focus:border-accent placeholder:text-fg-3 resize-none"
897
+ onkeydown={(e) => {
898
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
899
+ e.preventDefault()
900
+ runQuery(queryText)
901
+ }
902
+ }}
903
+ ></textarea>
904
+ <div class="flex flex-col gap-1.5">
905
+ <button
906
+ type="button"
907
+ onclick={() => runQuery(queryText)}
908
+ disabled={querying || !queryText.trim()}
909
+ class="inline-flex items-center gap-1.5 h-7 px-3 rounded bg-accent text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed hover:bg-[#ff3868]"
910
+ >
911
+ {#if querying}<Loader2 class="size-3 animate-spin" />{:else}<Play class="size-3" />{/if}
912
+ run
913
+ <span class="text-[9px] opacity-70">⌘↵</span>
914
+ </button>
915
+ </div>
916
+ </div>
917
+ {#if queryError}
918
+ <div class="mt-1.5 text-warn">{queryError}</div>
919
+ {/if}
920
+ </div>
921
+ {/if}
922
+
923
+ {#if canvasError}
924
+ <div class="border-b border-line-1 bg-warn/10 px-3 py-2 text-[11px] font-mono text-warn flex items-start gap-2">
925
+ <AlertTriangle class="mt-0.5 size-3.5 shrink-0" />
926
+ <div>
927
+ <div>WebGL renderer unavailable. Switch to SVG or Table.</div>
928
+ <div class="mt-0.5 break-words text-fg-3">{canvasError}</div>
929
+ </div>
930
+ </div>
931
+ {/if}
932
+
933
+ <!-- Body -->
934
+ <div class="flex-1 min-h-0 relative">
935
+ {#if viewMode === 'canvas'}
936
+ <!-- sigma.js mounts its WebGL canvases into this container -->
937
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
938
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
939
+ <div
940
+ bind:this={sigmaContainer}
941
+ class="sigma-canvas absolute inset-0 h-full w-full bg-bg-0"
942
+ role="application"
943
+ tabindex="0"
944
+ aria-label={`${drawNodes.length} graph nodes and ${drawEdges.length} graph edges`}
945
+ onkeydown={(event) => { if (event.key === 'Escape') { clearGraphFocus(); clearSelection() } }}
946
+ ></div>
947
+ {:else if viewMode === 'svg'}
948
+ <div class="absolute inset-0 overflow-hidden bg-bg-0">
949
+ <svg
950
+ class="h-full w-full"
951
+ viewBox="0 0 100 100"
952
+ preserveAspectRatio="xMidYMid meet"
953
+ role="button"
954
+ tabindex="0"
955
+ aria-label={`${drawNodes.length} graph nodes and ${drawEdges.length} graph edges`}
956
+ onwheel={onSvgWheel}
957
+ onclick={() => { clearGraphFocus(); clearSelection() }}
958
+ onkeydown={(event) => { if (event.key === 'Escape') { clearGraphFocus(); clearSelection() } }}
959
+ >
960
+ <rect x="0" y="0" width="100" height="100" fill="transparent" />
961
+ <g transform={graphTransform}>
962
+ <g>
963
+ {#each fallbackSvgEdges as e (e.id)}
964
+ {@const focusedEdge = focusEdgeIds.has(e.id)}
965
+ <line
966
+ x1={e.x1}
967
+ y1={e.y1}
968
+ x2={e.x2}
969
+ y2={e.y2}
970
+ stroke={activeFocusNodeId ? (focusedEdge ? focusEdgeColor : 'currentColor') : 'currentColor'}
971
+ stroke-width={activeFocusNodeId ? (focusedEdge ? '0.27' : '0.055') : '0.105'}
972
+ opacity={activeFocusNodeId ? (focusedEdge ? '0.98' : '0.055') : '0.36'}
973
+ class="text-line-3 cursor-pointer hover:text-accent"
974
+ vector-effect="non-scaling-stroke"
975
+ role="button"
976
+ tabindex="0"
977
+ aria-label={`${e.label ?? 'edge'} from ${e.source} to ${e.target}`}
978
+ onclick={(event) => { event.stopPropagation(); clearGraphFocus(); selectEdge(e) }}
979
+ onkeydown={(event) => onSelectableKey(event, () => { clearGraphFocus(); selectEdge(e) })}
980
+ >
981
+ <title>{e.label ?? 'edge'}: {e.source} -> {e.target}</title>
982
+ </line>
983
+ {#if showSvgEdgeLabel(e)}
984
+ <text
985
+ x={(e.x1 + e.x2) / 2 + 0.45}
986
+ y={(e.y1 + e.y2) / 2 - 0.45}
987
+ fill="#f6d36b"
988
+ opacity="0.96"
989
+ font-size="1"
990
+ font-weight="700"
991
+ font-family="JetBrains Mono Variable, monospace"
992
+ paint-order="stroke"
993
+ stroke="#020617"
994
+ stroke-width="0.38"
995
+ class="pointer-events-none select-none"
996
+ >
997
+ {e.label?.slice(0, 28)}
998
+ </text>
999
+ {/if}
1000
+ {/each}
1001
+ </g>
1002
+ <g>
1003
+ {#each fallbackSvgNodes as n (n.id)}
1004
+ {@const orphan = orphanNodeIds.has(n.id)}
1005
+ {#if orphan}
1006
+ <circle
1007
+ cx={n.x}
1008
+ cy={n.y}
1009
+ r={svgNodeRadius(n) + 0.65}
1010
+ fill="none"
1011
+ stroke="var(--color-warn)"
1012
+ stroke-width={activeFocusNodeId === n.id ? '0.38' : '0.24'}
1013
+ opacity={activeFocusNodeId && !focusNodeIds.has(n.id) ? '0.16' : '0.72'}
1014
+ vector-effect="non-scaling-stroke"
1015
+ class="pointer-events-none"
1016
+ />
1017
+ {/if}
1018
+ <circle
1019
+ cx={n.x}
1020
+ cy={n.y}
1021
+ r={svgNodeRadius(n)}
1022
+ fill={n.color}
1023
+ opacity={svgNodeOpacity(n)}
1024
+ stroke={activeFocusNodeId === n.id ? focusNodeStrokeColor : selectedNode?.id === n.id ? '#0a0a0b' : orphan ? 'var(--color-warn)' : '#ffffff'}
1025
+ stroke-width={activeFocusNodeId === n.id ? '0.55' : focusNodeIds.has(n.id) ? '0.22' : selectedNode?.id === n.id || orphan ? '0.4' : '0.14'}
1026
+ class="cursor-pointer graph-node"
1027
+ class:focus-primary={activeFocusNodeId === n.id}
1028
+ vector-effect="non-scaling-stroke"
1029
+ role="button"
1030
+ tabindex="0"
1031
+ aria-label={n.label}
1032
+ onpointerenter={() => onSvgNodePointerEnter(n)}
1033
+ onpointerleave={() => onSvgNodePointerLeave(n)}
1034
+ onclick={(event) => { event.stopPropagation(); clickGraphNode(n) }}
1035
+ onkeydown={(event) => onSelectableKey(event, () => clickGraphNode(n))}
1036
+ >
1037
+ <title>{n.label}</title>
1038
+ </circle>
1039
+ {#if showSvgLabel(n)}
1040
+ <text
1041
+ x={n.x + svgNodeRadius(n) + 0.85}
1042
+ y={n.y + 0.15}
1043
+ fill={activeFocusNodeId === n.id ? '#f8fafc' : '#cbd5e1'}
1044
+ opacity={activeFocusNodeId === n.id ? '1' : '0.9'}
1045
+ font-size={activeFocusNodeId === n.id ? '1.35' : '1.15'}
1046
+ font-weight={activeFocusNodeId === n.id ? '700' : '600'}
1047
+ font-family="JetBrains Mono Variable, monospace"
1048
+ paint-order="stroke"
1049
+ stroke="#020617"
1050
+ stroke-width={activeFocusNodeId === n.id ? '0.48' : '0.38'}
1051
+ class="pointer-events-none select-none"
1052
+ >
1053
+ {n.label.slice(0, 36)}
1054
+ </text>
1055
+ {/if}
1056
+ {/each}
1057
+ </g>
1058
+ </g>
1059
+ </svg>
1060
+ </div>
1061
+ {:else}
1062
+ <div class="absolute inset-0 overflow-auto">
1063
+ <table class="w-full border-collapse text-[12px] font-mono">
1064
+ <thead class="sticky top-0 bg-bg-1 z-10">
1065
+ <tr class="border-b border-line-1 text-fg-3">
1066
+ <th class="px-2 py-1.5 text-left font-normal whitespace-nowrap">type</th>
1067
+ <th class="px-2 py-1.5 text-left font-normal whitespace-nowrap">id / rid</th>
1068
+ <th class="px-2 py-1.5 text-left font-normal whitespace-nowrap">label</th>
1069
+ <th class="px-2 py-1.5 text-left font-normal whitespace-nowrap">node_type / endpoints</th>
1070
+ </tr>
1071
+ </thead>
1072
+ <tbody>
1073
+ {#each graph.nodes as n (n.id)}
1074
+ {@const orphan = orphanNodeIds.has(n.id)}
1075
+ <tr class={['border-b border-line-1/60 hover:bg-bg-1/40 cursor-pointer', orphan ? 'bg-warn/10' : ''].join(' ')} onclick={() => { selectedEdge = null; selectedNode = n }}>
1076
+ <td class="px-2 py-1 text-fg-3">node</td>
1077
+ <td class="px-2 py-1 text-accent font-semibold">{n.id}</td>
1078
+ <td class="px-2 py-1 text-fg-0">{n.label}</td>
1079
+ <td class="px-2 py-1 text-fg-2">
1080
+ {String(n.data.node_type ?? '')}
1081
+ {#if orphan}<span class="ml-2 text-warn">orphan</span>{/if}
1082
+ </td>
1083
+ </tr>
1084
+ {/each}
1085
+ {#each graph.edges as e (e.id)}
1086
+ <tr class="border-b border-line-1/60 hover:bg-bg-1/40 cursor-pointer" onclick={() => { selectedNode = null; selectedEdge = e }}>
1087
+ <td class="px-2 py-1 text-fg-3">edge</td>
1088
+ <td class="px-2 py-1 text-fg-2">{e.id}</td>
1089
+ <td class="px-2 py-1 text-fg-0">{e.label ?? ''}</td>
1090
+ <td class="px-2 py-1 text-fg-2">{e.source} → {e.target}</td>
1091
+ </tr>
1092
+ {/each}
1093
+ </tbody>
1094
+ </table>
1095
+ </div>
1096
+ {/if}
1097
+
1098
+ <!-- Side panel: node / edge details -->
1099
+ {#if selectedNode || selectedEdge}
1100
+ <aside class="absolute top-0 right-0 bottom-0 w-[340px] bg-bg-1 border-l border-line-2 shadow-2xl flex flex-col z-20">
1101
+ <header class="flex items-center gap-2 px-3 py-2 border-b border-line-1 text-[11px] font-mono">
1102
+ <Info class="size-3.5 text-accent" />
1103
+ <span class="type-label">
1104
+ {selectedNode ? 'Node' : 'Edge'}
1105
+ </span>
1106
+ <span class="ml-auto"></span>
1107
+ <button type="button" onclick={() => { clearGraphFocus(); clearSelection() }} aria-label="Close panel" class="text-fg-3 hover:text-fg-1 cursor-pointer">
1108
+ <X class="size-3.5" />
1109
+ </button>
1110
+ </header>
1111
+ <div class="flex-1 overflow-auto p-3 text-[12px] font-mono">
1112
+ {#if selectedNode && selectedNodeDetail}
1113
+ <div class="mb-3">
1114
+ <div class="flex items-start gap-2">
1115
+ <div class="min-w-0 flex-1">
1116
+ <div class="text-fg-3 text-[10px] uppercase tracking-wider">label</div>
1117
+ <div class="break-words text-[13px] font-semibold text-fg-0">{selectedNode.label}</div>
1118
+ </div>
1119
+ {#if selectedNodeDetail.orphan}
1120
+ <span class="inline-flex shrink-0 items-center gap-1 rounded border border-warn/40 bg-warn/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-warn">
1121
+ <AlertTriangle class="size-3" /> orphan
1122
+ </span>
1123
+ {/if}
1124
+ </div>
1125
+ <div class="mt-1 break-all text-accent">{String(selectedNode.data.rid ?? selectedNode.id)}</div>
1126
+ </div>
1127
+
1128
+ <div class="mb-3 border-t border-line-1 pt-3">
1129
+ <div class="mb-1 text-fg-3 text-[10px] uppercase tracking-wider">description</div>
1130
+ <div class="whitespace-pre-wrap break-words text-fg-1">
1131
+ {selectedNodeDetail.description ?? 'No description in graph contract.'}
1132
+ </div>
1133
+ </div>
1134
+
1135
+ <div class="mb-3 border-t border-line-1 pt-3">
1136
+ <div class="mb-1.5 flex items-center justify-between gap-2">
1137
+ <div class="text-fg-3 text-[10px] uppercase tracking-wider">exports</div>
1138
+ <span class="text-[10px] text-fg-3">{selectedNodeDetail.exports.length.toLocaleString()}</span>
1139
+ </div>
1140
+ {#if selectedNodeDetail.exports.length > 0}
1141
+ <div class="flex flex-wrap gap-1">
1142
+ {#each selectedNodeDetail.exports as name}
1143
+ <span class="max-w-full truncate rounded border border-line-1 bg-bg-0 px-1.5 py-0.5 text-[11px] text-fg-1">
1144
+ {name}
1145
+ </span>
1146
+ {/each}
1147
+ </div>
1148
+ {:else}
1149
+ <div class="text-fg-3">No exports recorded.</div>
1150
+ {/if}
1151
+ </div>
1152
+
1153
+ <div class="mb-3 border-t border-line-1 pt-3">
1154
+ <div class="mb-1.5 flex items-center justify-between gap-2">
1155
+ <div class="text-fg-3 text-[10px] uppercase tracking-wider">where used</div>
1156
+ <span class="text-[10px] text-fg-3">{selectedNodeDetail.whereUsed.length.toLocaleString()} inbound</span>
1157
+ </div>
1158
+ {#if selectedNodeDetail.whereUsed.length > 0}
1159
+ <div class="space-y-1">
1160
+ {#each selectedNodeDetail.whereUsed as relation (relation.id)}
1161
+ <button
1162
+ type="button"
1163
+ class="grid w-full grid-cols-[82px_1fr] gap-2 rounded border border-line-1 bg-bg-0 px-2 py-1.5 text-left hover:border-line-2 hover:bg-bg-2"
1164
+ onclick={() => selectRelationNode(relation)}
1165
+ >
1166
+ <span class="text-[10px] uppercase tracking-wider text-fg-3">{relation.label}</span>
1167
+ <span class="min-w-0">
1168
+ <span class="block truncate text-fg-0">{relation.nodeLabel}</span>
1169
+ <span class="block truncate text-[10px] text-fg-3">{relation.edgeLabel ?? relation.kind}</span>
1170
+ </span>
1171
+ </button>
1172
+ {/each}
1173
+ </div>
1174
+ {:else}
1175
+ <div class="text-fg-3">No inbound uses.</div>
1176
+ {/if}
1177
+ </div>
1178
+
1179
+ <div class="mb-3 border-t border-line-1 pt-3">
1180
+ <div class="mb-1.5 grid grid-cols-2 gap-2">
1181
+ <div>
1182
+ <div class="text-fg-3 text-[10px] uppercase tracking-wider">parents</div>
1183
+ <div class="text-[10px] text-fg-3">incoming direction</div>
1184
+ </div>
1185
+ <div>
1186
+ <div class="text-fg-3 text-[10px] uppercase tracking-wider">children</div>
1187
+ <div class="text-[10px] text-fg-3">outgoing direction</div>
1188
+ </div>
1189
+ </div>
1190
+ <div class="grid grid-cols-2 gap-2">
1191
+ <div class="space-y-1">
1192
+ {#if selectedNodeDetail.incoming.length > 0}
1193
+ {#each selectedNodeDetail.incoming as relation (relation.id)}
1194
+ <button
1195
+ type="button"
1196
+ class="w-full rounded border border-line-1 bg-bg-0 px-2 py-1 text-left hover:border-line-2 hover:bg-bg-2"
1197
+ onclick={() => selectRelationNode(relation)}
1198
+ >
1199
+ <span class="block truncate text-[10px] text-fg-3">{relation.label}</span>
1200
+ <span class="block truncate text-fg-1">{relation.nodeLabel}</span>
1201
+ </button>
1202
+ {/each}
1203
+ {:else}
1204
+ <div class="rounded border border-line-1 px-2 py-1 text-fg-3">none</div>
1205
+ {/if}
1206
+ </div>
1207
+ <div class="space-y-1">
1208
+ {#if selectedNodeDetail.outgoing.length > 0}
1209
+ {#each selectedNodeDetail.outgoing as relation (relation.id)}
1210
+ <button
1211
+ type="button"
1212
+ class="w-full rounded border border-line-1 bg-bg-0 px-2 py-1 text-left hover:border-line-2 hover:bg-bg-2"
1213
+ onclick={() => selectRelationNode(relation)}
1214
+ >
1215
+ <span class="block truncate text-[10px] text-fg-3">{relation.label}</span>
1216
+ <span class="block truncate text-fg-1">{relation.nodeLabel}</span>
1217
+ </button>
1218
+ {/each}
1219
+ {:else}
1220
+ <div class="rounded border border-line-1 px-2 py-1 text-fg-3">none</div>
1221
+ {/if}
1222
+ </div>
1223
+ </div>
1224
+ </div>
1225
+ {:else if selectedEdge}
1226
+ <div class="mb-2">
1227
+ <div class="text-fg-3 text-[10px] uppercase tracking-wider">label</div>
1228
+ <div class="text-fg-0 font-semibold">{selectedEdge.label ?? '(unlabelled)'}</div>
1229
+ </div>
1230
+ <div class="mb-2 grid grid-cols-[60px_1fr] gap-x-2 gap-y-1">
1231
+ <div class="text-fg-3 text-[10px] uppercase tracking-wider">from</div>
1232
+ <div class="text-fg-1">
1233
+ {#if nodeById(selectedEdge.source)}{nodeById(selectedEdge.source)!.label}{:else}<code>{selectedEdge.source}</code>{/if}
1234
+ </div>
1235
+ <div class="text-fg-3 text-[10px] uppercase tracking-wider">to</div>
1236
+ <div class="text-fg-1">
1237
+ {#if nodeById(selectedEdge.target)}{nodeById(selectedEdge.target)!.label}{:else}<code>{selectedEdge.target}</code>{/if}
1238
+ </div>
1239
+ </div>
1240
+ {/if}
1241
+
1242
+ <div class="mt-3 mb-1 text-fg-3 text-[10px] uppercase tracking-wider">properties</div>
1243
+ <dl class="grid grid-cols-[110px_1fr] gap-x-2 gap-y-1">
1244
+ {#each Object.entries((selectedNode ? selectedNode.data : selectedEdge?.data) ?? {}) as [k, v]}
1245
+ {#if v !== null && v !== undefined && !(selectedNode && isPromotedNodeProperty(k)) && !(selectedEdge && k === 'label')}
1246
+ <dt class="text-fg-3 truncate">{k}</dt>
1247
+ <dd class="text-fg-1 break-words">
1248
+ {#if typeof v === 'object'}
1249
+ <pre class="whitespace-pre-wrap text-[11px]">{JSON.stringify(v, null, 2)}</pre>
1250
+ {:else}
1251
+ {String(v)}
1252
+ {/if}
1253
+ </dd>
1254
+ {/if}
1255
+ {/each}
1256
+ </dl>
1257
+ </div>
1258
+ </aside>
1259
+ {/if}
1260
+ </div>
1261
+
1262
+ <!-- Footer: node-type chips + top edge labels -->
1263
+ <div class="border-t border-line-1 px-3 py-1.5 text-[10px] font-mono text-fg-3 flex items-center gap-2 flex-wrap">
1264
+ {#if availableTypes.length > 1}
1265
+ <span class="text-fg-3">types:</span>
1266
+ {#each availableTypes as [t, count] (t)}
1267
+ {@const hidden = hiddenTypes.has(t)}
1268
+ <button
1269
+ type="button"
1270
+ onclick={() => toggleType(t)}
1271
+ title={hidden ? `Show ${t}` : `Hide ${t}`}
1272
+ class={[
1273
+ 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border transition-colors cursor-pointer',
1274
+ hidden
1275
+ ? 'border-line-1 text-fg-3 line-through opacity-60'
1276
+ : 'border-line-2 text-fg-1 hover:border-accent',
1277
+ ].join(' ')}
1278
+ >
1279
+ <span style:color={hidden ? undefined : colorForType(t)}>●</span>
1280
+ <span>{t}</span>
1281
+ <span class="text-fg-3">{count.toLocaleString()}</span>
1282
+ </button>
1283
+ {/each}
1284
+ {/if}
1285
+ <span class="ml-auto"></span>
1286
+ {#if topEdgeLabels.length > 0}
1287
+ {#each topEdgeLabels as [label, count]}
1288
+ <span><span class="text-fg-2">{label}</span> <span class="text-fg-3">·</span> {count.toLocaleString()}</span>
1289
+ {/each}
1290
+ {/if}
1291
+ </div>
1292
+ {/if}
1293
+ </div>
1294
+
1295
+ <style>
1296
+ .sigma-canvas {
1297
+ background-color: var(--color-bg-0);
1298
+ }
1299
+ .sigma-canvas :global(canvas) {
1300
+ background-color: var(--color-bg-0) !important;
1301
+ }
1302
+ .graph-node {
1303
+ transition:
1304
+ r 80ms ease-out,
1305
+ opacity 80ms ease-out,
1306
+ stroke-width 80ms ease-out;
1307
+ }
1308
+ .focus-primary {
1309
+ animation: graph-focus-pop 170ms ease-out;
1310
+ transform-box: fill-box;
1311
+ transform-origin: center;
1312
+ }
1313
+ @keyframes graph-focus-pop {
1314
+ 0% { transform: scale(0.72); }
1315
+ 72% { transform: scale(1.12); }
1316
+ 100% { transform: scale(1); }
1317
+ }
1318
+ /* Respect reduced-motion on the focus pulse (SVG fallback; the sigma
1319
+ renderer is gated separately via prefersReducedMotion()). */
1320
+ @media (prefers-reduced-motion: reduce) {
1321
+ .graph-node {
1322
+ transition: none;
1323
+ }
1324
+ .focus-primary {
1325
+ animation: none;
1326
+ }
1327
+ }
1328
+ </style>