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

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