@andespindola/brainlink 0.1.0-beta.6 → 0.1.0-beta.60

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 +1765 -117
  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 +62 -15
  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,19 +1,67 @@
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 = 1500
21
+ const zoomCapTargetViewportShare = 0.72
22
+ const meshEdgeScaleThreshold = 0.09
23
+ const meshEdgeMinBudget = 140
24
+ const meshEdgeMaxBudget = 1400
3
25
  const state = {
4
26
  graph: { nodes: [], edges: [] },
5
27
  nodes: [],
28
+ nodeById: new Map(),
6
29
  edges: [],
30
+ visibleNodes: [],
31
+ visibleEdges: [],
32
+ renderNodes: [],
33
+ renderEdges: [],
34
+ renderClusters: [],
35
+ nodeDegrees: new Map(),
7
36
  selected: null,
8
37
  hovered: null,
9
38
  query: '',
39
+ contentFilter: { query: '', ids: null, token: 0, timer: null },
10
40
  agentId: '',
11
41
  agentsSignature: '',
42
+ nodeDetails: new Map(),
12
43
  transform: { x: 0, y: 0, scale: 1 },
13
44
  pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
45
+ cursor: { x: 0, y: 0, inCanvas: false },
14
46
  graphSignature: '',
15
47
  graphStatus: '',
16
- last: performance.now()
48
+ graphTotals: { nodes: 0, edges: 0 },
49
+ last: performance.now(),
50
+ offscreenFrameCount: 0,
51
+ recoveringViewport: false,
52
+ renderVisibilityDirty: true,
53
+ lastViewportKey: '',
54
+ visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
55
+ visibleEdgeByNode: new Map(),
56
+ overviewClusters: [],
57
+ macroCenter: { x: 0, y: 0 },
58
+ macroRepresentative: null,
59
+ primaryHub: null,
60
+ hubNeighborDistance: Number.POSITIVE_INFINITY,
61
+ filterWorker: null,
62
+ filterReady: false,
63
+ lastHoverHitAt: 0,
64
+ lastManualZoomAt: 0
17
65
  }
18
66
 
19
67
  const byId = id => document.getElementById(id)
@@ -24,39 +72,80 @@ const escapeHtml = value => String(value)
24
72
  .replaceAll('"', '"')
25
73
  .replaceAll("'", ''')
26
74
  const elements = {
27
- stats: byId('stats'),
28
75
  search: byId('search'),
29
76
  agent: byId('agent'),
30
- title: byId('title'),
31
- path: byId('path'),
32
- tags: byId('tags'),
33
- notes: byId('notes'),
34
- content: byId('content'),
35
- outgoing: byId('outgoing'),
36
- incoming: byId('incoming'),
37
77
  nodeCount: byId('nodeCount'),
38
78
  edgeCount: byId('edgeCount'),
39
79
  tagCount: byId('tagCount'),
40
80
  zoomIn: byId('zoomIn'),
41
81
  zoomOut: byId('zoomOut'),
42
- reset: byId('reset')
82
+ fit: byId('fit'),
83
+ reset: byId('reset'),
84
+ contentDialog: byId('contentDialog'),
85
+ contentTitle: byId('contentTitle'),
86
+ contentPath: byId('contentPath'),
87
+ contentTags: byId('contentTags'),
88
+ contentOutgoing: byId('contentOutgoing'),
89
+ contentIncoming: byId('contentIncoming'),
90
+ contentBody: byId('contentBody'),
91
+ contentClose: byId('contentClose')
43
92
  }
44
93
 
45
- const agentQuery = () => state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''
94
+ const zoomRange = {
95
+ min: 0.0002,
96
+ max: 4.5
97
+ }
98
+
99
+ const initialAgentFromUrl = (() => {
100
+ try {
101
+ const raw = new URL(window.location.href).searchParams.get('agent')
102
+ const value = raw?.trim() ?? ''
103
+ return value.length > 0 ? value : ''
104
+ } catch {
105
+ return ''
106
+ }
107
+ })()
108
+
109
+ const selectedAgentStorageKey = 'brainlink:selected-agent'
110
+
111
+ const readStoredAgent = () => {
112
+ try {
113
+ const value = window.localStorage.getItem(selectedAgentStorageKey)?.trim() ?? ''
114
+ return value.length > 0 ? value : ''
115
+ } catch {
116
+ return ''
117
+ }
118
+ }
119
+
120
+ const writeStoredAgent = (agentId) => {
121
+ try {
122
+ if (!agentId) {
123
+ window.localStorage.removeItem(selectedAgentStorageKey)
124
+ return
125
+ }
126
+ window.localStorage.setItem(selectedAgentStorageKey, agentId)
127
+ } catch {}
128
+ }
129
+
130
+ const syncAgentInUrl = (agentId) => {
131
+ try {
132
+ const url = new URL(window.location.href)
133
+ if (agentId && agentId.trim().length > 0) {
134
+ url.searchParams.set('agent', agentId)
135
+ } else {
136
+ url.searchParams.delete('agent')
137
+ }
138
+ window.history.replaceState({}, '', url.toString())
139
+ } catch {}
140
+ }
141
+
142
+ const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
46
143
 
47
144
  const setGraphStatus = text => {
48
145
  state.graphStatus = text
49
- elements.stats.textContent = text
50
146
  }
51
147
 
52
148
  const handleGraphRefreshError = error => {
53
- if (state.graphSignature) {
54
- elements.stats.textContent = state.graphStatus
55
- console.error(error)
56
- return
57
- }
58
-
59
- elements.stats.textContent = 'Failed to load graph'
60
149
  console.error(error)
61
150
  }
62
151
 
@@ -73,6 +162,67 @@ const graphTheme = {
73
162
  label: '#edf2f7'
74
163
  }
75
164
 
165
+ const initFilterWorker = () => {
166
+ if (typeof Worker === 'undefined') {
167
+ return
168
+ }
169
+ try {
170
+ const worker = new Worker('/app-worker.js')
171
+ worker.onmessage = event => {
172
+ const payload = event.data
173
+ if (!payload || typeof payload !== 'object') return
174
+
175
+ if (payload.type === 'ready') {
176
+ state.filterReady = true
177
+ if (state.nodes.length > 0) {
178
+ worker.postMessage({
179
+ type: 'load-nodes',
180
+ nodes: state.nodes.map(node => ({
181
+ id: node.id,
182
+ title: node.title,
183
+ path: node.path || '',
184
+ tags: Array.isArray(node.tags) ? node.tags : []
185
+ }))
186
+ })
187
+ }
188
+ return
189
+ }
190
+
191
+ if (payload.type === 'filter-result') {
192
+ const token = payload.token
193
+ if (token !== state.contentFilter.token) {
194
+ return
195
+ }
196
+
197
+ const ids = Array.isArray(payload.ids) ? payload.ids.filter(id => typeof id === 'string') : []
198
+ state.contentFilter.query = normalizeQuery(state.query)
199
+ state.contentFilter.ids = new Set(ids)
200
+ recomputeVisibility()
201
+ }
202
+ }
203
+ state.filterWorker = worker
204
+ } catch {
205
+ state.filterWorker = null
206
+ state.filterReady = false
207
+ }
208
+ }
209
+
210
+ const pushNodesToFilterWorker = () => {
211
+ if (!state.filterWorker || !state.filterReady) {
212
+ return
213
+ }
214
+
215
+ state.filterWorker.postMessage({
216
+ type: 'load-nodes',
217
+ nodes: state.nodes.map(node => ({
218
+ id: node.id,
219
+ title: node.title,
220
+ path: node.path || '',
221
+ tags: Array.isArray(node.tags) ? node.tags : []
222
+ }))
223
+ })
224
+ }
225
+
76
226
  const resize = () => {
77
227
  const rect = canvas.getBoundingClientRect()
78
228
  const width = Math.max(rect.width, 320)
@@ -81,40 +231,896 @@ const resize = () => {
81
231
  canvas.width = Math.floor(width * ratio)
82
232
  canvas.height = Math.floor(height * ratio)
83
233
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
234
+ markRenderDirty()
84
235
  }
85
236
 
86
- const filteredNodes = () => {
87
- const query = state.query.trim().toLowerCase()
88
- if (!query) return state.nodes
89
- return state.nodes.filter(node =>
237
+ const normalizeQuery = value => value.trim().toLowerCase()
238
+ const hubNodeRetentionLimit = 2
239
+ const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map|mapa)\b/i
240
+ const memoryHubPathPattern = /\bmemory[-_\s]*hub\b/i
241
+
242
+ const hubNodeScore = node => {
243
+ const title = node.title.trim().toLowerCase()
244
+ if (title === 'memory hub') return 6
245
+ if (title === 'knowledge hub') return 5
246
+ if (memoryHubPathPattern.test(node.path || '')) return 4
247
+ if (node.tags.some(tag => tag.trim().toLowerCase() === 'memory-hub')) return 3
248
+ if (/\bmoc\b/i.test(node.title)) return 2
249
+ return hubNodePattern.test(node.title) || hubNodePattern.test(node.path || '') || node.tags.some(tag => hubNodePattern.test(tag))
250
+ ? 1
251
+ : 0
252
+ }
253
+
254
+ const localFilteredNodes = query =>
255
+ state.nodes.filter(node =>
90
256
  node.title.toLowerCase().includes(query) ||
91
- node.path.toLowerCase().includes(query) ||
257
+ (node.path || '').toLowerCase().includes(query) ||
92
258
  node.tags.some(tag => tag.toLowerCase().includes(query))
93
259
  )
260
+
261
+ const rankedHubNodes = () => {
262
+ if (state.nodes.length === 0) {
263
+ return []
264
+ }
265
+
266
+ const byTitleAndDegree = [...state.nodes]
267
+ .filter(node => hubNodeScore(node) > 0)
268
+ .sort((left, right) => {
269
+ const byHubScore = hubNodeScore(right) - hubNodeScore(left)
270
+ if (byHubScore !== 0) return byHubScore
271
+ const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
272
+ if (byDegree !== 0) return byDegree
273
+ return left.title.localeCompare(right.title)
274
+ })
275
+
276
+ if (byTitleAndDegree.length > 0) {
277
+ return byTitleAndDegree.slice(0, hubNodeRetentionLimit)
278
+ }
279
+
280
+ return [...state.nodes]
281
+ .sort((left, right) => {
282
+ const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
283
+ if (byDegree !== 0) return byDegree
284
+ return left.title.localeCompare(right.title)
285
+ })
286
+ .slice(0, 1)
94
287
  }
95
288
 
96
- const visibleIds = () => new Set(filteredNodes().map(node => node.id))
289
+ const withPersistentHubNodes = nodes => {
290
+ if (nodes.length === 0) {
291
+ return rankedHubNodes()
292
+ }
97
293
 
98
- const visibleEdges = () => {
99
- const ids = visibleIds()
100
- return state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
294
+ const ids = new Set(nodes.map(node => node.id))
295
+ const hubsToKeep = rankedHubNodes().filter(node => !ids.has(node.id))
296
+ return nodes.concat(hubsToKeep)
297
+ }
298
+
299
+ const filteredNodes = () => {
300
+ const query = normalizeQuery(state.query)
301
+ if (!query) return state.nodes
302
+ if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
303
+ const matched = state.nodes.filter(node => state.contentFilter.ids.has(node.id))
304
+ return withPersistentHubNodes(matched)
305
+ }
306
+
307
+ return withPersistentHubNodes(localFilteredNodes(query))
308
+ }
309
+
310
+ const resolveMacroRepresentative = (nodes) => {
311
+ if (nodes.length === 0) {
312
+ return null
313
+ }
314
+
315
+ const hubCandidate = state.primaryHub && nodes.some(node => node.id === state.primaryHub.id)
316
+ ? state.primaryHub
317
+ : null
318
+ let best = hubCandidate ?? nodes[0]
319
+ let bestDegree = state.nodeDegrees.get(best.id) ?? 0
320
+
321
+ for (let index = 1; index < nodes.length; index += 1) {
322
+ const node = nodes[index]
323
+ const degree = state.nodeDegrees.get(node.id) ?? 0
324
+ if (degree > bestDegree) {
325
+ best = node
326
+ bestDegree = degree
327
+ }
328
+ }
329
+
330
+ return best
331
+ }
332
+
333
+ const nearestHubNeighborDistance = (hub, nodes) => {
334
+ if (!hub || nodes.length <= 1) {
335
+ return Number.POSITIVE_INFINITY
336
+ }
337
+
338
+ let minimum = Number.POSITIVE_INFINITY
339
+ for (let index = 0; index < nodes.length; index += 1) {
340
+ const node = nodes[index]
341
+ if (node.id === hub.id) continue
342
+ const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
343
+ if (distance < minimum) {
344
+ minimum = distance
345
+ }
346
+ }
347
+
348
+ return minimum
349
+ }
350
+
351
+ const recomputeVisibility = () => {
352
+ const nodes = filteredNodes()
353
+ const ids = new Set(nodes.map(node => node.id))
354
+ const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
355
+ const limitedEdges = state.nodes.length > largeGraphNodeThreshold
356
+ ? [...edges]
357
+ .sort((left, right) => edgeWeight(right) - edgeWeight(left))
358
+ .slice(0, largeGraphEdgeRenderLimit)
359
+ : edges
360
+
361
+ state.visibleNodes = nodes
362
+ state.visibleEdges = limitedEdges
363
+ state.visibleNodeSpatial = createSpatialIndex(nodes)
364
+ state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
365
+ state.overviewClusters = nodes.length > massiveGraphNodeThreshold ? buildOverviewClusters(nodes) : []
366
+ const primaryHub = rankedHubNodes()[0] ?? null
367
+ state.primaryHub = primaryHub
368
+ state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
369
+ const bounds = graphBounds(nodes)
370
+ state.macroCenter = bounds
371
+ ? {
372
+ x: primaryHub ? primaryHub.x : (bounds.minX + bounds.maxX) / 2,
373
+ y: primaryHub ? primaryHub.y : (bounds.minY + bounds.maxY) / 2
374
+ }
375
+ : { x: 0, y: 0 }
376
+ state.macroRepresentative = resolveMacroRepresentative(nodes)
377
+ markRenderDirty()
101
378
  }
102
379
 
103
380
  const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
381
+ const markRenderDirty = () => {
382
+ state.renderVisibilityDirty = true
383
+ }
384
+
385
+ const createSpatialIndex = nodes => {
386
+ if (nodes.length === 0) {
387
+ return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
388
+ }
389
+
390
+ const bounds = graphBounds(nodes)
391
+ if (!bounds) {
392
+ return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
393
+ }
394
+
395
+ const targetNodesPerCell = 18
396
+ const approximateCellArea = Math.max((bounds.width * bounds.height) / Math.max(nodes.length / targetNodesPerCell, 1), 1)
397
+ const cellSize = Math.max(90, Math.min(2200, Math.sqrt(approximateCellArea)))
398
+ const buckets = new Map()
399
+
400
+ for (let index = 0; index < nodes.length; index += 1) {
401
+ const node = nodes[index]
402
+ const cellX = Math.floor((node.x - bounds.minX) / cellSize)
403
+ const cellY = Math.floor((node.y - bounds.minY) / cellSize)
404
+ const key = cellX + ':' + cellY
405
+ const bucket = buckets.get(key)
406
+ if (bucket) {
407
+ bucket.push(node)
408
+ continue
409
+ }
410
+ buckets.set(key, [node])
411
+ }
412
+
413
+ return {
414
+ cellSize,
415
+ minX: bounds.minX,
416
+ minY: bounds.minY,
417
+ maxX: bounds.maxX,
418
+ maxY: bounds.maxY,
419
+ buckets
420
+ }
421
+ }
422
+
423
+ const viewportNodesFromSpatialIndex = viewport => {
424
+ if (state.visibleNodes.length <= 2500) {
425
+ return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
426
+ }
427
+
428
+ const spatial = state.visibleNodeSpatial
429
+ if (!spatial || spatial.buckets.size === 0) {
430
+ return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
431
+ }
432
+
433
+ const minCellX = Math.floor((viewport.minX - spatial.minX) / spatial.cellSize)
434
+ const maxCellX = Math.floor((viewport.maxX - spatial.minX) / spatial.cellSize)
435
+ const minCellY = Math.floor((viewport.minY - spatial.minY) / spatial.cellSize)
436
+ const maxCellY = Math.floor((viewport.maxY - spatial.minY) / spatial.cellSize)
437
+ const nodes = []
438
+
439
+ for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
440
+ for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
441
+ const bucket = spatial.buckets.get(cellX + ':' + cellY)
442
+ if (!bucket) continue
443
+
444
+ for (let index = 0; index < bucket.length; index += 1) {
445
+ const node = bucket[index]
446
+ if (isNodeInViewport(node, viewport)) {
447
+ nodes.push(node)
448
+ }
449
+ }
450
+ }
451
+ }
452
+
453
+ return nodes
454
+ }
455
+
456
+ const createVisibleEdgeLookup = edges => {
457
+ const lookup = new Map()
458
+
459
+ for (let index = 0; index < edges.length; index += 1) {
460
+ const edge = edges[index]
461
+ if (!edge.target) continue
462
+
463
+ const sourceList = lookup.get(edge.source)
464
+ if (sourceList) {
465
+ sourceList.push(edge)
466
+ } else {
467
+ lookup.set(edge.source, [edge])
468
+ }
469
+
470
+ const targetList = lookup.get(edge.target)
471
+ if (targetList) {
472
+ targetList.push(edge)
473
+ } else {
474
+ lookup.set(edge.target, [edge])
475
+ }
476
+ }
477
+
478
+ return lookup
479
+ }
480
+
481
+ const buildOverviewClusters = nodes => {
482
+ if (nodes.length === 0) {
483
+ return []
484
+ }
485
+
486
+ const bounds = graphBounds(nodes)
487
+ if (!bounds) {
488
+ return []
489
+ }
490
+
491
+ const longest = Math.max(bounds.width, bounds.height, 1)
492
+ const cellSize = Math.max(longest / 56, 900)
493
+ const buckets = new Map()
494
+
495
+ for (let index = 0; index < nodes.length; index += 1) {
496
+ const node = nodes[index]
497
+ const keyX = Math.floor((node.x - bounds.minX) / cellSize)
498
+ const keyY = Math.floor((node.y - bounds.minY) / cellSize)
499
+ const key = keyX + ':' + keyY
500
+ const degree = state.nodeDegrees.get(node.id) ?? 0
501
+ const current = buckets.get(key)
502
+ if (current) {
503
+ current.count += 1
504
+ current.sumX += node.x
505
+ current.sumY += node.y
506
+ if (degree > current.degree) {
507
+ current.representative = node
508
+ current.degree = degree
509
+ }
510
+ continue
511
+ }
512
+
513
+ buckets.set(key, {
514
+ id: key,
515
+ count: 1,
516
+ sumX: node.x,
517
+ sumY: node.y,
518
+ representative: node,
519
+ degree
520
+ })
521
+ }
522
+
523
+ return Array.from(buckets.values())
524
+ .sort((left, right) => right.count - left.count)
525
+ .slice(0, overviewClusterMaxCount)
526
+ .map((cluster) => ({
527
+ id: cluster.id,
528
+ x: cluster.sumX / Math.max(cluster.count, 1),
529
+ y: cluster.sumY / Math.max(cluster.count, 1),
530
+ count: cluster.count,
531
+ representative: cluster.representative
532
+ }))
533
+ }
534
+
535
+ const filterOverviewClustersByViewport = viewport =>
536
+ state.overviewClusters.filter((cluster) =>
537
+ cluster.x >= viewport.minX &&
538
+ cluster.x <= viewport.maxX &&
539
+ cluster.y >= viewport.minY &&
540
+ cluster.y <= viewport.maxY
541
+ )
542
+
543
+ const edgeBudgetForCurrentFrame = () => {
544
+ const zoom = state.transform.scale
545
+ if (zoom < 0.12) return 380
546
+ if (zoom < 0.18) return 900
547
+ if (zoom < 0.28) return 1700
548
+ if (zoom < 0.45) return 2800
549
+ if (zoom < 0.7) return 4200
550
+ if (zoom < 1.05) return 5600
551
+ return 7600
552
+ }
553
+
554
+ const clusterBudgetForScale = (scale) => {
555
+ if (scale < 0.008) return 90
556
+ if (scale < 0.014) return 150
557
+ if (scale < 0.022) return 240
558
+ if (scale < 0.035) return 360
559
+ return 520
560
+ }
561
+
562
+ const nodeBudgetForScale = (scale) => {
563
+ if (scale < 0.035) return 220
564
+ if (scale < 0.06) return 360
565
+ if (scale < 0.09) return 520
566
+ if (scale < 0.14) return 720
567
+ return renderNodeBudget
568
+ }
569
+
570
+ const edgeIdentityKey = edge => {
571
+ if (!edge.target) return ''
572
+ const pair = edge.source < edge.target
573
+ ? edge.source + '|' + edge.target
574
+ : edge.target + '|' + edge.source
575
+ return pair + '|' + (edge.inferred ? 'mesh' : 'real')
576
+ }
577
+
578
+ const edgeRelevanceScore = edge => {
579
+ let score = edgeWeight(edge) * 10
580
+ if (!edge.inferred) {
581
+ score += 8
582
+ }
583
+
584
+ const selectedId = state.selected?.id
585
+ if (selectedId && (edge.source === selectedId || edge.target === selectedId)) {
586
+ score += 120
587
+ }
588
+
589
+ const hoveredId = state.hovered?.id
590
+ if (hoveredId && (edge.source === hoveredId || edge.target === hoveredId)) {
591
+ score += 70
592
+ }
593
+
594
+ const hubId = state.primaryHub?.id
595
+ if (hubId && (edge.source === hubId || edge.target === hubId)) {
596
+ score += 42
597
+ }
598
+
599
+ return score
600
+ }
601
+
602
+ const collectVisibleEdgesForNodes = nodeIds => {
603
+ if (nodeIds.size === 0) {
604
+ return []
605
+ }
606
+
607
+ const seen = new Set()
608
+ const candidates = []
609
+ const limit = edgeBudgetForCurrentFrame()
610
+
611
+ nodeIds.forEach(nodeId => {
612
+ const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
613
+ for (let index = 0; index < candidateEdges.length; index += 1) {
614
+ const edge = candidateEdges[index]
615
+ if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
616
+ continue
617
+ }
618
+ const key = edgeIdentityKey(edge)
619
+ if (seen.has(key)) continue
620
+
621
+ seen.add(key)
622
+ candidates.push(edge)
623
+ }
624
+ })
625
+
626
+ if (candidates.length <= limit) {
627
+ return candidates
628
+ }
629
+
630
+ return candidates
631
+ .sort((left, right) => {
632
+ const scoreDelta = edgeRelevanceScore(right) - edgeRelevanceScore(left)
633
+ if (scoreDelta !== 0) {
634
+ return scoreDelta
635
+ }
636
+ const leftKey = edgeIdentityKey(left)
637
+ const rightKey = edgeIdentityKey(right)
638
+ return leftKey.localeCompare(rightKey)
639
+ })
640
+ .slice(0, limit)
641
+ }
642
+
643
+ const edgeOpacityForScale = (edge, scale) => {
644
+ if (edge.inferred) {
645
+ if (scale < 0.2) return 0.06
646
+ if (scale < 0.4) return 0.08
647
+ if (scale < 0.7) return 0.1
648
+ return 0.14
649
+ }
650
+
651
+ if (scale < 0.2) return 0.14
652
+ if (scale < 0.4) return 0.2
653
+ if (scale < 0.7) return 0.28
654
+ if (scale < 1.05) return 0.36
655
+ return 0.46
656
+ }
657
+
658
+ const edgeStrokeFor = (edge, selectedEdge) => {
659
+ if (selectedEdge) {
660
+ return graphTheme.edgeActive
661
+ }
662
+
663
+ const opacity = edgeOpacityForScale(edge, state.transform.scale)
664
+ return edge.inferred
665
+ ? 'rgba(203, 213, 225, ' + opacity + ')'
666
+ : 'rgba(153, 165, 181, ' + opacity + ')'
667
+ }
668
+
669
+ const edgeWidthFor = (edge, selectedEdge) => {
670
+ if (edge.inferred) {
671
+ return selectedEdge ? 1.22 : 0.84
672
+ }
673
+
674
+ return (selectedEdge ? 1.9 : 1.05) + Math.min(edgeWeight(edge) - 1, 8) * 0.24
675
+ }
676
+
677
+ const drawGraphEdge = (edge) => {
678
+ const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
679
+ ctx.beginPath()
680
+ ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
681
+ ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
682
+ ctx.strokeStyle = edgeStrokeFor(edge, selectedEdge)
683
+ ctx.lineWidth = edgeWidthFor(edge, selectedEdge)
684
+ ctx.stroke()
685
+ }
686
+
687
+ const drawGraphEdges = () => {
688
+ const selectedEdges = []
689
+ const regularEdges = []
690
+ for (let index = 0; index < state.renderEdges.length; index += 1) {
691
+ const edge = state.renderEdges[index]
692
+ const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
693
+ if (isSelected) {
694
+ selectedEdges.push(edge)
695
+ } else {
696
+ regularEdges.push(edge)
697
+ }
698
+ }
699
+
700
+ for (let index = 0; index < regularEdges.length; index += 1) {
701
+ drawGraphEdge(regularEdges[index])
702
+ }
703
+ for (let index = 0; index < selectedEdges.length; index += 1) {
704
+ drawGraphEdge(selectedEdges[index])
705
+ }
706
+ }
707
+
708
+ const edgePairKey = (source, target) =>
709
+ source < target ? source + '|' + target : target + '|' + source
710
+
711
+ const meshNeighborBuckets = (nodes, cellSize) => {
712
+ const buckets = new Map()
713
+
714
+ for (let index = 0; index < nodes.length; index += 1) {
715
+ const node = nodes[index]
716
+ const cellX = Math.floor(node.x / cellSize)
717
+ const cellY = Math.floor(node.y / cellSize)
718
+ const key = cellX + ':' + cellY
719
+ const bucket = buckets.get(key)
720
+ if (bucket) {
721
+ bucket.push(node)
722
+ } else {
723
+ buckets.set(key, [node])
724
+ }
725
+ }
726
+
727
+ return buckets
728
+ }
729
+
730
+ const meshCandidatesForNode = (node, buckets, cellSize) => {
731
+ const cellX = Math.floor(node.x / cellSize)
732
+ const cellY = Math.floor(node.y / cellSize)
733
+ const candidates = []
734
+
735
+ for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
736
+ for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
737
+ const bucket = buckets.get((cellX + offsetX) + ':' + (cellY + offsetY))
738
+ if (!bucket) continue
739
+ for (let index = 0; index < bucket.length; index += 1) {
740
+ const candidate = bucket[index]
741
+ if (candidate.id !== node.id) {
742
+ candidates.push(candidate)
743
+ }
744
+ }
745
+ }
746
+ }
747
+
748
+ return candidates
749
+ }
750
+
751
+ const buildMeshEdgesForNodes = (nodes, existingEdges) => {
752
+ if (nodes.length < 2 || state.transform.scale < meshEdgeScaleThreshold) {
753
+ return []
754
+ }
755
+
756
+ const existingKeys = new Set()
757
+ for (let index = 0; index < existingEdges.length; index += 1) {
758
+ const edge = existingEdges[index]
759
+ if (edge.target) {
760
+ existingKeys.add(edgePairKey(edge.source, edge.target))
761
+ }
762
+ }
763
+
764
+ const desiredBudget = Math.min(
765
+ meshEdgeMaxBudget,
766
+ Math.max(meshEdgeMinBudget, Math.floor(edgeBudgetForCurrentFrame() * 0.62))
767
+ )
768
+ const perNodeNeighborCount =
769
+ state.transform.scale >= 1.05 ? 4
770
+ : state.transform.scale >= 0.62 ? 3
771
+ : 2
772
+ const cellSize = Math.max(120, 280 / Math.max(state.transform.scale, 0.0001))
773
+ const maxDistance = 980
774
+ const maxDistanceSquared = maxDistance * maxDistance
775
+ const buckets = meshNeighborBuckets(nodes, cellSize)
776
+ const meshEdges = []
777
+ const meshKeys = new Set()
778
+
779
+ for (let index = 0; index < nodes.length && meshEdges.length < desiredBudget; index += 1) {
780
+ const node = nodes[index]
781
+ const candidates = meshCandidatesForNode(node, buckets, cellSize)
782
+ .map((candidate) => ({
783
+ node: candidate,
784
+ distanceSquared: (candidate.x - node.x) ** 2 + (candidate.y - node.y) ** 2
785
+ }))
786
+ .filter((candidate) => candidate.distanceSquared <= maxDistanceSquared)
787
+ .sort((left, right) => left.distanceSquared - right.distanceSquared)
788
+
789
+ let linked = 0
790
+ for (let candidateIndex = 0; candidateIndex < candidates.length && linked < perNodeNeighborCount && meshEdges.length < desiredBudget; candidateIndex += 1) {
791
+ const candidate = candidates[candidateIndex].node
792
+ const key = edgePairKey(node.id, candidate.id)
793
+ if (existingKeys.has(key) || meshKeys.has(key)) {
794
+ continue
795
+ }
796
+
797
+ meshKeys.add(key)
798
+ meshEdges.push({
799
+ source: node.id,
800
+ target: candidate.id,
801
+ targetTitle: candidate.title,
802
+ weight: 1,
803
+ priority: 'normal',
804
+ sourceNode: node,
805
+ targetNode: candidate,
806
+ inferred: true
807
+ })
808
+ linked += 1
809
+ }
810
+ }
811
+
812
+ return meshEdges
813
+ }
814
+
815
+ const withMeshEdges = (nodes, edges) => {
816
+ if (nodes.length === 0 || state.visibleNodes.length <= largeGraphNodeThreshold || state.transform.scale < meshEdgeScaleThreshold) {
817
+ return edges
818
+ }
819
+
820
+ const meshEdges = buildMeshEdgesForNodes(nodes, edges)
821
+ return meshEdges.length > 0 ? edges.concat(meshEdges) : edges
822
+ }
823
+
824
+ const fallbackViewportNodes = () => {
825
+ const nodes = []
826
+ const maxNodes = Math.min(renderNodeBudget, 220)
827
+ const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
828
+
829
+ for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
830
+ nodes.push(state.visibleNodes[index])
831
+ }
832
+
833
+ if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
834
+ nodes.push(state.selected)
835
+ }
836
+
837
+ return nodes
838
+ }
839
+
840
+ const sampleVisibleNodes = (limit = renderNodeBudget, sourceNodes = state.visibleNodes) => {
841
+ if (sourceNodes.length === 0 || limit <= 0) {
842
+ return []
843
+ }
844
+
845
+ const nodes = []
846
+ const maxNodes = Math.min(Math.max(limit, 1), sourceNodes.length)
847
+ const step = Math.max(1, Math.ceil(sourceNodes.length / maxNodes))
848
+
849
+ for (let index = 0; index < sourceNodes.length && nodes.length < maxNodes; index += step) {
850
+ nodes.push(sourceNodes[index])
851
+ }
852
+
853
+ if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
854
+ nodes.push(state.selected)
855
+ }
856
+
857
+ return nodes
858
+ }
859
+
860
+ const enrichSampleWithNeighbors = (nodes) => {
861
+ if (nodes.length === 0) {
862
+ return {
863
+ nodes,
864
+ edges: []
865
+ }
866
+ }
867
+
868
+ const maxNodes = Math.min(renderNodeBudget, nodes.length + 200)
869
+ const expanded = [...nodes]
870
+ const ids = new Set(expanded.map((node) => node.id))
871
+
872
+ for (let index = 0; index < nodes.length && expanded.length < maxNodes; index += 1) {
873
+ const node = nodes[index]
874
+ const candidates = [...(state.visibleEdgeByNode.get(node.id) ?? [])]
875
+ .filter((edge) => edge.target)
876
+ .sort((left, right) => edgeWeight(right) - edgeWeight(left))
877
+ .slice(0, 3)
878
+
879
+ for (let candidateIndex = 0; candidateIndex < candidates.length && expanded.length < maxNodes; candidateIndex += 1) {
880
+ const edge = candidates[candidateIndex]
881
+ const otherId = edge.source === node.id ? edge.target : edge.source
882
+
883
+ if (!otherId || ids.has(otherId)) {
884
+ continue
885
+ }
886
+
887
+ const otherNode = state.nodeById.get(otherId)
888
+ if (!otherNode) {
889
+ continue
890
+ }
891
+
892
+ ids.add(otherId)
893
+ expanded.push(otherNode)
894
+ }
895
+ }
896
+
897
+ const edges = collectVisibleEdgesForNodes(ids)
898
+
899
+ return {
900
+ nodes: expanded,
901
+ edges
902
+ }
903
+ }
904
+
905
+ const ensureHubNodesInRenderedSet = (nodes) => {
906
+ if (nodes.length === 0) {
907
+ return nodes
908
+ }
909
+
910
+ const maxNodes = Math.max(Math.min(renderNodeBudget, nodes.length), 1)
911
+ const ids = new Set(nodes.map((node) => node.id))
912
+ const hubs = rankedHubNodes()
913
+ const merged = [...nodes]
914
+
915
+ for (let index = 0; index < hubs.length; index += 1) {
916
+ const hub = hubs[index]
917
+ if (ids.has(hub.id)) {
918
+ continue
919
+ }
920
+
921
+ if (merged.length < maxNodes) {
922
+ merged.push(hub)
923
+ ids.add(hub.id)
924
+ continue
925
+ }
926
+
927
+ const replacementIndex = merged.findIndex((node) => !hubs.some((candidate) => candidate.id === node.id))
928
+ if (replacementIndex >= 0) {
929
+ ids.delete(merged[replacementIndex].id)
930
+ merged[replacementIndex] = hub
931
+ ids.add(hub.id)
932
+ }
933
+ }
934
+
935
+ return merged
936
+ }
937
+
938
+ const zoomCapByNodeCount = (nodeCount) => {
939
+ if (nodeCount > 50000) return 2.6
940
+ if (nodeCount > 20000) return 2.35
941
+ if (nodeCount > 6000) return 2.1
942
+ if (nodeCount > 2000) return 2.2
943
+ return zoomRange.max
944
+ }
945
+
946
+ const zoomCapByHubDistance = (distance) => {
947
+ if (!Number.isFinite(distance) || distance <= 0) {
948
+ return zoomRange.max
949
+ }
104
950
 
105
- const resetView = () => {
106
951
  const rect = canvas.getBoundingClientRect()
107
- state.transform = { x: Math.max(rect.width, 320) / 2, y: Math.max(rect.height, 320) / 2, scale: 1 }
952
+ const viewportWidth = Math.max(rect.width, 320)
953
+ const viewportHeight = Math.max(rect.height, 320)
954
+ const reference = Math.max(220, Math.min(viewportWidth, viewportHeight) * zoomCapTargetViewportShare)
955
+ return Math.max(0.3, Math.min(zoomRange.max, reference / distance))
956
+ }
957
+
958
+ const currentZoomMax = () => {
959
+ const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
960
+ const hubDistanceCap = zoomCapByHubDistance(state.hubNeighborDistance)
961
+ const minimumUsefulCap = nodeCount > massiveGraphNodeThreshold ? 1.9 : nodeCount > largeGraphNodeThreshold ? 1.35 : 0.8
962
+ const capped = Math.min(zoomCapByNodeCount(nodeCount), Math.max(minimumUsefulCap, hubDistanceCap))
963
+ return Math.max(zoomRange.min * 2, capped)
964
+ }
965
+
966
+ const clampScale = value => Math.max(zoomRange.min, Math.min(currentZoomMax(), value))
967
+ const isFiniteNumber = value => Number.isFinite(value)
968
+ const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
969
+ const clampTransformCoordinate = value => {
970
+ if (!isFiniteNumber(value)) return 0
971
+ if (value > transformCoordinateLimit) return transformCoordinateLimit
972
+ if (value < -transformCoordinateLimit) return -transformCoordinateLimit
973
+ return value
974
+ }
975
+
976
+ const graphBounds = nodes => {
977
+ if (nodes.length === 0) return null
978
+ let minX = Number.POSITIVE_INFINITY
979
+ let maxX = Number.NEGATIVE_INFINITY
980
+ let minY = Number.POSITIVE_INFINITY
981
+ let maxY = Number.NEGATIVE_INFINITY
982
+
983
+ nodes.forEach(node => {
984
+ const radius = baseNodeRadius(node)
985
+ minX = Math.min(minX, node.x - radius)
986
+ maxX = Math.max(maxX, node.x + radius)
987
+ minY = Math.min(minY, node.y - radius)
988
+ maxY = Math.max(maxY, node.y + radius)
989
+ })
990
+
991
+ return {
992
+ minX,
993
+ maxX,
994
+ minY,
995
+ maxY,
996
+ width: Math.max(maxX - minX, 1),
997
+ height: Math.max(maxY - minY, 1)
998
+ }
999
+ }
1000
+
1001
+ const fitScaleBiasByNodeCount = nodeCount => {
1002
+ if (nodeCount <= 6) return 1.22
1003
+ if (nodeCount <= 20) return 1.12
1004
+ if (nodeCount <= 60) return 1.04
1005
+ if (nodeCount <= 180) return 1
1006
+ if (nodeCount <= 600) return 0.94
1007
+ if (nodeCount <= 2000) return 0.82
1008
+ if (nodeCount <= 6000) return 0.68
1009
+ return 0.56
108
1010
  }
109
1011
 
1012
+ const autoFitScaleRangeByNodeCount = nodeCount => {
1013
+ if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
1014
+ if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
1015
+ if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
1016
+ if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
1017
+ if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
1018
+ if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
1019
+ if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
1020
+ return { min: 0.0008, max: 0.24 }
1021
+ }
1022
+
1023
+ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
1024
+ const rect = canvas.getBoundingClientRect()
1025
+ const width = Math.max(rect.width, 320)
1026
+ const height = Math.max(rect.height, 320)
1027
+ const nodes = options.useFiltered ? filteredNodes() : state.nodes
1028
+ const bounds = graphBounds(nodes)
1029
+
1030
+ if (!bounds) {
1031
+ state.transform = { x: width / 2, y: height / 2, scale: 1 }
1032
+ state.offscreenFrameCount = 0
1033
+ state.recoveringViewport = false
1034
+ markRenderDirty()
1035
+ return
1036
+ }
1037
+
1038
+ const paddingByNodeCount = nodeCount => {
1039
+ if (nodeCount <= 6) return 28
1040
+ if (nodeCount <= 20) return 44
1041
+ if (nodeCount <= 60) return 68
1042
+ if (nodeCount <= 180) return 86
1043
+ if (nodeCount <= 600) return 110
1044
+ if (nodeCount <= 2000) return 140
1045
+ return 180
1046
+ }
1047
+ const padding = paddingByNodeCount(nodes.length)
1048
+ const scaleX = width / (bounds.width + padding * 2)
1049
+ const scaleY = height / (bounds.height + padding * 2)
1050
+ const fitScale = Math.min(scaleX, scaleY)
1051
+ const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
1052
+ const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
1053
+ const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
1054
+ const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
1055
+ const scale = options.macro && nodes.length > 1
1056
+ ? clampScale(Math.min(baselineScale, macroScale))
1057
+ : nodes.length > massiveGraphNodeThreshold
1058
+ ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
1059
+ : baselineScale
1060
+ const hubCenter =
1061
+ options.preferHubCenter && state.primaryHub && nodes.some((node) => node.id === state.primaryHub.id)
1062
+ ? state.primaryHub
1063
+ : null
1064
+ const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
1065
+ const centerY = hubCenter ? hubCenter.y : (bounds.minY + bounds.maxY) / 2
1066
+
1067
+ state.transform = {
1068
+ x: clampTransformCoordinate(width / 2 - centerX * scale),
1069
+ y: clampTransformCoordinate(height / 2 - centerY * scale),
1070
+ scale: clampScale(scale)
1071
+ }
1072
+ state.offscreenFrameCount = 0
1073
+ state.recoveringViewport = false
1074
+ markRenderDirty()
1075
+ }
1076
+
1077
+ const resetView = () => fitView({ useFiltered: false, macro: true, preferHubCenter: true })
1078
+
110
1079
  const createLayout = graph => {
111
- const nodes = graph.nodes.map(node => ({
112
- ...node,
113
- x: Number.isFinite(node.x) ? node.x : 0,
114
- y: Number.isFinite(node.y) ? node.y : 0
115
- }))
1080
+ const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
1081
+ const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
1082
+ const nodes = nodeRows.map(node => {
1083
+ if (Array.isArray(node)) {
1084
+ const [id, title, x, y, group, segment] = node
1085
+ return {
1086
+ id: typeof id === 'string' ? id : '',
1087
+ title: typeof title === 'string' ? title : 'Untitled',
1088
+ path: '',
1089
+ tags: [],
1090
+ group: typeof group === 'string' ? group : 'root',
1091
+ segment: typeof segment === 'string' ? segment : 'root',
1092
+ x: Number.isFinite(x) ? x : 0,
1093
+ y: Number.isFinite(y) ? y : 0,
1094
+ vx: 0,
1095
+ vy: 0
1096
+ }
1097
+ }
1098
+
1099
+ return {
1100
+ ...node,
1101
+ path: typeof node.path === 'string' ? node.path : '',
1102
+ tags: Array.isArray(node.tags) ? node.tags : [],
1103
+ x: Number.isFinite(node.x) ? node.x : 0,
1104
+ y: Number.isFinite(node.y) ? node.y : 0,
1105
+ vx: Number.isFinite(node.vx) ? node.vx : 0,
1106
+ vy: Number.isFinite(node.vy) ? node.vy : 0
1107
+ }
1108
+ })
116
1109
  const nodeMap = new Map(nodes.map(node => [node.id, node]))
117
- const edges = graph.edges
1110
+ const edges = edgeRows
1111
+ .map(edge => {
1112
+ if (Array.isArray(edge)) {
1113
+ const [source, target, weight, priority] = edge
1114
+ return {
1115
+ source: typeof source === 'string' ? source : '',
1116
+ target: typeof target === 'string' ? target : null,
1117
+ targetTitle: '',
1118
+ weight: Number.isFinite(weight) ? weight : 1,
1119
+ priority: typeof priority === 'string' ? priority : 'normal'
1120
+ }
1121
+ }
1122
+ return edge
1123
+ })
118
1124
  .filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
119
1125
  .map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
120
1126
  return { nodes, edges }
@@ -128,29 +1134,107 @@ const encodeEntityTag = (value) => {
128
1134
  binary += String.fromCharCode(utf8[index])
129
1135
  }
130
1136
 
131
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
1137
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
132
1138
  }
133
1139
 
134
1140
  const graphSignature = graph => JSON.stringify({
135
- nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.content, node.tags]),
1141
+ nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
136
1142
  edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
137
1143
  })
138
1144
 
1145
+ const resetContentFilter = () => {
1146
+ if (state.contentFilter.timer) {
1147
+ clearTimeout(state.contentFilter.timer)
1148
+ }
1149
+ state.contentFilter = {
1150
+ query: '',
1151
+ ids: null,
1152
+ token: state.contentFilter.token + 1,
1153
+ timer: null
1154
+ }
1155
+ recomputeVisibility()
1156
+ }
1157
+
1158
+ const syncContentFilter = async (query, token) => {
1159
+ const response = await fetch(
1160
+ '/api/graph-filter?q=' +
1161
+ encodeURIComponent(query) +
1162
+ '&limit=' +
1163
+ encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
1164
+ agentQuery('&')
1165
+ )
1166
+
1167
+ if (!response.ok || token !== state.contentFilter.token) {
1168
+ return
1169
+ }
1170
+
1171
+ const payload = await response.json()
1172
+ const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
1173
+ if (token !== state.contentFilter.token) {
1174
+ return
1175
+ }
1176
+
1177
+ state.contentFilter.query = query
1178
+ const merged = new Set([...(state.contentFilter.ids instanceof Set ? state.contentFilter.ids : []), ...nodeIds])
1179
+ state.contentFilter.ids = merged
1180
+ recomputeVisibility()
1181
+ }
1182
+
1183
+ const scheduleContentFilterSync = () => {
1184
+ const query = normalizeQuery(state.query)
1185
+ if (!query) {
1186
+ resetContentFilter()
1187
+ return
1188
+ }
1189
+
1190
+ if (state.contentFilter.timer) {
1191
+ clearTimeout(state.contentFilter.timer)
1192
+ }
1193
+
1194
+ const token = state.contentFilter.token + 1
1195
+ state.contentFilter = {
1196
+ query: state.contentFilter.query,
1197
+ ids: state.contentFilter.ids,
1198
+ token,
1199
+ timer: setTimeout(() => {
1200
+ if (state.filterWorker && state.filterReady) {
1201
+ state.filterWorker.postMessage({
1202
+ type: 'filter',
1203
+ query,
1204
+ token,
1205
+ limit: Math.max(state.nodes.length, 1)
1206
+ })
1207
+ }
1208
+ syncContentFilter(query, token).catch(() => {})
1209
+ }, 180)
1210
+ }
1211
+ }
1212
+
139
1213
  const tick = delta => {
140
- const nodes = filteredNodes()
141
- const ids = new Set(nodes.map(node => node.id))
142
- const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
1214
+ const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
1215
+ const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
1216
+ const shouldRunPhysics =
1217
+ state.nodes.length <= 8000 &&
1218
+ nodes.length <= 320 &&
1219
+ state.transform.scale >= 0.08
1220
+ if (!shouldRunPhysics) {
1221
+ return
1222
+ }
143
1223
  const strength = Math.min(delta / 16, 2)
144
1224
 
145
1225
  edges.forEach(edge => {
146
1226
  const source = edge.sourceNode
147
1227
  const target = edge.targetNode
1228
+ source.vx = Number.isFinite(source.vx) ? source.vx : 0
1229
+ source.vy = Number.isFinite(source.vy) ? source.vy : 0
1230
+ target.vx = Number.isFinite(target.vx) ? target.vx : 0
1231
+ target.vy = Number.isFinite(target.vy) ? target.vy : 0
148
1232
  const dx = target.x - source.x
149
1233
  const dy = target.y - source.y
150
1234
  const distance = Math.max(Math.hypot(dx, dy), 1)
151
1235
  const force = (distance - 150) * 0.002 * strength
152
- const fx = dx * force
153
- const fy = dy * force
1236
+ const fx = (dx / distance) * force
1237
+ const fy = (dy / distance) * force
154
1238
  source.vx += fx
155
1239
  source.vy += fy
156
1240
  target.vx -= fx
@@ -161,6 +1245,10 @@ const tick = delta => {
161
1245
  for (let j = i + 1; j < nodes.length; j += 1) {
162
1246
  const a = nodes[i]
163
1247
  const b = nodes[j]
1248
+ a.vx = Number.isFinite(a.vx) ? a.vx : 0
1249
+ a.vy = Number.isFinite(a.vy) ? a.vy : 0
1250
+ b.vx = Number.isFinite(b.vx) ? b.vx : 0
1251
+ b.vy = Number.isFinite(b.vy) ? b.vy : 0
164
1252
  const dx = b.x - a.x
165
1253
  const dy = b.y - a.y
166
1254
  const distance = Math.max(Math.hypot(dx, dy), 1)
@@ -175,6 +1263,10 @@ const tick = delta => {
175
1263
  }
176
1264
 
177
1265
  nodes.forEach(node => {
1266
+ node.vx = Number.isFinite(node.vx) ? node.vx : 0
1267
+ node.vy = Number.isFinite(node.vy) ? node.vy : 0
1268
+ node.x = Number.isFinite(node.x) ? node.x : 0
1269
+ node.y = Number.isFinite(node.y) ? node.y : 0
178
1270
  if (state.pointer.dragNode === node) {
179
1271
  node.vx = 0
180
1272
  node.vy = 0
@@ -198,7 +1290,20 @@ const worldPoint = event => {
198
1290
  }
199
1291
 
200
1292
  const hitNode = point => {
201
- const nodes = filteredNodes()
1293
+ computeRenderVisibility()
1294
+ if (state.renderClusters.length > 0) {
1295
+ return null
1296
+ }
1297
+ const hitScaleFloor = state.nodes.length > massiveGraphNodeThreshold
1298
+ ? 0.2
1299
+ : state.nodes.length > largeGraphNodeThreshold
1300
+ ? 0.34
1301
+ : 0
1302
+ if (state.transform.scale < hitScaleFloor) {
1303
+ return null
1304
+ }
1305
+
1306
+ const nodes = state.renderNodes
202
1307
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
203
1308
  const node = nodes[index]
204
1309
  const radius = nodeRadius(node)
@@ -207,17 +1312,303 @@ const hitNode = point => {
207
1312
  return null
208
1313
  }
209
1314
 
210
- const nodeRadius = node => {
211
- const degree = state.edges.filter(edge => edge.source === node.id || edge.target === node.id).length
1315
+ const baseNodeRadius = node => {
1316
+ const degree = state.nodeDegrees.get(node.id) ?? 0
212
1317
  return 9 + Math.min(degree, 8) * 1.6
213
1318
  }
214
1319
 
1320
+ const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
1321
+
1322
+ const worldViewportBounds = () => {
1323
+ const rect = canvas.getBoundingClientRect()
1324
+ const width = Math.max(rect.width, 320)
1325
+ const height = Math.max(rect.height, 320)
1326
+ const padding = viewportPaddingPx
1327
+
1328
+ return {
1329
+ minX: (-state.transform.x - padding) / state.transform.scale,
1330
+ maxX: (width - state.transform.x + padding) / state.transform.scale,
1331
+ minY: (-state.transform.y - padding) / state.transform.scale,
1332
+ maxY: (height - state.transform.y + padding) / state.transform.scale
1333
+ }
1334
+ }
1335
+
1336
+ const isNodeInViewport = (node, viewport) =>
1337
+ node.x >= viewport.minX &&
1338
+ node.x <= viewport.maxX &&
1339
+ node.y >= viewport.minY &&
1340
+ node.y <= viewport.maxY
1341
+
1342
+ const viewportNodeStride = () => {
1343
+ if (state.nodes.length <= largeGraphNodeThreshold) {
1344
+ return 1
1345
+ }
1346
+
1347
+ if (state.transform.scale >= 0.95) {
1348
+ return 1
1349
+ }
1350
+ if (state.transform.scale >= 0.7) {
1351
+ return 2
1352
+ }
1353
+ if (state.transform.scale >= 0.48) {
1354
+ return 3
1355
+ }
1356
+ if (state.transform.scale >= 0.28) {
1357
+ return 5
1358
+ }
1359
+
1360
+ return 8
1361
+ }
1362
+
1363
+ const shouldRenderClusters = viewportNodes =>
1364
+ state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
1365
+
1366
+ const clusterViewportNodes = viewportNodes => {
1367
+ if (!shouldRenderClusters(viewportNodes)) {
1368
+ return []
1369
+ }
1370
+
1371
+ const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
1372
+ const buckets = new Map()
1373
+
1374
+ for (let index = 0; index < viewportNodes.length; index += 1) {
1375
+ const node = viewportNodes[index]
1376
+ const keyX = Math.floor(node.x / worldCellSize)
1377
+ const keyY = Math.floor(node.y / worldCellSize)
1378
+ const key = keyX + ':' + keyY
1379
+ const current = buckets.get(key)
1380
+ if (current) {
1381
+ current.count += 1
1382
+ current.sumX += node.x
1383
+ current.sumY += node.y
1384
+ if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
1385
+ current.representative = node
1386
+ current.degree = state.nodeDegrees.get(node.id) ?? 0
1387
+ }
1388
+ continue
1389
+ }
1390
+
1391
+ buckets.set(key, {
1392
+ id: key,
1393
+ count: 1,
1394
+ sumX: node.x,
1395
+ sumY: node.y,
1396
+ representative: node,
1397
+ degree: state.nodeDegrees.get(node.id) ?? 0
1398
+ })
1399
+ }
1400
+
1401
+ return Array.from(buckets.values())
1402
+ .sort((left, right) => right.count - left.count)
1403
+ .slice(0, Math.min(renderNodeBudget, 900))
1404
+ .map((cluster) => ({
1405
+ id: cluster.id,
1406
+ x: cluster.sumX / Math.max(cluster.count, 1),
1407
+ y: cluster.sumY / Math.max(cluster.count, 1),
1408
+ count: cluster.count,
1409
+ representative: cluster.representative
1410
+ }))
1411
+ }
1412
+
1413
+ const computeRenderVisibility = () => {
1414
+ if (!hasValidTransform()) {
1415
+ fitView({ useFiltered: true })
1416
+ }
1417
+ const viewport = worldViewportBounds()
1418
+ const viewportKey =
1419
+ Math.round(viewport.minX * 10) + ':' +
1420
+ Math.round(viewport.maxX * 10) + ':' +
1421
+ Math.round(viewport.minY * 10) + ':' +
1422
+ Math.round(viewport.maxY * 10) + ':' +
1423
+ Math.round(state.transform.scale * 1000)
1424
+
1425
+ if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
1426
+ return
1427
+ }
1428
+ state.lastViewportKey = viewportKey
1429
+ state.renderVisibilityDirty = false
1430
+
1431
+ const shouldRenderMacroGalaxy =
1432
+ state.transform.scale <= macroGalaxyZoomThreshold && state.visibleNodes.length > 1
1433
+
1434
+ if (shouldRenderMacroGalaxy) {
1435
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
1436
+ const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
1437
+ const representative = state.primaryHub ?? state.macroRepresentative ?? sourceNodes[0] ?? null
1438
+ if (representative) {
1439
+ state.renderClusters = [
1440
+ {
1441
+ id: 'macro-galaxy',
1442
+ x: state.macroCenter.x,
1443
+ y: state.macroCenter.y,
1444
+ count: sourceNodes.length,
1445
+ representative
1446
+ }
1447
+ ]
1448
+ state.renderNodes = [representative]
1449
+ } else {
1450
+ state.renderClusters = []
1451
+ state.renderNodes = []
1452
+ }
1453
+ state.renderEdges = []
1454
+ return
1455
+ }
1456
+
1457
+ if (state.visibleNodes.length <= 2000) {
1458
+ state.renderNodes = state.visibleNodes
1459
+ state.renderClusters = []
1460
+ const ids = new Set(state.renderNodes.map((node) => node.id))
1461
+ state.renderEdges = withMeshEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
1462
+ return
1463
+ }
1464
+
1465
+ if (state.visibleNodes.length > massiveGraphNodeThreshold) {
1466
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
1467
+ const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
1468
+ const sampleLimit = nodeBudgetForScale(state.transform.scale)
1469
+ const sampled = sourceNodes.length > sampleLimit
1470
+ ? sampleVisibleNodes(Math.min(sampleLimit, renderNodeBudget), sourceNodes)
1471
+ : sourceNodes.slice(0, Math.min(sourceNodes.length, renderNodeBudget))
1472
+ const sampledIds = new Set(sampled.map((node) => node.id))
1473
+ let sampledEdges = state.transform.scale >= 0.035 ? collectVisibleEdgesForNodes(sampledIds) : []
1474
+ let sampledNodes = ensureHubNodesInRenderedSet(sampled)
1475
+
1476
+ if (state.transform.scale >= 0.035 && sampledEdges.length === 0) {
1477
+ const enriched = enrichSampleWithNeighbors(sampledNodes)
1478
+ sampledNodes = ensureHubNodesInRenderedSet(enriched.nodes)
1479
+ const sampledWithHubsIds = new Set(sampledNodes.map((node) => node.id))
1480
+ sampledEdges = collectVisibleEdgesForNodes(sampledWithHubsIds)
1481
+ }
1482
+
1483
+ state.renderClusters = []
1484
+ state.renderNodes = sampledNodes
1485
+ state.renderEdges = withMeshEdges(sampledNodes, sampledEdges)
1486
+ return
1487
+ }
1488
+
1489
+ if (state.transform.scale <= 0.0015) {
1490
+ const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
1491
+ const sampledIds = new Set(sampled.map((node) => node.id))
1492
+ state.renderClusters = []
1493
+ state.renderNodes = sampled
1494
+ state.renderEdges = withMeshEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
1495
+ return
1496
+ }
1497
+
1498
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
1499
+ const clusters = clusterViewportNodes(viewportNodes)
1500
+ if (clusters.length > 0) {
1501
+ state.renderClusters = clusters
1502
+ state.renderNodes = clusters.map(cluster => cluster.representative)
1503
+ state.renderEdges = []
1504
+ return
1505
+ }
1506
+ state.renderClusters = []
1507
+ const stride = viewportNodeStride()
1508
+ const picked = []
1509
+
1510
+ for (let index = 0; index < viewportNodes.length; index += 1) {
1511
+ const node = viewportNodes[index]
1512
+
1513
+ const isPriority =
1514
+ node.id === state.selected?.id ||
1515
+ node.id === state.hovered?.id ||
1516
+ node.id === state.pointer.dragNode?.id
1517
+ if (isPriority || index % stride === 0) {
1518
+ picked.push(node)
1519
+ }
1520
+ }
1521
+
1522
+ const nodes = picked.length > renderNodeBudget
1523
+ ? picked.slice(0, renderNodeBudget)
1524
+ : picked
1525
+ if (nodes.length === 0 && state.visibleNodes.length > 0) {
1526
+ const fallbackNodes = fallbackViewportNodes()
1527
+ const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
1528
+ state.renderNodes = fallbackNodes
1529
+ state.renderClusters = []
1530
+ state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
1531
+ return
1532
+ }
1533
+
1534
+ const normalizedNodes = ensureHubNodesInRenderedSet(nodes)
1535
+ const nodeIds = new Set(normalizedNodes.map((node) => node.id))
1536
+ const edges = collectVisibleEdgesForNodes(nodeIds)
1537
+
1538
+ state.renderNodes = normalizedNodes
1539
+ state.renderEdges = withMeshEdges(normalizedNodes, edges)
1540
+
1541
+ if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
1542
+ const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
1543
+ const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
1544
+ state.renderClusters = []
1545
+ state.renderNodes = fallbackNodes
1546
+ state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
1547
+ }
1548
+ }
1549
+
1550
+ const isNodeVisibleOnScreen = (node, width, height) => {
1551
+ const radius = nodeRadius(node) * state.transform.scale
1552
+ const screenX = node.x * state.transform.scale + state.transform.x
1553
+ const screenY = node.y * state.transform.scale + state.transform.y
1554
+
1555
+ return (
1556
+ screenX + radius >= 0 &&
1557
+ screenX - radius <= width &&
1558
+ screenY + radius >= 0 &&
1559
+ screenY - radius <= height
1560
+ )
1561
+ }
1562
+
1563
+ const hasValidTransform = () =>
1564
+ isFiniteNumber(state.transform.x) &&
1565
+ isFiniteNumber(state.transform.y) &&
1566
+ isFiniteNumber(state.transform.scale) &&
1567
+ Math.abs(state.transform.x) <= transformCoordinateLimit &&
1568
+ Math.abs(state.transform.y) <= transformCoordinateLimit &&
1569
+ state.transform.scale > 0
1570
+
1571
+ const sanitizeNodePosition = node => {
1572
+ if (!isReasonableCoordinate(node.x)) node.x = 0
1573
+ if (!isReasonableCoordinate(node.y)) node.y = 0
1574
+ if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
1575
+ if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
1576
+ }
1577
+
1578
+ const sanitizeAllNodePositions = () => {
1579
+ state.nodes.forEach(sanitizeNodePosition)
1580
+ state.visibleNodes.forEach(sanitizeNodePosition)
1581
+ }
1582
+
1583
+ const sanitizeGraphState = () => {
1584
+ state.renderNodes.forEach(sanitizeNodePosition)
1585
+ }
1586
+
215
1587
  const render = now => {
216
1588
  const delta = now - state.last
217
1589
  state.last = now
1590
+ const backgroundFrameIntervalMs =
1591
+ state.nodes.length > massiveGraphNodeThreshold
1592
+ ? (state.transform.scale < 0.035 ? 130 : state.transform.scale < 0.08 ? 110 : 86)
1593
+ : state.nodes.length > largeGraphNodeThreshold
1594
+ ? 64
1595
+ : 16
1596
+ const isInteracting =
1597
+ state.pointer.down ||
1598
+ state.renderVisibilityDirty ||
1599
+ state.recoveringViewport
1600
+ const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
1601
+ if (delta < minFrameIntervalMs) {
1602
+ requestAnimationFrame(render)
1603
+ return
1604
+ }
218
1605
  const rect = canvas.getBoundingClientRect()
219
1606
  const width = Math.max(rect.width, 320)
220
1607
  const height = Math.max(rect.height, 320)
1608
+ sanitizeGraphState()
1609
+ if (!hasValidTransform()) {
1610
+ resetView()
1611
+ }
221
1612
  ctx.clearRect(0, 0, width, height)
222
1613
  if (state.nodes.length === 0) {
223
1614
  ctx.fillStyle = '#99a5b5'
@@ -231,17 +1622,69 @@ const render = now => {
231
1622
  ctx.translate(state.transform.x, state.transform.y)
232
1623
  ctx.scale(state.transform.scale, state.transform.scale)
233
1624
 
234
- visibleEdges().forEach(edge => {
235
- const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
236
- ctx.beginPath()
237
- ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
238
- ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
239
- ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
240
- ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
241
- ctx.stroke()
242
- })
1625
+ computeRenderVisibility()
1626
+ tick(delta)
1627
+ const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
1628
+ const manualZoomGuardActive = now - state.lastManualZoomAt < zoomRecoveryGuardMs
1629
+ if (!hasVisibleNodeOnScreen && state.renderNodes.length > 0 && !manualZoomGuardActive) {
1630
+ state.offscreenFrameCount += 1
1631
+ if (state.offscreenFrameCount >= 6 && !state.recoveringViewport) {
1632
+ state.recoveringViewport = true
1633
+ fitView({ useFiltered: true })
1634
+ state.offscreenFrameCount = 0
1635
+ requestAnimationFrame(() => {
1636
+ state.recoveringViewport = false
1637
+ })
1638
+ }
1639
+ } else {
1640
+ state.offscreenFrameCount = 0
1641
+ }
1642
+ const minimumEdgeScale =
1643
+ state.renderNodes.length > 1300
1644
+ ? 0.12
1645
+ : state.renderNodes.length > 900
1646
+ ? 0.085
1647
+ : state.renderNodes.length > 500
1648
+ ? 0.05
1649
+ : 0
1650
+ const drawEdges =
1651
+ state.renderClusters.length === 0 &&
1652
+ state.transform.scale >= minimumEdgeScale
1653
+ if (drawEdges) {
1654
+ drawGraphEdges()
1655
+ }
243
1656
 
244
- filteredNodes().forEach(node => {
1657
+ if (state.renderClusters.length > 0) {
1658
+ const safeScale = Math.max(state.transform.scale, 0.0001)
1659
+ state.renderClusters.forEach(cluster => {
1660
+ const isMacro = cluster.id === 'macro-galaxy'
1661
+ const radiusPx = isMacro
1662
+ ? 10
1663
+ : Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
1664
+ const radius = radiusPx / safeScale
1665
+ const haloRadius = (radiusPx + (isMacro ? 8 : 4)) / safeScale
1666
+ ctx.beginPath()
1667
+ ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
1668
+ ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
1669
+ ctx.fill()
1670
+ ctx.beginPath()
1671
+ ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
1672
+ ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
1673
+ ctx.fill()
1674
+ ctx.lineWidth = 1.4 / safeScale
1675
+ ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
1676
+ ctx.stroke()
1677
+ if (isMacro && cluster.representative?.title) {
1678
+ ctx.fillStyle = '#edf2f7'
1679
+ ctx.font = 12 / safeScale + 'px Inter, system-ui, sans-serif'
1680
+ ctx.textAlign = 'center'
1681
+ ctx.textBaseline = 'top'
1682
+ ctx.fillText(cluster.representative.title.slice(0, 28), cluster.x, cluster.y + (radiusPx + 9) / safeScale)
1683
+ }
1684
+ // Keep cluster markers minimal and faster to draw on large graphs.
1685
+ })
1686
+ } else {
1687
+ state.renderNodes.forEach(node => {
245
1688
  const radius = nodeRadius(node)
246
1689
  const isSelected = state.selected?.id === node.id
247
1690
  const isHovered = state.hovered?.id === node.id
@@ -257,16 +1700,28 @@ const render = now => {
257
1700
  ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
258
1701
  ctx.stroke()
259
1702
 
260
- if (isSelected || isHovered || state.transform.scale > 1.18 || state.nodes.length <= 25) {
1703
+ const shouldDrawLabels =
1704
+ isSelected ||
1705
+ isHovered ||
1706
+ (state.nodes.length > largeGraphNodeThreshold && state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) ||
1707
+ (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
1708
+ if (shouldDrawLabels) {
261
1709
  ctx.fillStyle = graphTheme.label
262
1710
  ctx.font = '12px Inter, system-ui, sans-serif'
263
1711
  ctx.textAlign = 'center'
264
1712
  ctx.textBaseline = 'top'
265
1713
  ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
266
1714
  }
267
- })
1715
+ })
1716
+ }
268
1717
 
269
1718
  ctx.restore()
1719
+ if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
1720
+ ctx.fillStyle = '#99a5b5'
1721
+ ctx.font = '12px Inter, system-ui, sans-serif'
1722
+ ctx.textAlign = 'center'
1723
+ ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
1724
+ }
270
1725
  requestAnimationFrame(render)
271
1726
  }
272
1727
 
@@ -274,88 +1729,209 @@ const list = items => items.length
274
1729
  ? 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('')
275
1730
  : '<li><small>No links found.</small></li>'
276
1731
 
277
- const allNotesList = () => state.nodes.length
278
- ? 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('')
279
- : '<li><small>No notes indexed.</small></li>'
280
-
281
- const selectNode = node => {
282
- state.selected = node
283
- if (!node) {
284
- elements.title.textContent = 'Graph Overview'
285
- elements.path.textContent = state.nodes.length + ' notes and ' + state.graph.edges.length + ' links indexed.'
286
- elements.tags.innerHTML = ''
287
- elements.notes.innerHTML = allNotesList()
288
- elements.content.textContent = 'Selecione uma nota no grafo ou na lista para ver o Markdown completo, backlinks e links de saida.'
289
- elements.outgoing.innerHTML = '<li><small>Select a note to inspect outgoing links.</small></li>'
290
- elements.incoming.innerHTML = '<li><small>Select a note to inspect backlinks.</small></li>'
291
- return
292
- }
1732
+ const linkedNodes = node => {
293
1733
  const nodeById = new Map(state.nodes.map(item => [item.id, item]))
294
1734
  const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
295
1735
  ...linkedNode,
296
1736
  weight: edge.weight,
297
1737
  priority: edge.priority
298
1738
  } : null
299
- const outgoing = state.graph.edges
1739
+ const outgoing = state.edges
300
1740
  .filter(edge => edge.source === node.id)
301
- .map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' }, edge))
1741
+ .map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
302
1742
  .filter(Boolean)
303
- const incoming = state.graph.edges
1743
+ const incoming = state.edges
304
1744
  .filter(edge => edge.target === node.id)
305
1745
  .map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
306
1746
  .filter(Boolean)
307
1747
 
308
- elements.title.textContent = node.title
309
- elements.path.textContent = node.path
310
- elements.tags.innerHTML = node.tags.length
1748
+ return { outgoing, incoming }
1749
+ }
1750
+
1751
+ const fetchNodeDetails = async node => {
1752
+ const cached = state.nodeDetails.get(node.id)
1753
+ if (cached) {
1754
+ return cached
1755
+ }
1756
+
1757
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
1758
+ if (!response.ok) {
1759
+ throw new Error('Failed to load graph node details')
1760
+ }
1761
+
1762
+ const payload = await response.json()
1763
+ const detail = payload?.node
1764
+ if (!detail || !detail.id) {
1765
+ throw new Error('Invalid graph node payload')
1766
+ }
1767
+ state.nodeDetails.set(detail.id, detail)
1768
+ return detail
1769
+ }
1770
+
1771
+ const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
1772
+
1773
+ const openContentDialog = async node => {
1774
+ if (!node) return
1775
+ elements.contentTitle.textContent = node.title || 'Loading...'
1776
+ elements.contentPath.textContent = node.path || 'Loading...'
1777
+ elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
311
1778
  ? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
312
1779
  : '<span>No tags</span>'
313
- elements.notes.innerHTML = allNotesList()
314
- elements.content.textContent = node.content
315
- elements.outgoing.innerHTML = list(outgoing)
316
- elements.incoming.innerHTML = list(incoming)
1780
+ const initialLinks = linkedNodes(node)
1781
+ elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
1782
+ elements.contentIncoming.innerHTML = list(initialLinks.incoming)
1783
+ elements.contentBody.textContent = 'Loading note content...'
1784
+ if (!elements.contentDialog.open) {
1785
+ elements.contentDialog.showModal()
1786
+ }
1787
+
1788
+ const applyDetailToDialog = detail => {
1789
+ elements.contentTitle.textContent = detail.title
1790
+ elements.contentPath.textContent = detail.path
1791
+ elements.contentTags.innerHTML = detail.tags.length
1792
+ ? detail.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
1793
+ : '<span>No tags</span>'
1794
+ elements.contentBody.textContent = detail.content
1795
+ }
1796
+
1797
+ try {
1798
+ const detailedNode = await fetchNodeDetails(node)
1799
+ if (state.selected?.id !== node.id) {
1800
+ return
1801
+ }
1802
+ applyDetailToDialog(detailedNode)
1803
+ } catch {
1804
+ try {
1805
+ await wait(120)
1806
+ const retriedNode = await fetchNodeDetails(node)
1807
+ if (state.selected?.id !== node.id) {
1808
+ return
1809
+ }
1810
+ applyDetailToDialog(retriedNode)
1811
+ } catch {
1812
+ elements.contentBody.textContent = 'Unable to load note content.'
1813
+ }
1814
+ }
1815
+ }
1816
+
1817
+ const selectNode = (node, options = { openContent: false }) => {
1818
+ state.selected = node
1819
+ if (node && options.openContent) {
1820
+ openContentDialog(node).catch(() => {
1821
+ elements.contentBody.textContent = 'Unable to load note content.'
1822
+ })
1823
+ }
317
1824
  }
318
1825
 
319
1826
  const selectNodeById = id => {
320
1827
  const node = state.nodes.find(item => item.id === id)
321
- if (node) selectNode(node)
1828
+ if (node) selectNode(node, { openContent: true })
1829
+ }
1830
+
1831
+ const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
1832
+ if (source === 'wheel') {
1833
+ state.lastManualZoomAt = performance.now()
1834
+ }
1835
+ const nextScale = clampScale(state.transform.scale * factor)
1836
+ if (nextScale === state.transform.scale) {
1837
+ return
1838
+ }
1839
+ const worldX = (screenX - state.transform.x) / state.transform.scale
1840
+ const worldY = (screenY - state.transform.y) / state.transform.scale
1841
+ state.transform.scale = clampScale(nextScale)
1842
+ state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
1843
+ state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
1844
+ state.offscreenFrameCount = 0
1845
+ markRenderDirty()
1846
+ }
1847
+
1848
+ const wheelZoomFactor = event => {
1849
+ const isModifierZoom = event.metaKey || event.ctrlKey
1850
+ const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
1851
+ const absoluteDelta = Math.min(Math.abs(event.deltaY * deltaModeFactor), 1600)
1852
+
1853
+ if (absoluteDelta <= 0.0001) {
1854
+ return 1
1855
+ }
1856
+
1857
+ const baseStep = Math.max(0.06, Math.min(0.45, absoluteDelta / 480))
1858
+ const adjustedStep = baseStep * (isModifierZoom ? 1.4 : 1)
1859
+
1860
+ return event.deltaY < 0 ? 1 + adjustedStep : 1 / (1 + adjustedStep)
322
1861
  }
323
1862
 
324
- const zoom = factor => {
325
- state.transform.scale = Math.max(0.25, Math.min(3.5, state.transform.scale * factor))
1863
+ const handleWheelZoom = event => {
1864
+ if (elements.contentDialog?.open) {
1865
+ return
1866
+ }
1867
+
1868
+ event.preventDefault()
1869
+ const rect = canvas.getBoundingClientRect()
1870
+ const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
1871
+ const rawCursorY = Number.isFinite(event.offsetY) ? event.offsetY : event.clientY - rect.top
1872
+ const cursorX = Math.max(0, Math.min(Math.max(rect.width, 320), rawCursorX))
1873
+ const cursorY = Math.max(0, Math.min(Math.max(rect.height, 320), rawCursorY))
1874
+ const factor = wheelZoomFactor(event)
1875
+
1876
+ if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
1877
+ return
1878
+ }
1879
+
1880
+ zoomAtPoint(cursorX, cursorY, factor, 'wheel')
326
1881
  }
327
1882
 
328
1883
  const bindEvents = () => {
329
1884
  window.addEventListener('resize', resize)
330
1885
  elements.search.addEventListener('input', event => {
331
1886
  state.query = event.target.value
332
- elements.stats.textContent = state.query
333
- ? filteredNodes().length + ' filtered notes'
334
- : state.nodes.length + ' notes · ' + state.edges.length + ' links'
1887
+ recomputeVisibility()
1888
+ scheduleContentFilterSync()
335
1889
  })
336
1890
  elements.agent.addEventListener('change', event => {
337
1891
  state.agentId = event.target.value
1892
+ writeStoredAgent(state.agentId)
1893
+ syncAgentInUrl(state.agentId)
338
1894
  state.selected = null
1895
+ state.nodeDetails = new Map()
1896
+ resetContentFilter()
1897
+ recomputeVisibility()
1898
+ scheduleContentFilterSync()
339
1899
  loadGraph({ reset: true }).catch(error => {
340
- elements.stats.textContent = 'Failed to load agent graph'
341
1900
  console.error(error)
342
1901
  })
343
1902
  })
344
- elements.zoomIn.addEventListener('click', () => zoom(1.18))
345
- elements.zoomOut.addEventListener('click', () => zoom(0.84))
346
- elements.reset.addEventListener('click', resetView)
347
- ;[elements.notes, elements.outgoing, elements.incoming].forEach(element => {
348
- element.addEventListener('click', event => {
349
- const target = event.target
350
- if (!(target instanceof HTMLElement)) return
351
- const nodeId = target.dataset.nodeId
352
- if (nodeId) selectNodeById(nodeId)
1903
+ elements.zoomIn.addEventListener('click', () => {
1904
+ const rect = canvas.getBoundingClientRect()
1905
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.3)
1906
+ })
1907
+ elements.zoomOut.addEventListener('click', () => {
1908
+ const rect = canvas.getBoundingClientRect()
1909
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.77)
1910
+ })
1911
+ if (elements.fit) {
1912
+ elements.fit.addEventListener('click', () => {
1913
+ fitView({ useFiltered: true })
353
1914
  })
1915
+ }
1916
+ elements.reset.addEventListener('click', () => {
1917
+ resetView()
1918
+ })
1919
+ elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
1920
+ elements.contentDialog.addEventListener('click', event => {
1921
+ const target = event.target
1922
+ if (target instanceof HTMLElement && target.dataset.nodeId) {
1923
+ selectNodeById(target.dataset.nodeId)
1924
+ return
1925
+ }
1926
+ if (event.target === elements.contentDialog) elements.contentDialog.close()
1927
+ })
1928
+ canvas.addEventListener('wheel', handleWheelZoom, { passive: false })
1929
+ canvas.addEventListener('dblclick', event => {
1930
+ const rect = canvas.getBoundingClientRect()
1931
+ const cursorX = event.clientX - rect.left
1932
+ const cursorY = event.clientY - rect.top
1933
+ zoomAtPoint(cursorX, cursorY, 1.25)
354
1934
  })
355
- canvas.addEventListener('wheel', event => {
356
- event.preventDefault()
357
- zoom(event.deltaY < 0 ? 1.08 : 0.92)
358
- }, { passive: false })
359
1935
  canvas.addEventListener('pointerdown', event => {
360
1936
  const point = worldPoint(event)
361
1937
  const node = hitNode(point)
@@ -363,12 +1939,24 @@ const bindEvents = () => {
363
1939
  if (node) {
364
1940
  node.x = point.x
365
1941
  node.y = point.y
1942
+ markRenderDirty()
366
1943
  }
367
1944
  canvas.setPointerCapture(event.pointerId)
368
1945
  })
369
1946
  canvas.addEventListener('pointermove', event => {
370
1947
  const point = worldPoint(event)
371
- state.hovered = hitNode(point)
1948
+ const now = performance.now()
1949
+ const canHoverHitTest =
1950
+ !(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.06)
1951
+ const shouldHitTest = canHoverHitTest &&
1952
+ (state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
1953
+ if (shouldHitTest) {
1954
+ state.hovered = hitNode(point)
1955
+ state.lastHoverHitAt = now
1956
+ } else if (!canHoverHitTest) {
1957
+ state.hovered = null
1958
+ }
1959
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
372
1960
  if (!state.pointer.down) return
373
1961
  const dx = event.clientX - state.pointer.x
374
1962
  const dy = event.clientY - state.pointer.y
@@ -378,34 +1966,72 @@ const bindEvents = () => {
378
1966
  if (state.pointer.dragNode) {
379
1967
  state.pointer.dragNode.x = point.x
380
1968
  state.pointer.dragNode.y = point.y
1969
+ markRenderDirty()
381
1970
  return
382
1971
  }
383
1972
  state.transform.x += dx
384
1973
  state.transform.y += dy
1974
+ state.transform.x = clampTransformCoordinate(state.transform.x)
1975
+ state.transform.y = clampTransformCoordinate(state.transform.y)
1976
+ state.offscreenFrameCount = 0
1977
+ markRenderDirty()
385
1978
  })
386
1979
  canvas.addEventListener('pointerup', event => {
387
- if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode)
388
- if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered)
1980
+ if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
1981
+ if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
389
1982
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
390
1983
  canvas.releasePointerCapture(event.pointerId)
391
1984
  })
1985
+ canvas.addEventListener('pointercancel', () => {
1986
+ state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
1987
+ })
1988
+ canvas.addEventListener('pointerenter', event => {
1989
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
1990
+ })
1991
+ canvas.addEventListener('pointerleave', event => {
1992
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
1993
+ })
1994
+ window.addEventListener('keydown', event => {
1995
+ if (event.key === '+' || event.key === '=') {
1996
+ event.preventDefault()
1997
+ const rect = canvas.getBoundingClientRect()
1998
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.25)
1999
+ return
2000
+ }
2001
+
2002
+ if (event.key === '-' || event.key === '_') {
2003
+ event.preventDefault()
2004
+ const rect = canvas.getBoundingClientRect()
2005
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.8)
2006
+ return
2007
+ }
2008
+
2009
+ if (event.key === '0') {
2010
+ event.preventDefault()
2011
+ resetView()
2012
+ }
2013
+ })
392
2014
  }
393
2015
 
394
2016
  const loadAgents = async () => {
395
2017
  const response = await fetch('/api/agents')
396
2018
  const payload = await response.json()
397
2019
  const agents = Array.isArray(payload.agents) ? payload.agents : []
398
- const currentExists = agents.some(agent => agent.id === state.agentId)
2020
+ const preferredAgent = state.agentId || initialAgentFromUrl || readStoredAgent()
2021
+ const currentExists = agents.some(agent => agent.id === preferredAgent)
399
2022
  const selected = currentExists
400
- ? state.agentId
2023
+ ? preferredAgent
401
2024
  : (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
402
2025
  const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
403
2026
 
404
2027
  state.agentId = selected
2028
+ writeStoredAgent(selected)
2029
+ syncAgentInUrl(selected)
405
2030
  if (signature !== state.agentsSignature) {
2031
+ const formatAgentLabel = (agent) => agent.id
406
2032
  elements.agent.innerHTML = agents.length
407
- ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent.id) + ' · ' + agent.documentCount + '</option>').join('')
408
- : '<option value="shared">shared · 0</option>'
2033
+ ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
2034
+ : '<option value="shared">shared</option>'
409
2035
  state.agentsSignature = signature
410
2036
  }
411
2037
  elements.agent.value = selected
@@ -426,6 +2052,10 @@ const loadGraph = async (options = { reset: false }) => {
426
2052
 
427
2053
  const payload = await response.json()
428
2054
  const graph = payload?.layout ?? payload
2055
+ state.graphTotals = {
2056
+ nodes: Number.isFinite(payload?.totals?.nodes) ? payload.totals.nodes : (Array.isArray(graph.nodes) ? graph.nodes.length : 0),
2057
+ edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
2058
+ }
429
2059
  const signature = payload?.signature ?? graphSignature(graph)
430
2060
  if (!options.reset && signature === state.graphSignature) return
431
2061
  const selectedId = state.selected?.id
@@ -433,18 +2063,37 @@ const loadGraph = async (options = { reset: false }) => {
433
2063
  state.graphSignature = signature
434
2064
  state.graph = graph
435
2065
  state.nodes = layout.nodes
2066
+ state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
436
2067
  state.edges = layout.edges
437
- const tags = new Set(graph.nodes.flatMap(node => node.tags))
438
- setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
439
- elements.nodeCount.textContent = graph.nodes.length
440
- elements.edgeCount.textContent = graph.edges.length
2068
+ state.nodeDegrees = state.edges.reduce((degrees, edge) => {
2069
+ degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
2070
+ if (edge.target) {
2071
+ degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
2072
+ }
2073
+ return degrees
2074
+ }, new Map())
2075
+ state.nodeDetails = new Map()
2076
+ pushNodesToFilterWorker()
2077
+ resetContentFilter()
2078
+ sanitizeAllNodePositions()
2079
+ recomputeVisibility()
2080
+ scheduleContentFilterSync()
2081
+ const tags = new Set(state.nodes.flatMap(node => node.tags))
2082
+ setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
2083
+ elements.nodeCount.textContent = state.graphTotals.nodes
2084
+ elements.edgeCount.textContent = state.graphTotals.edges
441
2085
  elements.tagCount.textContent = tags.size
442
2086
  resize()
443
2087
  if (options.reset) resetView()
444
- selectNode(state.nodes.find(node => node.id === selectedId) ?? null)
2088
+ const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
2089
+ selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
2090
+ if (!selectedNode && elements.contentDialog.open) {
2091
+ elements.contentDialog.close()
2092
+ }
445
2093
  }
446
2094
 
447
2095
  bindEvents()
2096
+ initFilterWorker()
448
2097
  requestAnimationFrame(() => {
449
2098
  resize()
450
2099
  resetView()
@@ -475,7 +2124,6 @@ loadAgents()
475
2124
  setInterval(refreshGraphLoop, pollIntervalMs)
476
2125
  })
477
2126
  .catch(error => {
478
- elements.stats.textContent = 'Failed to load graph'
479
2127
  console.error(error)
480
2128
  })
481
2129