@andespindola/brainlink 0.1.0-beta.16 → 0.1.0-beta.160
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/AGENTS.md +9 -6
- package/CHANGELOG.md +27 -0
- package/COPYRIGHT.md +5 -0
- package/README.md +177 -20
- package/dist/application/add-note.js +13 -44
- package/dist/application/auto-migrate-configured-vault.js +37 -0
- package/dist/application/build-context.js +64 -3
- package/dist/application/canonical-context-links.js +209 -0
- package/dist/application/dedupe-notes.js +226 -0
- package/dist/application/frontend/client-css.js +241 -51
- package/dist/application/frontend/client-html.js +50 -27
- package/dist/application/frontend/client-js.js +1369 -605
- package/dist/application/frontend/client-render-worker-js.js +622 -0
- package/dist/application/frontend/client-worker-js.js +66 -0
- package/dist/application/get-graph-contexts.js +33 -0
- package/dist/application/get-graph-layout.js +62 -8
- package/dist/application/get-graph-stream-chunk.js +326 -0
- package/dist/application/get-graph-view.js +246 -0
- package/dist/application/graph-view-state.js +66 -0
- package/dist/application/import-legacy-sqlite.js +266 -0
- package/dist/application/index-vault.js +262 -23
- package/dist/application/migrate-context-links.js +79 -0
- package/dist/application/offline-pack-backup.js +44 -0
- package/dist/application/search-graph-node-ids.js +63 -3
- package/dist/application/server/routes.js +247 -7
- package/dist/application/start-server.js +75 -4
- package/dist/application/watch-vault.js +23 -2
- package/dist/cli/commands/agent-commands.js +7 -0
- package/dist/cli/commands/write-commands.js +924 -14
- package/dist/cli/runtime.js +10 -2
- package/dist/domain/context.js +54 -11
- package/dist/domain/graph-contexts.js +180 -0
- package/dist/domain/graph-layout.js +389 -18
- package/dist/domain/markdown.js +53 -9
- package/dist/domain/middle-out.js +18 -0
- package/dist/infrastructure/config.js +121 -4
- package/dist/infrastructure/file-index.js +76 -6
- package/dist/infrastructure/file-system-vault.js +15 -0
- package/dist/infrastructure/index-state.js +58 -0
- package/dist/infrastructure/private-pack-codec.js +71 -10
- package/dist/infrastructure/search-packs.js +286 -15
- package/dist/infrastructure/vault-migration-state.js +69 -0
- package/dist/infrastructure/volatile-memory.js +100 -0
- package/dist/mcp/runtime.js +20 -0
- package/dist/mcp/server.js +39 -11
- package/dist/mcp/tools.js +183 -7
- package/docs/AGENT_USAGE.md +96 -5
- package/docs/ARCHITECTURE.md +8 -0
- package/docs/QUICKSTART.md +7 -0
- package/package.json +7 -2
|
@@ -1,53 +1,26 @@
|
|
|
1
1
|
export const createClientJs = () => `const canvas = document.getElementById('graph')
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
const largeGraphEdgeRenderLimit = 16000
|
|
5
|
-
const renderNodeBudget = 1800
|
|
6
|
-
const minNodePixelRadius = 1.8
|
|
7
|
-
const viewportPaddingPx = 280
|
|
8
|
-
const state = {
|
|
9
|
-
graph: { nodes: [], edges: [] },
|
|
10
|
-
nodes: [],
|
|
11
|
-
edges: [],
|
|
12
|
-
visibleNodes: [],
|
|
13
|
-
visibleEdges: [],
|
|
14
|
-
renderNodes: [],
|
|
15
|
-
renderEdges: [],
|
|
16
|
-
nodeDegrees: new Map(),
|
|
17
|
-
selected: null,
|
|
18
|
-
hovered: null,
|
|
19
|
-
query: '',
|
|
20
|
-
contentFilter: { query: '', ids: null, token: 0, timer: null },
|
|
21
|
-
agentId: '',
|
|
22
|
-
agentsSignature: '',
|
|
23
|
-
nodeDetails: new Map(),
|
|
24
|
-
transform: { x: 0, y: 0, scale: 1 },
|
|
25
|
-
pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
|
|
26
|
-
graphSignature: '',
|
|
27
|
-
graphStatus: '',
|
|
28
|
-
last: performance.now()
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const byId = id => document.getElementById(id)
|
|
32
|
-
const escapeHtml = value => String(value)
|
|
33
|
-
.replaceAll('&', '&')
|
|
34
|
-
.replaceAll('<', '<')
|
|
35
|
-
.replaceAll('>', '>')
|
|
36
|
-
.replaceAll('"', '"')
|
|
37
|
-
.replaceAll("'", ''')
|
|
2
|
+
let ctx2dFallback = null
|
|
3
|
+
const byId = (id) => document.getElementById(id)
|
|
38
4
|
const elements = {
|
|
39
5
|
search: byId('search'),
|
|
40
6
|
agent: byId('agent'),
|
|
7
|
+
context: byId('context'),
|
|
41
8
|
nodeCount: byId('nodeCount'),
|
|
42
9
|
edgeCount: byId('edgeCount'),
|
|
43
10
|
tagCount: byId('tagCount'),
|
|
44
11
|
zoomIn: byId('zoomIn'),
|
|
45
12
|
zoomOut: byId('zoomOut'),
|
|
46
13
|
fit: byId('fit'),
|
|
14
|
+
releaseNode: byId('releaseNode'),
|
|
47
15
|
reset: byId('reset'),
|
|
16
|
+
labels: byId('graphLabels'),
|
|
17
|
+
tooltip: byId('graphTooltip'),
|
|
18
|
+
miniMap: byId('miniMap'),
|
|
48
19
|
contentDialog: byId('contentDialog'),
|
|
49
20
|
contentTitle: byId('contentTitle'),
|
|
50
21
|
contentPath: byId('contentPath'),
|
|
22
|
+
contentFacts: byId('contentFacts'),
|
|
23
|
+
contentContextLinks: byId('contentContextLinks'),
|
|
51
24
|
contentTags: byId('contentTags'),
|
|
52
25
|
contentOutgoing: byId('contentOutgoing'),
|
|
53
26
|
contentIncoming: byId('contentIncoming'),
|
|
@@ -55,743 +28,1534 @@ const elements = {
|
|
|
55
28
|
contentClose: byId('contentClose')
|
|
56
29
|
}
|
|
57
30
|
|
|
31
|
+
const state = {
|
|
32
|
+
camera: {
|
|
33
|
+
x: 0,
|
|
34
|
+
y: 0,
|
|
35
|
+
scale: 0.22
|
|
36
|
+
},
|
|
37
|
+
pointer: {
|
|
38
|
+
down: false,
|
|
39
|
+
moved: false,
|
|
40
|
+
dragging: false,
|
|
41
|
+
dragNodeId: '',
|
|
42
|
+
x: 0,
|
|
43
|
+
y: 0,
|
|
44
|
+
startX: 0,
|
|
45
|
+
startY: 0,
|
|
46
|
+
startWorldX: 0,
|
|
47
|
+
startWorldY: 0,
|
|
48
|
+
nodeStartX: 0,
|
|
49
|
+
nodeStartY: 0,
|
|
50
|
+
worldAnchorX: 0,
|
|
51
|
+
worldAnchorY: 0
|
|
52
|
+
},
|
|
53
|
+
viewport: {
|
|
54
|
+
width: 320,
|
|
55
|
+
height: 320,
|
|
56
|
+
ratio: window.devicePixelRatio || 1
|
|
57
|
+
},
|
|
58
|
+
workerReady: false,
|
|
59
|
+
rendererMode: 'worker',
|
|
60
|
+
renderWorker: null,
|
|
61
|
+
agentId: '',
|
|
62
|
+
contextId: '',
|
|
63
|
+
graphSignature: '',
|
|
64
|
+
graphMode: 'near',
|
|
65
|
+
nodePositionsSignature: '',
|
|
66
|
+
nodePositionsScope: '',
|
|
67
|
+
serverNodePositionsScope: '',
|
|
68
|
+
nodePositions: new Map(),
|
|
69
|
+
hoveredNodeId: '',
|
|
70
|
+
focusedNodeIds: new Set(),
|
|
71
|
+
spatialIndex: {
|
|
72
|
+
key: '',
|
|
73
|
+
cells: new Map()
|
|
74
|
+
},
|
|
75
|
+
miniMapView: null,
|
|
76
|
+
miniMapDirty: true,
|
|
77
|
+
overlayScheduled: false,
|
|
78
|
+
overlayIdleTimer: null,
|
|
79
|
+
chunk: {
|
|
80
|
+
nodes: [],
|
|
81
|
+
edges: []
|
|
82
|
+
},
|
|
83
|
+
selectedNodeId: null,
|
|
84
|
+
searchToken: 0,
|
|
85
|
+
searchTimer: null,
|
|
86
|
+
searchResultIds: new Set(),
|
|
87
|
+
fetchToken: 0,
|
|
88
|
+
fetchTimer: null,
|
|
89
|
+
fetchAbortController: null,
|
|
90
|
+
lastChunkRequestKey: '',
|
|
91
|
+
cameraSyncScheduled: false,
|
|
92
|
+
lastWheelAt: 0,
|
|
93
|
+
lastVisibleNodes: 0,
|
|
94
|
+
lastVisibleEdges: 0,
|
|
95
|
+
totals: {
|
|
96
|
+
nodes: 0,
|
|
97
|
+
edges: 0
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
58
101
|
const zoomRange = {
|
|
59
|
-
min: 0.
|
|
102
|
+
min: 0.0002,
|
|
60
103
|
max: 4.5
|
|
61
104
|
}
|
|
62
105
|
|
|
63
|
-
const
|
|
106
|
+
const selectedAgentStorageKey = 'brainlink:selected-agent'
|
|
107
|
+
const selectedContextStorageKey = 'brainlink:selected-context'
|
|
108
|
+
const nodePositionsStoragePrefix = 'brainlink:graph-node-positions:'
|
|
64
109
|
|
|
65
|
-
const
|
|
66
|
-
|
|
110
|
+
const escapeHtml = (value) => String(value)
|
|
111
|
+
.replaceAll('&', '&')
|
|
112
|
+
.replaceAll('<', '<')
|
|
113
|
+
.replaceAll('>', '>')
|
|
114
|
+
.replaceAll('"', '"')
|
|
115
|
+
.replaceAll("'", ''')
|
|
116
|
+
|
|
117
|
+
const readStoredAgent = () => {
|
|
118
|
+
try {
|
|
119
|
+
const value = window.localStorage.getItem(selectedAgentStorageKey)?.trim() ?? ''
|
|
120
|
+
return value.length > 0 ? value : ''
|
|
121
|
+
} catch {
|
|
122
|
+
return ''
|
|
123
|
+
}
|
|
67
124
|
}
|
|
68
125
|
|
|
69
|
-
const
|
|
70
|
-
|
|
126
|
+
const writeStoredAgent = (agentId) => {
|
|
127
|
+
try {
|
|
128
|
+
if (!agentId) {
|
|
129
|
+
window.localStorage.removeItem(selectedAgentStorageKey)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
window.localStorage.setItem(selectedAgentStorageKey, agentId)
|
|
133
|
+
} catch {}
|
|
71
134
|
}
|
|
72
135
|
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
nodeStrokeActive: '#ffffff',
|
|
81
|
-
edge: 'rgba(153, 165, 181, 0.16)',
|
|
82
|
-
edgeActive: 'rgba(226, 232, 240, 0.52)',
|
|
83
|
-
label: '#edf2f7'
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const resize = () => {
|
|
87
|
-
const rect = canvas.getBoundingClientRect()
|
|
88
|
-
const width = Math.max(rect.width, 320)
|
|
89
|
-
const height = Math.max(rect.height, 320)
|
|
90
|
-
const ratio = window.devicePixelRatio || 1
|
|
91
|
-
canvas.width = Math.floor(width * ratio)
|
|
92
|
-
canvas.height = Math.floor(height * ratio)
|
|
93
|
-
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
136
|
+
const readStoredContext = () => {
|
|
137
|
+
try {
|
|
138
|
+
const value = window.localStorage.getItem(selectedContextStorageKey)?.trim() ?? ''
|
|
139
|
+
return value.length > 0 ? value : ''
|
|
140
|
+
} catch {
|
|
141
|
+
return ''
|
|
142
|
+
}
|
|
94
143
|
}
|
|
95
144
|
|
|
96
|
-
const
|
|
145
|
+
const writeStoredContext = (contextId) => {
|
|
146
|
+
try {
|
|
147
|
+
if (!contextId) {
|
|
148
|
+
window.localStorage.removeItem(selectedContextStorageKey)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
window.localStorage.setItem(selectedContextStorageKey, contextId)
|
|
152
|
+
} catch {}
|
|
153
|
+
}
|
|
97
154
|
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
155
|
+
const nodePositionsStorageKey = () => [
|
|
156
|
+
nodePositionsStoragePrefix,
|
|
157
|
+
state.graphSignature || 'unknown',
|
|
158
|
+
state.agentId || 'all-agents',
|
|
159
|
+
state.contextId || 'all-contexts'
|
|
160
|
+
].join(':')
|
|
104
161
|
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
162
|
+
const readStoredNodePositions = () => {
|
|
163
|
+
try {
|
|
164
|
+
const raw = window.localStorage.getItem(nodePositionsStorageKey())
|
|
165
|
+
const parsed = raw ? JSON.parse(raw) : []
|
|
166
|
+
if (!Array.isArray(parsed)) {
|
|
167
|
+
return new Map()
|
|
168
|
+
}
|
|
111
169
|
|
|
112
|
-
|
|
170
|
+
return new Map(parsed.flatMap((entry) => {
|
|
171
|
+
const id = typeof entry?.[0] === 'string' ? entry[0] : ''
|
|
172
|
+
const x = Number(entry?.[1])
|
|
173
|
+
const y = Number(entry?.[2])
|
|
174
|
+
return id && Number.isFinite(x) && Number.isFinite(y) ? [[id, { x, y }]] : []
|
|
175
|
+
}))
|
|
176
|
+
} catch {
|
|
177
|
+
return new Map()
|
|
178
|
+
}
|
|
113
179
|
}
|
|
114
180
|
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
? [...edges]
|
|
121
|
-
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
122
|
-
.slice(0, largeGraphEdgeRenderLimit)
|
|
123
|
-
: edges
|
|
181
|
+
const ensureNodePositionsLoaded = () => {
|
|
182
|
+
const storageKey = nodePositionsStorageKey()
|
|
183
|
+
if (!state.graphSignature || (state.nodePositionsSignature === state.graphSignature && state.nodePositionsScope === storageKey)) {
|
|
184
|
+
return
|
|
185
|
+
}
|
|
124
186
|
|
|
125
|
-
state.
|
|
126
|
-
state.
|
|
187
|
+
state.nodePositions = readStoredNodePositions()
|
|
188
|
+
state.nodePositionsSignature = state.graphSignature
|
|
189
|
+
state.nodePositionsScope = storageKey
|
|
127
190
|
}
|
|
128
191
|
|
|
129
|
-
const
|
|
192
|
+
const writeStoredNodePositions = () => {
|
|
193
|
+
try {
|
|
194
|
+
if (!state.graphSignature) {
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const entries = Array.from(state.nodePositions.entries())
|
|
199
|
+
.filter((entry) => Number.isFinite(entry[1]?.x) && Number.isFinite(entry[1]?.y))
|
|
200
|
+
.map((entry) => [entry[0], entry[1].x, entry[1].y])
|
|
130
201
|
|
|
131
|
-
|
|
202
|
+
if (entries.length === 0) {
|
|
203
|
+
window.localStorage.removeItem(nodePositionsStorageKey())
|
|
204
|
+
return
|
|
205
|
+
}
|
|
132
206
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
let maxX = Number.NEGATIVE_INFINITY
|
|
137
|
-
let minY = Number.POSITIVE_INFINITY
|
|
138
|
-
let maxY = Number.NEGATIVE_INFINITY
|
|
207
|
+
window.localStorage.setItem(nodePositionsStorageKey(), JSON.stringify(entries))
|
|
208
|
+
} catch {}
|
|
209
|
+
}
|
|
139
210
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
211
|
+
const clearStoredNodePositions = () => {
|
|
212
|
+
try {
|
|
213
|
+
if (state.graphSignature) {
|
|
214
|
+
window.localStorage.removeItem(nodePositionsStorageKey())
|
|
215
|
+
}
|
|
216
|
+
} catch {}
|
|
217
|
+
state.nodePositions = new Map()
|
|
218
|
+
state.nodePositionsSignature = state.graphSignature
|
|
219
|
+
state.nodePositionsScope = nodePositionsStorageKey()
|
|
220
|
+
}
|
|
147
221
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
maxY,
|
|
153
|
-
width: Math.max(maxX - minX, 1),
|
|
154
|
-
height: Math.max(maxY - minY, 1)
|
|
222
|
+
const graphViewStateQuery = () => {
|
|
223
|
+
const params = new URLSearchParams({ signature: state.graphSignature })
|
|
224
|
+
if (state.agentId) {
|
|
225
|
+
params.set('agent', state.agentId)
|
|
155
226
|
}
|
|
227
|
+
if (state.contextId) {
|
|
228
|
+
params.set('context', state.contextId)
|
|
229
|
+
}
|
|
230
|
+
return params.toString()
|
|
156
231
|
}
|
|
157
232
|
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
233
|
+
const syncNodePositionsFromServer = async () => {
|
|
234
|
+
if (!state.graphSignature) {
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
const scope = nodePositionsStorageKey()
|
|
238
|
+
if (state.serverNodePositionsScope === scope) {
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
state.serverNodePositionsScope = scope
|
|
164
242
|
|
|
165
|
-
|
|
166
|
-
|
|
243
|
+
try {
|
|
244
|
+
const response = await fetch('/api/graph-view-state?' + graphViewStateQuery())
|
|
245
|
+
if (!response.ok) {
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
const payload = await response.json()
|
|
249
|
+
const positions = Array.isArray(payload?.positions) ? payload.positions : []
|
|
250
|
+
if (positions.length === 0) {
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
state.nodePositions = new Map(positions.flatMap((position) => {
|
|
254
|
+
const id = typeof position?.id === 'string' ? position.id : ''
|
|
255
|
+
const x = Number(position?.x)
|
|
256
|
+
const y = Number(position?.y)
|
|
257
|
+
return id && Number.isFinite(x) && Number.isFinite(y) ? [[id, { x, y }]] : []
|
|
258
|
+
}))
|
|
259
|
+
writeStoredNodePositions()
|
|
260
|
+
} catch {}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const persistNodePositionsToServer = () => {
|
|
264
|
+
if (!state.graphSignature) {
|
|
167
265
|
return
|
|
168
266
|
}
|
|
169
267
|
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
268
|
+
const positions = Array.from(state.nodePositions.entries()).map(([id, position]) => ({
|
|
269
|
+
id,
|
|
270
|
+
x: position.x,
|
|
271
|
+
y: position.y
|
|
272
|
+
}))
|
|
273
|
+
|
|
274
|
+
fetch('/api/graph-view-state?' + graphViewStateQuery(), {
|
|
275
|
+
method: 'POST',
|
|
276
|
+
headers: { 'content-type': 'application/json' },
|
|
277
|
+
body: JSON.stringify({ positions })
|
|
278
|
+
}).catch(() => {})
|
|
279
|
+
}
|
|
178
280
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
scale
|
|
281
|
+
const clearNodePositionsOnServer = () => {
|
|
282
|
+
if (!state.graphSignature) {
|
|
283
|
+
return
|
|
183
284
|
}
|
|
285
|
+
|
|
286
|
+
fetch('/api/graph-view-state?' + graphViewStateQuery(), { method: 'DELETE' }).catch(() => {})
|
|
184
287
|
}
|
|
185
288
|
|
|
186
|
-
const
|
|
289
|
+
const releaseSelectedNodePosition = () => {
|
|
290
|
+
if (!state.selectedNodeId || !state.nodePositions.has(state.selectedNodeId)) {
|
|
291
|
+
return
|
|
292
|
+
}
|
|
187
293
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
y: Number.isFinite(node.y) ? node.y : 0
|
|
193
|
-
}))
|
|
194
|
-
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
195
|
-
const edges = graph.edges
|
|
196
|
-
.filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
|
|
197
|
-
.map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
|
|
198
|
-
return { nodes, edges }
|
|
294
|
+
state.nodePositions.delete(state.selectedNodeId)
|
|
295
|
+
writeStoredNodePositions()
|
|
296
|
+
persistNodePositionsToServer()
|
|
297
|
+
scheduleChunkFetch({ fit: false })
|
|
199
298
|
}
|
|
200
299
|
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
300
|
+
const syncAgentInUrl = (agentId) => {
|
|
301
|
+
try {
|
|
302
|
+
const url = new URL(window.location.href)
|
|
303
|
+
if (agentId && agentId.trim().length > 0) {
|
|
304
|
+
url.searchParams.set('agent', agentId)
|
|
305
|
+
} else {
|
|
306
|
+
url.searchParams.delete('agent')
|
|
307
|
+
}
|
|
308
|
+
window.history.replaceState({}, '', url.toString())
|
|
309
|
+
} catch {}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const syncContextInUrl = (contextId) => {
|
|
313
|
+
try {
|
|
314
|
+
const url = new URL(window.location.href)
|
|
315
|
+
if (contextId && contextId.trim().length > 0) {
|
|
316
|
+
url.searchParams.set('context', contextId)
|
|
317
|
+
} else {
|
|
318
|
+
url.searchParams.delete('context')
|
|
319
|
+
}
|
|
320
|
+
window.history.replaceState({}, '', url.toString())
|
|
321
|
+
} catch {}
|
|
322
|
+
}
|
|
204
323
|
|
|
205
|
-
|
|
206
|
-
|
|
324
|
+
const initialAgentFromUrl = (() => {
|
|
325
|
+
try {
|
|
326
|
+
const raw = new URL(window.location.href).searchParams.get('agent')
|
|
327
|
+
const value = raw?.trim() ?? ''
|
|
328
|
+
return value.length > 0 ? value : ''
|
|
329
|
+
} catch {
|
|
330
|
+
return ''
|
|
331
|
+
}
|
|
332
|
+
})()
|
|
333
|
+
|
|
334
|
+
const initialContextFromUrl = (() => {
|
|
335
|
+
try {
|
|
336
|
+
const raw = new URL(window.location.href).searchParams.get('context')
|
|
337
|
+
const value = raw?.trim() ?? ''
|
|
338
|
+
return value.length > 0 ? value : ''
|
|
339
|
+
} catch {
|
|
340
|
+
return ''
|
|
341
|
+
}
|
|
342
|
+
})()
|
|
343
|
+
|
|
344
|
+
const scopeQuery = (separator = '?') => {
|
|
345
|
+
const params = new URLSearchParams()
|
|
346
|
+
if (state.agentId) {
|
|
347
|
+
params.set('agent', state.agentId)
|
|
348
|
+
}
|
|
349
|
+
if (state.contextId) {
|
|
350
|
+
params.set('context', state.contextId)
|
|
207
351
|
}
|
|
352
|
+
const query = params.toString()
|
|
353
|
+
|
|
354
|
+
return query ? separator + query : ''
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const parseColor = (hex) => {
|
|
358
|
+
const normalized = String(hex || '#ffffff').replace('#', '')
|
|
359
|
+
const expanded = normalized.length === 3
|
|
360
|
+
? normalized.split('').map((char) => char + char).join('')
|
|
361
|
+
: normalized.padEnd(6, 'f')
|
|
362
|
+
const value = Number.parseInt(expanded, 16)
|
|
363
|
+
return [
|
|
364
|
+
((value >> 16) & 255) / 255,
|
|
365
|
+
((value >> 8) & 255) / 255,
|
|
366
|
+
(value & 255) / 255,
|
|
367
|
+
1
|
|
368
|
+
]
|
|
369
|
+
}
|
|
208
370
|
|
|
209
|
-
|
|
371
|
+
const graphTheme = {
|
|
372
|
+
node: parseColor('#aeb8c5'),
|
|
373
|
+
nodeCluster: parseColor('#6bb7e8'),
|
|
374
|
+
nodeHighlight: parseColor('#f5c24a'),
|
|
375
|
+
nodeSelected: parseColor('#ffffff'),
|
|
376
|
+
edge: [0.58, 0.64, 0.74, 0.24],
|
|
377
|
+
edgeHeavy: [0.78, 0.84, 0.92, 0.44],
|
|
378
|
+
clear: parseColor('#0d0f12')
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const clampScale = (scale) => Math.max(zoomRange.min, Math.min(zoomRange.max, scale))
|
|
382
|
+
|
|
383
|
+
const getZoomNodeBudget = () => {
|
|
384
|
+
const scale = state.camera.scale
|
|
385
|
+
if (scale < 0.06) return 900
|
|
386
|
+
if (scale < 0.12) return 1600
|
|
387
|
+
if (scale < 0.24) return 2600
|
|
388
|
+
if (scale < 0.7) return 4000
|
|
389
|
+
return 6000
|
|
210
390
|
}
|
|
211
391
|
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
392
|
+
const getZoomEdgeBudget = () => {
|
|
393
|
+
const scale = state.camera.scale
|
|
394
|
+
if (scale < 0.06) return 2000
|
|
395
|
+
if (scale < 0.12) return 4800
|
|
396
|
+
if (scale < 0.24) return 9000
|
|
397
|
+
if (scale < 0.7) return 15000
|
|
398
|
+
return 26000
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const zoomDetailBand = () => {
|
|
402
|
+
const scale = state.camera.scale
|
|
403
|
+
if (scale < 0.06) return 'far'
|
|
404
|
+
if (scale < 0.12) return 'wide'
|
|
405
|
+
if (scale < 0.24) return 'mid'
|
|
406
|
+
if (scale < 0.7) return 'near'
|
|
407
|
+
return 'detail'
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const graphStreamRequestKey = ({ x, y, w, h }) => {
|
|
411
|
+
const grid = Math.max(80, Math.min(720, Math.max(w, h) / 6))
|
|
412
|
+
return [
|
|
413
|
+
state.agentId || '*',
|
|
414
|
+
state.contextId || '*',
|
|
415
|
+
zoomDetailBand(),
|
|
416
|
+
getZoomNodeBudget(),
|
|
417
|
+
getZoomEdgeBudget(),
|
|
418
|
+
Math.round(x / grid),
|
|
419
|
+
Math.round(y / grid),
|
|
420
|
+
Math.round(w / grid),
|
|
421
|
+
Math.round(h / grid)
|
|
422
|
+
].join(':')
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const screenToWorld = (screenX, screenY) => ({
|
|
426
|
+
x: (screenX - state.camera.x) / state.camera.scale,
|
|
427
|
+
y: (screenY - state.camera.y) / state.camera.scale
|
|
215
428
|
})
|
|
216
429
|
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
430
|
+
const worldToScreen = (x, y) => ({
|
|
431
|
+
x: x * state.camera.scale + state.camera.x,
|
|
432
|
+
y: y * state.camera.scale + state.camera.y
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const spatialIndexKey = () => [
|
|
436
|
+
state.graphSignature,
|
|
437
|
+
state.camera.x.toFixed(1),
|
|
438
|
+
state.camera.y.toFixed(1),
|
|
439
|
+
state.camera.scale.toFixed(4),
|
|
440
|
+
normalizeList(state.chunk.nodes).length
|
|
441
|
+
].join(':')
|
|
442
|
+
|
|
443
|
+
const rebuildSpatialIndex = () => {
|
|
444
|
+
const key = spatialIndexKey()
|
|
445
|
+
if (state.spatialIndex.key === key) {
|
|
446
|
+
return
|
|
220
447
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
448
|
+
|
|
449
|
+
const cellSize = 44
|
|
450
|
+
const cells = new Map()
|
|
451
|
+
normalizeList(state.chunk.nodes).forEach((node) => {
|
|
452
|
+
const id = typeof node?.[0] === 'string' ? node[0] : ''
|
|
453
|
+
if (!id) return
|
|
454
|
+
const x = Number(node?.[2])
|
|
455
|
+
const y = Number(node?.[3])
|
|
456
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return
|
|
457
|
+
const point = worldToScreen(x, y)
|
|
458
|
+
const cellX = Math.floor(point.x / cellSize)
|
|
459
|
+
const cellY = Math.floor(point.y / cellSize)
|
|
460
|
+
const key = cellX + ',' + cellY
|
|
461
|
+
const bucket = cells.get(key)
|
|
462
|
+
if (bucket) {
|
|
463
|
+
bucket.push(node)
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
cells.set(key, [node])
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
state.spatialIndex = { key, cells }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const spatialCandidates = (screenX, screenY) => {
|
|
473
|
+
rebuildSpatialIndex()
|
|
474
|
+
const cellSize = 44
|
|
475
|
+
const cellX = Math.floor(screenX / cellSize)
|
|
476
|
+
const cellY = Math.floor(screenY / cellSize)
|
|
477
|
+
const nodes = []
|
|
478
|
+
|
|
479
|
+
for (let y = cellY - 1; y <= cellY + 1; y += 1) {
|
|
480
|
+
for (let x = cellX - 1; x <= cellX + 1; x += 1) {
|
|
481
|
+
nodes.push(...(state.spatialIndex.cells.get(x + ',' + y) ?? []))
|
|
482
|
+
}
|
|
226
483
|
}
|
|
227
|
-
|
|
484
|
+
|
|
485
|
+
return nodes
|
|
228
486
|
}
|
|
229
487
|
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
)
|
|
488
|
+
const nodeByIdFromChunk = () => new Map(normalizeList(state.chunk.nodes).map((node) => [node[0], node]))
|
|
489
|
+
|
|
490
|
+
const linkedNodeIds = (nodeId) => {
|
|
491
|
+
const ids = new Set(nodeId ? [nodeId] : [])
|
|
492
|
+
normalizeList(state.chunk.edges).forEach((edge) => {
|
|
493
|
+
if (edge?.[0] === nodeId && typeof edge?.[1] === 'string') ids.add(edge[1])
|
|
494
|
+
if (edge?.[1] === nodeId && typeof edge?.[0] === 'string') ids.add(edge[0])
|
|
495
|
+
})
|
|
496
|
+
return ids
|
|
497
|
+
}
|
|
238
498
|
|
|
239
|
-
|
|
499
|
+
const setFocusedNodeIds = (ids) => {
|
|
500
|
+
state.focusedNodeIds = ids
|
|
501
|
+
if (state.renderWorker && state.workerReady) {
|
|
502
|
+
state.renderWorker.postMessage({ type: 'focus', ids: Array.from(ids) })
|
|
503
|
+
}
|
|
504
|
+
updateGraphOverlays()
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const drawFallback = () => {
|
|
508
|
+
if (state.rendererMode !== 'fallback') {
|
|
240
509
|
return
|
|
241
510
|
}
|
|
511
|
+
ctx2dFallback = ctx2dFallback ?? canvas.getContext('2d')
|
|
512
|
+
if (!ctx2dFallback) {
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
const width = state.viewport.width
|
|
516
|
+
const height = state.viewport.height
|
|
517
|
+
const ratio = state.viewport.ratio
|
|
518
|
+
canvas.width = Math.floor(width * ratio)
|
|
519
|
+
canvas.height = Math.floor(height * ratio)
|
|
520
|
+
ctx2dFallback.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
521
|
+
ctx2dFallback.fillStyle = '#0d0f12'
|
|
522
|
+
ctx2dFallback.fillRect(0, 0, width, height)
|
|
242
523
|
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
|
|
524
|
+
const nodes = Array.isArray(state.chunk.nodes) ? state.chunk.nodes : []
|
|
525
|
+
const edges = Array.isArray(state.chunk.edges) ? state.chunk.edges : []
|
|
526
|
+
const nodeById = new Map()
|
|
527
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
528
|
+
nodeById.set(nodes[i][0], nodes[i])
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
ctx2dFallback.strokeStyle = 'rgba(150,165,190,0.2)'
|
|
532
|
+
ctx2dFallback.lineWidth = 1
|
|
533
|
+
for (let i = 0; i < edges.length; i += 1) {
|
|
534
|
+
const edge = edges[i]
|
|
535
|
+
const source = nodeById.get(edge[0])
|
|
536
|
+
const target = nodeById.get(edge[1])
|
|
537
|
+
if (!source || !target) continue
|
|
538
|
+
const from = worldToScreen(source[2], source[3])
|
|
539
|
+
const to = worldToScreen(target[2], target[3])
|
|
540
|
+
ctx2dFallback.beginPath()
|
|
541
|
+
ctx2dFallback.moveTo(from.x, from.y)
|
|
542
|
+
ctx2dFallback.lineTo(to.x, to.y)
|
|
543
|
+
ctx2dFallback.stroke()
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
547
|
+
const node = nodes[i]
|
|
548
|
+
const p = worldToScreen(node[2], node[3])
|
|
549
|
+
const selected = state.selectedNodeId === node[0]
|
|
550
|
+
const color = node[6] === 'cluster' ? '#6bb7e8' : '#aeb8c5'
|
|
551
|
+
const radius = Math.max(2.4, Math.min(14, 4 + node[7] * 0.55))
|
|
552
|
+
|
|
553
|
+
ctx2dFallback.beginPath()
|
|
554
|
+
ctx2dFallback.fillStyle = selected ? '#ffffff' : color
|
|
555
|
+
ctx2dFallback.arc(p.x, p.y, radius, 0, Math.PI * 2)
|
|
556
|
+
ctx2dFallback.fill()
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
ctx2dFallback.fillStyle = '#edf2f7'
|
|
560
|
+
ctx2dFallback.font = '12px Inter, system-ui, sans-serif'
|
|
561
|
+
ctx2dFallback.textAlign = 'center'
|
|
562
|
+
ctx2dFallback.fillText('Fallback canvas mode', Math.max(width, 320) / 2, 24)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const updateTotals = () => {
|
|
566
|
+
elements.nodeCount.textContent = String(state.totals.nodes)
|
|
567
|
+
elements.edgeCount.textContent = String(state.totals.edges)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const updateTagCount = () => {
|
|
571
|
+
elements.tagCount.textContent = state.graphMode === 'far' ? 'clusters' : state.graphMode
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const updateWorkerCamera = () => {
|
|
575
|
+
updateGraphOverlays()
|
|
576
|
+
if (!state.renderWorker || !state.workerReady) {
|
|
246
577
|
return
|
|
247
578
|
}
|
|
579
|
+
if (state.cameraSyncScheduled) {
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
state.cameraSyncScheduled = true
|
|
583
|
+
requestAnimationFrame(() => {
|
|
584
|
+
state.cameraSyncScheduled = false
|
|
585
|
+
if (!state.renderWorker || !state.workerReady) {
|
|
586
|
+
return
|
|
587
|
+
}
|
|
588
|
+
state.renderWorker.postMessage({
|
|
589
|
+
type: 'camera',
|
|
590
|
+
camera: state.camera
|
|
591
|
+
})
|
|
592
|
+
})
|
|
593
|
+
}
|
|
248
594
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
595
|
+
const updateWorkerSize = () => {
|
|
596
|
+
updateGraphOverlays()
|
|
597
|
+
if (!state.renderWorker || !state.workerReady) {
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
state.renderWorker.postMessage({
|
|
601
|
+
type: 'resize',
|
|
602
|
+
width: state.viewport.width,
|
|
603
|
+
height: state.viewport.height,
|
|
604
|
+
devicePixelRatio: state.viewport.ratio
|
|
605
|
+
})
|
|
252
606
|
}
|
|
253
607
|
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
608
|
+
const normalizeList = (items) => Array.isArray(items) ? items : []
|
|
609
|
+
|
|
610
|
+
const applyManualNodePositions = (nodes) => normalizeList(nodes).map((node) => {
|
|
611
|
+
const id = typeof node?.[0] === 'string' ? node[0] : ''
|
|
612
|
+
const position = id ? state.nodePositions.get(id) : null
|
|
613
|
+
if (!position || !Number.isFinite(position.x) || !Number.isFinite(position.y)) {
|
|
614
|
+
return node
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const next = [...node]
|
|
618
|
+
next[2] = position.x
|
|
619
|
+
next[3] = position.y
|
|
620
|
+
return next
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
const updateNodePositionInChunk = (nodeId, x, y) => {
|
|
624
|
+
if (!nodeId || !Number.isFinite(x) || !Number.isFinite(y)) {
|
|
258
625
|
return
|
|
259
626
|
}
|
|
260
627
|
|
|
261
|
-
|
|
262
|
-
|
|
628
|
+
state.chunk = {
|
|
629
|
+
...state.chunk,
|
|
630
|
+
nodes: normalizeList(state.chunk.nodes).map((node) => {
|
|
631
|
+
if (node?.[0] !== nodeId) {
|
|
632
|
+
return node
|
|
633
|
+
}
|
|
634
|
+
const next = [...node]
|
|
635
|
+
next[2] = x
|
|
636
|
+
next[3] = y
|
|
637
|
+
return next
|
|
638
|
+
})
|
|
263
639
|
}
|
|
640
|
+
state.spatialIndex.key = ''
|
|
264
641
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
query: state.contentFilter.query,
|
|
268
|
-
ids: state.contentFilter.ids,
|
|
269
|
-
token,
|
|
270
|
-
timer: setTimeout(() => {
|
|
271
|
-
syncContentFilter(query, token).catch(() => {})
|
|
272
|
-
}, 180)
|
|
642
|
+
if (state.renderWorker && state.workerReady) {
|
|
643
|
+
state.renderWorker.postMessage({ type: 'move-node', id: nodeId, x, y })
|
|
273
644
|
}
|
|
645
|
+
updateGraphOverlays()
|
|
274
646
|
}
|
|
275
647
|
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
|
|
279
|
-
if (nodes.length > 1200) {
|
|
648
|
+
const showTooltip = (node, pointer) => {
|
|
649
|
+
if (!elements.tooltip || !node) {
|
|
280
650
|
return
|
|
281
651
|
}
|
|
282
|
-
const strength = Math.min(delta / 16, 2)
|
|
283
|
-
|
|
284
|
-
edges.forEach(edge => {
|
|
285
|
-
const source = edge.sourceNode
|
|
286
|
-
const target = edge.targetNode
|
|
287
|
-
const dx = target.x - source.x
|
|
288
|
-
const dy = target.y - source.y
|
|
289
|
-
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
290
|
-
const force = (distance - 150) * 0.002 * strength
|
|
291
|
-
const fx = dx * force
|
|
292
|
-
const fy = dy * force
|
|
293
|
-
source.vx += fx
|
|
294
|
-
source.vy += fy
|
|
295
|
-
target.vx -= fx
|
|
296
|
-
target.vy -= fy
|
|
297
|
-
})
|
|
298
652
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
node.vx += -node.x * 0.0008 * strength
|
|
323
|
-
node.vy += -node.y * 0.0008 * strength
|
|
324
|
-
node.vx *= 0.88
|
|
325
|
-
node.vy *= 0.88
|
|
326
|
-
node.x += node.vx * strength
|
|
327
|
-
node.y += node.vy * strength
|
|
653
|
+
elements.tooltip.hidden = false
|
|
654
|
+
elements.tooltip.innerHTML =
|
|
655
|
+
'<strong>' + escapeHtml(node[1] || node[0]) + '</strong>' +
|
|
656
|
+
'<small>' + escapeHtml(node[4] || node[5] || '') + '</small>'
|
|
657
|
+
elements.tooltip.style.left = Math.min(state.viewport.width - 24, pointer.x + 14) + 'px'
|
|
658
|
+
elements.tooltip.style.top = Math.min(state.viewport.height - 24, pointer.y + 14) + 'px'
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const hideTooltip = () => {
|
|
662
|
+
if (elements.tooltip) {
|
|
663
|
+
elements.tooltip.hidden = true
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const labelCandidates = () => {
|
|
668
|
+
const nodes = normalizeList(state.chunk.nodes)
|
|
669
|
+
const visible = nodes.filter((node) => {
|
|
670
|
+
const x = Number(node?.[2])
|
|
671
|
+
const y = Number(node?.[3])
|
|
672
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return false
|
|
673
|
+
const point = worldToScreen(x, y)
|
|
674
|
+
return point.x >= -80 && point.x <= state.viewport.width + 80 && point.y >= -80 && point.y <= state.viewport.height + 80
|
|
328
675
|
})
|
|
676
|
+
const shouldShowMany = state.camera.scale >= 0.72 || visible.length <= 120
|
|
677
|
+
const focused = state.focusedNodeIds
|
|
678
|
+
|
|
679
|
+
return visible
|
|
680
|
+
.filter((node) => shouldShowMany || focused.has(node[0]) || node[0] === state.hoveredNodeId || node[0] === state.selectedNodeId || Number(node?.[7]) > 5.5)
|
|
681
|
+
.sort((left, right) => {
|
|
682
|
+
const leftFocused = focused.has(left[0]) || left[0] === state.hoveredNodeId || left[0] === state.selectedNodeId ? 1 : 0
|
|
683
|
+
const rightFocused = focused.has(right[0]) || right[0] === state.hoveredNodeId || right[0] === state.selectedNodeId ? 1 : 0
|
|
684
|
+
if (rightFocused !== leftFocused) return rightFocused - leftFocused
|
|
685
|
+
return Number(right?.[7] ?? 0) - Number(left?.[7] ?? 0)
|
|
686
|
+
})
|
|
687
|
+
.slice(0, state.camera.scale >= 0.72 ? 160 : 48)
|
|
329
688
|
}
|
|
330
689
|
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
x: (event.clientX - rect.left - state.transform.x) / state.transform.scale,
|
|
335
|
-
y: (event.clientY - rect.top - state.transform.y) / state.transform.scale
|
|
690
|
+
const drawLabels = () => {
|
|
691
|
+
if (!elements.labels) {
|
|
692
|
+
return
|
|
336
693
|
}
|
|
694
|
+
|
|
695
|
+
elements.labels.innerHTML = labelCandidates().map((node) => {
|
|
696
|
+
const point = worldToScreen(Number(node[2]), Number(node[3]))
|
|
697
|
+
const focused = state.focusedNodeIds.has(node[0]) || node[0] === state.hoveredNodeId || node[0] === state.selectedNodeId
|
|
698
|
+
return '<span class="graph-label' + (focused ? ' is-focused' : '') + '" style="left:' +
|
|
699
|
+
point.x.toFixed(1) + 'px;top:' + point.y.toFixed(1) + 'px">' + escapeHtml(node[1] || node[0]) + '</span>'
|
|
700
|
+
}).join('')
|
|
337
701
|
}
|
|
338
702
|
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
703
|
+
const drawMiniMap = () => {
|
|
704
|
+
const miniMap = elements.miniMap
|
|
705
|
+
if (!(miniMap instanceof HTMLCanvasElement)) {
|
|
706
|
+
return
|
|
707
|
+
}
|
|
708
|
+
const nodes = normalizeList(state.chunk.nodes)
|
|
709
|
+
const ctx = miniMap.getContext('2d')
|
|
710
|
+
if (!ctx || nodes.length === 0) {
|
|
711
|
+
return
|
|
342
712
|
}
|
|
343
713
|
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
714
|
+
const ratio = window.devicePixelRatio || 1
|
|
715
|
+
const width = miniMap.clientWidth || 180
|
|
716
|
+
const height = miniMap.clientHeight || 120
|
|
717
|
+
miniMap.width = Math.floor(width * ratio)
|
|
718
|
+
miniMap.height = Math.floor(height * ratio)
|
|
719
|
+
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
720
|
+
ctx.clearRect(0, 0, width, height)
|
|
721
|
+
ctx.fillStyle = 'rgba(13, 16, 20, 0.86)'
|
|
722
|
+
ctx.fillRect(0, 0, width, height)
|
|
723
|
+
|
|
724
|
+
const xs = nodes.map((node) => Number(node[2])).filter(Number.isFinite)
|
|
725
|
+
const ys = nodes.map((node) => Number(node[3])).filter(Number.isFinite)
|
|
726
|
+
const minX = Math.min(...xs)
|
|
727
|
+
const maxX = Math.max(...xs)
|
|
728
|
+
const minY = Math.min(...ys)
|
|
729
|
+
const maxY = Math.max(...ys)
|
|
730
|
+
const graphWidth = Math.max(1, maxX - minX)
|
|
731
|
+
const graphHeight = Math.max(1, maxY - minY)
|
|
732
|
+
const scale = Math.min((width - 18) / graphWidth, (height - 18) / graphHeight)
|
|
733
|
+
const offsetX = (width - graphWidth * scale) / 2
|
|
734
|
+
const offsetY = (height - graphHeight * scale) / 2
|
|
735
|
+
const toMini = (x, y) => ({
|
|
736
|
+
x: offsetX + (x - minX) * scale,
|
|
737
|
+
y: offsetY + (y - minY) * scale
|
|
738
|
+
})
|
|
739
|
+
state.miniMapView = { minX, minY, scale, offsetX, offsetY, width, height }
|
|
740
|
+
|
|
741
|
+
ctx.fillStyle = 'rgba(174, 184, 197, 0.62)'
|
|
742
|
+
nodes.forEach((node) => {
|
|
743
|
+
const point = toMini(Number(node[2]), Number(node[3]))
|
|
744
|
+
ctx.fillRect(point.x - 1, point.y - 1, 2, 2)
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
const worldTopLeft = screenToWorld(0, 0)
|
|
748
|
+
const worldBottomRight = screenToWorld(state.viewport.width, state.viewport.height)
|
|
749
|
+
const topLeft = toMini(Math.min(worldTopLeft.x, worldBottomRight.x), Math.min(worldTopLeft.y, worldBottomRight.y))
|
|
750
|
+
const bottomRight = toMini(Math.max(worldTopLeft.x, worldBottomRight.x), Math.max(worldTopLeft.y, worldBottomRight.y))
|
|
751
|
+
ctx.strokeStyle = 'rgba(53, 208, 162, 0.86)'
|
|
752
|
+
ctx.lineWidth = 1
|
|
753
|
+
ctx.strokeRect(topLeft.x, topLeft.y, Math.max(3, bottomRight.x - topLeft.x), Math.max(3, bottomRight.y - topLeft.y))
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const shouldDeferGraphOverlays = () => state.pointer.down || performance.now() - state.lastWheelAt < 150
|
|
757
|
+
|
|
758
|
+
const updateGraphOverlays = () => {
|
|
759
|
+
if (state.overlayScheduled) {
|
|
760
|
+
return
|
|
349
761
|
}
|
|
350
|
-
|
|
762
|
+
state.overlayScheduled = true
|
|
763
|
+
requestAnimationFrame(() => {
|
|
764
|
+
state.overlayScheduled = false
|
|
765
|
+
if (shouldDeferGraphOverlays()) {
|
|
766
|
+
elements.labels?.classList.add('is-stale')
|
|
767
|
+
if (!state.overlayIdleTimer) {
|
|
768
|
+
state.overlayIdleTimer = setTimeout(() => {
|
|
769
|
+
state.overlayIdleTimer = null
|
|
770
|
+
updateGraphOverlays()
|
|
771
|
+
}, 170)
|
|
772
|
+
}
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
elements.labels?.classList.remove('is-stale')
|
|
776
|
+
drawLabels()
|
|
777
|
+
if (state.miniMapDirty) {
|
|
778
|
+
drawMiniMap()
|
|
779
|
+
state.miniMapDirty = false
|
|
780
|
+
}
|
|
781
|
+
})
|
|
351
782
|
}
|
|
352
783
|
|
|
353
|
-
const
|
|
354
|
-
const
|
|
355
|
-
|
|
784
|
+
const list = (items) => {
|
|
785
|
+
const rows = normalizeList(items)
|
|
786
|
+
if (rows.length === 0) {
|
|
787
|
+
return '<li><small>No links found.</small></li>'
|
|
788
|
+
}
|
|
789
|
+
return rows
|
|
790
|
+
.map((item) => {
|
|
791
|
+
const title = typeof item?.title === 'string' ? item.title : 'Untitled'
|
|
792
|
+
const id = typeof item?.id === 'string' ? item.id : ''
|
|
793
|
+
const path = typeof item?.path === 'string' ? item.path : ''
|
|
794
|
+
const meta = item?.weight ? ' · weight ' + escapeHtml(item.weight) + ' · ' + escapeHtml(item.priority || 'normal') : ''
|
|
795
|
+
return '<li>' +
|
|
796
|
+
(id ? '<button type="button" data-node-id="' + escapeHtml(id) + '">' + escapeHtml(title) + '</button>' : escapeHtml(title)) +
|
|
797
|
+
'<small>' + escapeHtml(path) + meta + '</small>' +
|
|
798
|
+
'</li>'
|
|
799
|
+
})
|
|
800
|
+
.join('')
|
|
356
801
|
}
|
|
357
802
|
|
|
358
|
-
const
|
|
803
|
+
const buildFacts = (node, outgoingCount, incomingCount) => {
|
|
804
|
+
const content = typeof node?.content === 'string' ? node.content : ''
|
|
805
|
+
const words = content.trim().length > 0 ? content.trim().split(/\\s+/).length : 0
|
|
806
|
+
return [
|
|
807
|
+
{ label: 'Agent', value: typeof node?.agentId === 'string' && node.agentId ? node.agentId : 'shared' },
|
|
808
|
+
{ label: 'Words', value: String(words) },
|
|
809
|
+
{ label: 'Chars', value: String(content.length) },
|
|
810
|
+
{ label: 'Outgoing', value: String(outgoingCount) },
|
|
811
|
+
{ label: 'Backlinks', value: String(incomingCount) }
|
|
812
|
+
]
|
|
813
|
+
}
|
|
359
814
|
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const height = Math.max(rect.height, 320)
|
|
364
|
-
const padding = viewportPaddingPx
|
|
815
|
+
const listFacts = (facts) => facts
|
|
816
|
+
.map((fact) => '<li><strong>' + escapeHtml(fact.label) + ':</strong> <small>' + escapeHtml(fact.value) + '</small></li>')
|
|
817
|
+
.join('')
|
|
365
818
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
minY: (-state.transform.y - padding) / state.transform.scale,
|
|
370
|
-
maxY: (height - state.transform.y + padding) / state.transform.scale
|
|
819
|
+
const listContextLinks = (links) => {
|
|
820
|
+
if (!Array.isArray(links) || links.length === 0) {
|
|
821
|
+
return '<li><small>No context links found.</small></li>'
|
|
371
822
|
}
|
|
823
|
+
return links
|
|
824
|
+
.map((link) => '<li><span>' + escapeHtml(link.title) + '</span><small>' + escapeHtml(link.priority || 'normal') + '</small></li>')
|
|
825
|
+
.join('')
|
|
372
826
|
}
|
|
373
827
|
|
|
374
|
-
const
|
|
375
|
-
node
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
828
|
+
const nodeContextLinks = (node, outgoing) => {
|
|
829
|
+
const titles = Array.isArray(node?.contextLinks) ? node.contextLinks : []
|
|
830
|
+
const outgoingByTitle = new Map(normalizeList(outgoing).map((link) => [String(link.title || '').toLowerCase(), link]))
|
|
831
|
+
|
|
832
|
+
return titles
|
|
833
|
+
.map((title) => {
|
|
834
|
+
const match = outgoingByTitle.get(String(title).toLowerCase())
|
|
835
|
+
return {
|
|
836
|
+
title,
|
|
837
|
+
priority: match?.priority || 'normal'
|
|
838
|
+
}
|
|
839
|
+
})
|
|
840
|
+
.filter((link) => typeof link.title === 'string' && link.title.trim().length > 0)
|
|
841
|
+
}
|
|
379
842
|
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
843
|
+
const linkedNodes = (node) => {
|
|
844
|
+
const nodeById = new Map((state.chunk.nodes || []).map((item) => [item[0], item]))
|
|
845
|
+
const edges = normalizeList(state.chunk.edges)
|
|
846
|
+
|
|
847
|
+
const outgoing = []
|
|
848
|
+
const incoming = []
|
|
849
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
850
|
+
const edge = edges[index]
|
|
851
|
+
if (edge[0] === node.id) {
|
|
852
|
+
const target = nodeById.get(edge[1])
|
|
853
|
+
if (target) {
|
|
854
|
+
outgoing.push({ id: target[0], title: target[1], path: target[4] || '', weight: edge[2], priority: edge[3] })
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (edge[1] === node.id) {
|
|
858
|
+
const source = nodeById.get(edge[0])
|
|
859
|
+
if (source) {
|
|
860
|
+
incoming.push({ id: source[0], title: source[1], path: source[4] || '', weight: edge[2], priority: edge[3] })
|
|
861
|
+
}
|
|
862
|
+
}
|
|
383
863
|
}
|
|
384
864
|
|
|
385
|
-
|
|
386
|
-
|
|
865
|
+
return { outgoing, incoming }
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const openContentDialog = () => {
|
|
869
|
+
const dialog = elements.contentDialog
|
|
870
|
+
if (!dialog.open) {
|
|
871
|
+
dialog.show()
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const loadNodeDetails = async (nodeId) => {
|
|
876
|
+
if (!nodeId) {
|
|
877
|
+
return
|
|
387
878
|
}
|
|
388
|
-
|
|
389
|
-
|
|
879
|
+
|
|
880
|
+
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(nodeId) + scopeQuery('&'))
|
|
881
|
+
if (!response.ok) {
|
|
882
|
+
throw new Error('Failed to load graph node details')
|
|
390
883
|
}
|
|
391
|
-
|
|
392
|
-
|
|
884
|
+
|
|
885
|
+
const payload = await response.json()
|
|
886
|
+
if (!payload || typeof payload !== 'object' || !payload.node) {
|
|
887
|
+
throw new Error('Invalid graph node payload')
|
|
393
888
|
}
|
|
394
|
-
|
|
395
|
-
|
|
889
|
+
|
|
890
|
+
const node = payload.node
|
|
891
|
+
state.selectedNodeId = node.id
|
|
892
|
+
setFocusedNodeIds(linkedNodeIds(node.id))
|
|
893
|
+
|
|
894
|
+
if (state.renderWorker && state.workerReady) {
|
|
895
|
+
state.renderWorker.postMessage({ type: 'select', id: node.id })
|
|
396
896
|
}
|
|
397
897
|
|
|
398
|
-
|
|
898
|
+
elements.contentTitle.textContent = node.title || 'Untitled'
|
|
899
|
+
elements.contentPath.textContent = node.path || ''
|
|
900
|
+
|
|
901
|
+
const tags = Array.isArray(node.tags) ? node.tags : []
|
|
902
|
+
elements.contentTags.innerHTML = tags.length > 0
|
|
903
|
+
? tags.map((tag) => '<span>' + escapeHtml(tag) + '</span>').join('')
|
|
904
|
+
: '<span>No tags</span>'
|
|
905
|
+
|
|
906
|
+
const related = linkedNodes(node)
|
|
907
|
+
const contextLinks = nodeContextLinks(node, related.outgoing)
|
|
908
|
+
const facts = buildFacts(node, related.outgoing.length, related.incoming.length)
|
|
909
|
+
elements.contentFacts.innerHTML = listFacts(facts)
|
|
910
|
+
elements.contentContextLinks.innerHTML = listContextLinks(contextLinks)
|
|
911
|
+
elements.contentOutgoing.innerHTML = list(related.outgoing)
|
|
912
|
+
elements.contentIncoming.innerHTML = list(related.incoming)
|
|
913
|
+
elements.contentBody.textContent = typeof node.content === 'string' ? node.content : ''
|
|
914
|
+
|
|
915
|
+
openContentDialog()
|
|
399
916
|
}
|
|
400
917
|
|
|
401
|
-
const
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
918
|
+
const fitFromChunk = () => {
|
|
919
|
+
const nodes = normalizeList(state.chunk.nodes)
|
|
920
|
+
if (nodes.length === 0) {
|
|
921
|
+
return
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
let minX = Infinity
|
|
925
|
+
let minY = Infinity
|
|
926
|
+
let maxX = -Infinity
|
|
927
|
+
let maxY = -Infinity
|
|
405
928
|
|
|
406
|
-
for (let index = 0; index <
|
|
407
|
-
const node =
|
|
408
|
-
|
|
929
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
930
|
+
const node = nodes[index]
|
|
931
|
+
const x = Number(node[2])
|
|
932
|
+
const y = Number(node[3])
|
|
933
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
409
934
|
continue
|
|
410
935
|
}
|
|
936
|
+
if (x < minX) minX = x
|
|
937
|
+
if (y < minY) minY = y
|
|
938
|
+
if (x > maxX) maxX = x
|
|
939
|
+
if (y > maxY) maxY = y
|
|
940
|
+
}
|
|
411
941
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
node.id === state.hovered?.id ||
|
|
415
|
-
node.id === state.pointer.dragNode?.id
|
|
416
|
-
if (isPriority || index % stride === 0) {
|
|
417
|
-
picked.push(node)
|
|
418
|
-
}
|
|
942
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
|
|
943
|
+
return
|
|
419
944
|
}
|
|
420
945
|
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
const
|
|
946
|
+
const width = Math.max(1, maxX - minX)
|
|
947
|
+
const height = Math.max(1, maxY - minY)
|
|
948
|
+
const scaleX = state.viewport.width / width
|
|
949
|
+
const scaleY = state.viewport.height / height
|
|
950
|
+
const scale = clampScale(Math.min(scaleX, scaleY) * 0.72)
|
|
426
951
|
|
|
427
|
-
state.
|
|
428
|
-
state.
|
|
952
|
+
state.camera.scale = scale
|
|
953
|
+
state.camera.x = state.viewport.width / 2 - (minX + width / 2) * scale
|
|
954
|
+
state.camera.y = state.viewport.height / 2 - (minY + height / 2) * scale
|
|
955
|
+
updateWorkerCamera()
|
|
429
956
|
}
|
|
430
957
|
|
|
431
|
-
const
|
|
432
|
-
const
|
|
433
|
-
state.
|
|
434
|
-
|
|
435
|
-
if (delta < minFrameIntervalMs) {
|
|
436
|
-
requestAnimationFrame(render)
|
|
437
|
-
return
|
|
958
|
+
const fetchChunk = async ({ fit } = { fit: false }) => {
|
|
959
|
+
const token = ++state.fetchToken
|
|
960
|
+
if (state.fetchAbortController) {
|
|
961
|
+
state.fetchAbortController.abort()
|
|
438
962
|
}
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
963
|
+
const controller = new AbortController()
|
|
964
|
+
state.fetchAbortController = controller
|
|
965
|
+
const worldTopLeft = screenToWorld(0, 0)
|
|
966
|
+
const worldBottomRight = screenToWorld(state.viewport.width, state.viewport.height)
|
|
967
|
+
const x = Math.min(worldTopLeft.x, worldBottomRight.x)
|
|
968
|
+
const y = Math.min(worldTopLeft.y, worldBottomRight.y)
|
|
969
|
+
const w = Math.abs(worldBottomRight.x - worldTopLeft.x)
|
|
970
|
+
const h = Math.abs(worldBottomRight.y - worldTopLeft.y)
|
|
971
|
+
|
|
972
|
+
const params = new URLSearchParams({
|
|
973
|
+
x: String(x),
|
|
974
|
+
y: String(y),
|
|
975
|
+
w: String(Math.max(1, w)),
|
|
976
|
+
h: String(Math.max(1, h)),
|
|
977
|
+
scale: String(state.camera.scale),
|
|
978
|
+
nodeBudget: String(getZoomNodeBudget()),
|
|
979
|
+
edgeBudget: String(getZoomEdgeBudget())
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
if (state.agentId) {
|
|
983
|
+
params.set('agent', state.agentId)
|
|
984
|
+
}
|
|
985
|
+
if (state.contextId) {
|
|
986
|
+
params.set('context', state.contextId)
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const requestKey = graphStreamRequestKey({ x, y, w, h })
|
|
990
|
+
if (!fit && state.lastChunkRequestKey === requestKey && state.chunk.nodes.length > 0) {
|
|
449
991
|
return
|
|
450
992
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
computeRenderVisibility()
|
|
456
|
-
tick(delta)
|
|
457
|
-
const drawEdges = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
|
|
458
|
-
if (drawEdges) {
|
|
459
|
-
state.renderEdges.forEach(edge => {
|
|
460
|
-
const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
461
|
-
ctx.beginPath()
|
|
462
|
-
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
|
|
463
|
-
ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
|
|
464
|
-
ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
|
|
465
|
-
ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
|
|
466
|
-
ctx.stroke()
|
|
467
|
-
})
|
|
993
|
+
|
|
994
|
+
const response = await fetch('/api/graph-stream?' + params.toString(), { signal: controller.signal })
|
|
995
|
+
if (!response.ok) {
|
|
996
|
+
throw new Error('Failed to fetch graph stream chunk')
|
|
468
997
|
}
|
|
469
998
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
ctx.fill()
|
|
478
|
-
ctx.beginPath()
|
|
479
|
-
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
|
|
480
|
-
ctx.fillStyle = isSelected ? graphTheme.nodeSelected : isHovered ? graphTheme.nodeHover : graphTheme.node
|
|
481
|
-
ctx.fill()
|
|
482
|
-
ctx.lineWidth = isSelected ? 2.6 : 1.5
|
|
483
|
-
ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
|
|
484
|
-
ctx.stroke()
|
|
485
|
-
|
|
486
|
-
const shouldDrawLabels =
|
|
487
|
-
isSelected ||
|
|
488
|
-
isHovered ||
|
|
489
|
-
(state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
|
|
490
|
-
if (shouldDrawLabels) {
|
|
491
|
-
ctx.fillStyle = graphTheme.label
|
|
492
|
-
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
493
|
-
ctx.textAlign = 'center'
|
|
494
|
-
ctx.textBaseline = 'top'
|
|
495
|
-
ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
|
|
496
|
-
}
|
|
497
|
-
})
|
|
999
|
+
const chunk = await response.json()
|
|
1000
|
+
if (controller.signal.aborted) {
|
|
1001
|
+
return
|
|
1002
|
+
}
|
|
1003
|
+
if (token !== state.fetchToken) {
|
|
1004
|
+
return
|
|
1005
|
+
}
|
|
498
1006
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
:
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
...linkedNode,
|
|
517
|
-
weight: edge.weight,
|
|
518
|
-
priority: edge.priority
|
|
519
|
-
} : null
|
|
520
|
-
const outgoing = state.graph.edges
|
|
521
|
-
.filter(edge => edge.source === node.id)
|
|
522
|
-
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' }, edge))
|
|
523
|
-
.filter(Boolean)
|
|
524
|
-
const incoming = state.graph.edges
|
|
525
|
-
.filter(edge => edge.target === node.id)
|
|
526
|
-
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
527
|
-
.filter(Boolean)
|
|
1007
|
+
state.graphSignature = typeof chunk.signature === 'string' ? chunk.signature : ''
|
|
1008
|
+
state.lastChunkRequestKey = requestKey
|
|
1009
|
+
ensureNodePositionsLoaded()
|
|
1010
|
+
await syncNodePositionsFromServer()
|
|
1011
|
+
state.graphMode = typeof chunk.mode === 'string' ? chunk.mode : 'near'
|
|
1012
|
+
const chunkNodes = applyManualNodePositions(chunk.nodes)
|
|
1013
|
+
state.chunk = {
|
|
1014
|
+
nodes: chunkNodes,
|
|
1015
|
+
edges: normalizeList(chunk.edges)
|
|
1016
|
+
}
|
|
1017
|
+
state.miniMapDirty = true
|
|
1018
|
+
state.spatialIndex.key = ''
|
|
1019
|
+
const renderChunk = { ...chunk, nodes: chunkNodes }
|
|
1020
|
+
state.totals = {
|
|
1021
|
+
nodes: Number.isFinite(chunk?.totals?.nodes) ? Number(chunk.totals.nodes) : state.chunk.nodes.length,
|
|
1022
|
+
edges: Number.isFinite(chunk?.totals?.edges) ? Number(chunk.totals.edges) : state.chunk.edges.length
|
|
1023
|
+
}
|
|
528
1024
|
|
|
529
|
-
|
|
530
|
-
|
|
1025
|
+
updateTotals()
|
|
1026
|
+
updateTagCount()
|
|
531
1027
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
if (cached) {
|
|
535
|
-
return cached
|
|
1028
|
+
if (fit) {
|
|
1029
|
+
fitFromChunk()
|
|
536
1030
|
}
|
|
537
1031
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
1032
|
+
if (state.renderWorker && state.workerReady) {
|
|
1033
|
+
state.renderWorker.postMessage({ type: 'chunk', chunk: renderChunk })
|
|
1034
|
+
state.renderWorker.postMessage({ type: 'select', id: state.selectedNodeId })
|
|
1035
|
+
state.renderWorker.postMessage({ type: 'highlight', ids: Array.from(state.searchResultIds) })
|
|
541
1036
|
}
|
|
542
1037
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
1038
|
+
updateGraphOverlays()
|
|
1039
|
+
drawFallback()
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const scheduleChunkFetch = ({ fit } = { fit: false }) => {
|
|
1043
|
+
if (state.fetchTimer) {
|
|
1044
|
+
clearTimeout(state.fetchTimer)
|
|
547
1045
|
}
|
|
548
|
-
|
|
549
|
-
|
|
1046
|
+
|
|
1047
|
+
const now = performance.now()
|
|
1048
|
+
const recentlyWheeling = now - state.lastWheelAt < 320
|
|
1049
|
+
const heavyScene = state.lastVisibleNodes > 1200 || state.lastVisibleEdges > 3500
|
|
1050
|
+
const delay = fit ? 0 : (state.pointer.down ? 320 : (recentlyWheeling ? (heavyScene ? 420 : 300) : (heavyScene ? 120 : 72)))
|
|
1051
|
+
state.fetchTimer = setTimeout(() => {
|
|
1052
|
+
state.fetchTimer = null
|
|
1053
|
+
fetchChunk({ fit }).catch((error) => {
|
|
1054
|
+
if (error && error.name === 'AbortError') {
|
|
1055
|
+
return
|
|
1056
|
+
}
|
|
1057
|
+
console.error(error)
|
|
1058
|
+
})
|
|
1059
|
+
}, delay)
|
|
550
1060
|
}
|
|
551
1061
|
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
1062
|
+
const setViewportFromCanvas = () => {
|
|
1063
|
+
const rect = canvas.getBoundingClientRect()
|
|
1064
|
+
state.viewport.width = Math.max(320, rect.width)
|
|
1065
|
+
state.viewport.height = Math.max(320, rect.height)
|
|
1066
|
+
state.viewport.ratio = window.devicePixelRatio || 1
|
|
1067
|
+
state.miniMapDirty = true
|
|
1068
|
+
updateWorkerSize()
|
|
1069
|
+
drawFallback()
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const pickFallbackNode = (screenX, screenY) => {
|
|
1073
|
+
const nodes = spatialCandidates(screenX, screenY)
|
|
1074
|
+
if (nodes.length === 0) {
|
|
1075
|
+
return null
|
|
565
1076
|
}
|
|
566
1077
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
1078
|
+
let bestNode = null
|
|
1079
|
+
let bestDistance = Infinity
|
|
1080
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
1081
|
+
const node = nodes[index]
|
|
1082
|
+
const id = typeof node[0] === 'string' ? node[0] : ''
|
|
1083
|
+
if (!id) continue
|
|
1084
|
+
const x = Number(node[2])
|
|
1085
|
+
const y = Number(node[3])
|
|
1086
|
+
const weight = Number(node[7])
|
|
1087
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) continue
|
|
1088
|
+
const point = worldToScreen(x, y)
|
|
1089
|
+
const radius = Math.max(2.4, Math.min(14, 4 + (Number.isFinite(weight) ? weight : 0) * 0.55))
|
|
1090
|
+
const distance = Math.hypot(screenX - point.x, screenY - point.y)
|
|
1091
|
+
if (distance <= radius && distance < bestDistance) {
|
|
1092
|
+
bestDistance = distance
|
|
1093
|
+
bestNode = node
|
|
571
1094
|
}
|
|
572
|
-
elements.contentBody.textContent = detailedNode.content
|
|
573
|
-
} catch {
|
|
574
|
-
elements.contentBody.textContent = 'Unable to load note content.'
|
|
575
1095
|
}
|
|
1096
|
+
|
|
1097
|
+
return bestNode
|
|
576
1098
|
}
|
|
577
1099
|
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
openContentDialog(node).catch(() => {
|
|
582
|
-
elements.contentBody.textContent = 'Unable to load note content.'
|
|
583
|
-
})
|
|
584
|
-
}
|
|
1100
|
+
const pickFallbackNodeId = (screenX, screenY) => {
|
|
1101
|
+
const node = pickFallbackNode(screenX, screenY)
|
|
1102
|
+
return typeof node?.[0] === 'string' ? node[0] : ''
|
|
585
1103
|
}
|
|
586
1104
|
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
1105
|
+
const pickAt = (screenX, screenY) => {
|
|
1106
|
+
if (state.rendererMode === 'fallback') {
|
|
1107
|
+
const nodeId = pickFallbackNodeId(screenX, screenY)
|
|
1108
|
+
if (nodeId) {
|
|
1109
|
+
loadNodeDetails(nodeId).catch((error) => console.error(error))
|
|
1110
|
+
}
|
|
1111
|
+
return
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if (!state.renderWorker || !state.workerReady) {
|
|
1115
|
+
return
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const requestId = Math.random().toString(36).slice(2)
|
|
1119
|
+
state.renderWorker.postMessage({
|
|
1120
|
+
type: 'pick',
|
|
1121
|
+
requestId,
|
|
1122
|
+
x: screenX,
|
|
1123
|
+
y: screenY
|
|
1124
|
+
})
|
|
590
1125
|
}
|
|
591
1126
|
|
|
592
1127
|
const zoomAtPoint = (screenX, screenY, factor) => {
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
state.
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
1128
|
+
const clamped = Math.max(0.92, Math.min(1.09, factor))
|
|
1129
|
+
const before = screenToWorld(screenX, screenY)
|
|
1130
|
+
state.camera.scale = clampScale(state.camera.scale * clamped)
|
|
1131
|
+
state.camera.x = screenX - before.x * state.camera.scale
|
|
1132
|
+
state.camera.y = screenY - before.y * state.camera.scale
|
|
1133
|
+
updateWorkerCamera()
|
|
1134
|
+
scheduleChunkFetch()
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const resolvePointer = (event) => {
|
|
1138
|
+
const rect = canvas.getBoundingClientRect()
|
|
1139
|
+
return {
|
|
1140
|
+
x: event.clientX - rect.left,
|
|
1141
|
+
y: event.clientY - rect.top
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const setupInput = () => {
|
|
1146
|
+
const dragActivationDistance = 6
|
|
1147
|
+
|
|
1148
|
+
canvas.addEventListener('wheel', (event) => {
|
|
1149
|
+
event.preventDefault()
|
|
1150
|
+
state.lastWheelAt = performance.now()
|
|
1151
|
+
const pointer = resolvePointer(event)
|
|
1152
|
+
const exponent = Math.max(-0.05, Math.min(0.05, -event.deltaY * 0.001))
|
|
1153
|
+
zoomAtPoint(pointer.x, pointer.y, Math.exp(exponent))
|
|
1154
|
+
}, { passive: false })
|
|
1155
|
+
|
|
1156
|
+
canvas.addEventListener('pointerdown', (event) => {
|
|
1157
|
+
const pointer = resolvePointer(event)
|
|
1158
|
+
const candidateNode = pickFallbackNode(pointer.x, pointer.y)
|
|
1159
|
+
const candidateNodeId = candidateNode?.[6] === 'node' && typeof candidateNode?.[0] === 'string' ? candidateNode[0] : ''
|
|
1160
|
+
const candidateX = Number(candidateNode?.[2])
|
|
1161
|
+
const candidateY = Number(candidateNode?.[3])
|
|
1162
|
+
const world = screenToWorld(pointer.x, pointer.y)
|
|
1163
|
+
state.pointer.down = true
|
|
1164
|
+
state.pointer.moved = false
|
|
1165
|
+
state.pointer.dragging = false
|
|
1166
|
+
state.pointer.dragNodeId = candidateNodeId
|
|
1167
|
+
state.pointer.x = pointer.x
|
|
1168
|
+
state.pointer.y = pointer.y
|
|
1169
|
+
state.pointer.startX = pointer.x
|
|
1170
|
+
state.pointer.startY = pointer.y
|
|
1171
|
+
state.pointer.startWorldX = world.x
|
|
1172
|
+
state.pointer.startWorldY = world.y
|
|
1173
|
+
state.pointer.nodeStartX = candidateNodeId && Number.isFinite(candidateX) ? candidateX : 0
|
|
1174
|
+
state.pointer.nodeStartY = candidateNodeId && Number.isFinite(candidateY) ? candidateY : 0
|
|
1175
|
+
state.pointer.worldAnchorX = world.x
|
|
1176
|
+
state.pointer.worldAnchorY = world.y
|
|
1177
|
+
canvas.setPointerCapture(event.pointerId)
|
|
608
1178
|
})
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
1179
|
+
|
|
1180
|
+
canvas.addEventListener('pointermove', (event) => {
|
|
1181
|
+
const pointer = resolvePointer(event)
|
|
1182
|
+
|
|
1183
|
+
if (state.pointer.down) {
|
|
1184
|
+
const dx = pointer.x - state.pointer.x
|
|
1185
|
+
const dy = pointer.y - state.pointer.y
|
|
1186
|
+
const distanceFromStart = Math.hypot(pointer.x - state.pointer.startX, pointer.y - state.pointer.startY)
|
|
1187
|
+
if (distanceFromStart >= dragActivationDistance) {
|
|
1188
|
+
state.pointer.moved = true
|
|
1189
|
+
state.pointer.dragging = true
|
|
1190
|
+
canvas.classList.toggle('is-node-dragging', Boolean(state.pointer.dragNodeId))
|
|
1191
|
+
}
|
|
1192
|
+
if (!state.pointer.dragging) {
|
|
1193
|
+
state.pointer.x = pointer.x
|
|
1194
|
+
state.pointer.y = pointer.y
|
|
1195
|
+
return
|
|
1196
|
+
}
|
|
1197
|
+
if (state.pointer.dragNodeId) {
|
|
1198
|
+
const world = screenToWorld(pointer.x, pointer.y)
|
|
1199
|
+
const x = state.pointer.nodeStartX + world.x - state.pointer.startWorldX
|
|
1200
|
+
const y = state.pointer.nodeStartY + world.y - state.pointer.startWorldY
|
|
1201
|
+
state.nodePositions.set(state.pointer.dragNodeId, { x, y })
|
|
1202
|
+
updateNodePositionInChunk(state.pointer.dragNodeId, x, y)
|
|
1203
|
+
state.pointer.x = pointer.x
|
|
1204
|
+
state.pointer.y = pointer.y
|
|
1205
|
+
drawFallback()
|
|
1206
|
+
return
|
|
1207
|
+
}
|
|
1208
|
+
state.camera.x += dx
|
|
1209
|
+
state.camera.y += dy
|
|
1210
|
+
state.pointer.x = pointer.x
|
|
1211
|
+
state.pointer.y = pointer.y
|
|
1212
|
+
updateWorkerCamera()
|
|
1213
|
+
drawFallback()
|
|
1214
|
+
return
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const hovered = pickFallbackNode(pointer.x, pointer.y)
|
|
1218
|
+
const hoveredId = hovered?.[6] === 'node' && typeof hovered?.[0] === 'string' ? hovered[0] : ''
|
|
1219
|
+
if (state.hoveredNodeId !== hoveredId) {
|
|
1220
|
+
state.hoveredNodeId = hoveredId
|
|
1221
|
+
canvas.classList.toggle('is-node-hover', Boolean(hoveredId))
|
|
1222
|
+
updateGraphOverlays()
|
|
1223
|
+
}
|
|
1224
|
+
if (hoveredId) {
|
|
1225
|
+
showTooltip(hovered, pointer)
|
|
1226
|
+
} else {
|
|
1227
|
+
hideTooltip()
|
|
1228
|
+
}
|
|
619
1229
|
})
|
|
1230
|
+
|
|
1231
|
+
canvas.addEventListener('pointerup', (event) => {
|
|
1232
|
+
const pointer = resolvePointer(event)
|
|
1233
|
+
const distanceFromStart = Math.hypot(pointer.x - state.pointer.startX, pointer.y - state.pointer.startY)
|
|
1234
|
+
const shouldPick = !state.pointer.dragging && distanceFromStart < dragActivationDistance
|
|
1235
|
+
const shouldRefreshAfterDrag = state.pointer.dragging
|
|
1236
|
+
const shouldPersistNodePosition = state.pointer.dragging && Boolean(state.pointer.dragNodeId)
|
|
1237
|
+
state.pointer.down = false
|
|
1238
|
+
state.pointer.dragging = false
|
|
1239
|
+
canvas.classList.remove('is-node-dragging')
|
|
1240
|
+
state.pointer.dragNodeId = ''
|
|
1241
|
+
canvas.releasePointerCapture(event.pointerId)
|
|
1242
|
+
|
|
1243
|
+
if (shouldPick) {
|
|
1244
|
+
pickAt(pointer.x, pointer.y)
|
|
1245
|
+
return
|
|
1246
|
+
}
|
|
1247
|
+
if (shouldPersistNodePosition) {
|
|
1248
|
+
writeStoredNodePositions()
|
|
1249
|
+
persistNodePositionsToServer()
|
|
1250
|
+
return
|
|
1251
|
+
}
|
|
1252
|
+
if (shouldRefreshAfterDrag) {
|
|
1253
|
+
scheduleChunkFetch()
|
|
1254
|
+
}
|
|
1255
|
+
})
|
|
1256
|
+
|
|
1257
|
+
canvas.addEventListener('pointerleave', () => {
|
|
1258
|
+
state.hoveredNodeId = ''
|
|
1259
|
+
canvas.classList.remove('is-node-hover')
|
|
1260
|
+
hideTooltip()
|
|
1261
|
+
updateGraphOverlays()
|
|
1262
|
+
})
|
|
1263
|
+
|
|
1264
|
+
elements.miniMap.addEventListener('click', (event) => {
|
|
1265
|
+
if (!state.miniMapView) {
|
|
1266
|
+
return
|
|
1267
|
+
}
|
|
1268
|
+
const rect = elements.miniMap.getBoundingClientRect()
|
|
1269
|
+
const x = event.clientX - rect.left
|
|
1270
|
+
const y = event.clientY - rect.top
|
|
1271
|
+
const worldX = state.miniMapView.minX + (x - state.miniMapView.offsetX) / state.miniMapView.scale
|
|
1272
|
+
const worldY = state.miniMapView.minY + (y - state.miniMapView.offsetY) / state.miniMapView.scale
|
|
1273
|
+
state.camera.x = state.viewport.width / 2 - worldX * state.camera.scale
|
|
1274
|
+
state.camera.y = state.viewport.height / 2 - worldY * state.camera.scale
|
|
1275
|
+
updateWorkerCamera()
|
|
1276
|
+
scheduleChunkFetch()
|
|
1277
|
+
})
|
|
1278
|
+
|
|
1279
|
+
canvas.addEventListener('dblclick', (event) => {
|
|
1280
|
+
const pointer = resolvePointer(event)
|
|
1281
|
+
zoomAtPoint(pointer.x, pointer.y, 1.065)
|
|
1282
|
+
})
|
|
1283
|
+
|
|
1284
|
+
window.addEventListener('keydown', (event) => {
|
|
1285
|
+
if (event.key === '+') {
|
|
1286
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
|
|
1287
|
+
return
|
|
1288
|
+
}
|
|
1289
|
+
if (event.key === '-') {
|
|
1290
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
|
|
1291
|
+
return
|
|
1292
|
+
}
|
|
1293
|
+
if (event.key === '0') {
|
|
1294
|
+
scheduleChunkFetch({ fit: true })
|
|
1295
|
+
}
|
|
1296
|
+
})
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
const setupControls = () => {
|
|
620
1300
|
elements.zoomIn.addEventListener('click', () => {
|
|
621
|
-
|
|
622
|
-
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.18)
|
|
1301
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
|
|
623
1302
|
})
|
|
1303
|
+
|
|
624
1304
|
elements.zoomOut.addEventListener('click', () => {
|
|
625
|
-
|
|
626
|
-
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.84)
|
|
1305
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
|
|
627
1306
|
})
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
}
|
|
1307
|
+
|
|
1308
|
+
elements.fit.addEventListener('click', () => {
|
|
1309
|
+
fitFromChunk()
|
|
1310
|
+
scheduleChunkFetch()
|
|
1311
|
+
})
|
|
1312
|
+
|
|
1313
|
+
elements.releaseNode.addEventListener('click', () => {
|
|
1314
|
+
releaseSelectedNodePosition()
|
|
1315
|
+
})
|
|
1316
|
+
|
|
633
1317
|
elements.reset.addEventListener('click', () => {
|
|
634
|
-
|
|
1318
|
+
clearStoredNodePositions()
|
|
1319
|
+
clearNodePositionsOnServer()
|
|
1320
|
+
state.camera = { x: 0, y: 0, scale: 0.22 }
|
|
1321
|
+
updateWorkerCamera()
|
|
1322
|
+
scheduleChunkFetch({ fit: true })
|
|
635
1323
|
})
|
|
636
|
-
|
|
637
|
-
elements.
|
|
638
|
-
|
|
639
|
-
if (target instanceof HTMLElement && target.dataset.nodeId) {
|
|
640
|
-
selectNodeById(target.dataset.nodeId)
|
|
641
|
-
return
|
|
642
|
-
}
|
|
643
|
-
if (event.target === elements.contentDialog) elements.contentDialog.close()
|
|
1324
|
+
|
|
1325
|
+
elements.contentClose.addEventListener('click', () => {
|
|
1326
|
+
elements.contentDialog.close()
|
|
644
1327
|
})
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
const cursorY = event.clientY - rect.top
|
|
650
|
-
const factor = event.deltaY < 0 ? 1.08 : 0.92
|
|
651
|
-
zoomAtPoint(cursorX, cursorY, factor)
|
|
652
|
-
}, { passive: false })
|
|
653
|
-
canvas.addEventListener('pointerdown', event => {
|
|
654
|
-
const point = worldPoint(event)
|
|
655
|
-
const node = hitNode(point)
|
|
656
|
-
state.pointer = { x: event.clientX, y: event.clientY, down: true, dragNode: node, moved: false }
|
|
657
|
-
if (node) {
|
|
658
|
-
node.x = point.x
|
|
659
|
-
node.y = point.y
|
|
1328
|
+
|
|
1329
|
+
elements.contentDialog.addEventListener('click', (event) => {
|
|
1330
|
+
if (event.target === elements.contentDialog) {
|
|
1331
|
+
elements.contentDialog.close()
|
|
660
1332
|
}
|
|
661
|
-
canvas.setPointerCapture(event.pointerId)
|
|
662
1333
|
})
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
state.
|
|
666
|
-
|
|
667
|
-
const dx = event.clientX - state.pointer.x
|
|
668
|
-
const dy = event.clientY - state.pointer.y
|
|
669
|
-
state.pointer.x = event.clientX
|
|
670
|
-
state.pointer.y = event.clientY
|
|
671
|
-
state.pointer.moved = state.pointer.moved || Math.abs(dx) + Math.abs(dy) > 3
|
|
672
|
-
if (state.pointer.dragNode) {
|
|
673
|
-
state.pointer.dragNode.x = point.x
|
|
674
|
-
state.pointer.dragNode.y = point.y
|
|
675
|
-
return
|
|
1334
|
+
|
|
1335
|
+
elements.search.addEventListener('input', () => {
|
|
1336
|
+
if (state.searchTimer) {
|
|
1337
|
+
clearTimeout(state.searchTimer)
|
|
676
1338
|
}
|
|
677
|
-
state.
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
|
|
682
|
-
if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
|
|
683
|
-
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
684
|
-
canvas.releasePointerCapture(event.pointerId)
|
|
1339
|
+
state.searchTimer = setTimeout(() => {
|
|
1340
|
+
state.searchTimer = null
|
|
1341
|
+
runGraphSearch().catch((error) => console.error(error))
|
|
1342
|
+
}, 160)
|
|
685
1343
|
})
|
|
686
1344
|
}
|
|
687
1345
|
|
|
1346
|
+
const runGraphSearch = async () => {
|
|
1347
|
+
const token = ++state.searchToken
|
|
1348
|
+
const query = (elements.search.value || '').trim()
|
|
1349
|
+
if (!query) {
|
|
1350
|
+
state.searchResultIds = new Set()
|
|
1351
|
+
setFocusedNodeIds(new Set())
|
|
1352
|
+
if (state.renderWorker && state.workerReady) {
|
|
1353
|
+
state.renderWorker.postMessage({ type: 'highlight', ids: [] })
|
|
1354
|
+
}
|
|
1355
|
+
return
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const response = await fetch('/api/graph-filter?q=' + encodeURIComponent(query) + '&limit=1800' + scopeQuery('&'))
|
|
1359
|
+
if (!response.ok) {
|
|
1360
|
+
throw new Error('Failed to search graph')
|
|
1361
|
+
}
|
|
1362
|
+
const payload = await response.json()
|
|
1363
|
+
if (token !== state.searchToken) {
|
|
1364
|
+
return
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const ids = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter((id) => typeof id === 'string' && id.length > 0) : []
|
|
1368
|
+
state.searchResultIds = new Set(ids)
|
|
1369
|
+
setFocusedNodeIds(state.searchResultIds)
|
|
1370
|
+
if (state.renderWorker && state.workerReady) {
|
|
1371
|
+
state.renderWorker.postMessage({ type: 'highlight', ids })
|
|
1372
|
+
}
|
|
1373
|
+
if (ids.length > 0 && state.graphMode === 'far') {
|
|
1374
|
+
state.camera.scale = Math.max(state.camera.scale, 0.82)
|
|
1375
|
+
updateWorkerCamera()
|
|
1376
|
+
scheduleChunkFetch()
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
688
1380
|
const loadAgents = async () => {
|
|
689
1381
|
const response = await fetch('/api/agents')
|
|
1382
|
+
if (!response.ok) {
|
|
1383
|
+
throw new Error('Failed to load agents')
|
|
1384
|
+
}
|
|
1385
|
+
|
|
690
1386
|
const payload = await response.json()
|
|
691
|
-
const agents = Array.isArray(payload
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
elements.agent.value =
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
'if-none-match': encodeEntityTag(state.graphSignature)
|
|
713
|
-
}
|
|
714
|
-
: undefined
|
|
1387
|
+
const agents = Array.isArray(payload?.agents) ? payload.agents : []
|
|
1388
|
+
|
|
1389
|
+
elements.agent.innerHTML = agents
|
|
1390
|
+
.map((agent) => {
|
|
1391
|
+
const id = String(agent?.id || '')
|
|
1392
|
+
const count = Number.isFinite(agent?.documentCount) ? agent.documentCount : 0
|
|
1393
|
+
const label = id === 'shared' ? 'shared' : id
|
|
1394
|
+
return '<option value="' + escapeHtml(id) + '">' + escapeHtml(label) + ' (' + count + ')</option>'
|
|
1395
|
+
})
|
|
1396
|
+
.join('')
|
|
1397
|
+
|
|
1398
|
+
const preferredAgent = initialAgentFromUrl || readStoredAgent()
|
|
1399
|
+
const hasPreferred = preferredAgent && agents.some((agent) => agent?.id === preferredAgent)
|
|
1400
|
+
state.agentId = hasPreferred ? preferredAgent : String(agents[0]?.id || '')
|
|
1401
|
+
elements.agent.value = state.agentId
|
|
1402
|
+
|
|
1403
|
+
elements.agent.addEventListener('change', () => {
|
|
1404
|
+
state.agentId = elements.agent.value || ''
|
|
1405
|
+
writeStoredAgent(state.agentId)
|
|
1406
|
+
syncAgentInUrl(state.agentId)
|
|
1407
|
+
loadContexts().then(() => scheduleChunkFetch({ fit: true })).catch((error) => console.error(error))
|
|
715
1408
|
})
|
|
716
1409
|
|
|
717
|
-
|
|
718
|
-
|
|
1410
|
+
syncAgentInUrl(state.agentId)
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const loadContexts = async () => {
|
|
1414
|
+
const response = await fetch('/api/graph-contexts' + (state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''))
|
|
1415
|
+
if (!response.ok) {
|
|
1416
|
+
throw new Error('Failed to load graph contexts')
|
|
719
1417
|
}
|
|
720
1418
|
|
|
721
1419
|
const payload = await response.json()
|
|
722
|
-
const
|
|
723
|
-
const
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
scheduleContentFilterSync()
|
|
742
|
-
const tags = new Set(graph.nodes.flatMap(node => node.tags))
|
|
743
|
-
setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
|
|
744
|
-
elements.nodeCount.textContent = graph.nodes.length
|
|
745
|
-
elements.edgeCount.textContent = graph.edges.length
|
|
746
|
-
elements.tagCount.textContent = tags.size
|
|
747
|
-
resize()
|
|
748
|
-
if (options.reset) resetView()
|
|
749
|
-
const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
|
|
750
|
-
selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
|
|
751
|
-
if (!selectedNode && elements.contentDialog.open) {
|
|
752
|
-
elements.contentDialog.close()
|
|
753
|
-
}
|
|
1420
|
+
const contexts = Array.isArray(payload?.contexts) ? payload.contexts : []
|
|
1421
|
+
const options = [
|
|
1422
|
+
'<option value="">All contexts</option>',
|
|
1423
|
+
...contexts.map((context) => {
|
|
1424
|
+
const id = String(context?.id || '')
|
|
1425
|
+
const title = String(context?.title || id || 'Untitled')
|
|
1426
|
+
const count = Number.isFinite(context?.nodeCount) ? context.nodeCount : 0
|
|
1427
|
+
return '<option value="' + escapeHtml(id) + '">' + escapeHtml(title) + ' (' + count + ')</option>'
|
|
1428
|
+
})
|
|
1429
|
+
]
|
|
1430
|
+
|
|
1431
|
+
elements.context.innerHTML = options.join('')
|
|
1432
|
+
|
|
1433
|
+
const preferredContext = initialContextFromUrl || readStoredContext()
|
|
1434
|
+
const hasPreferred = preferredContext && contexts.some((context) => context?.id === preferredContext)
|
|
1435
|
+
state.contextId = hasPreferred ? preferredContext : ''
|
|
1436
|
+
elements.context.value = state.contextId
|
|
1437
|
+
writeStoredContext(state.contextId)
|
|
1438
|
+
syncContextInUrl(state.contextId)
|
|
754
1439
|
}
|
|
755
1440
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1441
|
+
const setupContextControl = () => {
|
|
1442
|
+
elements.context.addEventListener('change', () => {
|
|
1443
|
+
state.contextId = elements.context.value || ''
|
|
1444
|
+
state.selectedNodeId = null
|
|
1445
|
+
writeStoredContext(state.contextId)
|
|
1446
|
+
syncContextInUrl(state.contextId)
|
|
1447
|
+
scheduleChunkFetch({ fit: true })
|
|
1448
|
+
})
|
|
1449
|
+
}
|
|
761
1450
|
|
|
762
|
-
const
|
|
763
|
-
|
|
1451
|
+
const setupRenderWorker = () => {
|
|
1452
|
+
const hasWorker = typeof Worker !== 'undefined'
|
|
1453
|
+
const canTransfer = typeof canvas.transferControlToOffscreen === 'function'
|
|
764
1454
|
|
|
765
|
-
|
|
766
|
-
|
|
1455
|
+
if (!hasWorker || !canTransfer) {
|
|
1456
|
+
state.rendererMode = 'fallback'
|
|
1457
|
+
drawFallback()
|
|
767
1458
|
return
|
|
768
1459
|
}
|
|
769
1460
|
|
|
770
|
-
|
|
1461
|
+
try {
|
|
1462
|
+
const offscreen = canvas.transferControlToOffscreen()
|
|
1463
|
+
const worker = new Worker('/render-worker.js')
|
|
1464
|
+
state.renderWorker = worker
|
|
1465
|
+
|
|
1466
|
+
worker.onmessage = (event) => {
|
|
1467
|
+
const payload = event.data
|
|
1468
|
+
if (!payload || typeof payload !== 'object') {
|
|
1469
|
+
return
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
if (payload.type === 'ready') {
|
|
1473
|
+
state.workerReady = true
|
|
1474
|
+
scheduleChunkFetch({ fit: true })
|
|
1475
|
+
return
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
if (payload.type === 'pick-result') {
|
|
1479
|
+
if (payload.node && typeof payload.node.id === 'string' && payload.node.id.length > 0) {
|
|
1480
|
+
loadNodeDetails(payload.node.id).catch((error) => console.error(error))
|
|
1481
|
+
}
|
|
1482
|
+
return
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (payload.type === 'frame-stats') {
|
|
1486
|
+
state.lastVisibleNodes = Number.isFinite(payload.visibleNodes) ? payload.visibleNodes : state.lastVisibleNodes
|
|
1487
|
+
state.lastVisibleEdges = Number.isFinite(payload.visibleEdges) ? payload.visibleEdges : state.lastVisibleEdges
|
|
1488
|
+
return
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
if (payload.type === 'fatal') {
|
|
1492
|
+
console.error(payload.message)
|
|
1493
|
+
state.rendererMode = 'fallback'
|
|
1494
|
+
state.workerReady = false
|
|
1495
|
+
state.renderWorker.terminate()
|
|
1496
|
+
state.renderWorker = null
|
|
1497
|
+
drawFallback()
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
771
1500
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1501
|
+
worker.postMessage({
|
|
1502
|
+
type: 'init',
|
|
1503
|
+
canvas: offscreen,
|
|
1504
|
+
width: state.viewport.width,
|
|
1505
|
+
height: state.viewport.height,
|
|
1506
|
+
devicePixelRatio: state.viewport.ratio,
|
|
1507
|
+
camera: state.camera,
|
|
1508
|
+
theme: graphTheme
|
|
1509
|
+
}, [offscreen])
|
|
1510
|
+
} catch (error) {
|
|
1511
|
+
console.error(error)
|
|
1512
|
+
state.rendererMode = 'fallback'
|
|
1513
|
+
drawFallback()
|
|
777
1514
|
}
|
|
778
1515
|
}
|
|
779
1516
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
.
|
|
783
|
-
|
|
784
|
-
|
|
1517
|
+
const wireNodeLinkClicks = () => {
|
|
1518
|
+
const dialog = elements.contentDialog
|
|
1519
|
+
dialog.addEventListener('click', (event) => {
|
|
1520
|
+
const target = event.target
|
|
1521
|
+
if (!(target instanceof HTMLElement)) {
|
|
1522
|
+
return
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
const button = target.closest('button[data-node-id]')
|
|
1526
|
+
if (!button) {
|
|
1527
|
+
return
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const id = button.getAttribute('data-node-id') || ''
|
|
1531
|
+
if (id) {
|
|
1532
|
+
loadNodeDetails(id).catch((error) => console.error(error))
|
|
1533
|
+
}
|
|
785
1534
|
})
|
|
786
|
-
|
|
787
|
-
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
const bootstrap = async () => {
|
|
1538
|
+
setViewportFromCanvas()
|
|
1539
|
+
setupRenderWorker()
|
|
1540
|
+
setupInput()
|
|
1541
|
+
setupControls()
|
|
1542
|
+
setupContextControl()
|
|
1543
|
+
wireNodeLinkClicks()
|
|
1544
|
+
|
|
1545
|
+
window.addEventListener('resize', () => {
|
|
1546
|
+
setViewportFromCanvas()
|
|
1547
|
+
scheduleChunkFetch()
|
|
788
1548
|
})
|
|
789
1549
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1550
|
+
await loadAgents()
|
|
1551
|
+
await loadContexts()
|
|
1552
|
+
updateTotals()
|
|
1553
|
+
updateTagCount()
|
|
794
1554
|
|
|
795
|
-
|
|
1555
|
+
scheduleChunkFetch({ fit: true })
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
bootstrap().catch((error) => {
|
|
1559
|
+
console.error(error)
|
|
796
1560
|
})
|
|
797
1561
|
`;
|