@andespindola/brainlink 0.1.0-beta.8 → 0.1.0-beta.81

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