@andespindola/brainlink 0.1.0-beta.16 → 0.1.0-beta.161

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