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