@andespindola/brainlink 0.1.0-beta.7 → 0.1.0-beta.70

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