@andespindola/brainlink 0.1.0-beta.5 → 0.1.0-beta.50

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 (63) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +58 -2
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +266 -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 +1332 -110
  14. package/dist/application/frontend/client-worker-js.js +66 -0
  15. package/dist/application/get-graph-layout.js +18 -6
  16. package/dist/application/get-graph-node.js +12 -0
  17. package/dist/application/get-graph-summary.js +12 -0
  18. package/dist/application/get-graph.js +3 -3
  19. package/dist/application/import-legacy-sqlite.js +296 -0
  20. package/dist/application/index-vault.js +252 -19
  21. package/dist/application/list-agents.js +3 -3
  22. package/dist/application/list-links.js +5 -5
  23. package/dist/application/migrate-vault.js +91 -0
  24. package/dist/application/offline-pack-backup.js +44 -0
  25. package/dist/application/search-graph-node-ids.js +12 -0
  26. package/dist/application/search-knowledge.js +75 -5
  27. package/dist/application/server/routes.js +102 -1
  28. package/dist/application/start-server.js +75 -4
  29. package/dist/application/watch-vault.js +23 -2
  30. package/dist/benchmarks/large-vault.js +1 -1
  31. package/dist/cli/commands/agent-commands.js +419 -0
  32. package/dist/cli/commands/config-commands.js +167 -0
  33. package/dist/cli/commands/read-commands.js +25 -8
  34. package/dist/cli/commands/write-commands.js +989 -10
  35. package/dist/cli/main.js +4 -0
  36. package/dist/cli/runtime.js +5 -2
  37. package/dist/domain/context.js +53 -11
  38. package/dist/domain/embeddings.js +2 -1
  39. package/dist/domain/graph-layout.js +20 -14
  40. package/dist/domain/markdown.js +36 -4
  41. package/dist/domain/middle-out.js +18 -0
  42. package/dist/infrastructure/config.js +132 -8
  43. package/dist/infrastructure/file-index.js +358 -0
  44. package/dist/infrastructure/file-system-vault.js +30 -0
  45. package/dist/infrastructure/index-state.js +56 -0
  46. package/dist/infrastructure/paths.js +9 -1
  47. package/dist/infrastructure/private-pack-codec.js +134 -0
  48. package/dist/infrastructure/search-packs.js +452 -0
  49. package/dist/infrastructure/session-state.js +172 -0
  50. package/dist/mcp/main.js +11 -3
  51. package/dist/mcp/server.js +27 -2
  52. package/dist/mcp/startup.js +35 -0
  53. package/dist/mcp/tools.js +633 -19
  54. package/docs/AGENT_USAGE.md +178 -16
  55. package/docs/ARCHITECTURE.md +37 -26
  56. package/docs/QUICKSTART.md +111 -0
  57. package/package.json +6 -4
  58. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  59. package/dist/infrastructure/sqlite/graph-reader.js +0 -120
  60. package/dist/infrastructure/sqlite/schema.js +0 -111
  61. package/dist/infrastructure/sqlite/search-reader.js +0 -156
  62. package/dist/infrastructure/sqlite/types.js +0 -1
  63. package/dist/infrastructure/sqlite-index.js +0 -25
@@ -1,18 +1,60 @@
1
1
  export const createClientJs = () => `const canvas = document.getElementById('graph')
2
2
  const ctx = canvas.getContext('2d')
3
+ const largeGraphNodeThreshold = 4000
4
+ const massiveGraphNodeThreshold = 20000
5
+ const largeGraphEdgeRenderLimit = 120000
6
+ const renderNodeBudget = 900
7
+ const renderEdgeBudget = 2400
8
+ const clusterActivationNodeThreshold = 600
9
+ const clusterZoomThreshold = 0.18
10
+ const macroGalaxyZoomThreshold = 0.012
11
+ const massiveAutoFitMacroScale = 0.006
12
+ const defaultMacroScale = 0.006
13
+ const clusterCellPixelSize = 64
14
+ const minNodePixelRadius = 2.3
15
+ const viewportPaddingPx = 280
16
+ const worldCoordinateLimit = 5_000_000
17
+ const transformCoordinateLimit = 20_000_000
18
+ const hoverHitTestIntervalMs = 64
19
+ const overviewClusterMaxCount = 1400
20
+ const zoomRecoveryGuardMs = 560
3
21
  const state = {
4
22
  graph: { nodes: [], edges: [] },
5
23
  nodes: [],
6
24
  edges: [],
25
+ visibleNodes: [],
26
+ visibleEdges: [],
27
+ renderNodes: [],
28
+ renderEdges: [],
29
+ renderClusters: [],
30
+ nodeDegrees: new Map(),
7
31
  selected: null,
8
32
  hovered: null,
9
33
  query: '',
34
+ contentFilter: { query: '', ids: null, token: 0, timer: null },
10
35
  agentId: '',
11
36
  agentsSignature: '',
37
+ nodeDetails: new Map(),
12
38
  transform: { x: 0, y: 0, scale: 1 },
13
39
  pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
40
+ cursor: { x: 0, y: 0, inCanvas: false },
14
41
  graphSignature: '',
15
- last: performance.now()
42
+ graphStatus: '',
43
+ graphTotals: { nodes: 0, edges: 0 },
44
+ last: performance.now(),
45
+ offscreenFrameCount: 0,
46
+ recoveringViewport: false,
47
+ renderVisibilityDirty: true,
48
+ lastViewportKey: '',
49
+ visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
50
+ visibleEdgeByNode: new Map(),
51
+ overviewClusters: [],
52
+ macroCenter: { x: 0, y: 0 },
53
+ macroRepresentative: null,
54
+ filterWorker: null,
55
+ filterReady: false,
56
+ lastHoverHitAt: 0,
57
+ lastManualZoomAt: 0
16
58
  }
17
59
 
18
60
  const byId = id => document.getElementById(id)
@@ -23,25 +65,49 @@ const escapeHtml = value => String(value)
23
65
  .replaceAll('"', '"')
24
66
  .replaceAll("'", ''')
25
67
  const elements = {
26
- stats: byId('stats'),
27
68
  search: byId('search'),
28
69
  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
70
  nodeCount: byId('nodeCount'),
37
71
  edgeCount: byId('edgeCount'),
38
72
  tagCount: byId('tagCount'),
39
73
  zoomIn: byId('zoomIn'),
40
74
  zoomOut: byId('zoomOut'),
41
- reset: byId('reset')
75
+ fit: byId('fit'),
76
+ reset: byId('reset'),
77
+ contentDialog: byId('contentDialog'),
78
+ contentTitle: byId('contentTitle'),
79
+ contentPath: byId('contentPath'),
80
+ contentTags: byId('contentTags'),
81
+ contentOutgoing: byId('contentOutgoing'),
82
+ contentIncoming: byId('contentIncoming'),
83
+ contentBody: byId('contentBody'),
84
+ contentClose: byId('contentClose')
42
85
  }
43
86
 
44
- const agentQuery = () => state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''
87
+ const zoomRange = {
88
+ min: 0.0002,
89
+ max: 4.5
90
+ }
91
+
92
+ const initialAgentFromUrl = (() => {
93
+ try {
94
+ const raw = new URL(window.location.href).searchParams.get('agent')
95
+ const value = raw?.trim() ?? ''
96
+ return value.length > 0 ? value : ''
97
+ } catch {
98
+ return ''
99
+ }
100
+ })()
101
+
102
+ const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
103
+
104
+ const setGraphStatus = text => {
105
+ state.graphStatus = text
106
+ }
107
+
108
+ const handleGraphRefreshError = error => {
109
+ console.error(error)
110
+ }
45
111
 
46
112
  const graphTheme = {
47
113
  node: '#aeb8c5',
@@ -56,6 +122,67 @@ const graphTheme = {
56
122
  label: '#edf2f7'
57
123
  }
58
124
 
125
+ const initFilterWorker = () => {
126
+ if (typeof Worker === 'undefined') {
127
+ return
128
+ }
129
+ try {
130
+ const worker = new Worker('/app-worker.js')
131
+ worker.onmessage = event => {
132
+ const payload = event.data
133
+ if (!payload || typeof payload !== 'object') return
134
+
135
+ if (payload.type === 'ready') {
136
+ state.filterReady = true
137
+ if (state.nodes.length > 0) {
138
+ worker.postMessage({
139
+ type: 'load-nodes',
140
+ nodes: state.nodes.map(node => ({
141
+ id: node.id,
142
+ title: node.title,
143
+ path: node.path || '',
144
+ tags: Array.isArray(node.tags) ? node.tags : []
145
+ }))
146
+ })
147
+ }
148
+ return
149
+ }
150
+
151
+ if (payload.type === 'filter-result') {
152
+ const token = payload.token
153
+ if (token !== state.contentFilter.token) {
154
+ return
155
+ }
156
+
157
+ const ids = Array.isArray(payload.ids) ? payload.ids.filter(id => typeof id === 'string') : []
158
+ state.contentFilter.query = normalizeQuery(state.query)
159
+ state.contentFilter.ids = new Set(ids)
160
+ recomputeVisibility()
161
+ }
162
+ }
163
+ state.filterWorker = worker
164
+ } catch {
165
+ state.filterWorker = null
166
+ state.filterReady = false
167
+ }
168
+ }
169
+
170
+ const pushNodesToFilterWorker = () => {
171
+ if (!state.filterWorker || !state.filterReady) {
172
+ return
173
+ }
174
+
175
+ state.filterWorker.postMessage({
176
+ type: 'load-nodes',
177
+ nodes: state.nodes.map(node => ({
178
+ id: node.id,
179
+ title: node.title,
180
+ path: node.path || '',
181
+ tags: Array.isArray(node.tags) ? node.tags : []
182
+ }))
183
+ })
184
+ }
185
+
59
186
  const resize = () => {
60
187
  const rect = canvas.getBoundingClientRect()
61
188
  const width = Math.max(rect.width, 320)
@@ -64,40 +191,524 @@ const resize = () => {
64
191
  canvas.width = Math.floor(width * ratio)
65
192
  canvas.height = Math.floor(height * ratio)
66
193
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
194
+ markRenderDirty()
67
195
  }
68
196
 
69
- const filteredNodes = () => {
70
- const query = state.query.trim().toLowerCase()
71
- if (!query) return state.nodes
72
- return state.nodes.filter(node =>
197
+ const normalizeQuery = value => value.trim().toLowerCase()
198
+ const hubNodeRetentionLimit = 2
199
+ const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map|mapa)\b/i
200
+
201
+ const localFilteredNodes = query =>
202
+ state.nodes.filter(node =>
73
203
  node.title.toLowerCase().includes(query) ||
74
- node.path.toLowerCase().includes(query) ||
204
+ (node.path || '').toLowerCase().includes(query) ||
75
205
  node.tags.some(tag => tag.toLowerCase().includes(query))
76
206
  )
207
+
208
+ const rankedHubNodes = () => {
209
+ if (state.nodes.length === 0) {
210
+ return []
211
+ }
212
+
213
+ const byTitleAndDegree = [...state.nodes]
214
+ .filter(node => hubNodePattern.test(node.title) || hubNodePattern.test(node.path) || node.tags.some(tag => hubNodePattern.test(tag)))
215
+ .sort((left, right) => {
216
+ const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
217
+ if (byDegree !== 0) return byDegree
218
+ return left.title.localeCompare(right.title)
219
+ })
220
+
221
+ if (byTitleAndDegree.length > 0) {
222
+ return byTitleAndDegree.slice(0, hubNodeRetentionLimit)
223
+ }
224
+
225
+ return [...state.nodes]
226
+ .sort((left, right) => {
227
+ const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
228
+ if (byDegree !== 0) return byDegree
229
+ return left.title.localeCompare(right.title)
230
+ })
231
+ .slice(0, 1)
232
+ }
233
+
234
+ const withPersistentHubNodes = nodes => {
235
+ if (nodes.length === 0) {
236
+ return rankedHubNodes()
237
+ }
238
+
239
+ const ids = new Set(nodes.map(node => node.id))
240
+ const hubsToKeep = rankedHubNodes().filter(node => !ids.has(node.id))
241
+ return nodes.concat(hubsToKeep)
242
+ }
243
+
244
+ const filteredNodes = () => {
245
+ const query = normalizeQuery(state.query)
246
+ if (!query) return state.nodes
247
+ if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
248
+ const matched = state.nodes.filter(node => state.contentFilter.ids.has(node.id))
249
+ return withPersistentHubNodes(matched)
250
+ }
251
+
252
+ return withPersistentHubNodes(localFilteredNodes(query))
77
253
  }
78
254
 
79
- const visibleIds = () => new Set(filteredNodes().map(node => node.id))
255
+ const resolveMacroRepresentative = (nodes) => {
256
+ if (nodes.length === 0) {
257
+ return null
258
+ }
259
+
260
+ let best = nodes[0]
261
+ let bestDegree = state.nodeDegrees.get(best.id) ?? 0
262
+
263
+ for (let index = 1; index < nodes.length; index += 1) {
264
+ const node = nodes[index]
265
+ const degree = state.nodeDegrees.get(node.id) ?? 0
266
+ if (degree > bestDegree) {
267
+ best = node
268
+ bestDegree = degree
269
+ }
270
+ }
80
271
 
81
- const visibleEdges = () => {
82
- const ids = visibleIds()
83
- return state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
272
+ return best
273
+ }
274
+
275
+ const recomputeVisibility = () => {
276
+ const nodes = filteredNodes()
277
+ const ids = new Set(nodes.map(node => node.id))
278
+ const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
279
+ const limitedEdges = state.nodes.length > largeGraphNodeThreshold
280
+ ? [...edges]
281
+ .sort((left, right) => edgeWeight(right) - edgeWeight(left))
282
+ .slice(0, largeGraphEdgeRenderLimit)
283
+ : edges
284
+
285
+ state.visibleNodes = nodes
286
+ state.visibleEdges = limitedEdges
287
+ state.visibleNodeSpatial = createSpatialIndex(nodes)
288
+ state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
289
+ state.overviewClusters = nodes.length > massiveGraphNodeThreshold ? buildOverviewClusters(nodes) : []
290
+ const bounds = graphBounds(nodes)
291
+ state.macroCenter = bounds
292
+ ? {
293
+ x: (bounds.minX + bounds.maxX) / 2,
294
+ y: (bounds.minY + bounds.maxY) / 2
295
+ }
296
+ : { x: 0, y: 0 }
297
+ state.macroRepresentative = resolveMacroRepresentative(nodes)
298
+ markRenderDirty()
84
299
  }
85
300
 
86
301
  const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
302
+ const markRenderDirty = () => {
303
+ state.renderVisibilityDirty = true
304
+ }
305
+
306
+ const createSpatialIndex = nodes => {
307
+ if (nodes.length === 0) {
308
+ return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
309
+ }
310
+
311
+ const bounds = graphBounds(nodes)
312
+ if (!bounds) {
313
+ return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
314
+ }
315
+
316
+ const targetNodesPerCell = 18
317
+ const approximateCellArea = Math.max((bounds.width * bounds.height) / Math.max(nodes.length / targetNodesPerCell, 1), 1)
318
+ const cellSize = Math.max(90, Math.min(2200, Math.sqrt(approximateCellArea)))
319
+ const buckets = new Map()
320
+
321
+ for (let index = 0; index < nodes.length; index += 1) {
322
+ const node = nodes[index]
323
+ const cellX = Math.floor((node.x - bounds.minX) / cellSize)
324
+ const cellY = Math.floor((node.y - bounds.minY) / cellSize)
325
+ const key = cellX + ':' + cellY
326
+ const bucket = buckets.get(key)
327
+ if (bucket) {
328
+ bucket.push(node)
329
+ continue
330
+ }
331
+ buckets.set(key, [node])
332
+ }
333
+
334
+ return {
335
+ cellSize,
336
+ minX: bounds.minX,
337
+ minY: bounds.minY,
338
+ maxX: bounds.maxX,
339
+ maxY: bounds.maxY,
340
+ buckets
341
+ }
342
+ }
343
+
344
+ const viewportNodesFromSpatialIndex = viewport => {
345
+ if (state.visibleNodes.length <= 2500) {
346
+ return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
347
+ }
348
+
349
+ const spatial = state.visibleNodeSpatial
350
+ if (!spatial || spatial.buckets.size === 0) {
351
+ return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
352
+ }
353
+
354
+ const minCellX = Math.floor((viewport.minX - spatial.minX) / spatial.cellSize)
355
+ const maxCellX = Math.floor((viewport.maxX - spatial.minX) / spatial.cellSize)
356
+ const minCellY = Math.floor((viewport.minY - spatial.minY) / spatial.cellSize)
357
+ const maxCellY = Math.floor((viewport.maxY - spatial.minY) / spatial.cellSize)
358
+ const nodes = []
359
+
360
+ for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
361
+ for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
362
+ const bucket = spatial.buckets.get(cellX + ':' + cellY)
363
+ if (!bucket) continue
364
+
365
+ for (let index = 0; index < bucket.length; index += 1) {
366
+ const node = bucket[index]
367
+ if (isNodeInViewport(node, viewport)) {
368
+ nodes.push(node)
369
+ }
370
+ }
371
+ }
372
+ }
373
+
374
+ return nodes
375
+ }
376
+
377
+ const createVisibleEdgeLookup = edges => {
378
+ const lookup = new Map()
379
+
380
+ for (let index = 0; index < edges.length; index += 1) {
381
+ const edge = edges[index]
382
+ if (!edge.target) continue
383
+
384
+ const sourceList = lookup.get(edge.source)
385
+ if (sourceList) {
386
+ sourceList.push(edge)
387
+ } else {
388
+ lookup.set(edge.source, [edge])
389
+ }
390
+
391
+ const targetList = lookup.get(edge.target)
392
+ if (targetList) {
393
+ targetList.push(edge)
394
+ } else {
395
+ lookup.set(edge.target, [edge])
396
+ }
397
+ }
398
+
399
+ return lookup
400
+ }
401
+
402
+ const buildOverviewClusters = nodes => {
403
+ if (nodes.length === 0) {
404
+ return []
405
+ }
406
+
407
+ const bounds = graphBounds(nodes)
408
+ if (!bounds) {
409
+ return []
410
+ }
411
+
412
+ const longest = Math.max(bounds.width, bounds.height, 1)
413
+ const cellSize = Math.max(longest / 56, 900)
414
+ const buckets = new Map()
415
+
416
+ for (let index = 0; index < nodes.length; index += 1) {
417
+ const node = nodes[index]
418
+ const keyX = Math.floor((node.x - bounds.minX) / cellSize)
419
+ const keyY = Math.floor((node.y - bounds.minY) / cellSize)
420
+ const key = keyX + ':' + keyY
421
+ const degree = state.nodeDegrees.get(node.id) ?? 0
422
+ const current = buckets.get(key)
423
+ if (current) {
424
+ current.count += 1
425
+ current.sumX += node.x
426
+ current.sumY += node.y
427
+ if (degree > current.degree) {
428
+ current.representative = node
429
+ current.degree = degree
430
+ }
431
+ continue
432
+ }
433
+
434
+ buckets.set(key, {
435
+ id: key,
436
+ count: 1,
437
+ sumX: node.x,
438
+ sumY: node.y,
439
+ representative: node,
440
+ degree
441
+ })
442
+ }
443
+
444
+ return Array.from(buckets.values())
445
+ .sort((left, right) => right.count - left.count)
446
+ .slice(0, overviewClusterMaxCount)
447
+ .map((cluster) => ({
448
+ id: cluster.id,
449
+ x: cluster.sumX / Math.max(cluster.count, 1),
450
+ y: cluster.sumY / Math.max(cluster.count, 1),
451
+ count: cluster.count,
452
+ representative: cluster.representative
453
+ }))
454
+ }
455
+
456
+ const filterOverviewClustersByViewport = viewport =>
457
+ state.overviewClusters.filter((cluster) =>
458
+ cluster.x >= viewport.minX &&
459
+ cluster.x <= viewport.maxX &&
460
+ cluster.y >= viewport.minY &&
461
+ cluster.y <= viewport.maxY
462
+ )
463
+
464
+ const edgeBudgetForCurrentFrame = () => {
465
+ const zoom = state.transform.scale
466
+ if (zoom < 0.12) return 380
467
+ if (zoom < 0.18) return 700
468
+ if (zoom < 0.28) return 1100
469
+ if (zoom < 0.45) return 1600
470
+ if (zoom < 0.7) return 2100
471
+ return renderEdgeBudget
472
+ }
473
+
474
+ const clusterBudgetForScale = (scale) => {
475
+ if (scale < 0.008) return 90
476
+ if (scale < 0.014) return 150
477
+ if (scale < 0.022) return 240
478
+ if (scale < 0.035) return 360
479
+ return 520
480
+ }
481
+
482
+ const nodeBudgetForScale = (scale) => {
483
+ if (scale < 0.035) return 220
484
+ if (scale < 0.06) return 360
485
+ if (scale < 0.09) return 520
486
+ if (scale < 0.14) return 720
487
+ return renderNodeBudget
488
+ }
489
+
490
+ const collectVisibleEdgesForNodes = nodeIds => {
491
+ if (nodeIds.size === 0) {
492
+ return []
493
+ }
494
+
495
+ const seen = new Set()
496
+ const collected = []
497
+ const limit = edgeBudgetForCurrentFrame()
498
+
499
+ nodeIds.forEach(nodeId => {
500
+ const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
501
+ for (let index = 0; index < candidateEdges.length; index += 1) {
502
+ const edge = candidateEdges[index]
503
+ if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
504
+ continue
505
+ }
506
+ const key = edge.source < edge.target
507
+ ? edge.source + '|' + edge.target + '|' + edge.targetTitle
508
+ : edge.target + '|' + edge.source + '|' + edge.targetTitle
509
+ if (seen.has(key)) continue
510
+
511
+ seen.add(key)
512
+ collected.push(edge)
513
+ if (collected.length >= limit) {
514
+ return
515
+ }
516
+ }
517
+ })
518
+
519
+ return collected
520
+ }
521
+
522
+ const fallbackViewportNodes = () => {
523
+ const nodes = []
524
+ const maxNodes = Math.min(renderNodeBudget, 220)
525
+ const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
526
+
527
+ for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
528
+ nodes.push(state.visibleNodes[index])
529
+ }
530
+
531
+ if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
532
+ nodes.push(state.selected)
533
+ }
534
+
535
+ return nodes
536
+ }
537
+
538
+ const sampleVisibleNodes = (limit = renderNodeBudget, sourceNodes = state.visibleNodes) => {
539
+ if (sourceNodes.length === 0 || limit <= 0) {
540
+ return []
541
+ }
542
+
543
+ const nodes = []
544
+ const maxNodes = Math.min(Math.max(limit, 1), sourceNodes.length)
545
+ const step = Math.max(1, Math.ceil(sourceNodes.length / maxNodes))
546
+
547
+ for (let index = 0; index < sourceNodes.length && nodes.length < maxNodes; index += step) {
548
+ nodes.push(sourceNodes[index])
549
+ }
87
550
 
88
- const resetView = () => {
551
+ if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
552
+ nodes.push(state.selected)
553
+ }
554
+
555
+ return nodes
556
+ }
557
+
558
+ const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
559
+ const isFiniteNumber = value => Number.isFinite(value)
560
+ const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
561
+ const clampTransformCoordinate = value => {
562
+ if (!isFiniteNumber(value)) return 0
563
+ if (value > transformCoordinateLimit) return transformCoordinateLimit
564
+ if (value < -transformCoordinateLimit) return -transformCoordinateLimit
565
+ return value
566
+ }
567
+
568
+ const graphBounds = nodes => {
569
+ if (nodes.length === 0) return null
570
+ let minX = Number.POSITIVE_INFINITY
571
+ let maxX = Number.NEGATIVE_INFINITY
572
+ let minY = Number.POSITIVE_INFINITY
573
+ let maxY = Number.NEGATIVE_INFINITY
574
+
575
+ nodes.forEach(node => {
576
+ const radius = baseNodeRadius(node)
577
+ minX = Math.min(minX, node.x - radius)
578
+ maxX = Math.max(maxX, node.x + radius)
579
+ minY = Math.min(minY, node.y - radius)
580
+ maxY = Math.max(maxY, node.y + radius)
581
+ })
582
+
583
+ return {
584
+ minX,
585
+ maxX,
586
+ minY,
587
+ maxY,
588
+ width: Math.max(maxX - minX, 1),
589
+ height: Math.max(maxY - minY, 1)
590
+ }
591
+ }
592
+
593
+ const fitScaleBiasByNodeCount = nodeCount => {
594
+ if (nodeCount <= 6) return 1.22
595
+ if (nodeCount <= 20) return 1.12
596
+ if (nodeCount <= 60) return 1.04
597
+ if (nodeCount <= 180) return 1
598
+ if (nodeCount <= 600) return 0.94
599
+ if (nodeCount <= 2000) return 0.82
600
+ if (nodeCount <= 6000) return 0.68
601
+ return 0.56
602
+ }
603
+
604
+ const autoFitScaleRangeByNodeCount = nodeCount => {
605
+ if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
606
+ if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
607
+ if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
608
+ if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
609
+ if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
610
+ if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
611
+ if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
612
+ return { min: 0.0008, max: 0.24 }
613
+ }
614
+
615
+ const fitView = (options = { useFiltered: true, macro: false }) => {
89
616
  const rect = canvas.getBoundingClientRect()
90
- state.transform = { x: Math.max(rect.width, 320) / 2, y: Math.max(rect.height, 320) / 2, scale: 1 }
617
+ const width = Math.max(rect.width, 320)
618
+ const height = Math.max(rect.height, 320)
619
+ const nodes = options.useFiltered ? filteredNodes() : state.nodes
620
+ const bounds = graphBounds(nodes)
621
+
622
+ if (!bounds) {
623
+ state.transform = { x: width / 2, y: height / 2, scale: 1 }
624
+ state.offscreenFrameCount = 0
625
+ state.recoveringViewport = false
626
+ markRenderDirty()
627
+ return
628
+ }
629
+
630
+ const paddingByNodeCount = nodeCount => {
631
+ if (nodeCount <= 6) return 28
632
+ if (nodeCount <= 20) return 44
633
+ if (nodeCount <= 60) return 68
634
+ if (nodeCount <= 180) return 86
635
+ if (nodeCount <= 600) return 110
636
+ if (nodeCount <= 2000) return 140
637
+ return 180
638
+ }
639
+ const padding = paddingByNodeCount(nodes.length)
640
+ const scaleX = width / (bounds.width + padding * 2)
641
+ const scaleY = height / (bounds.height + padding * 2)
642
+ const fitScale = Math.min(scaleX, scaleY)
643
+ const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
644
+ const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
645
+ const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
646
+ const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
647
+ const scale = options.macro && nodes.length > 1
648
+ ? clampScale(Math.min(baselineScale, macroScale))
649
+ : nodes.length > massiveGraphNodeThreshold
650
+ ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
651
+ : baselineScale
652
+ const centerX = (bounds.minX + bounds.maxX) / 2
653
+ const centerY = (bounds.minY + bounds.maxY) / 2
654
+
655
+ state.transform = {
656
+ x: clampTransformCoordinate(width / 2 - centerX * scale),
657
+ y: clampTransformCoordinate(height / 2 - centerY * scale),
658
+ scale: clampScale(scale)
659
+ }
660
+ state.offscreenFrameCount = 0
661
+ state.recoveringViewport = false
662
+ markRenderDirty()
91
663
  }
92
664
 
665
+ const resetView = () => fitView({ useFiltered: false, macro: true })
666
+
93
667
  const createLayout = graph => {
94
- const nodes = graph.nodes.map(node => ({
95
- ...node,
96
- x: Number.isFinite(node.x) ? node.x : 0,
97
- y: Number.isFinite(node.y) ? node.y : 0
98
- }))
668
+ const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
669
+ const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
670
+ const nodes = nodeRows.map(node => {
671
+ if (Array.isArray(node)) {
672
+ const [id, title, x, y, group, segment] = node
673
+ return {
674
+ id: typeof id === 'string' ? id : '',
675
+ title: typeof title === 'string' ? title : 'Untitled',
676
+ path: '',
677
+ tags: [],
678
+ group: typeof group === 'string' ? group : 'root',
679
+ segment: typeof segment === 'string' ? segment : 'root',
680
+ x: Number.isFinite(x) ? x : 0,
681
+ y: Number.isFinite(y) ? y : 0,
682
+ vx: 0,
683
+ vy: 0
684
+ }
685
+ }
686
+
687
+ return {
688
+ ...node,
689
+ path: typeof node.path === 'string' ? node.path : '',
690
+ tags: Array.isArray(node.tags) ? node.tags : [],
691
+ x: Number.isFinite(node.x) ? node.x : 0,
692
+ y: Number.isFinite(node.y) ? node.y : 0,
693
+ vx: Number.isFinite(node.vx) ? node.vx : 0,
694
+ vy: Number.isFinite(node.vy) ? node.vy : 0
695
+ }
696
+ })
99
697
  const nodeMap = new Map(nodes.map(node => [node.id, node]))
100
- const edges = graph.edges
698
+ const edges = edgeRows
699
+ .map(edge => {
700
+ if (Array.isArray(edge)) {
701
+ const [source, target, weight, priority] = edge
702
+ return {
703
+ source: typeof source === 'string' ? source : '',
704
+ target: typeof target === 'string' ? target : null,
705
+ targetTitle: '',
706
+ weight: Number.isFinite(weight) ? weight : 1,
707
+ priority: typeof priority === 'string' ? priority : 'normal'
708
+ }
709
+ }
710
+ return edge
711
+ })
101
712
  .filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
102
713
  .map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
103
714
  return { nodes, edges }
@@ -111,29 +722,107 @@ const encodeEntityTag = (value) => {
111
722
  binary += String.fromCharCode(utf8[index])
112
723
  }
113
724
 
114
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
725
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
115
726
  }
116
727
 
117
728
  const graphSignature = graph => JSON.stringify({
118
- nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.content, node.tags]),
729
+ nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
119
730
  edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
120
731
  })
121
732
 
733
+ const resetContentFilter = () => {
734
+ if (state.contentFilter.timer) {
735
+ clearTimeout(state.contentFilter.timer)
736
+ }
737
+ state.contentFilter = {
738
+ query: '',
739
+ ids: null,
740
+ token: state.contentFilter.token + 1,
741
+ timer: null
742
+ }
743
+ recomputeVisibility()
744
+ }
745
+
746
+ const syncContentFilter = async (query, token) => {
747
+ const response = await fetch(
748
+ '/api/graph-filter?q=' +
749
+ encodeURIComponent(query) +
750
+ '&limit=' +
751
+ encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
752
+ agentQuery('&')
753
+ )
754
+
755
+ if (!response.ok || token !== state.contentFilter.token) {
756
+ return
757
+ }
758
+
759
+ const payload = await response.json()
760
+ const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
761
+ if (token !== state.contentFilter.token) {
762
+ return
763
+ }
764
+
765
+ state.contentFilter.query = query
766
+ const merged = new Set([...(state.contentFilter.ids instanceof Set ? state.contentFilter.ids : []), ...nodeIds])
767
+ state.contentFilter.ids = merged
768
+ recomputeVisibility()
769
+ }
770
+
771
+ const scheduleContentFilterSync = () => {
772
+ const query = normalizeQuery(state.query)
773
+ if (!query) {
774
+ resetContentFilter()
775
+ return
776
+ }
777
+
778
+ if (state.contentFilter.timer) {
779
+ clearTimeout(state.contentFilter.timer)
780
+ }
781
+
782
+ const token = state.contentFilter.token + 1
783
+ state.contentFilter = {
784
+ query: state.contentFilter.query,
785
+ ids: state.contentFilter.ids,
786
+ token,
787
+ timer: setTimeout(() => {
788
+ if (state.filterWorker && state.filterReady) {
789
+ state.filterWorker.postMessage({
790
+ type: 'filter',
791
+ query,
792
+ token,
793
+ limit: Math.max(state.nodes.length, 1)
794
+ })
795
+ }
796
+ syncContentFilter(query, token).catch(() => {})
797
+ }, 180)
798
+ }
799
+ }
800
+
122
801
  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))
802
+ const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
803
+ const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
804
+ const shouldRunPhysics =
805
+ state.nodes.length <= 8000 &&
806
+ nodes.length <= 320 &&
807
+ state.transform.scale >= 0.08
808
+ if (!shouldRunPhysics) {
809
+ return
810
+ }
126
811
  const strength = Math.min(delta / 16, 2)
127
812
 
128
813
  edges.forEach(edge => {
129
814
  const source = edge.sourceNode
130
815
  const target = edge.targetNode
816
+ source.vx = Number.isFinite(source.vx) ? source.vx : 0
817
+ source.vy = Number.isFinite(source.vy) ? source.vy : 0
818
+ target.vx = Number.isFinite(target.vx) ? target.vx : 0
819
+ target.vy = Number.isFinite(target.vy) ? target.vy : 0
131
820
  const dx = target.x - source.x
132
821
  const dy = target.y - source.y
133
822
  const distance = Math.max(Math.hypot(dx, dy), 1)
134
823
  const force = (distance - 150) * 0.002 * strength
135
- const fx = dx * force
136
- const fy = dy * force
824
+ const fx = (dx / distance) * force
825
+ const fy = (dy / distance) * force
137
826
  source.vx += fx
138
827
  source.vy += fy
139
828
  target.vx -= fx
@@ -144,6 +833,10 @@ const tick = delta => {
144
833
  for (let j = i + 1; j < nodes.length; j += 1) {
145
834
  const a = nodes[i]
146
835
  const b = nodes[j]
836
+ a.vx = Number.isFinite(a.vx) ? a.vx : 0
837
+ a.vy = Number.isFinite(a.vy) ? a.vy : 0
838
+ b.vx = Number.isFinite(b.vx) ? b.vx : 0
839
+ b.vy = Number.isFinite(b.vy) ? b.vy : 0
147
840
  const dx = b.x - a.x
148
841
  const dy = b.y - a.y
149
842
  const distance = Math.max(Math.hypot(dx, dy), 1)
@@ -158,6 +851,10 @@ const tick = delta => {
158
851
  }
159
852
 
160
853
  nodes.forEach(node => {
854
+ node.vx = Number.isFinite(node.vx) ? node.vx : 0
855
+ node.vy = Number.isFinite(node.vy) ? node.vy : 0
856
+ node.x = Number.isFinite(node.x) ? node.x : 0
857
+ node.y = Number.isFinite(node.y) ? node.y : 0
161
858
  if (state.pointer.dragNode === node) {
162
859
  node.vx = 0
163
860
  node.vy = 0
@@ -181,7 +878,15 @@ const worldPoint = event => {
181
878
  }
182
879
 
183
880
  const hitNode = point => {
184
- const nodes = filteredNodes()
881
+ computeRenderVisibility()
882
+ if (state.renderClusters.length > 0) {
883
+ return null
884
+ }
885
+ if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.9) {
886
+ return null
887
+ }
888
+
889
+ const nodes = state.renderNodes
185
890
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
186
891
  const node = nodes[index]
187
892
  const radius = nodeRadius(node)
@@ -190,17 +895,292 @@ const hitNode = point => {
190
895
  return null
191
896
  }
192
897
 
193
- const nodeRadius = node => {
194
- const degree = state.edges.filter(edge => edge.source === node.id || edge.target === node.id).length
898
+ const baseNodeRadius = node => {
899
+ const degree = state.nodeDegrees.get(node.id) ?? 0
195
900
  return 9 + Math.min(degree, 8) * 1.6
196
901
  }
197
902
 
903
+ const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
904
+
905
+ const worldViewportBounds = () => {
906
+ const rect = canvas.getBoundingClientRect()
907
+ const width = Math.max(rect.width, 320)
908
+ const height = Math.max(rect.height, 320)
909
+ const padding = viewportPaddingPx
910
+
911
+ return {
912
+ minX: (-state.transform.x - padding) / state.transform.scale,
913
+ maxX: (width - state.transform.x + padding) / state.transform.scale,
914
+ minY: (-state.transform.y - padding) / state.transform.scale,
915
+ maxY: (height - state.transform.y + padding) / state.transform.scale
916
+ }
917
+ }
918
+
919
+ const isNodeInViewport = (node, viewport) =>
920
+ node.x >= viewport.minX &&
921
+ node.x <= viewport.maxX &&
922
+ node.y >= viewport.minY &&
923
+ node.y <= viewport.maxY
924
+
925
+ const viewportNodeStride = () => {
926
+ if (state.nodes.length <= largeGraphNodeThreshold) {
927
+ return 1
928
+ }
929
+
930
+ if (state.transform.scale >= 0.95) {
931
+ return 1
932
+ }
933
+ if (state.transform.scale >= 0.7) {
934
+ return 2
935
+ }
936
+ if (state.transform.scale >= 0.48) {
937
+ return 3
938
+ }
939
+ if (state.transform.scale >= 0.28) {
940
+ return 5
941
+ }
942
+
943
+ return 8
944
+ }
945
+
946
+ const shouldRenderClusters = viewportNodes =>
947
+ state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
948
+
949
+ const clusterViewportNodes = viewportNodes => {
950
+ if (!shouldRenderClusters(viewportNodes)) {
951
+ return []
952
+ }
953
+
954
+ const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
955
+ const buckets = new Map()
956
+
957
+ for (let index = 0; index < viewportNodes.length; index += 1) {
958
+ const node = viewportNodes[index]
959
+ const keyX = Math.floor(node.x / worldCellSize)
960
+ const keyY = Math.floor(node.y / worldCellSize)
961
+ const key = keyX + ':' + keyY
962
+ const current = buckets.get(key)
963
+ if (current) {
964
+ current.count += 1
965
+ current.sumX += node.x
966
+ current.sumY += node.y
967
+ if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
968
+ current.representative = node
969
+ current.degree = state.nodeDegrees.get(node.id) ?? 0
970
+ }
971
+ continue
972
+ }
973
+
974
+ buckets.set(key, {
975
+ id: key,
976
+ count: 1,
977
+ sumX: node.x,
978
+ sumY: node.y,
979
+ representative: node,
980
+ degree: state.nodeDegrees.get(node.id) ?? 0
981
+ })
982
+ }
983
+
984
+ return Array.from(buckets.values())
985
+ .sort((left, right) => right.count - left.count)
986
+ .slice(0, Math.min(renderNodeBudget, 900))
987
+ .map((cluster) => ({
988
+ id: cluster.id,
989
+ x: cluster.sumX / Math.max(cluster.count, 1),
990
+ y: cluster.sumY / Math.max(cluster.count, 1),
991
+ count: cluster.count,
992
+ representative: cluster.representative
993
+ }))
994
+ }
995
+
996
+ const computeRenderVisibility = () => {
997
+ if (!hasValidTransform()) {
998
+ fitView({ useFiltered: true })
999
+ }
1000
+ const viewport = worldViewportBounds()
1001
+ const viewportKey =
1002
+ Math.round(viewport.minX * 10) + ':' +
1003
+ Math.round(viewport.maxX * 10) + ':' +
1004
+ Math.round(viewport.minY * 10) + ':' +
1005
+ Math.round(viewport.maxY * 10) + ':' +
1006
+ Math.round(state.transform.scale * 1000)
1007
+
1008
+ if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
1009
+ return
1010
+ }
1011
+ state.lastViewportKey = viewportKey
1012
+ state.renderVisibilityDirty = false
1013
+
1014
+ const shouldRenderMacroGalaxy =
1015
+ state.transform.scale <= macroGalaxyZoomThreshold && state.visibleNodes.length > 1
1016
+
1017
+ if (shouldRenderMacroGalaxy) {
1018
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
1019
+ const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
1020
+ const representative = state.macroRepresentative ?? sourceNodes[0] ?? null
1021
+ if (representative) {
1022
+ state.renderClusters = [
1023
+ {
1024
+ id: 'macro-galaxy',
1025
+ x: state.macroCenter.x,
1026
+ y: state.macroCenter.y,
1027
+ count: sourceNodes.length,
1028
+ representative
1029
+ }
1030
+ ]
1031
+ state.renderNodes = [representative]
1032
+ } else {
1033
+ state.renderClusters = []
1034
+ state.renderNodes = []
1035
+ }
1036
+ state.renderEdges = []
1037
+ return
1038
+ }
1039
+
1040
+ if (state.visibleNodes.length <= 2000) {
1041
+ state.renderNodes = state.visibleNodes
1042
+ state.renderClusters = []
1043
+ const ids = new Set(state.renderNodes.map((node) => node.id))
1044
+ state.renderEdges = collectVisibleEdgesForNodes(ids)
1045
+ return
1046
+ }
1047
+
1048
+ if (state.visibleNodes.length > massiveGraphNodeThreshold) {
1049
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
1050
+ const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
1051
+ const sampleLimit = nodeBudgetForScale(state.transform.scale)
1052
+ const sampled = sourceNodes.length > sampleLimit
1053
+ ? sampleVisibleNodes(Math.min(sampleLimit, renderNodeBudget), sourceNodes)
1054
+ : sourceNodes.slice(0, Math.min(sourceNodes.length, renderNodeBudget))
1055
+ const sampledIds = new Set(sampled.map((node) => node.id))
1056
+ state.renderClusters = []
1057
+ state.renderNodes = sampled
1058
+ state.renderEdges = state.transform.scale >= 0.1 ? collectVisibleEdgesForNodes(sampledIds) : []
1059
+ return
1060
+ }
1061
+
1062
+ if (state.transform.scale <= 0.0015) {
1063
+ const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
1064
+ const sampledIds = new Set(sampled.map((node) => node.id))
1065
+ state.renderClusters = []
1066
+ state.renderNodes = sampled
1067
+ state.renderEdges = collectVisibleEdgesForNodes(sampledIds)
1068
+ return
1069
+ }
1070
+
1071
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
1072
+ const clusters = clusterViewportNodes(viewportNodes)
1073
+ if (clusters.length > 0) {
1074
+ state.renderClusters = clusters
1075
+ state.renderNodes = clusters.map(cluster => cluster.representative)
1076
+ state.renderEdges = []
1077
+ return
1078
+ }
1079
+ state.renderClusters = []
1080
+ const stride = viewportNodeStride()
1081
+ const picked = []
1082
+
1083
+ for (let index = 0; index < viewportNodes.length; index += 1) {
1084
+ const node = viewportNodes[index]
1085
+
1086
+ const isPriority =
1087
+ node.id === state.selected?.id ||
1088
+ node.id === state.hovered?.id ||
1089
+ node.id === state.pointer.dragNode?.id
1090
+ if (isPriority || index % stride === 0) {
1091
+ picked.push(node)
1092
+ }
1093
+ }
1094
+
1095
+ const nodes = picked.length > renderNodeBudget
1096
+ ? picked.slice(0, renderNodeBudget)
1097
+ : picked
1098
+ if (nodes.length === 0 && state.visibleNodes.length > 0) {
1099
+ const fallbackNodes = fallbackViewportNodes()
1100
+ const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
1101
+ state.renderNodes = fallbackNodes
1102
+ state.renderClusters = []
1103
+ state.renderEdges = collectVisibleEdgesForNodes(fallbackIds)
1104
+ return
1105
+ }
1106
+
1107
+ const nodeIds = new Set(nodes.map((node) => node.id))
1108
+ const edges = collectVisibleEdgesForNodes(nodeIds)
1109
+
1110
+ state.renderNodes = nodes
1111
+ state.renderEdges = edges
1112
+
1113
+ if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
1114
+ const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
1115
+ const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
1116
+ state.renderClusters = []
1117
+ state.renderNodes = fallbackNodes
1118
+ state.renderEdges = collectVisibleEdgesForNodes(fallbackIds)
1119
+ }
1120
+ }
1121
+
1122
+ const isNodeVisibleOnScreen = (node, width, height) => {
1123
+ const radius = nodeRadius(node) * state.transform.scale
1124
+ const screenX = node.x * state.transform.scale + state.transform.x
1125
+ const screenY = node.y * state.transform.scale + state.transform.y
1126
+
1127
+ return (
1128
+ screenX + radius >= 0 &&
1129
+ screenX - radius <= width &&
1130
+ screenY + radius >= 0 &&
1131
+ screenY - radius <= height
1132
+ )
1133
+ }
1134
+
1135
+ const hasValidTransform = () =>
1136
+ isFiniteNumber(state.transform.x) &&
1137
+ isFiniteNumber(state.transform.y) &&
1138
+ isFiniteNumber(state.transform.scale) &&
1139
+ Math.abs(state.transform.x) <= transformCoordinateLimit &&
1140
+ Math.abs(state.transform.y) <= transformCoordinateLimit &&
1141
+ state.transform.scale > 0
1142
+
1143
+ const sanitizeNodePosition = node => {
1144
+ if (!isReasonableCoordinate(node.x)) node.x = 0
1145
+ if (!isReasonableCoordinate(node.y)) node.y = 0
1146
+ if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
1147
+ if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
1148
+ }
1149
+
1150
+ const sanitizeAllNodePositions = () => {
1151
+ state.nodes.forEach(sanitizeNodePosition)
1152
+ state.visibleNodes.forEach(sanitizeNodePosition)
1153
+ }
1154
+
1155
+ const sanitizeGraphState = () => {
1156
+ state.renderNodes.forEach(sanitizeNodePosition)
1157
+ }
1158
+
198
1159
  const render = now => {
199
1160
  const delta = now - state.last
200
1161
  state.last = now
1162
+ const backgroundFrameIntervalMs =
1163
+ state.nodes.length > massiveGraphNodeThreshold
1164
+ ? (state.transform.scale < 0.035 ? 130 : state.transform.scale < 0.08 ? 110 : 86)
1165
+ : state.nodes.length > largeGraphNodeThreshold
1166
+ ? 64
1167
+ : 16
1168
+ const isInteracting =
1169
+ state.pointer.down ||
1170
+ state.renderVisibilityDirty ||
1171
+ state.recoveringViewport
1172
+ const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
1173
+ if (delta < minFrameIntervalMs) {
1174
+ requestAnimationFrame(render)
1175
+ return
1176
+ }
201
1177
  const rect = canvas.getBoundingClientRect()
202
1178
  const width = Math.max(rect.width, 320)
203
1179
  const height = Math.max(rect.height, 320)
1180
+ sanitizeGraphState()
1181
+ if (!hasValidTransform()) {
1182
+ resetView()
1183
+ }
204
1184
  ctx.clearRect(0, 0, width, height)
205
1185
  if (state.nodes.length === 0) {
206
1186
  ctx.fillStyle = '#99a5b5'
@@ -214,7 +1194,34 @@ const render = now => {
214
1194
  ctx.translate(state.transform.x, state.transform.y)
215
1195
  ctx.scale(state.transform.scale, state.transform.scale)
216
1196
 
217
- visibleEdges().forEach(edge => {
1197
+ computeRenderVisibility()
1198
+ tick(delta)
1199
+ const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
1200
+ const manualZoomGuardActive = now - state.lastManualZoomAt < zoomRecoveryGuardMs
1201
+ if (!hasVisibleNodeOnScreen && state.renderNodes.length > 0 && !manualZoomGuardActive) {
1202
+ state.offscreenFrameCount += 1
1203
+ if (state.offscreenFrameCount >= 6 && !state.recoveringViewport) {
1204
+ state.recoveringViewport = true
1205
+ fitView({ useFiltered: true })
1206
+ state.offscreenFrameCount = 0
1207
+ requestAnimationFrame(() => {
1208
+ state.recoveringViewport = false
1209
+ })
1210
+ }
1211
+ } else {
1212
+ state.offscreenFrameCount = 0
1213
+ }
1214
+ const minimumEdgeScale =
1215
+ state.nodes.length > massiveGraphNodeThreshold
1216
+ ? 0.1
1217
+ : state.nodes.length > largeGraphNodeThreshold
1218
+ ? 0.16
1219
+ : 0
1220
+ const drawEdges =
1221
+ state.renderClusters.length === 0 &&
1222
+ state.transform.scale >= minimumEdgeScale
1223
+ if (drawEdges) {
1224
+ state.renderEdges.forEach(edge => {
218
1225
  const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
219
1226
  ctx.beginPath()
220
1227
  ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
@@ -222,9 +1229,33 @@ const render = now => {
222
1229
  ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
223
1230
  ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
224
1231
  ctx.stroke()
225
- })
1232
+ })
1233
+ }
226
1234
 
227
- filteredNodes().forEach(node => {
1235
+ if (state.renderClusters.length > 0) {
1236
+ const safeScale = Math.max(state.transform.scale, 0.0001)
1237
+ state.renderClusters.forEach(cluster => {
1238
+ const isMacro = cluster.id === 'macro-galaxy'
1239
+ const radiusPx = isMacro
1240
+ ? 10
1241
+ : Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
1242
+ const radius = radiusPx / safeScale
1243
+ const haloRadius = (radiusPx + (isMacro ? 8 : 4)) / safeScale
1244
+ ctx.beginPath()
1245
+ ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
1246
+ ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
1247
+ ctx.fill()
1248
+ ctx.beginPath()
1249
+ ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
1250
+ ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
1251
+ ctx.fill()
1252
+ ctx.lineWidth = 1.4 / safeScale
1253
+ ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
1254
+ ctx.stroke()
1255
+ // Keep cluster markers minimal and faster to draw on large graphs.
1256
+ })
1257
+ } else {
1258
+ state.renderNodes.forEach(node => {
228
1259
  const radius = nodeRadius(node)
229
1260
  const isSelected = state.selected?.id === node.id
230
1261
  const isHovered = state.hovered?.id === node.id
@@ -240,16 +1271,27 @@ const render = now => {
240
1271
  ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
241
1272
  ctx.stroke()
242
1273
 
243
- if (isSelected || isHovered || state.transform.scale > 1.18 || state.nodes.length <= 25) {
1274
+ const shouldDrawLabels =
1275
+ isSelected ||
1276
+ isHovered ||
1277
+ (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
1278
+ if (shouldDrawLabels) {
244
1279
  ctx.fillStyle = graphTheme.label
245
1280
  ctx.font = '12px Inter, system-ui, sans-serif'
246
1281
  ctx.textAlign = 'center'
247
1282
  ctx.textBaseline = 'top'
248
1283
  ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
249
1284
  }
250
- })
1285
+ })
1286
+ }
251
1287
 
252
1288
  ctx.restore()
1289
+ if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
1290
+ ctx.fillStyle = '#99a5b5'
1291
+ ctx.font = '12px Inter, system-ui, sans-serif'
1292
+ ctx.textAlign = 'center'
1293
+ ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
1294
+ }
253
1295
  requestAnimationFrame(render)
254
1296
  }
255
1297
 
@@ -257,88 +1299,205 @@ const list = items => items.length
257
1299
  ? 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
1300
  : '<li><small>No links found.</small></li>'
259
1301
 
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
- }
1302
+ const linkedNodes = node => {
276
1303
  const nodeById = new Map(state.nodes.map(item => [item.id, item]))
277
1304
  const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
278
1305
  ...linkedNode,
279
1306
  weight: edge.weight,
280
1307
  priority: edge.priority
281
1308
  } : null
282
- const outgoing = state.graph.edges
1309
+ const outgoing = state.edges
283
1310
  .filter(edge => edge.source === node.id)
284
- .map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' }, edge))
1311
+ .map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
285
1312
  .filter(Boolean)
286
- const incoming = state.graph.edges
1313
+ const incoming = state.edges
287
1314
  .filter(edge => edge.target === node.id)
288
1315
  .map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
289
1316
  .filter(Boolean)
290
1317
 
291
- elements.title.textContent = node.title
292
- elements.path.textContent = node.path
293
- elements.tags.innerHTML = node.tags.length
1318
+ return { outgoing, incoming }
1319
+ }
1320
+
1321
+ const fetchNodeDetails = async node => {
1322
+ const cached = state.nodeDetails.get(node.id)
1323
+ if (cached) {
1324
+ return cached
1325
+ }
1326
+
1327
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
1328
+ if (!response.ok) {
1329
+ throw new Error('Failed to load graph node details')
1330
+ }
1331
+
1332
+ const payload = await response.json()
1333
+ const detail = payload?.node
1334
+ if (!detail || !detail.id) {
1335
+ throw new Error('Invalid graph node payload')
1336
+ }
1337
+ state.nodeDetails.set(detail.id, detail)
1338
+ return detail
1339
+ }
1340
+
1341
+ const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
1342
+
1343
+ const openContentDialog = async node => {
1344
+ if (!node) return
1345
+ elements.contentTitle.textContent = node.title || 'Loading...'
1346
+ elements.contentPath.textContent = node.path || 'Loading...'
1347
+ elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
294
1348
  ? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
295
1349
  : '<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)
1350
+ const initialLinks = linkedNodes(node)
1351
+ elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
1352
+ elements.contentIncoming.innerHTML = list(initialLinks.incoming)
1353
+ elements.contentBody.textContent = 'Loading note content...'
1354
+ if (!elements.contentDialog.open) {
1355
+ elements.contentDialog.showModal()
1356
+ }
1357
+
1358
+ const applyDetailToDialog = detail => {
1359
+ elements.contentTitle.textContent = detail.title
1360
+ elements.contentPath.textContent = detail.path
1361
+ elements.contentTags.innerHTML = detail.tags.length
1362
+ ? detail.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
1363
+ : '<span>No tags</span>'
1364
+ elements.contentBody.textContent = detail.content
1365
+ }
1366
+
1367
+ try {
1368
+ const detailedNode = await fetchNodeDetails(node)
1369
+ if (state.selected?.id !== node.id) {
1370
+ return
1371
+ }
1372
+ applyDetailToDialog(detailedNode)
1373
+ } catch {
1374
+ try {
1375
+ await wait(120)
1376
+ const retriedNode = await fetchNodeDetails(node)
1377
+ if (state.selected?.id !== node.id) {
1378
+ return
1379
+ }
1380
+ applyDetailToDialog(retriedNode)
1381
+ } catch {
1382
+ elements.contentBody.textContent = 'Unable to load note content.'
1383
+ }
1384
+ }
1385
+ }
1386
+
1387
+ const selectNode = (node, options = { openContent: false }) => {
1388
+ state.selected = node
1389
+ if (node && options.openContent) {
1390
+ openContentDialog(node).catch(() => {
1391
+ elements.contentBody.textContent = 'Unable to load note content.'
1392
+ })
1393
+ }
300
1394
  }
301
1395
 
302
1396
  const selectNodeById = id => {
303
1397
  const node = state.nodes.find(item => item.id === id)
304
- if (node) selectNode(node)
1398
+ if (node) selectNode(node, { openContent: true })
1399
+ }
1400
+
1401
+ const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
1402
+ const nextScale = clampScale(state.transform.scale * factor)
1403
+ if (nextScale === state.transform.scale) return
1404
+ const worldX = (screenX - state.transform.x) / state.transform.scale
1405
+ const worldY = (screenY - state.transform.y) / state.transform.scale
1406
+ state.transform.scale = clampScale(nextScale)
1407
+ state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
1408
+ state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
1409
+ state.offscreenFrameCount = 0
1410
+ if (source === 'wheel') {
1411
+ state.lastManualZoomAt = performance.now()
1412
+ }
1413
+ markRenderDirty()
305
1414
  }
306
1415
 
307
- const zoom = factor => {
308
- state.transform.scale = Math.max(0.25, Math.min(3.5, state.transform.scale * factor))
1416
+ const wheelZoomFactor = event => {
1417
+ const isModifierZoom = event.metaKey || event.ctrlKey
1418
+ const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
1419
+ const absoluteDelta = Math.min(Math.abs(event.deltaY * deltaModeFactor), 1600)
1420
+
1421
+ if (absoluteDelta <= 0.0001) {
1422
+ return 1
1423
+ }
1424
+
1425
+ const baseStep = Math.max(0.06, Math.min(0.45, absoluteDelta / 480))
1426
+ const adjustedStep = baseStep * (isModifierZoom ? 1.4 : 1)
1427
+
1428
+ return event.deltaY < 0 ? 1 + adjustedStep : 1 / (1 + adjustedStep)
1429
+ }
1430
+
1431
+ const handleWheelZoom = event => {
1432
+ if (elements.contentDialog?.open) {
1433
+ return
1434
+ }
1435
+
1436
+ event.preventDefault()
1437
+ const rect = canvas.getBoundingClientRect()
1438
+ const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
1439
+ const rawCursorY = Number.isFinite(event.offsetY) ? event.offsetY : event.clientY - rect.top
1440
+ const cursorX = Math.max(0, Math.min(Math.max(rect.width, 320), rawCursorX))
1441
+ const cursorY = Math.max(0, Math.min(Math.max(rect.height, 320), rawCursorY))
1442
+ const factor = wheelZoomFactor(event)
1443
+
1444
+ if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
1445
+ return
1446
+ }
1447
+
1448
+ zoomAtPoint(cursorX, cursorY, factor, 'wheel')
309
1449
  }
310
1450
 
311
1451
  const bindEvents = () => {
312
1452
  window.addEventListener('resize', resize)
313
1453
  elements.search.addEventListener('input', event => {
314
1454
  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'
1455
+ recomputeVisibility()
1456
+ scheduleContentFilterSync()
318
1457
  })
319
1458
  elements.agent.addEventListener('change', event => {
320
1459
  state.agentId = event.target.value
321
1460
  state.selected = null
1461
+ state.nodeDetails = new Map()
1462
+ resetContentFilter()
1463
+ recomputeVisibility()
1464
+ scheduleContentFilterSync()
322
1465
  loadGraph({ reset: true }).catch(error => {
323
- elements.stats.textContent = 'Failed to load agent graph'
324
1466
  console.error(error)
325
1467
  })
326
1468
  })
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)
1469
+ elements.zoomIn.addEventListener('click', () => {
1470
+ const rect = canvas.getBoundingClientRect()
1471
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.3)
1472
+ })
1473
+ elements.zoomOut.addEventListener('click', () => {
1474
+ const rect = canvas.getBoundingClientRect()
1475
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.77)
1476
+ })
1477
+ if (elements.fit) {
1478
+ elements.fit.addEventListener('click', () => {
1479
+ fitView({ useFiltered: true })
336
1480
  })
1481
+ }
1482
+ elements.reset.addEventListener('click', () => {
1483
+ resetView()
1484
+ })
1485
+ elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
1486
+ elements.contentDialog.addEventListener('click', event => {
1487
+ const target = event.target
1488
+ if (target instanceof HTMLElement && target.dataset.nodeId) {
1489
+ selectNodeById(target.dataset.nodeId)
1490
+ return
1491
+ }
1492
+ if (event.target === elements.contentDialog) elements.contentDialog.close()
1493
+ })
1494
+ canvas.addEventListener('wheel', handleWheelZoom, { passive: false })
1495
+ canvas.addEventListener('dblclick', event => {
1496
+ const rect = canvas.getBoundingClientRect()
1497
+ const cursorX = event.clientX - rect.left
1498
+ const cursorY = event.clientY - rect.top
1499
+ zoomAtPoint(cursorX, cursorY, 1.25)
337
1500
  })
338
- canvas.addEventListener('wheel', event => {
339
- event.preventDefault()
340
- zoom(event.deltaY < 0 ? 1.08 : 0.92)
341
- }, { passive: false })
342
1501
  canvas.addEventListener('pointerdown', event => {
343
1502
  const point = worldPoint(event)
344
1503
  const node = hitNode(point)
@@ -346,12 +1505,24 @@ const bindEvents = () => {
346
1505
  if (node) {
347
1506
  node.x = point.x
348
1507
  node.y = point.y
1508
+ markRenderDirty()
349
1509
  }
350
1510
  canvas.setPointerCapture(event.pointerId)
351
1511
  })
352
1512
  canvas.addEventListener('pointermove', event => {
353
1513
  const point = worldPoint(event)
354
- state.hovered = hitNode(point)
1514
+ const now = performance.now()
1515
+ const canHoverHitTest =
1516
+ !(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.12)
1517
+ const shouldHitTest = canHoverHitTest &&
1518
+ (state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
1519
+ if (shouldHitTest) {
1520
+ state.hovered = hitNode(point)
1521
+ state.lastHoverHitAt = now
1522
+ } else if (!canHoverHitTest) {
1523
+ state.hovered = null
1524
+ }
1525
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
355
1526
  if (!state.pointer.down) return
356
1527
  const dx = event.clientX - state.pointer.x
357
1528
  const dy = event.clientY - state.pointer.y
@@ -361,34 +1532,70 @@ const bindEvents = () => {
361
1532
  if (state.pointer.dragNode) {
362
1533
  state.pointer.dragNode.x = point.x
363
1534
  state.pointer.dragNode.y = point.y
1535
+ markRenderDirty()
364
1536
  return
365
1537
  }
366
1538
  state.transform.x += dx
367
1539
  state.transform.y += dy
1540
+ state.transform.x = clampTransformCoordinate(state.transform.x)
1541
+ state.transform.y = clampTransformCoordinate(state.transform.y)
1542
+ state.offscreenFrameCount = 0
1543
+ markRenderDirty()
368
1544
  })
369
1545
  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)
1546
+ if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
1547
+ if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
372
1548
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
373
1549
  canvas.releasePointerCapture(event.pointerId)
374
1550
  })
1551
+ canvas.addEventListener('pointercancel', () => {
1552
+ state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
1553
+ })
1554
+ canvas.addEventListener('pointerenter', event => {
1555
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
1556
+ })
1557
+ canvas.addEventListener('pointerleave', event => {
1558
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
1559
+ })
1560
+ window.addEventListener('keydown', event => {
1561
+ if (event.key === '+' || event.key === '=') {
1562
+ event.preventDefault()
1563
+ const rect = canvas.getBoundingClientRect()
1564
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.25)
1565
+ return
1566
+ }
1567
+
1568
+ if (event.key === '-' || event.key === '_') {
1569
+ event.preventDefault()
1570
+ const rect = canvas.getBoundingClientRect()
1571
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.8)
1572
+ return
1573
+ }
1574
+
1575
+ if (event.key === '0') {
1576
+ event.preventDefault()
1577
+ resetView()
1578
+ }
1579
+ })
375
1580
  }
376
1581
 
377
1582
  const loadAgents = async () => {
378
1583
  const response = await fetch('/api/agents')
379
1584
  const payload = await response.json()
380
1585
  const agents = Array.isArray(payload.agents) ? payload.agents : []
381
- const currentExists = agents.some(agent => agent.id === state.agentId)
1586
+ const preferredAgent = state.agentId || initialAgentFromUrl
1587
+ const currentExists = agents.some(agent => agent.id === preferredAgent)
382
1588
  const selected = currentExists
383
- ? state.agentId
1589
+ ? preferredAgent
384
1590
  : (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
385
1591
  const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
386
1592
 
387
1593
  state.agentId = selected
388
1594
  if (signature !== state.agentsSignature) {
1595
+ const formatAgentLabel = (agent) => agent.id
389
1596
  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>'
1597
+ ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
1598
+ : '<option value="shared">shared</option>'
392
1599
  state.agentsSignature = signature
393
1600
  }
394
1601
  elements.agent.value = selected
@@ -409,6 +1616,10 @@ const loadGraph = async (options = { reset: false }) => {
409
1616
 
410
1617
  const payload = await response.json()
411
1618
  const graph = payload?.layout ?? payload
1619
+ state.graphTotals = {
1620
+ nodes: Number.isFinite(payload?.totals?.nodes) ? payload.totals.nodes : (Array.isArray(graph.nodes) ? graph.nodes.length : 0),
1621
+ edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
1622
+ }
412
1623
  const signature = payload?.signature ?? graphSignature(graph)
413
1624
  if (!options.reset && signature === state.graphSignature) return
414
1625
  const selectedId = state.selected?.id
@@ -417,17 +1628,35 @@ const loadGraph = async (options = { reset: false }) => {
417
1628
  state.graph = graph
418
1629
  state.nodes = layout.nodes
419
1630
  state.edges = layout.edges
420
- 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'
422
- elements.nodeCount.textContent = graph.nodes.length
423
- elements.edgeCount.textContent = graph.edges.length
1631
+ state.nodeDegrees = state.edges.reduce((degrees, edge) => {
1632
+ degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
1633
+ if (edge.target) {
1634
+ degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
1635
+ }
1636
+ return degrees
1637
+ }, new Map())
1638
+ state.nodeDetails = new Map()
1639
+ pushNodesToFilterWorker()
1640
+ resetContentFilter()
1641
+ sanitizeAllNodePositions()
1642
+ recomputeVisibility()
1643
+ scheduleContentFilterSync()
1644
+ const tags = new Set(state.nodes.flatMap(node => node.tags))
1645
+ setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
1646
+ elements.nodeCount.textContent = state.graphTotals.nodes
1647
+ elements.edgeCount.textContent = state.graphTotals.edges
424
1648
  elements.tagCount.textContent = tags.size
425
1649
  resize()
426
1650
  if (options.reset) resetView()
427
- selectNode(state.nodes.find(node => node.id === selectedId) ?? null)
1651
+ const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
1652
+ selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
1653
+ if (!selectedNode && elements.contentDialog.open) {
1654
+ elements.contentDialog.close()
1655
+ }
428
1656
  }
429
1657
 
430
1658
  bindEvents()
1659
+ initFilterWorker()
431
1660
  requestAnimationFrame(() => {
432
1661
  resize()
433
1662
  resetView()
@@ -441,10 +1670,7 @@ const refreshGraphLoop = () => {
441
1670
  return
442
1671
  }
443
1672
 
444
- loadGraph().catch((error) => {
445
- elements.stats.textContent = 'Failed to refresh graph'
446
- console.error(error)
447
- })
1673
+ loadGraph().catch(handleGraphRefreshError)
448
1674
 
449
1675
  tickCounter += 1
450
1676
  if (tickCounter % 3 === 0) {
@@ -461,7 +1687,6 @@ loadAgents()
461
1687
  setInterval(refreshGraphLoop, pollIntervalMs)
462
1688
  })
463
1689
  .catch(error => {
464
- elements.stats.textContent = 'Failed to load graph'
465
1690
  console.error(error)
466
1691
  })
467
1692
 
@@ -470,9 +1695,6 @@ document.addEventListener('visibilitychange', () => {
470
1695
  return
471
1696
  }
472
1697
 
473
- loadGraph({ reset: true }).catch(error => {
474
- elements.stats.textContent = 'Failed to refresh graph'
475
- console.error(error)
476
- })
1698
+ loadGraph({ reset: true }).catch(handleGraphRefreshError)
477
1699
  })
478
1700
  `;