@andespindola/brainlink 0.1.0-beta.4 → 0.1.0-beta.41

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 (59) hide show
  1. package/AGENTS.md +5 -5
  2. package/CHANGELOG.md +45 -2
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +216 -20
  6. package/SECURITY.md +1 -1
  7. package/dist/application/add-note.js +62 -13
  8. package/dist/application/analyze-vault.js +95 -8
  9. package/dist/application/build-context.js +56 -1
  10. package/dist/application/dedupe-notes.js +226 -0
  11. package/dist/application/frontend/client-css.js +214 -100
  12. package/dist/application/frontend/client-html.js +60 -45
  13. package/dist/application/frontend/client-js.js +818 -94
  14. package/dist/application/get-graph-layout.js +22 -7
  15. package/dist/application/get-graph-node.js +12 -0
  16. package/dist/application/get-graph-summary.js +12 -0
  17. package/dist/application/get-graph.js +3 -3
  18. package/dist/application/import-legacy-sqlite.js +296 -0
  19. package/dist/application/index-vault.js +143 -20
  20. package/dist/application/list-agents.js +3 -3
  21. package/dist/application/list-links.js +5 -5
  22. package/dist/application/migrate-vault.js +91 -0
  23. package/dist/application/search-graph-node-ids.js +12 -0
  24. package/dist/application/search-knowledge.js +75 -5
  25. package/dist/application/server/routes.js +27 -1
  26. package/dist/benchmarks/large-vault.js +1 -1
  27. package/dist/cli/commands/agent-commands.js +412 -0
  28. package/dist/cli/commands/config-commands.js +167 -0
  29. package/dist/cli/commands/read-commands.js +25 -8
  30. package/dist/cli/commands/write-commands.js +669 -9
  31. package/dist/cli/main.js +4 -0
  32. package/dist/cli/runtime.js +5 -2
  33. package/dist/domain/context.js +53 -11
  34. package/dist/domain/embeddings.js +2 -1
  35. package/dist/domain/graph-layout.js +20 -14
  36. package/dist/domain/markdown.js +36 -4
  37. package/dist/domain/middle-out.js +18 -0
  38. package/dist/infrastructure/config.js +94 -8
  39. package/dist/infrastructure/file-index.js +358 -0
  40. package/dist/infrastructure/file-system-vault.js +30 -0
  41. package/dist/infrastructure/index-state.js +50 -0
  42. package/dist/infrastructure/paths.js +9 -1
  43. package/dist/infrastructure/private-pack-codec.js +73 -0
  44. package/dist/infrastructure/search-packs.js +348 -0
  45. package/dist/infrastructure/session-state.js +172 -0
  46. package/dist/mcp/main.js +11 -3
  47. package/dist/mcp/server.js +27 -2
  48. package/dist/mcp/startup.js +35 -0
  49. package/dist/mcp/tools.js +633 -19
  50. package/docs/AGENT_USAGE.md +144 -16
  51. package/docs/ARCHITECTURE.md +37 -26
  52. package/docs/QUICKSTART.md +111 -0
  53. package/package.json +6 -4
  54. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  55. package/dist/infrastructure/sqlite/graph-reader.js +0 -120
  56. package/dist/infrastructure/sqlite/schema.js +0 -111
  57. package/dist/infrastructure/sqlite/search-reader.js +0 -156
  58. package/dist/infrastructure/sqlite/types.js +0 -1
  59. package/dist/infrastructure/sqlite-index.js +0 -25
@@ -1,18 +1,41 @@
1
1
  export const createClientJs = () => `const canvas = document.getElementById('graph')
2
2
  const ctx = canvas.getContext('2d')
3
+ const largeGraphNodeThreshold = 4000
4
+ const largeGraphEdgeRenderLimit = 16000
5
+ const renderNodeBudget = 1800
6
+ const renderEdgeBudget = 5200
7
+ const minNodePixelRadius = 1.8
8
+ const viewportPaddingPx = 280
9
+ const worldCoordinateLimit = 5_000_000
10
+ const transformCoordinateLimit = 20_000_000
3
11
  const state = {
4
12
  graph: { nodes: [], edges: [] },
5
13
  nodes: [],
6
14
  edges: [],
15
+ visibleNodes: [],
16
+ visibleEdges: [],
17
+ renderNodes: [],
18
+ renderEdges: [],
19
+ nodeDegrees: new Map(),
7
20
  selected: null,
8
21
  hovered: null,
9
22
  query: '',
23
+ contentFilter: { query: '', ids: null, token: 0, timer: null },
10
24
  agentId: '',
11
25
  agentsSignature: '',
26
+ nodeDetails: new Map(),
12
27
  transform: { x: 0, y: 0, scale: 1 },
13
28
  pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
29
+ cursor: { x: 0, y: 0, inCanvas: false },
14
30
  graphSignature: '',
15
- last: performance.now()
31
+ graphStatus: '',
32
+ last: performance.now(),
33
+ offscreenFrameCount: 0,
34
+ recoveringViewport: false,
35
+ renderVisibilityDirty: true,
36
+ lastViewportKey: '',
37
+ visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
38
+ visibleEdgeByNode: new Map()
16
39
  }
17
40
 
18
41
  const byId = id => document.getElementById(id)
@@ -23,25 +46,39 @@ const escapeHtml = value => String(value)
23
46
  .replaceAll('"', '"')
24
47
  .replaceAll("'", ''')
25
48
  const elements = {
26
- stats: byId('stats'),
27
49
  search: byId('search'),
28
50
  agent: byId('agent'),
29
- title: byId('title'),
30
- path: byId('path'),
31
- tags: byId('tags'),
32
- notes: byId('notes'),
33
- content: byId('content'),
34
- outgoing: byId('outgoing'),
35
- incoming: byId('incoming'),
36
51
  nodeCount: byId('nodeCount'),
37
52
  edgeCount: byId('edgeCount'),
38
53
  tagCount: byId('tagCount'),
39
54
  zoomIn: byId('zoomIn'),
40
55
  zoomOut: byId('zoomOut'),
41
- reset: byId('reset')
56
+ fit: byId('fit'),
57
+ reset: byId('reset'),
58
+ contentDialog: byId('contentDialog'),
59
+ contentTitle: byId('contentTitle'),
60
+ contentPath: byId('contentPath'),
61
+ contentTags: byId('contentTags'),
62
+ contentOutgoing: byId('contentOutgoing'),
63
+ contentIncoming: byId('contentIncoming'),
64
+ contentBody: byId('contentBody'),
65
+ contentClose: byId('contentClose')
42
66
  }
43
67
 
44
- const agentQuery = () => state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''
68
+ const zoomRange = {
69
+ min: 0.05,
70
+ max: 4.5
71
+ }
72
+
73
+ const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
74
+
75
+ const setGraphStatus = text => {
76
+ state.graphStatus = text
77
+ }
78
+
79
+ const handleGraphRefreshError = error => {
80
+ console.error(error)
81
+ }
45
82
 
46
83
  const graphTheme = {
47
84
  node: '#aeb8c5',
@@ -64,37 +101,332 @@ const resize = () => {
64
101
  canvas.width = Math.floor(width * ratio)
65
102
  canvas.height = Math.floor(height * ratio)
66
103
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
104
+ markRenderDirty()
67
105
  }
68
106
 
69
- const filteredNodes = () => {
70
- const query = state.query.trim().toLowerCase()
71
- if (!query) return state.nodes
72
- return state.nodes.filter(node =>
107
+ const normalizeQuery = value => value.trim().toLowerCase()
108
+ const hubNodeRetentionLimit = 2
109
+ const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map|mapa)\b/i
110
+
111
+ const localFilteredNodes = query =>
112
+ state.nodes.filter(node =>
73
113
  node.title.toLowerCase().includes(query) ||
74
114
  node.path.toLowerCase().includes(query) ||
75
115
  node.tags.some(tag => tag.toLowerCase().includes(query))
76
116
  )
117
+
118
+ const rankedHubNodes = () => {
119
+ if (state.nodes.length === 0) {
120
+ return []
121
+ }
122
+
123
+ const byTitleAndDegree = [...state.nodes]
124
+ .filter(node => hubNodePattern.test(node.title) || hubNodePattern.test(node.path) || node.tags.some(tag => hubNodePattern.test(tag)))
125
+ .sort((left, right) => {
126
+ const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
127
+ if (byDegree !== 0) return byDegree
128
+ return left.title.localeCompare(right.title)
129
+ })
130
+
131
+ if (byTitleAndDegree.length > 0) {
132
+ return byTitleAndDegree.slice(0, hubNodeRetentionLimit)
133
+ }
134
+
135
+ return [...state.nodes]
136
+ .sort((left, right) => {
137
+ const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
138
+ if (byDegree !== 0) return byDegree
139
+ return left.title.localeCompare(right.title)
140
+ })
141
+ .slice(0, 1)
77
142
  }
78
143
 
79
- const visibleIds = () => new Set(filteredNodes().map(node => node.id))
144
+ const withPersistentHubNodes = nodes => {
145
+ if (nodes.length === 0) {
146
+ return rankedHubNodes()
147
+ }
148
+
149
+ const ids = new Set(nodes.map(node => node.id))
150
+ const hubsToKeep = rankedHubNodes().filter(node => !ids.has(node.id))
151
+ return nodes.concat(hubsToKeep)
152
+ }
153
+
154
+ const filteredNodes = () => {
155
+ const query = normalizeQuery(state.query)
156
+ if (!query) return state.nodes
157
+ if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
158
+ const matched = state.nodes.filter(node => state.contentFilter.ids.has(node.id))
159
+ return withPersistentHubNodes(matched)
160
+ }
80
161
 
81
- const visibleEdges = () => {
82
- const ids = visibleIds()
83
- return state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
162
+ return withPersistentHubNodes(localFilteredNodes(query))
163
+ }
164
+
165
+ const recomputeVisibility = () => {
166
+ const nodes = filteredNodes()
167
+ const ids = new Set(nodes.map(node => node.id))
168
+ const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
169
+ const limitedEdges = state.nodes.length > largeGraphNodeThreshold
170
+ ? [...edges]
171
+ .sort((left, right) => edgeWeight(right) - edgeWeight(left))
172
+ .slice(0, largeGraphEdgeRenderLimit)
173
+ : edges
174
+
175
+ state.visibleNodes = nodes
176
+ state.visibleEdges = limitedEdges
177
+ state.visibleNodeSpatial = createSpatialIndex(nodes)
178
+ state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
179
+ markRenderDirty()
84
180
  }
85
181
 
86
182
  const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
183
+ const markRenderDirty = () => {
184
+ state.renderVisibilityDirty = true
185
+ }
186
+
187
+ const createSpatialIndex = nodes => {
188
+ if (nodes.length === 0) {
189
+ return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
190
+ }
191
+
192
+ const bounds = graphBounds(nodes)
193
+ if (!bounds) {
194
+ return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
195
+ }
196
+
197
+ const targetNodesPerCell = 18
198
+ const approximateCellArea = Math.max((bounds.width * bounds.height) / Math.max(nodes.length / targetNodesPerCell, 1), 1)
199
+ const cellSize = Math.max(90, Math.min(2200, Math.sqrt(approximateCellArea)))
200
+ const buckets = new Map()
201
+
202
+ for (let index = 0; index < nodes.length; index += 1) {
203
+ const node = nodes[index]
204
+ const cellX = Math.floor((node.x - bounds.minX) / cellSize)
205
+ const cellY = Math.floor((node.y - bounds.minY) / cellSize)
206
+ const key = cellX + ':' + cellY
207
+ const bucket = buckets.get(key)
208
+ if (bucket) {
209
+ bucket.push(node)
210
+ continue
211
+ }
212
+ buckets.set(key, [node])
213
+ }
214
+
215
+ return {
216
+ cellSize,
217
+ minX: bounds.minX,
218
+ minY: bounds.minY,
219
+ maxX: bounds.maxX,
220
+ maxY: bounds.maxY,
221
+ buckets
222
+ }
223
+ }
224
+
225
+ const viewportNodesFromSpatialIndex = viewport => {
226
+ if (state.visibleNodes.length <= 2500) {
227
+ return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
228
+ }
229
+
230
+ const spatial = state.visibleNodeSpatial
231
+ if (!spatial || spatial.buckets.size === 0) {
232
+ return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
233
+ }
234
+
235
+ const minCellX = Math.floor((viewport.minX - spatial.minX) / spatial.cellSize)
236
+ const maxCellX = Math.floor((viewport.maxX - spatial.minX) / spatial.cellSize)
237
+ const minCellY = Math.floor((viewport.minY - spatial.minY) / spatial.cellSize)
238
+ const maxCellY = Math.floor((viewport.maxY - spatial.minY) / spatial.cellSize)
239
+ const nodes = []
240
+
241
+ for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
242
+ for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
243
+ const bucket = spatial.buckets.get(cellX + ':' + cellY)
244
+ if (!bucket) continue
245
+
246
+ for (let index = 0; index < bucket.length; index += 1) {
247
+ const node = bucket[index]
248
+ if (isNodeInViewport(node, viewport)) {
249
+ nodes.push(node)
250
+ }
251
+ }
252
+ }
253
+ }
254
+
255
+ return nodes
256
+ }
257
+
258
+ const createVisibleEdgeLookup = edges => {
259
+ const lookup = new Map()
260
+
261
+ for (let index = 0; index < edges.length; index += 1) {
262
+ const edge = edges[index]
263
+ if (!edge.target) continue
264
+
265
+ const sourceList = lookup.get(edge.source)
266
+ if (sourceList) {
267
+ sourceList.push(edge)
268
+ } else {
269
+ lookup.set(edge.source, [edge])
270
+ }
271
+
272
+ const targetList = lookup.get(edge.target)
273
+ if (targetList) {
274
+ targetList.push(edge)
275
+ } else {
276
+ lookup.set(edge.target, [edge])
277
+ }
278
+ }
279
+
280
+ return lookup
281
+ }
87
282
 
88
- const resetView = () => {
283
+ const collectVisibleEdgesForNodes = nodeIds => {
284
+ if (nodeIds.size === 0) {
285
+ return []
286
+ }
287
+
288
+ const seen = new Set()
289
+ const collected = []
290
+
291
+ nodeIds.forEach(nodeId => {
292
+ const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
293
+ for (let index = 0; index < candidateEdges.length; index += 1) {
294
+ const edge = candidateEdges[index]
295
+ if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
296
+ continue
297
+ }
298
+ const key = edge.source < edge.target
299
+ ? edge.source + '|' + edge.target + '|' + edge.targetTitle
300
+ : edge.target + '|' + edge.source + '|' + edge.targetTitle
301
+ if (seen.has(key)) continue
302
+
303
+ seen.add(key)
304
+ collected.push(edge)
305
+ if (collected.length >= renderEdgeBudget) {
306
+ return
307
+ }
308
+ }
309
+ })
310
+
311
+ return collected
312
+ }
313
+
314
+ const fallbackViewportNodes = () => {
315
+ const nodes = []
316
+ const maxNodes = Math.min(renderNodeBudget, 220)
317
+ const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
318
+
319
+ for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
320
+ nodes.push(state.visibleNodes[index])
321
+ }
322
+
323
+ if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
324
+ nodes.push(state.selected)
325
+ }
326
+
327
+ return nodes
328
+ }
329
+
330
+ const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
331
+ const isFiniteNumber = value => Number.isFinite(value)
332
+ const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
333
+
334
+ const graphBounds = nodes => {
335
+ if (nodes.length === 0) return null
336
+ let minX = Number.POSITIVE_INFINITY
337
+ let maxX = Number.NEGATIVE_INFINITY
338
+ let minY = Number.POSITIVE_INFINITY
339
+ let maxY = Number.NEGATIVE_INFINITY
340
+
341
+ nodes.forEach(node => {
342
+ const radius = baseNodeRadius(node)
343
+ minX = Math.min(minX, node.x - radius)
344
+ maxX = Math.max(maxX, node.x + radius)
345
+ minY = Math.min(minY, node.y - radius)
346
+ maxY = Math.max(maxY, node.y + radius)
347
+ })
348
+
349
+ return {
350
+ minX,
351
+ maxX,
352
+ minY,
353
+ maxY,
354
+ width: Math.max(maxX - minX, 1),
355
+ height: Math.max(maxY - minY, 1)
356
+ }
357
+ }
358
+
359
+ const fitScaleBiasByNodeCount = nodeCount => {
360
+ if (nodeCount <= 6) return 1.22
361
+ if (nodeCount <= 20) return 1.12
362
+ if (nodeCount <= 60) return 1.04
363
+ if (nodeCount <= 180) return 1
364
+ if (nodeCount <= 600) return 0.94
365
+ if (nodeCount <= 2000) return 0.82
366
+ if (nodeCount <= 6000) return 0.68
367
+ return 0.56
368
+ }
369
+
370
+ const autoFitScaleRangeByNodeCount = nodeCount => {
371
+ if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
372
+ if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
373
+ if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
374
+ if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
375
+ if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
376
+ if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
377
+ if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
378
+ return { min: zoomRange.min, max: 0.24 }
379
+ }
380
+
381
+ const fitView = (options = { useFiltered: true }) => {
89
382
  const rect = canvas.getBoundingClientRect()
90
- state.transform = { x: Math.max(rect.width, 320) / 2, y: Math.max(rect.height, 320) / 2, scale: 1 }
383
+ const width = Math.max(rect.width, 320)
384
+ const height = Math.max(rect.height, 320)
385
+ const nodes = options.useFiltered ? filteredNodes() : state.nodes
386
+ const bounds = graphBounds(nodes)
387
+
388
+ if (!bounds) {
389
+ state.transform = { x: width / 2, y: height / 2, scale: 1 }
390
+ markRenderDirty()
391
+ return
392
+ }
393
+
394
+ const paddingByNodeCount = nodeCount => {
395
+ if (nodeCount <= 6) return 28
396
+ if (nodeCount <= 20) return 44
397
+ if (nodeCount <= 60) return 68
398
+ if (nodeCount <= 180) return 86
399
+ if (nodeCount <= 600) return 110
400
+ if (nodeCount <= 2000) return 140
401
+ return 180
402
+ }
403
+ const padding = paddingByNodeCount(nodes.length)
404
+ const scaleX = width / (bounds.width + padding * 2)
405
+ const scaleY = height / (bounds.height + padding * 2)
406
+ const fitScale = Math.min(scaleX, scaleY)
407
+ const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
408
+ const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
409
+ const scale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
410
+ const centerX = (bounds.minX + bounds.maxX) / 2
411
+ const centerY = (bounds.minY + bounds.maxY) / 2
412
+
413
+ state.transform = {
414
+ x: width / 2 - centerX * scale,
415
+ y: height / 2 - centerY * scale,
416
+ scale
417
+ }
418
+ markRenderDirty()
91
419
  }
92
420
 
421
+ const resetView = () => fitView({ useFiltered: false })
422
+
93
423
  const createLayout = graph => {
94
424
  const nodes = graph.nodes.map(node => ({
95
425
  ...node,
96
426
  x: Number.isFinite(node.x) ? node.x : 0,
97
- y: Number.isFinite(node.y) ? node.y : 0
427
+ y: Number.isFinite(node.y) ? node.y : 0,
428
+ vx: Number.isFinite(node.vx) ? node.vx : 0,
429
+ vy: Number.isFinite(node.vy) ? node.vy : 0
98
430
  }))
99
431
  const nodeMap = new Map(nodes.map(node => [node.id, node]))
100
432
  const edges = graph.edges
@@ -111,29 +443,94 @@ const encodeEntityTag = (value) => {
111
443
  binary += String.fromCharCode(utf8[index])
112
444
  }
113
445
 
114
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
446
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
115
447
  }
116
448
 
117
449
  const graphSignature = graph => JSON.stringify({
118
- nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.content, node.tags]),
450
+ nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
119
451
  edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
120
452
  })
121
453
 
454
+ const resetContentFilter = () => {
455
+ if (state.contentFilter.timer) {
456
+ clearTimeout(state.contentFilter.timer)
457
+ }
458
+ state.contentFilter = {
459
+ query: '',
460
+ ids: null,
461
+ token: state.contentFilter.token + 1,
462
+ timer: null
463
+ }
464
+ recomputeVisibility()
465
+ }
466
+
467
+ const syncContentFilter = async (query, token) => {
468
+ const response = await fetch(
469
+ '/api/graph-filter?q=' +
470
+ encodeURIComponent(query) +
471
+ '&limit=' +
472
+ encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
473
+ agentQuery('&')
474
+ )
475
+
476
+ if (!response.ok || token !== state.contentFilter.token) {
477
+ return
478
+ }
479
+
480
+ const payload = await response.json()
481
+ const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
482
+ if (token !== state.contentFilter.token) {
483
+ return
484
+ }
485
+
486
+ state.contentFilter.query = query
487
+ state.contentFilter.ids = new Set(nodeIds)
488
+ recomputeVisibility()
489
+ }
490
+
491
+ const scheduleContentFilterSync = () => {
492
+ const query = normalizeQuery(state.query)
493
+ if (!query) {
494
+ resetContentFilter()
495
+ return
496
+ }
497
+
498
+ if (state.contentFilter.timer) {
499
+ clearTimeout(state.contentFilter.timer)
500
+ }
501
+
502
+ const token = state.contentFilter.token + 1
503
+ state.contentFilter = {
504
+ query: state.contentFilter.query,
505
+ ids: state.contentFilter.ids,
506
+ token,
507
+ timer: setTimeout(() => {
508
+ syncContentFilter(query, token).catch(() => {})
509
+ }, 180)
510
+ }
511
+ }
512
+
122
513
  const tick = delta => {
123
- const nodes = filteredNodes()
124
- const ids = new Set(nodes.map(node => node.id))
125
- const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
514
+ const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
515
+ const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
516
+ if (nodes.length > 1200) {
517
+ return
518
+ }
126
519
  const strength = Math.min(delta / 16, 2)
127
520
 
128
521
  edges.forEach(edge => {
129
522
  const source = edge.sourceNode
130
523
  const target = edge.targetNode
524
+ source.vx = Number.isFinite(source.vx) ? source.vx : 0
525
+ source.vy = Number.isFinite(source.vy) ? source.vy : 0
526
+ target.vx = Number.isFinite(target.vx) ? target.vx : 0
527
+ target.vy = Number.isFinite(target.vy) ? target.vy : 0
131
528
  const dx = target.x - source.x
132
529
  const dy = target.y - source.y
133
530
  const distance = Math.max(Math.hypot(dx, dy), 1)
134
531
  const force = (distance - 150) * 0.002 * strength
135
- const fx = dx * force
136
- const fy = dy * force
532
+ const fx = (dx / distance) * force
533
+ const fy = (dy / distance) * force
137
534
  source.vx += fx
138
535
  source.vy += fy
139
536
  target.vx -= fx
@@ -144,6 +541,10 @@ const tick = delta => {
144
541
  for (let j = i + 1; j < nodes.length; j += 1) {
145
542
  const a = nodes[i]
146
543
  const b = nodes[j]
544
+ a.vx = Number.isFinite(a.vx) ? a.vx : 0
545
+ a.vy = Number.isFinite(a.vy) ? a.vy : 0
546
+ b.vx = Number.isFinite(b.vx) ? b.vx : 0
547
+ b.vy = Number.isFinite(b.vy) ? b.vy : 0
147
548
  const dx = b.x - a.x
148
549
  const dy = b.y - a.y
149
550
  const distance = Math.max(Math.hypot(dx, dy), 1)
@@ -158,6 +559,10 @@ const tick = delta => {
158
559
  }
159
560
 
160
561
  nodes.forEach(node => {
562
+ node.vx = Number.isFinite(node.vx) ? node.vx : 0
563
+ node.vy = Number.isFinite(node.vy) ? node.vy : 0
564
+ node.x = Number.isFinite(node.x) ? node.x : 0
565
+ node.y = Number.isFinite(node.y) ? node.y : 0
161
566
  if (state.pointer.dragNode === node) {
162
567
  node.vx = 0
163
568
  node.vy = 0
@@ -181,7 +586,12 @@ const worldPoint = event => {
181
586
  }
182
587
 
183
588
  const hitNode = point => {
184
- const nodes = filteredNodes()
589
+ computeRenderVisibility()
590
+ if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
591
+ return null
592
+ }
593
+
594
+ const nodes = state.renderNodes
185
595
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
186
596
  const node = nodes[index]
187
597
  const radius = nodeRadius(node)
@@ -190,17 +600,159 @@ const hitNode = point => {
190
600
  return null
191
601
  }
192
602
 
193
- const nodeRadius = node => {
194
- const degree = state.edges.filter(edge => edge.source === node.id || edge.target === node.id).length
603
+ const baseNodeRadius = node => {
604
+ const degree = state.nodeDegrees.get(node.id) ?? 0
195
605
  return 9 + Math.min(degree, 8) * 1.6
196
606
  }
197
607
 
608
+ const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
609
+
610
+ const worldViewportBounds = () => {
611
+ const rect = canvas.getBoundingClientRect()
612
+ const width = Math.max(rect.width, 320)
613
+ const height = Math.max(rect.height, 320)
614
+ const padding = viewportPaddingPx
615
+
616
+ return {
617
+ minX: (-state.transform.x - padding) / state.transform.scale,
618
+ maxX: (width - state.transform.x + padding) / state.transform.scale,
619
+ minY: (-state.transform.y - padding) / state.transform.scale,
620
+ maxY: (height - state.transform.y + padding) / state.transform.scale
621
+ }
622
+ }
623
+
624
+ const isNodeInViewport = (node, viewport) =>
625
+ node.x >= viewport.minX &&
626
+ node.x <= viewport.maxX &&
627
+ node.y >= viewport.minY &&
628
+ node.y <= viewport.maxY
629
+
630
+ const viewportNodeStride = () => {
631
+ if (state.nodes.length <= largeGraphNodeThreshold) {
632
+ return 1
633
+ }
634
+
635
+ if (state.transform.scale >= 0.95) {
636
+ return 1
637
+ }
638
+ if (state.transform.scale >= 0.7) {
639
+ return 2
640
+ }
641
+ if (state.transform.scale >= 0.48) {
642
+ return 3
643
+ }
644
+ if (state.transform.scale >= 0.28) {
645
+ return 5
646
+ }
647
+
648
+ return 8
649
+ }
650
+
651
+ const computeRenderVisibility = () => {
652
+ const viewport = worldViewportBounds()
653
+ const viewportKey =
654
+ Math.round(viewport.minX * 10) + ':' +
655
+ Math.round(viewport.maxX * 10) + ':' +
656
+ Math.round(viewport.minY * 10) + ':' +
657
+ Math.round(viewport.maxY * 10) + ':' +
658
+ Math.round(state.transform.scale * 1000)
659
+
660
+ if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
661
+ return
662
+ }
663
+ state.lastViewportKey = viewportKey
664
+ state.renderVisibilityDirty = false
665
+
666
+ if (state.visibleNodes.length <= 2000) {
667
+ state.renderNodes = state.visibleNodes
668
+ const ids = new Set(state.renderNodes.map((node) => node.id))
669
+ state.renderEdges = collectVisibleEdgesForNodes(ids)
670
+ return
671
+ }
672
+
673
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
674
+ const stride = viewportNodeStride()
675
+ const picked = []
676
+
677
+ for (let index = 0; index < viewportNodes.length; index += 1) {
678
+ const node = viewportNodes[index]
679
+
680
+ const isPriority =
681
+ node.id === state.selected?.id ||
682
+ node.id === state.hovered?.id ||
683
+ node.id === state.pointer.dragNode?.id
684
+ if (isPriority || index % stride === 0) {
685
+ picked.push(node)
686
+ }
687
+ }
688
+
689
+ const nodes = picked.length > renderNodeBudget
690
+ ? picked.slice(0, renderNodeBudget)
691
+ : picked
692
+ if (nodes.length === 0 && state.visibleNodes.length > 0) {
693
+ const fallbackNodes = fallbackViewportNodes()
694
+ const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
695
+ state.renderNodes = fallbackNodes
696
+ state.renderEdges = collectVisibleEdgesForNodes(fallbackIds)
697
+ return
698
+ }
699
+
700
+ const nodeIds = new Set(nodes.map((node) => node.id))
701
+ const edges = collectVisibleEdgesForNodes(nodeIds)
702
+
703
+ state.renderNodes = nodes
704
+ state.renderEdges = edges
705
+ }
706
+
707
+ const isNodeVisibleOnScreen = (node, width, height) => {
708
+ const radius = nodeRadius(node) * state.transform.scale
709
+ const screenX = node.x * state.transform.scale + state.transform.x
710
+ const screenY = node.y * state.transform.scale + state.transform.y
711
+
712
+ return (
713
+ screenX + radius >= 0 &&
714
+ screenX - radius <= width &&
715
+ screenY + radius >= 0 &&
716
+ screenY - radius <= height
717
+ )
718
+ }
719
+
720
+ const hasValidTransform = () =>
721
+ isFiniteNumber(state.transform.x) &&
722
+ isFiniteNumber(state.transform.y) &&
723
+ isFiniteNumber(state.transform.scale) &&
724
+ Math.abs(state.transform.x) <= transformCoordinateLimit &&
725
+ Math.abs(state.transform.y) <= transformCoordinateLimit &&
726
+ state.transform.scale > 0
727
+
728
+ const sanitizeNodePosition = node => {
729
+ if (!isReasonableCoordinate(node.x)) node.x = 0
730
+ if (!isReasonableCoordinate(node.y)) node.y = 0
731
+ if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
732
+ if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
733
+ }
734
+
735
+ const sanitizeGraphState = () => {
736
+ state.nodes.forEach(sanitizeNodePosition)
737
+ state.visibleNodes.forEach(sanitizeNodePosition)
738
+ state.renderNodes.forEach(sanitizeNodePosition)
739
+ }
740
+
198
741
  const render = now => {
199
742
  const delta = now - state.last
200
743
  state.last = now
744
+ const minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 48 : 16
745
+ if (delta < minFrameIntervalMs) {
746
+ requestAnimationFrame(render)
747
+ return
748
+ }
201
749
  const rect = canvas.getBoundingClientRect()
202
750
  const width = Math.max(rect.width, 320)
203
751
  const height = Math.max(rect.height, 320)
752
+ sanitizeGraphState()
753
+ if (!hasValidTransform()) {
754
+ resetView()
755
+ }
204
756
  ctx.clearRect(0, 0, width, height)
205
757
  if (state.nodes.length === 0) {
206
758
  ctx.fillStyle = '#99a5b5'
@@ -214,7 +766,25 @@ const render = now => {
214
766
  ctx.translate(state.transform.x, state.transform.y)
215
767
  ctx.scale(state.transform.scale, state.transform.scale)
216
768
 
217
- visibleEdges().forEach(edge => {
769
+ computeRenderVisibility()
770
+ tick(delta)
771
+ const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
772
+ if (!hasVisibleNodeOnScreen && state.renderNodes.length > 0) {
773
+ state.offscreenFrameCount += 1
774
+ if (state.offscreenFrameCount >= 6 && !state.recoveringViewport) {
775
+ state.recoveringViewport = true
776
+ fitView({ useFiltered: true })
777
+ state.offscreenFrameCount = 0
778
+ requestAnimationFrame(() => {
779
+ state.recoveringViewport = false
780
+ })
781
+ }
782
+ } else {
783
+ state.offscreenFrameCount = 0
784
+ }
785
+ const drawEdges = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
786
+ if (drawEdges) {
787
+ state.renderEdges.forEach(edge => {
218
788
  const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
219
789
  ctx.beginPath()
220
790
  ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
@@ -222,9 +792,10 @@ const render = now => {
222
792
  ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
223
793
  ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
224
794
  ctx.stroke()
225
- })
795
+ })
796
+ }
226
797
 
227
- filteredNodes().forEach(node => {
798
+ state.renderNodes.forEach(node => {
228
799
  const radius = nodeRadius(node)
229
800
  const isSelected = state.selected?.id === node.id
230
801
  const isHovered = state.hovered?.id === node.id
@@ -240,7 +811,11 @@ const render = now => {
240
811
  ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
241
812
  ctx.stroke()
242
813
 
243
- if (isSelected || isHovered || state.transform.scale > 1.18 || state.nodes.length <= 25) {
814
+ const shouldDrawLabels =
815
+ isSelected ||
816
+ isHovered ||
817
+ (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
818
+ if (shouldDrawLabels) {
244
819
  ctx.fillStyle = graphTheme.label
245
820
  ctx.font = '12px Inter, system-ui, sans-serif'
246
821
  ctx.textAlign = 'center'
@@ -250,6 +825,12 @@ const render = now => {
250
825
  })
251
826
 
252
827
  ctx.restore()
828
+ if (state.renderNodes.length === 0) {
829
+ ctx.fillStyle = '#99a5b5'
830
+ ctx.font = '12px Inter, system-ui, sans-serif'
831
+ ctx.textAlign = 'center'
832
+ ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
833
+ }
253
834
  requestAnimationFrame(render)
254
835
  }
255
836
 
@@ -257,22 +838,7 @@ const list = items => items.length
257
838
  ? 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('')
258
839
  : '<li><small>No links found.</small></li>'
259
840
 
260
- const allNotesList = () => state.nodes.length
261
- ? state.nodes.map(node => '<li><button type="button" data-node-id="' + escapeHtml(node.id) + '">' + escapeHtml(node.title) + '</button><small>' + escapeHtml(node.path) + '</small></li>').join('')
262
- : '<li><small>No notes indexed.</small></li>'
263
-
264
- const selectNode = node => {
265
- state.selected = node
266
- if (!node) {
267
- elements.title.textContent = 'Graph Overview'
268
- elements.path.textContent = state.nodes.length + ' notes and ' + state.graph.edges.length + ' links indexed.'
269
- elements.tags.innerHTML = ''
270
- elements.notes.innerHTML = allNotesList()
271
- elements.content.textContent = 'Selecione uma nota no grafo ou na lista para ver o Markdown completo, backlinks e links de saida.'
272
- elements.outgoing.innerHTML = '<li><small>Select a note to inspect outgoing links.</small></li>'
273
- elements.incoming.innerHTML = '<li><small>Select a note to inspect backlinks.</small></li>'
274
- return
275
- }
841
+ const linkedNodes = node => {
276
842
  const nodeById = new Map(state.nodes.map(item => [item.id, item]))
277
843
  const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
278
844
  ...linkedNode,
@@ -288,57 +854,173 @@ const selectNode = node => {
288
854
  .map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
289
855
  .filter(Boolean)
290
856
 
291
- elements.title.textContent = node.title
292
- elements.path.textContent = node.path
293
- elements.tags.innerHTML = node.tags.length
857
+ return { outgoing, incoming }
858
+ }
859
+
860
+ const fetchNodeDetails = async node => {
861
+ const cached = state.nodeDetails.get(node.id)
862
+ if (cached) {
863
+ return cached
864
+ }
865
+
866
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
867
+ if (!response.ok) {
868
+ throw new Error('Failed to load graph node details')
869
+ }
870
+
871
+ const payload = await response.json()
872
+ const detail = payload?.node
873
+ if (!detail || !detail.id) {
874
+ throw new Error('Invalid graph node payload')
875
+ }
876
+ state.nodeDetails.set(detail.id, detail)
877
+ return detail
878
+ }
879
+
880
+ const openContentDialog = async node => {
881
+ if (!node) return
882
+ const { outgoing, incoming } = linkedNodes(node)
883
+ elements.contentTitle.textContent = node.title
884
+ elements.contentPath.textContent = node.path
885
+ elements.contentTags.innerHTML = node.tags.length
294
886
  ? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
295
887
  : '<span>No tags</span>'
296
- elements.notes.innerHTML = allNotesList()
297
- elements.content.textContent = node.content
298
- elements.outgoing.innerHTML = list(outgoing)
299
- elements.incoming.innerHTML = list(incoming)
888
+ elements.contentOutgoing.innerHTML = list(outgoing)
889
+ elements.contentIncoming.innerHTML = list(incoming)
890
+ elements.contentBody.textContent = 'Loading note content...'
891
+ if (!elements.contentDialog.open) {
892
+ elements.contentDialog.showModal()
893
+ }
894
+
895
+ try {
896
+ const detailedNode = await fetchNodeDetails(node)
897
+ if (state.selected?.id !== node.id) {
898
+ return
899
+ }
900
+ elements.contentBody.textContent = detailedNode.content
901
+ } catch {
902
+ elements.contentBody.textContent = 'Unable to load note content.'
903
+ }
904
+ }
905
+
906
+ const selectNode = (node, options = { openContent: false }) => {
907
+ state.selected = node
908
+ if (node && options.openContent) {
909
+ openContentDialog(node).catch(() => {
910
+ elements.contentBody.textContent = 'Unable to load note content.'
911
+ })
912
+ }
300
913
  }
301
914
 
302
915
  const selectNodeById = id => {
303
916
  const node = state.nodes.find(item => item.id === id)
304
- if (node) selectNode(node)
917
+ if (node) selectNode(node, { openContent: true })
918
+ }
919
+
920
+ const zoomAtPoint = (screenX, screenY, factor) => {
921
+ const nextScale = clampScale(state.transform.scale * factor)
922
+ if (nextScale === state.transform.scale) return
923
+ const worldX = (screenX - state.transform.x) / state.transform.scale
924
+ const worldY = (screenY - state.transform.y) / state.transform.scale
925
+ state.transform.scale = nextScale
926
+ state.transform.x = screenX - worldX * nextScale
927
+ state.transform.y = screenY - worldY * nextScale
928
+ markRenderDirty()
929
+ }
930
+
931
+ const wheelZoomFactor = event => {
932
+ const isModifierZoom = event.metaKey || event.ctrlKey
933
+ const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
934
+ const absoluteDelta = Math.min(Math.abs(event.deltaY * deltaModeFactor), 1600)
935
+
936
+ if (absoluteDelta <= 0.0001) {
937
+ return 1
938
+ }
939
+
940
+ const baseStep = Math.max(0.06, Math.min(0.45, absoluteDelta / 480))
941
+ const adjustedStep = baseStep * (isModifierZoom ? 1.4 : 1)
942
+
943
+ return event.deltaY < 0 ? 1 + adjustedStep : 1 / (1 + adjustedStep)
944
+ }
945
+
946
+ const isScreenPointInsideCanvas = (screenX, screenY) => {
947
+ const rect = canvas.getBoundingClientRect()
948
+
949
+ return screenX >= rect.left && screenX <= rect.right && screenY >= rect.top && screenY <= rect.bottom
305
950
  }
306
951
 
307
- const zoom = factor => {
308
- state.transform.scale = Math.max(0.25, Math.min(3.5, state.transform.scale * factor))
952
+ const handleWheelZoom = event => {
953
+ if (elements.contentDialog?.open) {
954
+ return
955
+ }
956
+
957
+ if (!isScreenPointInsideCanvas(event.clientX, event.clientY)) {
958
+ return
959
+ }
960
+
961
+ event.preventDefault()
962
+ const rect = canvas.getBoundingClientRect()
963
+ const cursorX = event.clientX - rect.left
964
+ const cursorY = event.clientY - rect.top
965
+ const factor = wheelZoomFactor(event)
966
+
967
+ if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
968
+ return
969
+ }
970
+
971
+ zoomAtPoint(cursorX, cursorY, factor)
309
972
  }
310
973
 
311
974
  const bindEvents = () => {
312
975
  window.addEventListener('resize', resize)
313
976
  elements.search.addEventListener('input', event => {
314
977
  state.query = event.target.value
315
- elements.stats.textContent = state.query
316
- ? filteredNodes().length + ' filtered notes'
317
- : state.nodes.length + ' notes · ' + state.edges.length + ' links'
978
+ recomputeVisibility()
979
+ scheduleContentFilterSync()
318
980
  })
319
981
  elements.agent.addEventListener('change', event => {
320
982
  state.agentId = event.target.value
321
983
  state.selected = null
984
+ state.nodeDetails = new Map()
985
+ resetContentFilter()
986
+ recomputeVisibility()
987
+ scheduleContentFilterSync()
322
988
  loadGraph({ reset: true }).catch(error => {
323
- elements.stats.textContent = 'Failed to load agent graph'
324
989
  console.error(error)
325
990
  })
326
991
  })
327
- elements.zoomIn.addEventListener('click', () => zoom(1.18))
328
- elements.zoomOut.addEventListener('click', () => zoom(0.84))
329
- elements.reset.addEventListener('click', resetView)
330
- ;[elements.notes, elements.outgoing, elements.incoming].forEach(element => {
331
- element.addEventListener('click', event => {
332
- const target = event.target
333
- if (!(target instanceof HTMLElement)) return
334
- const nodeId = target.dataset.nodeId
335
- if (nodeId) selectNodeById(nodeId)
992
+ elements.zoomIn.addEventListener('click', () => {
993
+ const rect = canvas.getBoundingClientRect()
994
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.3)
995
+ })
996
+ elements.zoomOut.addEventListener('click', () => {
997
+ const rect = canvas.getBoundingClientRect()
998
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.77)
999
+ })
1000
+ if (elements.fit) {
1001
+ elements.fit.addEventListener('click', () => {
1002
+ fitView({ useFiltered: true })
336
1003
  })
1004
+ }
1005
+ elements.reset.addEventListener('click', () => {
1006
+ resetView()
1007
+ })
1008
+ elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
1009
+ elements.contentDialog.addEventListener('click', event => {
1010
+ const target = event.target
1011
+ if (target instanceof HTMLElement && target.dataset.nodeId) {
1012
+ selectNodeById(target.dataset.nodeId)
1013
+ return
1014
+ }
1015
+ if (event.target === elements.contentDialog) elements.contentDialog.close()
1016
+ })
1017
+ window.addEventListener('wheel', handleWheelZoom, { passive: false })
1018
+ canvas.addEventListener('dblclick', event => {
1019
+ const rect = canvas.getBoundingClientRect()
1020
+ const cursorX = event.clientX - rect.left
1021
+ const cursorY = event.clientY - rect.top
1022
+ zoomAtPoint(cursorX, cursorY, 1.25)
337
1023
  })
338
- canvas.addEventListener('wheel', event => {
339
- event.preventDefault()
340
- zoom(event.deltaY < 0 ? 1.08 : 0.92)
341
- }, { passive: false })
342
1024
  canvas.addEventListener('pointerdown', event => {
343
1025
  const point = worldPoint(event)
344
1026
  const node = hitNode(point)
@@ -346,12 +1028,14 @@ const bindEvents = () => {
346
1028
  if (node) {
347
1029
  node.x = point.x
348
1030
  node.y = point.y
1031
+ markRenderDirty()
349
1032
  }
350
1033
  canvas.setPointerCapture(event.pointerId)
351
1034
  })
352
1035
  canvas.addEventListener('pointermove', event => {
353
1036
  const point = worldPoint(event)
354
1037
  state.hovered = hitNode(point)
1038
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
355
1039
  if (!state.pointer.down) return
356
1040
  const dx = event.clientX - state.pointer.x
357
1041
  const dy = event.clientY - state.pointer.y
@@ -361,17 +1045,48 @@ const bindEvents = () => {
361
1045
  if (state.pointer.dragNode) {
362
1046
  state.pointer.dragNode.x = point.x
363
1047
  state.pointer.dragNode.y = point.y
1048
+ markRenderDirty()
364
1049
  return
365
1050
  }
366
1051
  state.transform.x += dx
367
1052
  state.transform.y += dy
1053
+ markRenderDirty()
368
1054
  })
369
1055
  canvas.addEventListener('pointerup', event => {
370
- if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode)
371
- if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered)
1056
+ if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
1057
+ if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
372
1058
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
373
1059
  canvas.releasePointerCapture(event.pointerId)
374
1060
  })
1061
+ canvas.addEventListener('pointercancel', () => {
1062
+ state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
1063
+ })
1064
+ canvas.addEventListener('pointerenter', event => {
1065
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
1066
+ })
1067
+ canvas.addEventListener('pointerleave', event => {
1068
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
1069
+ })
1070
+ window.addEventListener('keydown', event => {
1071
+ if (event.key === '+' || event.key === '=') {
1072
+ event.preventDefault()
1073
+ const rect = canvas.getBoundingClientRect()
1074
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.25)
1075
+ return
1076
+ }
1077
+
1078
+ if (event.key === '-' || event.key === '_') {
1079
+ event.preventDefault()
1080
+ const rect = canvas.getBoundingClientRect()
1081
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.8)
1082
+ return
1083
+ }
1084
+
1085
+ if (event.key === '0') {
1086
+ event.preventDefault()
1087
+ resetView()
1088
+ }
1089
+ })
375
1090
  }
376
1091
 
377
1092
  const loadAgents = async () => {
@@ -386,9 +1101,10 @@ const loadAgents = async () => {
386
1101
 
387
1102
  state.agentId = selected
388
1103
  if (signature !== state.agentsSignature) {
1104
+ const formatAgentLabel = (agent) => agent.id
389
1105
  elements.agent.innerHTML = agents.length
390
- ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent.id) + ' · ' + agent.documentCount + '</option>').join('')
391
- : '<option value="shared">shared · 0</option>'
1106
+ ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
1107
+ : '<option value="shared">shared</option>'
392
1108
  state.agentsSignature = signature
393
1109
  }
394
1110
  elements.agent.value = selected
@@ -417,14 +1133,29 @@ const loadGraph = async (options = { reset: false }) => {
417
1133
  state.graph = graph
418
1134
  state.nodes = layout.nodes
419
1135
  state.edges = layout.edges
1136
+ state.nodeDegrees = state.edges.reduce((degrees, edge) => {
1137
+ degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
1138
+ if (edge.target) {
1139
+ degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
1140
+ }
1141
+ return degrees
1142
+ }, new Map())
1143
+ state.nodeDetails = new Map()
1144
+ resetContentFilter()
1145
+ recomputeVisibility()
1146
+ scheduleContentFilterSync()
420
1147
  const tags = new Set(graph.nodes.flatMap(node => node.tags))
421
- elements.stats.textContent = state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live'
1148
+ setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
422
1149
  elements.nodeCount.textContent = graph.nodes.length
423
1150
  elements.edgeCount.textContent = graph.edges.length
424
1151
  elements.tagCount.textContent = tags.size
425
1152
  resize()
426
1153
  if (options.reset) resetView()
427
- selectNode(state.nodes.find(node => node.id === selectedId) ?? null)
1154
+ const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
1155
+ selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
1156
+ if (!selectedNode && elements.contentDialog.open) {
1157
+ elements.contentDialog.close()
1158
+ }
428
1159
  }
429
1160
 
430
1161
  bindEvents()
@@ -441,10 +1172,7 @@ const refreshGraphLoop = () => {
441
1172
  return
442
1173
  }
443
1174
 
444
- loadGraph().catch((error) => {
445
- elements.stats.textContent = 'Failed to refresh graph'
446
- console.error(error)
447
- })
1175
+ loadGraph().catch(handleGraphRefreshError)
448
1176
 
449
1177
  tickCounter += 1
450
1178
  if (tickCounter % 3 === 0) {
@@ -461,7 +1189,6 @@ loadAgents()
461
1189
  setInterval(refreshGraphLoop, pollIntervalMs)
462
1190
  })
463
1191
  .catch(error => {
464
- elements.stats.textContent = 'Failed to load graph'
465
1192
  console.error(error)
466
1193
  })
467
1194
 
@@ -470,9 +1197,6 @@ document.addEventListener('visibilitychange', () => {
470
1197
  return
471
1198
  }
472
1199
 
473
- loadGraph({ reset: true }).catch(error => {
474
- elements.stats.textContent = 'Failed to refresh graph'
475
- console.error(error)
476
- })
1200
+ loadGraph({ reset: true }).catch(handleGraphRefreshError)
477
1201
  })
478
1202
  `;