@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.
- package/LICENSE +17 -0
- package/bin/red-ui.js +259 -0
- package/build/CNAME +1 -0
- package/build/_app/env.js +1 -0
- package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
- package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
- package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
- package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
- package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
- package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
- package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
- package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
- package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
- package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
- package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
- package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
- package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
- package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
- package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
- package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
- package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
- package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
- package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
- package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
- package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
- package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
- package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
- package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
- package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
- package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
- package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
- package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
- package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
- package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
- package/build/_app/version.json +1 -0
- package/build/favicon.png +0 -0
- package/build/index.html +59 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
- package/dist/embed/core-QuRYvMt8.js +118 -0
- package/dist/embed/core-QuRYvMt8.js.map +1 -0
- package/dist/embed/index-B6ZQhCsu.js +577 -0
- package/dist/embed/index-B6ZQhCsu.js.map +1 -0
- package/dist/embed/index-client-ItCEU64C.js +3087 -0
- package/dist/embed/index-client-ItCEU64C.js.map +1 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
- package/dist/embed/index.js +11 -0
- package/dist/embed/index.js.map +1 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
- package/dist/embed/ui.css +1 -0
- package/package.json +101 -0
- package/src/app.css +238 -0
- package/src/lib/ActivityIndicator.svelte +100 -0
- package/src/lib/ClusterNode.svelte +119 -0
- package/src/lib/ClusterView.svelte +541 -0
- package/src/lib/CollectionHistory.svelte +266 -0
- package/src/lib/CollectionsView.svelte +14 -0
- package/src/lib/CommandPalette.svelte +237 -0
- package/src/lib/ConnectDropdown.svelte +222 -0
- package/src/lib/EmptyState.svelte +27 -0
- package/src/lib/LiveChanges.svelte +195 -0
- package/src/lib/MasterPasswordDialog.svelte +168 -0
- package/src/lib/PageHeader.svelte +32 -0
- package/src/lib/PendingChangesPanel.svelte +153 -0
- package/src/lib/QueryEditor.svelte +122 -0
- package/src/lib/ResultsPane.svelte +747 -0
- package/src/lib/SchemaTree.svelte +193 -0
- package/src/lib/SecurityView.svelte +255 -0
- package/src/lib/ShortcutOverlay.svelte +104 -0
- package/src/lib/Splash.svelte +46 -0
- package/src/lib/StatusBar.svelte +163 -0
- package/src/lib/SubNav.svelte +62 -0
- package/src/lib/Topbar.svelte +119 -0
- package/src/lib/Workspace.svelte +247 -0
- package/src/lib/activity.svelte.ts +91 -0
- package/src/lib/build-info.ts +41 -0
- package/src/lib/capability.ts +221 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/collection-catalog.ts +116 -0
- package/src/lib/collection-pages.ts +33 -0
- package/src/lib/connection-history.ts +301 -0
- package/src/lib/connections.svelte.ts +493 -0
- package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
- package/src/lib/embed/index.ts +234 -0
- package/src/lib/graph-loader/contract.ts +336 -0
- package/src/lib/graph-loader/drilldown.ts +334 -0
- package/src/lib/graph-loader/index.ts +13 -0
- package/src/lib/index.ts +114 -0
- package/src/lib/pending-changes.svelte.ts +149 -0
- package/src/lib/query-tabs.svelte.ts +130 -0
- package/src/lib/reddb/boot-params.ts +254 -0
- package/src/lib/reddb/cdc-stream-client.ts +234 -0
- package/src/lib/reddb/client.ts +698 -0
- package/src/lib/reddb/connection-bootstrap.ts +260 -0
- package/src/lib/reddb/connection-provider.contract.ts +72 -0
- package/src/lib/reddb/connection-provider.ts +72 -0
- package/src/lib/reddb/index.ts +14 -0
- package/src/lib/reddb/injected-client-provider.ts +76 -0
- package/src/lib/reddb/local-url-provider.ts +245 -0
- package/src/lib/reddb/secure-store.ts +41 -0
- package/src/lib/reddb/server-capabilities.ts +71 -0
- package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
- package/src/lib/reddb/transports.ts +89 -0
- package/src/lib/reddb/types.ts +32 -0
- package/src/lib/reddb/uri.ts +54 -0
- package/src/lib/reddb/web-encrypted-store.ts +201 -0
- package/src/lib/renderers/DiffRenderer.svelte +184 -0
- package/src/lib/renderers/DocumentRenderer.svelte +134 -0
- package/src/lib/renderers/GraphRenderer.svelte +1328 -0
- package/src/lib/renderers/HypertableRenderer.svelte +253 -0
- package/src/lib/renderers/JsonRenderer.svelte +30 -0
- package/src/lib/renderers/KvRenderer.svelte +150 -0
- package/src/lib/renderers/QueueRenderer.svelte +286 -0
- package/src/lib/renderers/StatsRenderer.svelte +123 -0
- package/src/lib/renderers/TableRenderer.svelte +440 -0
- package/src/lib/renderers/VectorRenderer.svelte +388 -0
- package/src/lib/renderers/diff-render.ts +122 -0
- package/src/lib/renderers/diff.ts +9 -0
- package/src/lib/renderers/document-render.ts +64 -0
- package/src/lib/renderers/document.ts +9 -0
- package/src/lib/renderers/graph-render.ts +631 -0
- package/src/lib/renderers/graph-sigma.ts +395 -0
- package/src/lib/renderers/graph.ts +9 -0
- package/src/lib/renderers/hypertable-render.ts +320 -0
- package/src/lib/renderers/hypertable.ts +9 -0
- package/src/lib/renderers/index.ts +42 -0
- package/src/lib/renderers/json-render.ts +89 -0
- package/src/lib/renderers/json.ts +10 -0
- package/src/lib/renderers/kv-render.ts +193 -0
- package/src/lib/renderers/kv.ts +9 -0
- package/src/lib/renderers/queue-render.ts +197 -0
- package/src/lib/renderers/queue.ts +9 -0
- package/src/lib/renderers/registry.ts +68 -0
- package/src/lib/renderers/stats-render.ts +73 -0
- package/src/lib/renderers/stats.ts +9 -0
- package/src/lib/renderers/table-render.ts +156 -0
- package/src/lib/renderers/table.ts +11 -0
- package/src/lib/renderers/types.ts +33 -0
- package/src/lib/renderers/vector-render.ts +154 -0
- package/src/lib/renderers/vector.ts +9 -0
- package/src/lib/router.svelte.ts +163 -0
- package/src/lib/secureStore.svelte.ts +148 -0
- package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
- package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
- package/src/lib/shortcuts.ts +22 -0
- package/src/lib/surface.ts +80 -0
- package/src/lib/tabs.svelte.ts +115 -0
- 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>
|