@andespindola/brainlink 1.0.5 → 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.
Files changed (51) hide show
  1. package/README.md +8 -0
  2. package/dist/application/add-note.js +2 -2
  3. package/dist/application/build-context.js +16 -10
  4. package/dist/application/canonical-context-links.js +44 -5
  5. package/dist/application/check-package-update.js +105 -0
  6. package/dist/application/frontend/client/chunk-fetch.js +236 -0
  7. package/dist/application/frontend/client/controls.js +178 -0
  8. package/dist/application/frontend/client/elements.js +122 -0
  9. package/dist/application/frontend/client/input.js +202 -0
  10. package/dist/application/frontend/client/node-details.js +191 -0
  11. package/dist/application/frontend/client/rendering.js +296 -0
  12. package/dist/application/frontend/client/scope-theme.js +114 -0
  13. package/dist/application/frontend/client/spatial.js +98 -0
  14. package/dist/application/frontend/client/storage.js +215 -0
  15. package/dist/application/frontend/client/upload.js +90 -0
  16. package/dist/application/frontend/client/worker-bootstrap.js +147 -0
  17. package/dist/application/frontend/client-js.js +24 -1837
  18. package/dist/application/frontend/client-render-worker-js.js +1 -1
  19. package/dist/application/index-vault-phases.js +189 -0
  20. package/dist/application/index-vault.js +44 -165
  21. package/dist/cli/commands/write/dedupe-commands.js +59 -0
  22. package/dist/cli/commands/write/index-commands.js +205 -0
  23. package/dist/cli/commands/write/link-commands.js +68 -0
  24. package/dist/cli/commands/write/note-commands.js +146 -0
  25. package/dist/cli/commands/write/server-commands.js +553 -0
  26. package/dist/cli/commands/write/shared.js +35 -0
  27. package/dist/cli/commands/write/vault-lifecycle-commands.js +270 -0
  28. package/dist/cli/commands/write-commands.js +12 -1303
  29. package/dist/cli/main.js +39 -3
  30. package/dist/domain/context.js +39 -3
  31. package/dist/domain/embeddings.js +31 -5
  32. package/dist/domain/graph-contexts.js +62 -57
  33. package/dist/domain/graph-layout/cauliflower-layout.js +116 -0
  34. package/dist/domain/graph-layout/collisions.js +100 -0
  35. package/dist/domain/graph-layout/hierarchy.js +135 -0
  36. package/dist/domain/graph-layout/metrics.js +111 -0
  37. package/dist/domain/graph-layout/segments.js +76 -0
  38. package/dist/domain/graph-layout/star-layout.js +110 -0
  39. package/dist/domain/graph-layout.js +4 -625
  40. package/dist/infrastructure/config.js +6 -0
  41. package/dist/infrastructure/file-index.js +13 -4
  42. package/dist/infrastructure/semantic-prefilter.js +24 -0
  43. package/dist/mcp/server.js +7 -0
  44. package/dist/mcp/tool-guard.js +29 -0
  45. package/dist/mcp/tools/maintenance-tools.js +409 -0
  46. package/dist/mcp/tools/read-tools.js +504 -0
  47. package/dist/mcp/tools/shared.js +216 -0
  48. package/dist/mcp/tools/write-tools.js +247 -0
  49. package/dist/mcp/tools.js +3 -1357
  50. package/docs/QUICKSTART.md +4 -0
  51. package/package.json +2 -2
@@ -1,1837 +1,24 @@
1
- export const createClientJs = () => `const canvas = document.getElementById('graph')
2
- let ctx2dFallback = null
3
- const byId = (id) => document.getElementById(id)
4
- const elements = {
5
- search: byId('search'),
6
- agent: byId('agent'),
7
- context: byId('context'),
8
- nodeCount: byId('nodeCount'),
9
- edgeCount: byId('edgeCount'),
10
- zoomIn: byId('zoomIn'),
11
- zoomOut: byId('zoomOut'),
12
- fit: byId('fit'),
13
- releaseNode: byId('releaseNode'),
14
- reset: byId('reset'),
15
- uploadOpen: byId('uploadOpen'),
16
- uploadDialog: byId('uploadDialog'),
17
- uploadForm: byId('uploadForm'),
18
- uploadFile: byId('uploadFile'),
19
- uploadTitleInput: byId('uploadTitleInput'),
20
- uploadAllowSensitive: byId('uploadAllowSensitive'),
21
- uploadClose: byId('uploadClose'),
22
- uploadSubmit: byId('uploadSubmit'),
23
- uploadStatus: byId('uploadStatus'),
24
- labels: byId('graphLabels'),
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('<', '&lt;')
125
- .replaceAll('>', '&gt;')
126
- .replaceAll('"', '&quot;')
127
- .replaceAll("'", '&#039;')
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('');