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

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 +2469 -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,1489 @@ 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)
98
300
  }
99
301
 
100
- const visibleIds = () => new Set(filteredNodes().map(node => node.id))
302
+ const withPersistentHubNodes = nodes => {
303
+ if (nodes.length === 0) {
304
+ return rankedHubNodes()
305
+ }
101
306
 
102
- const visibleEdges = () => {
103
- const ids = visibleIds()
104
- return state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
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
362
+ }
363
+
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
373
+
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 drawGraphEdges = () => {
934
+ if (state.nodes.length > largeGraphNodeThreshold) {
935
+ const regularEdges = []
936
+ const inferredEdges = []
937
+ const selectedEdges = []
938
+
939
+ for (let index = 0; index < state.renderEdges.length; index += 1) {
940
+ const edge = state.renderEdges[index]
941
+ const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
942
+ if (isSelected) {
943
+ selectedEdges.push(edge)
944
+ } else if (edge.inferred) {
945
+ inferredEdges.push(edge)
946
+ } else {
947
+ regularEdges.push(edge)
948
+ }
949
+ }
950
+
951
+ const scale = state.transform.scale
952
+ const regularOpacity = edgeOpacityForScale({ inferred: false }, scale)
953
+ const inferredOpacity = edgeOpacityForScale({ inferred: true }, scale)
954
+ drawEdgeBatch(regularEdges, {
955
+ strokeStyle: 'rgba(153, 165, 181, ' + regularOpacity + ')',
956
+ lineWidth: 1.05
957
+ })
958
+ drawEdgeBatch(inferredEdges, {
959
+ strokeStyle: 'rgba(203, 213, 225, ' + inferredOpacity + ')',
960
+ lineWidth: 0.84
961
+ })
962
+
963
+ for (let index = 0; index < selectedEdges.length; index += 1) {
964
+ drawGraphEdge(selectedEdges[index])
965
+ }
966
+ return
967
+ }
968
+
969
+ const selectedEdges = []
970
+ const regularEdges = []
971
+ for (let index = 0; index < state.renderEdges.length; index += 1) {
972
+ const edge = state.renderEdges[index]
973
+ const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
974
+ if (isSelected) {
975
+ selectedEdges.push(edge)
976
+ } else {
977
+ regularEdges.push(edge)
978
+ }
979
+ }
980
+
981
+ for (let index = 0; index < regularEdges.length; index += 1) {
982
+ drawGraphEdge(regularEdges[index])
983
+ }
984
+ for (let index = 0; index < selectedEdges.length; index += 1) {
985
+ drawGraphEdge(selectedEdges[index])
986
+ }
987
+ }
988
+
989
+ const shouldDrawNodeLabels = (node, isSelected, isHovered) =>
990
+ isSelected ||
991
+ isHovered ||
992
+ (state.nodes.length > largeGraphNodeThreshold && state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) ||
993
+ (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
994
+
995
+ const drawSingleNode = (node, options = { drawLabel: true }) => {
996
+ const radius = nodeRadius(node)
997
+ const isSelected = state.selected?.id === node.id
998
+ const isHovered = state.hovered?.id === node.id
999
+ ctx.beginPath()
1000
+ ctx.arc(node.x, node.y, radius + (isSelected ? 7 : isHovered ? 4 : 0), 0, Math.PI * 2)
1001
+ ctx.fillStyle = isSelected || isHovered ? graphTheme.nodeHaloActive : graphTheme.nodeHalo
1002
+ ctx.fill()
1003
+ ctx.beginPath()
1004
+ ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
1005
+ ctx.fillStyle = isSelected ? graphTheme.nodeSelected : isHovered ? graphTheme.nodeHover : graphTheme.node
1006
+ ctx.fill()
1007
+ ctx.lineWidth = isSelected ? 2.6 : 1.5
1008
+ ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
1009
+ ctx.stroke()
1010
+
1011
+ if (options.drawLabel && shouldDrawNodeLabels(node, isSelected, isHovered)) {
1012
+ ctx.fillStyle = graphTheme.label
1013
+ ctx.font = '12px Inter, system-ui, sans-serif'
1014
+ ctx.textAlign = 'center'
1015
+ ctx.textBaseline = 'top'
1016
+ ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
1017
+ }
1018
+ }
1019
+
1020
+ const drawNodeBatch = (nodes) => {
1021
+ if (nodes.length === 0) {
1022
+ return
1023
+ }
1024
+
1025
+ const drawHalos = state.renderNodes.length <= 1200 || state.transform.scale >= 0.45
1026
+ if (drawHalos) {
1027
+ ctx.beginPath()
1028
+ for (let index = 0; index < nodes.length; index += 1) {
1029
+ const node = nodes[index]
1030
+ ctx.moveTo(node.x + nodeRadius(node) + 3, node.y)
1031
+ ctx.arc(node.x, node.y, nodeRadius(node) + 3, 0, Math.PI * 2)
1032
+ }
1033
+ ctx.fillStyle = graphTheme.nodeHalo
1034
+ ctx.fill()
1035
+ }
1036
+
1037
+ ctx.beginPath()
1038
+ for (let index = 0; index < nodes.length; index += 1) {
1039
+ const node = nodes[index]
1040
+ const radius = nodeRadius(node)
1041
+ ctx.moveTo(node.x + radius, node.y)
1042
+ ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
1043
+ }
1044
+ ctx.fillStyle = graphTheme.node
1045
+ ctx.fill()
1046
+ ctx.lineWidth = 1.25
1047
+ ctx.strokeStyle = graphTheme.nodeStroke
1048
+ ctx.stroke()
1049
+ }
1050
+
1051
+ const drawGraphNodes = () => {
1052
+ if (state.nodes.length <= largeGraphNodeThreshold) {
1053
+ state.renderNodes.forEach(node => drawSingleNode(node))
1054
+ return
1055
+ }
1056
+
1057
+ const regularNodes = []
1058
+ const priorityNodes = []
1059
+
1060
+ for (let index = 0; index < state.renderNodes.length; index += 1) {
1061
+ const node = state.renderNodes[index]
1062
+ const isPriority =
1063
+ state.selected?.id === node.id ||
1064
+ state.hovered?.id === node.id
1065
+ if (isPriority) {
1066
+ priorityNodes.push(node)
1067
+ } else {
1068
+ regularNodes.push(node)
1069
+ }
1070
+ }
1071
+
1072
+ drawNodeBatch(regularNodes)
1073
+
1074
+ if (state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) {
1075
+ ctx.fillStyle = graphTheme.label
1076
+ ctx.font = '12px Inter, system-ui, sans-serif'
1077
+ ctx.textAlign = 'center'
1078
+ ctx.textBaseline = 'top'
1079
+ for (let index = 0; index < regularNodes.length; index += 1) {
1080
+ const node = regularNodes[index]
1081
+ ctx.fillText(node.title.slice(0, 34), node.x, node.y + nodeRadius(node) + 8)
1082
+ }
1083
+ }
1084
+
1085
+ priorityNodes.forEach(node => drawSingleNode(node))
1086
+ }
1087
+
1088
+ const edgePairKey = (source, target) =>
1089
+ source < target ? source + '|' + target : target + '|' + source
1090
+
1091
+ const meshNeighborBuckets = (nodes, cellSize) => {
1092
+ const buckets = new Map()
1093
+
1094
+ for (let index = 0; index < nodes.length; index += 1) {
1095
+ const node = nodes[index]
1096
+ const cellX = Math.floor(node.x / cellSize)
1097
+ const cellY = Math.floor(node.y / cellSize)
1098
+ const key = cellX + ':' + cellY
1099
+ const bucket = buckets.get(key)
1100
+ if (bucket) {
1101
+ bucket.push(node)
1102
+ } else {
1103
+ buckets.set(key, [node])
1104
+ }
1105
+ }
1106
+
1107
+ return buckets
1108
+ }
1109
+
1110
+ const meshCandidatesForNode = (node, buckets, cellSize) => {
1111
+ const cellX = Math.floor(node.x / cellSize)
1112
+ const cellY = Math.floor(node.y / cellSize)
1113
+ const candidates = []
1114
+
1115
+ for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
1116
+ for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
1117
+ const bucket = buckets.get((cellX + offsetX) + ':' + (cellY + offsetY))
1118
+ if (!bucket) continue
1119
+ for (let index = 0; index < bucket.length; index += 1) {
1120
+ const candidate = bucket[index]
1121
+ if (candidate.id !== node.id) {
1122
+ candidates.push(candidate)
1123
+ }
1124
+ }
1125
+ }
1126
+ }
1127
+
1128
+ return candidates
1129
+ }
1130
+
1131
+ const buildMeshEdgesForNodes = (nodes, existingEdges) => {
1132
+ if (nodes.length < 2 || state.transform.scale < meshEdgeScaleThreshold) {
1133
+ return []
1134
+ }
1135
+
1136
+ const existingKeys = new Set()
1137
+ for (let index = 0; index < existingEdges.length; index += 1) {
1138
+ const edge = existingEdges[index]
1139
+ if (edge.target) {
1140
+ existingKeys.add(edgePairKey(edge.source, edge.target))
1141
+ }
1142
+ }
1143
+
1144
+ const desiredBudget = Math.min(
1145
+ meshEdgeMaxBudget,
1146
+ Math.max(meshEdgeMinBudget, Math.floor(edgeBudgetForCurrentFrame() * 0.62))
1147
+ )
1148
+ const perNodeNeighborCount =
1149
+ state.transform.scale >= 1.05 ? 4
1150
+ : state.transform.scale >= 0.62 ? 3
1151
+ : 2
1152
+ const cellSize = Math.max(120, 280 / Math.max(state.transform.scale, 0.0001))
1153
+ const maxDistance = 980
1154
+ const maxDistanceSquared = maxDistance * maxDistance
1155
+ const buckets = meshNeighborBuckets(nodes, cellSize)
1156
+ const meshEdges = []
1157
+ const meshKeys = new Set()
1158
+
1159
+ for (let index = 0; index < nodes.length && meshEdges.length < desiredBudget; index += 1) {
1160
+ const node = nodes[index]
1161
+ const candidates = meshCandidatesForNode(node, buckets, cellSize)
1162
+ .map((candidate) => ({
1163
+ node: candidate,
1164
+ distanceSquared: (candidate.x - node.x) ** 2 + (candidate.y - node.y) ** 2
1165
+ }))
1166
+ .filter((candidate) => candidate.distanceSquared <= maxDistanceSquared)
1167
+ .sort((left, right) => left.distanceSquared - right.distanceSquared)
1168
+
1169
+ let linked = 0
1170
+ for (let candidateIndex = 0; candidateIndex < candidates.length && linked < perNodeNeighborCount && meshEdges.length < desiredBudget; candidateIndex += 1) {
1171
+ const candidate = candidates[candidateIndex].node
1172
+ const key = edgePairKey(node.id, candidate.id)
1173
+ if (existingKeys.has(key) || meshKeys.has(key)) {
1174
+ continue
1175
+ }
1176
+
1177
+ meshKeys.add(key)
1178
+ meshEdges.push({
1179
+ source: node.id,
1180
+ target: candidate.id,
1181
+ targetTitle: candidate.title,
1182
+ weight: 1,
1183
+ priority: 'normal',
1184
+ sourceNode: node,
1185
+ targetNode: candidate,
1186
+ inferred: true
1187
+ })
1188
+ linked += 1
1189
+ }
1190
+ }
1191
+
1192
+ return meshEdges
1193
+ }
1194
+
1195
+ const withMeshEdges = (nodes, edges) => {
1196
+ if (nodes.length === 0 || state.visibleNodes.length <= largeGraphNodeThreshold || state.transform.scale < meshEdgeScaleThreshold) {
1197
+ return edges
1198
+ }
1199
+
1200
+ const meshEdges = buildMeshEdgesForNodes(nodes, edges)
1201
+ return meshEdges.length > 0 ? edges.concat(meshEdges) : edges
1202
+ }
1203
+
1204
+ const fallbackViewportNodes = () => {
1205
+ const nodes = []
1206
+ const maxNodes = Math.min(renderNodeBudget, 220)
1207
+ const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
1208
+
1209
+ for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
1210
+ nodes.push(state.visibleNodes[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 sampleVisibleNodes = (limit = renderNodeBudget, sourceNodes = state.visibleNodes) => {
1221
+ if (sourceNodes.length === 0 || limit <= 0) {
1222
+ return []
1223
+ }
1224
+
1225
+ const nodes = []
1226
+ const maxNodes = Math.min(Math.max(limit, 1), sourceNodes.length)
1227
+ const step = Math.max(1, Math.ceil(sourceNodes.length / maxNodes))
1228
+
1229
+ for (let index = 0; index < sourceNodes.length && nodes.length < maxNodes; index += step) {
1230
+ nodes.push(sourceNodes[index])
1231
+ }
1232
+
1233
+ if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
1234
+ nodes.push(state.selected)
1235
+ }
1236
+
1237
+ return nodes
1238
+ }
1239
+
1240
+ const enrichSampleWithNeighbors = (nodes) => {
1241
+ if (nodes.length === 0) {
1242
+ return {
1243
+ nodes,
1244
+ edges: []
1245
+ }
1246
+ }
1247
+
1248
+ const maxNodes = Math.min(renderNodeBudget, nodes.length + 200)
1249
+ const expanded = [...nodes]
1250
+ const ids = new Set(expanded.map((node) => node.id))
1251
+
1252
+ for (let index = 0; index < nodes.length && expanded.length < maxNodes; index += 1) {
1253
+ const node = nodes[index]
1254
+ const candidates = [...(state.visibleEdgeByNode.get(node.id) ?? [])]
1255
+ .filter((edge) => edge.target)
1256
+ .sort((left, right) => edgeWeight(right) - edgeWeight(left))
1257
+ .slice(0, 3)
1258
+
1259
+ for (let candidateIndex = 0; candidateIndex < candidates.length && expanded.length < maxNodes; candidateIndex += 1) {
1260
+ const edge = candidates[candidateIndex]
1261
+ const otherId = edge.source === node.id ? edge.target : edge.source
1262
+
1263
+ if (!otherId || ids.has(otherId)) {
1264
+ continue
1265
+ }
1266
+
1267
+ const otherNode = state.nodeById.get(otherId)
1268
+ if (!otherNode) {
1269
+ continue
1270
+ }
1271
+
1272
+ ids.add(otherId)
1273
+ expanded.push(otherNode)
1274
+ }
1275
+ }
1276
+
1277
+ const edges = collectVisibleEdgesForNodes(ids)
1278
+
1279
+ return {
1280
+ nodes: expanded,
1281
+ edges
1282
+ }
1283
+ }
1284
+
1285
+ const includeHubPreviewNeighborhood = (nodes, limit) => {
1286
+ const hub = state.primaryHub
1287
+ if (!hub) {
1288
+ return nodes
1289
+ }
1290
+
1291
+ const maxNodes = Math.max(1, Math.min(renderNodeBudget, limit))
1292
+ const merged = [...nodes]
1293
+ const ids = new Set(merged.map((node) => node.id))
1294
+ const protectedIds = new Set()
1295
+
1296
+ if (!ids.has(hub.id)) {
1297
+ if (merged.length < maxNodes) {
1298
+ merged.push(hub)
1299
+ ids.add(hub.id)
1300
+ } else {
1301
+ const replaceIndex = merged.findIndex((node) => node.id !== hub.id)
1302
+ if (replaceIndex >= 0) {
1303
+ ids.delete(merged[replaceIndex].id)
1304
+ merged[replaceIndex] = hub
1305
+ ids.add(hub.id)
1306
+ }
1307
+ }
1308
+ }
1309
+ protectedIds.add(hub.id)
1310
+
1311
+ const hubEdges = [...(state.visibleEdgeByNode.get(hub.id) ?? [])]
1312
+ .filter((edge) => edge.target && (edge.source === hub.id || edge.target === hub.id))
1313
+ .sort((left, right) => {
1314
+ const byWeight = edgeWeight(right) - edgeWeight(left)
1315
+ if (byWeight !== 0) return byWeight
1316
+
1317
+ const leftOtherId = left.source === hub.id ? left.target : left.source
1318
+ const rightOtherId = right.source === hub.id ? right.target : right.source
1319
+ const leftDegree = state.nodeDegrees.get(leftOtherId ?? '') ?? 0
1320
+ const rightDegree = state.nodeDegrees.get(rightOtherId ?? '') ?? 0
1321
+ if (leftDegree !== rightDegree) return rightDegree - leftDegree
1322
+
1323
+ return edgeIdentityKey(left).localeCompare(edgeIdentityKey(right))
1324
+ })
1325
+
1326
+ for (let index = 0; index < hubEdges.length && merged.length < maxNodes; index += 1) {
1327
+ const edge = hubEdges[index]
1328
+ const otherId = edge.source === hub.id ? edge.target : edge.source
1329
+ if (!otherId || ids.has(otherId)) {
1330
+ continue
1331
+ }
1332
+
1333
+ const otherNode = state.nodeById.get(otherId)
1334
+ if (!otherNode) {
1335
+ continue
1336
+ }
1337
+
1338
+ if (merged.length < maxNodes) {
1339
+ ids.add(otherId)
1340
+ merged.push(otherNode)
1341
+ protectedIds.add(otherId)
1342
+ continue
1343
+ }
1344
+
1345
+ const replaceIndex = (() => {
1346
+ for (let cursor = merged.length - 1; cursor >= 0; cursor -= 1) {
1347
+ const candidateId = merged[cursor]?.id
1348
+ if (candidateId && !protectedIds.has(candidateId)) {
1349
+ return cursor
1350
+ }
1351
+ }
1352
+ return -1
1353
+ })()
1354
+ if (replaceIndex >= 0) {
1355
+ const replacedId = merged[replaceIndex]?.id
1356
+ if (replacedId) {
1357
+ ids.delete(replacedId)
1358
+ }
1359
+ merged[replaceIndex] = otherNode
1360
+ ids.add(otherId)
1361
+ protectedIds.add(otherId)
1362
+ }
1363
+ }
1364
+
1365
+ return merged
1366
+ }
1367
+
1368
+ const ensureHubNodesInRenderedSet = (nodes) => {
1369
+ if (nodes.length === 0) {
1370
+ return nodes
1371
+ }
1372
+
1373
+ const maxNodes = Math.max(Math.min(renderNodeBudget, nodes.length), 1)
1374
+ const ids = new Set(nodes.map((node) => node.id))
1375
+ const hubs = rankedHubNodes()
1376
+ const merged = [...nodes]
1377
+
1378
+ for (let index = 0; index < hubs.length; index += 1) {
1379
+ const hub = hubs[index]
1380
+ if (ids.has(hub.id)) {
1381
+ continue
1382
+ }
1383
+
1384
+ if (merged.length < maxNodes) {
1385
+ merged.push(hub)
1386
+ ids.add(hub.id)
1387
+ continue
1388
+ }
1389
+
1390
+ const replacementIndex = merged.findIndex((node) => !hubs.some((candidate) => candidate.id === node.id))
1391
+ if (replacementIndex >= 0) {
1392
+ ids.delete(merged[replacementIndex].id)
1393
+ merged[replacementIndex] = hub
1394
+ ids.add(hub.id)
1395
+ }
1396
+ }
1397
+
1398
+ return merged
1399
+ }
1400
+
1401
+ const zoomCapByNodeCount = (nodeCount) => {
1402
+ if (nodeCount > 50000) return 2.6
1403
+ if (nodeCount > 20000) return 2.35
1404
+ if (nodeCount > 6000) return 2.1
1405
+ if (nodeCount > 2000) return 2.2
1406
+ return zoomRange.max
1407
+ }
1408
+
1409
+ const zoomCapByHubDistance = (distance) => {
1410
+ if (!Number.isFinite(distance) || distance <= 0) {
1411
+ return zoomRange.max
1412
+ }
1413
+
1414
+ const rect = canvas.getBoundingClientRect()
1415
+ const viewportWidth = Math.max(rect.width, 320)
1416
+ const viewportHeight = Math.max(rect.height, 320)
1417
+ const reference = Math.max(220, Math.min(viewportWidth, viewportHeight) * zoomCapTargetViewportShare)
1418
+ return Math.max(0.3, Math.min(zoomRange.max, reference / distance))
1419
+ }
1420
+
1421
+ const currentZoomMax = () => {
1422
+ const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
1423
+ const hubDistanceCap = zoomCapByHubDistance(state.hubNeighborDistance)
1424
+ const minimumUsefulCap = nodeCount > massiveGraphNodeThreshold ? 1.9 : nodeCount > largeGraphNodeThreshold ? 1.35 : 0.8
1425
+ const capped = Math.min(zoomCapByNodeCount(nodeCount), Math.max(minimumUsefulCap, hubDistanceCap))
1426
+ return Math.max(zoomRange.min * 2, capped)
1427
+ }
1428
+
1429
+ const clampScale = value => Math.max(zoomRange.min, Math.min(currentZoomMax(), value))
1430
+ const isFiniteNumber = value => Number.isFinite(value)
1431
+ const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
1432
+ const clampTransformCoordinate = value => {
1433
+ if (!isFiniteNumber(value)) return 0
1434
+ if (value > transformCoordinateLimit) return transformCoordinateLimit
1435
+ if (value < -transformCoordinateLimit) return -transformCoordinateLimit
1436
+ return value
1437
+ }
1438
+
1439
+ const graphBounds = nodes => {
1440
+ if (nodes.length === 0) return null
1441
+ let minX = Number.POSITIVE_INFINITY
1442
+ let maxX = Number.NEGATIVE_INFINITY
1443
+ let minY = Number.POSITIVE_INFINITY
1444
+ let maxY = Number.NEGATIVE_INFINITY
1445
+
1446
+ nodes.forEach(node => {
1447
+ const radius = baseNodeRadius(node)
1448
+ minX = Math.min(minX, node.x - radius)
1449
+ maxX = Math.max(maxX, node.x + radius)
1450
+ minY = Math.min(minY, node.y - radius)
1451
+ maxY = Math.max(maxY, node.y + radius)
1452
+ })
1453
+
1454
+ return {
1455
+ minX,
1456
+ maxX,
1457
+ minY,
1458
+ maxY,
1459
+ width: Math.max(maxX - minX, 1),
1460
+ height: Math.max(maxY - minY, 1)
1461
+ }
1462
+ }
1463
+
1464
+ const fitScaleBiasByNodeCount = nodeCount => {
1465
+ if (nodeCount <= 6) return 1.22
1466
+ if (nodeCount <= 20) return 1.12
1467
+ if (nodeCount <= 60) return 1.04
1468
+ if (nodeCount <= 180) return 1
1469
+ if (nodeCount <= 600) return 0.94
1470
+ if (nodeCount <= 2000) return 0.82
1471
+ if (nodeCount <= 6000) return 0.68
1472
+ return 0.56
1473
+ }
1474
+
1475
+ const autoFitScaleRangeByNodeCount = nodeCount => {
1476
+ if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
1477
+ if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
1478
+ if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
1479
+ if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
1480
+ if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
1481
+ if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
1482
+ if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
1483
+ return { min: 0.0012, max: 0.24 }
1484
+ }
1485
+
1486
+ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
1487
+ const rect = canvas.getBoundingClientRect()
1488
+ const width = Math.max(rect.width, 320)
1489
+ const height = Math.max(rect.height, 320)
1490
+ const nodes = options.useFiltered ? filteredNodes() : state.nodes
1491
+ const bounds = graphBounds(nodes)
1492
+
1493
+ if (!bounds) {
1494
+ state.transform = { x: width / 2, y: height / 2, scale: 1 }
1495
+ state.offscreenFrameCount = 0
1496
+ state.recoveringViewport = false
1497
+ markRenderDirty()
1498
+ return
1499
+ }
1500
+
1501
+ const paddingByNodeCount = nodeCount => {
1502
+ if (nodeCount <= 6) return 28
1503
+ if (nodeCount <= 20) return 44
1504
+ if (nodeCount <= 60) return 68
1505
+ if (nodeCount <= 180) return 86
1506
+ if (nodeCount <= 600) return 110
1507
+ if (nodeCount <= 2000) return 140
1508
+ return 180
1509
+ }
1510
+ const padding = paddingByNodeCount(nodes.length)
1511
+ const scaleX = width / (bounds.width + padding * 2)
1512
+ const scaleY = height / (bounds.height + padding * 2)
1513
+ const fitScale = Math.min(scaleX, scaleY)
1514
+ const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
1515
+ const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
1516
+ const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
1517
+ const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
1518
+ const scale = options.macro && nodes.length > 1
1519
+ ? clampScale(Math.min(baselineScale, macroScale))
1520
+ : nodes.length > massiveGraphNodeThreshold
1521
+ ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
1522
+ : baselineScale
1523
+ const hubCenter =
1524
+ options.preferHubCenter && state.primaryHub && nodes.some((node) => node.id === state.primaryHub.id)
1525
+ ? state.primaryHub
1526
+ : null
1527
+ const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
1528
+ const centerY = hubCenter ? hubCenter.y : (bounds.minY + bounds.maxY) / 2
1529
+
1530
+ state.transform = {
1531
+ x: clampTransformCoordinate(width / 2 - centerX * scale),
1532
+ y: clampTransformCoordinate(height / 2 - centerY * scale),
1533
+ scale: clampScale(scale)
1534
+ }
1535
+ state.offscreenFrameCount = 0
1536
+ state.recoveringViewport = false
1537
+ markRenderDirty()
1538
+ }
1539
+
1540
+ const resetView = () => fitView({ useFiltered: false, macro: true, preferHubCenter: true })
1541
+
1542
+ const focusPrimaryHub = () => {
1543
+ const hub = state.primaryHub
1544
+ if (!hub) {
1545
+ fitView({ useFiltered: true, macro: false, preferHubCenter: true })
1546
+ return
1547
+ }
108
1548
 
109
- const resetView = () => {
110
1549
  const rect = canvas.getBoundingClientRect()
111
- state.transform = { x: Math.max(rect.width, 320) / 2, y: Math.max(rect.height, 320) / 2, scale: 1 }
1550
+ const width = Math.max(rect.width, 320)
1551
+ const height = Math.max(rect.height, 320)
1552
+ const targetScale = clampScale(Math.max(0.78, state.transform.scale))
1553
+
1554
+ state.transform = {
1555
+ x: clampTransformCoordinate(width / 2 - hub.x * targetScale),
1556
+ y: clampTransformCoordinate(height / 2 - hub.y * targetScale),
1557
+ scale: targetScale
1558
+ }
1559
+ state.offscreenFrameCount = 0
1560
+ markRenderDirty()
1561
+ }
1562
+
1563
+ const layoutDensityScaleForNodeCount = (nodeCount) => {
1564
+ if (nodeCount > 50000) return 0.56
1565
+ if (nodeCount > 20000) return 0.64
1566
+ if (nodeCount > 6000) return 0.76
1567
+ return 1
112
1568
  }
113
1569
 
114
1570
  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
- }))
1571
+ const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
1572
+ const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
1573
+ const densityScale = layoutDensityScaleForNodeCount(nodeRows.length)
1574
+ const nodes = nodeRows.map(node => {
1575
+ if (Array.isArray(node)) {
1576
+ const [id, title, x, y, group, segment] = node
1577
+ return {
1578
+ id: typeof id === 'string' ? id : '',
1579
+ title: typeof title === 'string' ? title : 'Untitled',
1580
+ path: '',
1581
+ tags: [],
1582
+ group: typeof group === 'string' ? group : 'root',
1583
+ segment: typeof segment === 'string' ? segment : 'root',
1584
+ x: Number.isFinite(x) ? x * densityScale : 0,
1585
+ y: Number.isFinite(y) ? y * densityScale : 0,
1586
+ vx: 0,
1587
+ vy: 0
1588
+ }
1589
+ }
1590
+
1591
+ return {
1592
+ ...node,
1593
+ path: typeof node.path === 'string' ? node.path : '',
1594
+ tags: Array.isArray(node.tags) ? node.tags : [],
1595
+ x: Number.isFinite(node.x) ? node.x * densityScale : 0,
1596
+ y: Number.isFinite(node.y) ? node.y * densityScale : 0,
1597
+ vx: Number.isFinite(node.vx) ? node.vx : 0,
1598
+ vy: Number.isFinite(node.vy) ? node.vy : 0
1599
+ }
1600
+ })
120
1601
  const nodeMap = new Map(nodes.map(node => [node.id, node]))
121
- const edges = graph.edges
1602
+ const edges = edgeRows
1603
+ .map(edge => {
1604
+ if (Array.isArray(edge)) {
1605
+ const [source, target, weight, priority] = edge
1606
+ return {
1607
+ source: typeof source === 'string' ? source : '',
1608
+ target: typeof target === 'string' ? target : null,
1609
+ targetTitle: '',
1610
+ weight: Number.isFinite(weight) ? weight : 1,
1611
+ priority: typeof priority === 'string' ? priority : 'normal'
1612
+ }
1613
+ }
1614
+ return edge
1615
+ })
122
1616
  .filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
123
1617
  .map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
124
1618
  return { nodes, edges }
125
1619
  }
126
1620
 
127
- const encodeEntityTag = (value) => {
128
- const utf8 = new TextEncoder().encode(value)
129
- let binary = ''
1621
+ const encodeEntityTag = (value) => {
1622
+ const utf8 = new TextEncoder().encode(value)
1623
+ let binary = ''
1624
+
1625
+ for (let index = 0; index < utf8.length; index += 1) {
1626
+ binary += String.fromCharCode(utf8[index])
1627
+ }
1628
+
1629
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
1630
+ }
1631
+
1632
+ const graphSignature = graph => JSON.stringify({
1633
+ nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
1634
+ edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
1635
+ })
1636
+
1637
+ const resetContentFilter = () => {
1638
+ if (state.contentFilter.timer) {
1639
+ clearTimeout(state.contentFilter.timer)
1640
+ }
1641
+ state.contentFilter = {
1642
+ query: '',
1643
+ ids: null,
1644
+ token: state.contentFilter.token + 1,
1645
+ timer: null
1646
+ }
1647
+ recomputeVisibility()
1648
+ }
1649
+
1650
+ const syncContentFilter = async (query, token) => {
1651
+ const response = await fetch(
1652
+ '/api/graph-filter?q=' +
1653
+ encodeURIComponent(query) +
1654
+ '&limit=' +
1655
+ encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
1656
+ agentQuery('&')
1657
+ )
1658
+
1659
+ if (!response.ok || token !== state.contentFilter.token) {
1660
+ return
1661
+ }
1662
+
1663
+ const payload = await response.json()
1664
+ const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
1665
+ if (token !== state.contentFilter.token) {
1666
+ return
1667
+ }
1668
+
1669
+ state.contentFilter.query = query
1670
+ const merged = new Set([...(state.contentFilter.ids instanceof Set ? state.contentFilter.ids : []), ...nodeIds])
1671
+ state.contentFilter.ids = merged
1672
+ recomputeVisibility()
1673
+ }
1674
+
1675
+ const scheduleContentFilterSync = () => {
1676
+ const query = normalizeQuery(state.query)
1677
+ if (!query) {
1678
+ resetContentFilter()
1679
+ return
1680
+ }
130
1681
 
131
- for (let index = 0; index < utf8.length; index += 1) {
132
- binary += String.fromCharCode(utf8[index])
1682
+ if (state.contentFilter.timer) {
1683
+ clearTimeout(state.contentFilter.timer)
133
1684
  }
134
1685
 
135
- return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
1686
+ const token = state.contentFilter.token + 1
1687
+ state.contentFilter = {
1688
+ query: state.contentFilter.query,
1689
+ ids: state.contentFilter.ids,
1690
+ token,
1691
+ timer: setTimeout(() => {
1692
+ if (state.filterWorker && state.filterReady) {
1693
+ state.filterWorker.postMessage({
1694
+ type: 'filter',
1695
+ query,
1696
+ token,
1697
+ limit: Math.max(state.nodes.length, 1)
1698
+ })
1699
+ }
1700
+ syncContentFilter(query, token).catch(() => {})
1701
+ }, 180)
1702
+ }
136
1703
  }
137
1704
 
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
1705
  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))
1706
+ const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
1707
+ const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
1708
+ const shouldRunPhysics =
1709
+ state.nodes.length <= 8000 &&
1710
+ nodes.length <= 320 &&
1711
+ state.transform.scale >= 0.08
1712
+ if (!shouldRunPhysics) {
1713
+ return
1714
+ }
147
1715
  const strength = Math.min(delta / 16, 2)
148
1716
 
149
1717
  edges.forEach(edge => {
150
1718
  const source = edge.sourceNode
151
1719
  const target = edge.targetNode
1720
+ source.vx = Number.isFinite(source.vx) ? source.vx : 0
1721
+ source.vy = Number.isFinite(source.vy) ? source.vy : 0
1722
+ target.vx = Number.isFinite(target.vx) ? target.vx : 0
1723
+ target.vy = Number.isFinite(target.vy) ? target.vy : 0
152
1724
  const dx = target.x - source.x
153
1725
  const dy = target.y - source.y
154
1726
  const distance = Math.max(Math.hypot(dx, dy), 1)
155
1727
  const force = (distance - 150) * 0.002 * strength
156
- const fx = dx * force
157
- const fy = dy * force
1728
+ const fx = (dx / distance) * force
1729
+ const fy = (dy / distance) * force
158
1730
  source.vx += fx
159
1731
  source.vy += fy
160
1732
  target.vx -= fx
@@ -165,6 +1737,10 @@ const tick = delta => {
165
1737
  for (let j = i + 1; j < nodes.length; j += 1) {
166
1738
  const a = nodes[i]
167
1739
  const b = nodes[j]
1740
+ a.vx = Number.isFinite(a.vx) ? a.vx : 0
1741
+ a.vy = Number.isFinite(a.vy) ? a.vy : 0
1742
+ b.vx = Number.isFinite(b.vx) ? b.vx : 0
1743
+ b.vy = Number.isFinite(b.vy) ? b.vy : 0
168
1744
  const dx = b.x - a.x
169
1745
  const dy = b.y - a.y
170
1746
  const distance = Math.max(Math.hypot(dx, dy), 1)
@@ -179,6 +1755,10 @@ const tick = delta => {
179
1755
  }
180
1756
 
181
1757
  nodes.forEach(node => {
1758
+ node.vx = Number.isFinite(node.vx) ? node.vx : 0
1759
+ node.vy = Number.isFinite(node.vy) ? node.vy : 0
1760
+ node.x = Number.isFinite(node.x) ? node.x : 0
1761
+ node.y = Number.isFinite(node.y) ? node.y : 0
182
1762
  if (state.pointer.dragNode === node) {
183
1763
  node.vx = 0
184
1764
  node.vy = 0
@@ -201,8 +1781,119 @@ const worldPoint = event => {
201
1781
  }
202
1782
  }
203
1783
 
1784
+ const connectedNodeIdsFor = (nodeId) => {
1785
+ const edges = state.visibleEdgeByNode.get(nodeId) ?? []
1786
+ const ids = new Set()
1787
+
1788
+ for (let index = 0; index < edges.length; index += 1) {
1789
+ const edge = edges[index]
1790
+ if (!edge.target) continue
1791
+ if (edge.source === nodeId) {
1792
+ ids.add(edge.target)
1793
+ } else if (edge.target === nodeId) {
1794
+ ids.add(edge.source)
1795
+ }
1796
+ }
1797
+
1798
+ return ids
1799
+ }
1800
+
1801
+ const applyDragNeighborhoodAdjustment = (dragNode, deltaX, deltaY) => {
1802
+ if (!dragNode) return
1803
+ if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) return
1804
+ if (Math.abs(deltaX) + Math.abs(deltaY) <= 0.001) return
1805
+
1806
+ const scale = Math.max(state.transform.scale, 0.0001)
1807
+ const influenceRadius = Math.max(220, Math.min(920, 440 / scale))
1808
+ const influenceRadiusSquared = influenceRadius * influenceRadius
1809
+ const connectedIds = connectedNodeIdsFor(dragNode.id)
1810
+ const candidates = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
1811
+ let adjusted = 0
1812
+
1813
+ for (let index = 0; index < candidates.length && adjusted < dragNeighborhoodMaxAffected; index += 1) {
1814
+ const node = candidates[index]
1815
+ if (node.id === dragNode.id) continue
1816
+
1817
+ const isConnected = connectedIds.has(node.id)
1818
+ const dx = node.x - dragNode.x
1819
+ const dy = node.y - dragNode.y
1820
+ const distanceSquared = dx * dx + dy * dy
1821
+ const withinRadius = distanceSquared <= influenceRadiusSquared
1822
+ if (!isConnected && !withinRadius) continue
1823
+
1824
+ const distance = Math.max(Math.sqrt(distanceSquared), 0.0001)
1825
+ const proximity = withinRadius ? 1 - (distance / influenceRadius) : 0
1826
+ const coupledStrength = isConnected ? 0.28 : 0.12
1827
+ const influence = Math.min(0.46, coupledStrength + proximity * 0.34)
1828
+ node.x += deltaX * influence
1829
+ node.y += deltaY * influence
1830
+ node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + deltaX * influence * 0.06
1831
+ node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + deltaY * influence * 0.06
1832
+ adjusted += 1
1833
+ }
1834
+ }
1835
+
1836
+ const settleNeighborhoodAroundNode = (dragNode) => {
1837
+ if (!dragNode) return
1838
+
1839
+ const scale = Math.max(state.transform.scale, 0.0001)
1840
+ const settleRadius = Math.max(240, Math.min(980, 520 / scale))
1841
+ const settleRadiusSquared = settleRadius * settleRadius
1842
+ const connectedIds = connectedNodeIdsFor(dragNode.id)
1843
+ const candidates = (state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes)
1844
+ .filter((node) => {
1845
+ if (node.id === dragNode.id) return true
1846
+ const dx = node.x - dragNode.x
1847
+ const dy = node.y - dragNode.y
1848
+ const distanceSquared = dx * dx + dy * dy
1849
+ return connectedIds.has(node.id) || distanceSquared <= settleRadiusSquared
1850
+ })
1851
+ .slice(0, dragNeighborhoodMaxAffected)
1852
+
1853
+ if (candidates.length <= 1) return
1854
+
1855
+ for (let round = 0; round < dragSettleRounds; round += 1) {
1856
+ for (let leftIndex = 0; leftIndex < candidates.length; leftIndex += 1) {
1857
+ const left = candidates[leftIndex]
1858
+ for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex += 1) {
1859
+ const right = candidates[rightIndex]
1860
+ const dx = right.x - left.x
1861
+ const dy = right.y - left.y
1862
+ const distance = Math.max(Math.hypot(dx, dy), 0.001)
1863
+ const minDistance = baseNodeRadius(left) + baseNodeRadius(right) + 10
1864
+ if (distance >= minDistance) continue
1865
+
1866
+ const push = (minDistance - distance) * 0.36
1867
+ const ux = dx / distance
1868
+ const uy = dy / distance
1869
+ if (left.id !== dragNode.id) {
1870
+ left.x -= ux * push
1871
+ left.y -= uy * push
1872
+ }
1873
+ if (right.id !== dragNode.id) {
1874
+ right.x += ux * push
1875
+ right.y += uy * push
1876
+ }
1877
+ }
1878
+ }
1879
+ }
1880
+ }
1881
+
204
1882
  const hitNode = point => {
205
- const nodes = filteredNodes()
1883
+ computeRenderVisibility()
1884
+ if (state.renderClusters.length > 0) {
1885
+ return null
1886
+ }
1887
+ const hitScaleFloor = state.nodes.length > massiveGraphNodeThreshold
1888
+ ? 0.2
1889
+ : state.nodes.length > largeGraphNodeThreshold
1890
+ ? 0.34
1891
+ : 0
1892
+ if (state.transform.scale < hitScaleFloor) {
1893
+ return null
1894
+ }
1895
+
1896
+ const nodes = state.renderNodes
206
1897
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
207
1898
  const node = nodes[index]
208
1899
  const radius = nodeRadius(node)
@@ -211,17 +1902,379 @@ const hitNode = point => {
211
1902
  return null
212
1903
  }
213
1904
 
214
- const nodeRadius = node => {
215
- const degree = state.edges.filter(edge => edge.source === node.id || edge.target === node.id).length
1905
+ const baseNodeRadius = node => {
1906
+ const degree = state.nodeDegrees.get(node.id) ?? 0
216
1907
  return 9 + Math.min(degree, 8) * 1.6
217
1908
  }
218
1909
 
1910
+ const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
1911
+
1912
+ const worldViewportBounds = () => {
1913
+ const rect = canvas.getBoundingClientRect()
1914
+ const width = Math.max(rect.width, 320)
1915
+ const height = Math.max(rect.height, 320)
1916
+ const paddingMultiplier =
1917
+ state.nodes.length > massiveGraphNodeThreshold
1918
+ ? (state.transform.scale >= 0.6 ? 2.8 : state.transform.scale >= 0.25 ? 2.35 : 1.9)
1919
+ : state.nodes.length > largeGraphNodeThreshold
1920
+ ? 1.45
1921
+ : 1
1922
+ const padding = viewportPaddingPx * paddingMultiplier
1923
+
1924
+ return {
1925
+ minX: (-state.transform.x - padding) / state.transform.scale,
1926
+ maxX: (width - state.transform.x + padding) / state.transform.scale,
1927
+ minY: (-state.transform.y - padding) / state.transform.scale,
1928
+ maxY: (height - state.transform.y + padding) / state.transform.scale
1929
+ }
1930
+ }
1931
+
1932
+ const isNodeInViewport = (node, viewport) =>
1933
+ node.x >= viewport.minX &&
1934
+ node.x <= viewport.maxX &&
1935
+ node.y >= viewport.minY &&
1936
+ node.y <= viewport.maxY
1937
+
1938
+ const expandViewportBounds = (viewport, worldMargin) => ({
1939
+ minX: viewport.minX - worldMargin,
1940
+ maxX: viewport.maxX + worldMargin,
1941
+ minY: viewport.minY - worldMargin,
1942
+ maxY: viewport.maxY + worldMargin
1943
+ })
1944
+
1945
+ const viewportNodeStride = () => {
1946
+ if (state.nodes.length <= largeGraphNodeThreshold) {
1947
+ return 1
1948
+ }
1949
+
1950
+ if (state.transform.scale >= 0.95) {
1951
+ return 1
1952
+ }
1953
+ if (state.transform.scale >= 0.7) {
1954
+ return 2
1955
+ }
1956
+ if (state.transform.scale >= 0.48) {
1957
+ return 3
1958
+ }
1959
+ if (state.transform.scale >= 0.28) {
1960
+ return 5
1961
+ }
1962
+
1963
+ return 8
1964
+ }
1965
+
1966
+ const shouldRenderClusters = viewportNodes =>
1967
+ state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
1968
+
1969
+ const clusterViewportNodes = viewportNodes => {
1970
+ if (!shouldRenderClusters(viewportNodes)) {
1971
+ return []
1972
+ }
1973
+
1974
+ const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
1975
+ const buckets = new Map()
1976
+
1977
+ for (let index = 0; index < viewportNodes.length; index += 1) {
1978
+ const node = viewportNodes[index]
1979
+ const keyX = Math.floor(node.x / worldCellSize)
1980
+ const keyY = Math.floor(node.y / worldCellSize)
1981
+ const key = keyX + ':' + keyY
1982
+ const current = buckets.get(key)
1983
+ if (current) {
1984
+ current.count += 1
1985
+ current.sumX += node.x
1986
+ current.sumY += node.y
1987
+ if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
1988
+ current.representative = node
1989
+ current.degree = state.nodeDegrees.get(node.id) ?? 0
1990
+ }
1991
+ continue
1992
+ }
1993
+
1994
+ buckets.set(key, {
1995
+ id: key,
1996
+ count: 1,
1997
+ sumX: node.x,
1998
+ sumY: node.y,
1999
+ representative: node,
2000
+ degree: state.nodeDegrees.get(node.id) ?? 0
2001
+ })
2002
+ }
2003
+
2004
+ return Array.from(buckets.values())
2005
+ .sort((left, right) => right.count - left.count)
2006
+ .slice(0, Math.min(renderNodeBudget, 900))
2007
+ .map((cluster) => ({
2008
+ id: cluster.id,
2009
+ x: cluster.sumX / Math.max(cluster.count, 1),
2010
+ y: cluster.sumY / Math.max(cluster.count, 1),
2011
+ count: cluster.count,
2012
+ representative: cluster.representative
2013
+ }))
2014
+ }
2015
+
2016
+ const representativeNodesFromClusters = (clusters, limit) => {
2017
+ const representatives = clusters
2018
+ .map((cluster) => cluster.representative)
2019
+ .filter((node) => Boolean(node))
2020
+ const merged = mergeUniqueNodes(
2021
+ representatives,
2022
+ state.renderNodes ?? [],
2023
+ Math.max(1, limit)
2024
+ )
2025
+ return ensureHubNodesInRenderedSet(merged)
2026
+ }
2027
+
2028
+ const computeRenderVisibility = () => {
2029
+ if (!hasValidTransform()) {
2030
+ fitView({ useFiltered: true })
2031
+ }
2032
+ const viewport = worldViewportBounds()
2033
+ const viewportKey =
2034
+ Math.round(viewport.minX * 10) + ':' +
2035
+ Math.round(viewport.maxX * 10) + ':' +
2036
+ Math.round(viewport.minY * 10) + ':' +
2037
+ Math.round(viewport.maxY * 10) + ':' +
2038
+ visibilityScaleBucket(state.transform.scale)
2039
+
2040
+ if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
2041
+ return
2042
+ }
2043
+ state.lastViewportKey = viewportKey
2044
+ state.renderVisibilityDirty = false
2045
+
2046
+ const shouldRenderMacroGalaxy = shouldRenderMacroGalaxyView()
2047
+
2048
+ if (shouldRenderMacroGalaxy) {
2049
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2050
+ const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
2051
+ const representative = state.primaryHub ?? state.macroRepresentative ?? sourceNodes[0] ?? null
2052
+ if (representative) {
2053
+ state.renderClusters = [
2054
+ {
2055
+ id: 'macro-galaxy',
2056
+ x: state.macroCenter.x,
2057
+ y: state.macroCenter.y,
2058
+ count: sourceNodes.length,
2059
+ representative
2060
+ }
2061
+ ]
2062
+ state.renderNodes = [representative]
2063
+ } else {
2064
+ state.renderClusters = []
2065
+ state.renderNodes = []
2066
+ }
2067
+ state.renderEdges = []
2068
+ return
2069
+ }
2070
+
2071
+ if (state.visibleNodes.length <= 2000) {
2072
+ state.renderNodes = state.visibleNodes
2073
+ state.renderClusters = []
2074
+ const ids = new Set(state.renderNodes.map((node) => node.id))
2075
+ state.renderEdges = withMeshEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
2076
+ return
2077
+ }
2078
+
2079
+ if (state.visibleNodes.length > massiveGraphNodeThreshold) {
2080
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2081
+ if (state.transform.scale <= massiveOverviewClusterScaleThreshold) {
2082
+ const overviewLimit = Math.min(renderNodeBudget, massiveLowZoomNodeBudgetForScale(state.transform.scale))
2083
+ const overviewClusters = filterOverviewClustersByViewport(viewport)
2084
+ .sort((left, right) => right.count - left.count)
2085
+ .slice(0, overviewLimit)
2086
+ if (overviewClusters.length > 0) {
2087
+ const overviewNodes = representativeNodesFromClusters(
2088
+ overviewClusters,
2089
+ overviewLimit
2090
+ )
2091
+ const anchoredNodes = includeHubPreviewNeighborhood(
2092
+ overviewNodes,
2093
+ Math.min(renderNodeBudget, overviewLimit)
2094
+ )
2095
+ const enriched = enrichSampleWithNeighbors(anchoredNodes)
2096
+ const previewNodes = ensureHubNodesInRenderedSet(enriched.nodes)
2097
+ const previewIds = new Set(previewNodes.map((node) => node.id))
2098
+ const previewEdges = collectVisibleEdgesForNodes(previewIds)
2099
+ state.renderClusters = []
2100
+ state.renderNodes = previewNodes
2101
+ state.renderEdges = previewEdges
2102
+ return
2103
+ }
2104
+ }
2105
+ const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
2106
+ const sampleLimit = nodeBudgetForScale(state.transform.scale)
2107
+ const carryMargin = Math.max(240, Math.min(1200, 340 / Math.max(state.transform.scale, 0.0001)))
2108
+ const carryViewport = expandViewportBounds(viewport, carryMargin)
2109
+ const carryOverLimit = Math.max(180, Math.min(sampleLimit, Math.floor(sampleLimit * 0.5)))
2110
+ const carryOverNodes = (state.renderNodes ?? [])
2111
+ .filter((node) => isNodeInViewport(node, carryViewport))
2112
+ .slice(0, carryOverLimit)
2113
+ const sourceWithCarry = mergeUniqueNodes(
2114
+ sourceNodes,
2115
+ carryOverNodes,
2116
+ Math.max(sampleLimit * 7, carryOverLimit)
2117
+ )
2118
+ const sourceWithCarryIds = new Set(sourceWithCarry.map((node) => node.id))
2119
+ const sampledRaw = selectStableSampleNodes(
2120
+ sourceWithCarry,
2121
+ sampleLimit
2122
+ )
2123
+ const continuityBudget = Math.max(24, Math.min(sampleLimit - 8, Math.floor(sampleLimit * 0.42)))
2124
+ const previousVisibleNodes = (state.renderNodes ?? [])
2125
+ .filter((node) => sourceWithCarryIds.has(node.id))
2126
+ const continuityNodes = selectStableSampleNodes(previousVisibleNodes, continuityBudget)
2127
+ const sampled = mergeUniqueNodes(
2128
+ continuityNodes,
2129
+ sampledRaw,
2130
+ sampleLimit
2131
+ )
2132
+ let sampledNodes = ensureHubNodesInRenderedSet(sampled)
2133
+ if (state.transform.scale < 0.035) {
2134
+ sampledNodes = includeHubPreviewNeighborhood(
2135
+ sampledNodes,
2136
+ Math.min(renderNodeBudget, sampleLimit + 160)
2137
+ )
2138
+ }
2139
+ const sampledIds = new Set(sampledNodes.map((node) => node.id))
2140
+ let sampledEdges = collectVisibleEdgesForNodes(sampledIds)
2141
+
2142
+ if (state.transform.scale >= 0.035 && sampledEdges.length === 0) {
2143
+ const enriched = enrichSampleWithNeighbors(sampledNodes)
2144
+ sampledNodes = ensureHubNodesInRenderedSet(enriched.nodes)
2145
+ const sampledWithHubsIds = new Set(sampledNodes.map((node) => node.id))
2146
+ sampledEdges = collectVisibleEdgesForNodes(sampledWithHubsIds)
2147
+ }
2148
+
2149
+ state.renderClusters = []
2150
+ state.renderNodes = sampledNodes
2151
+ state.renderEdges = withMeshEdges(sampledNodes, sampledEdges)
2152
+ return
2153
+ }
2154
+
2155
+ if (state.transform.scale <= 0.0015) {
2156
+ const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
2157
+ const sampledIds = new Set(sampled.map((node) => node.id))
2158
+ state.renderClusters = []
2159
+ state.renderNodes = sampled
2160
+ state.renderEdges = withMeshEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
2161
+ return
2162
+ }
2163
+
2164
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2165
+ const clusters = clusterViewportNodes(viewportNodes)
2166
+ if (clusters.length > 0) {
2167
+ state.renderClusters = []
2168
+ state.renderNodes = representativeNodesFromClusters(clusters, Math.min(renderNodeBudget, 900))
2169
+ state.renderEdges = []
2170
+ return
2171
+ }
2172
+ state.renderClusters = []
2173
+ const stride = viewportNodeStride()
2174
+ const picked = []
2175
+
2176
+ for (let index = 0; index < viewportNodes.length; index += 1) {
2177
+ const node = viewportNodes[index]
2178
+
2179
+ const isPriority =
2180
+ node.id === state.selected?.id ||
2181
+ node.id === state.hovered?.id ||
2182
+ node.id === state.pointer.dragNode?.id
2183
+ if (isPriority || index % stride === 0) {
2184
+ picked.push(node)
2185
+ }
2186
+ }
2187
+
2188
+ const nodes = picked.length > renderNodeBudget
2189
+ ? picked.slice(0, renderNodeBudget)
2190
+ : picked
2191
+ if (nodes.length === 0 && state.visibleNodes.length > 0) {
2192
+ const fallbackNodes = fallbackViewportNodes()
2193
+ const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
2194
+ state.renderNodes = fallbackNodes
2195
+ state.renderClusters = []
2196
+ state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
2197
+ return
2198
+ }
2199
+
2200
+ const normalizedNodes = ensureHubNodesInRenderedSet(nodes)
2201
+ const nodeIds = new Set(normalizedNodes.map((node) => node.id))
2202
+ const edges = collectVisibleEdgesForNodes(nodeIds)
2203
+
2204
+ state.renderNodes = normalizedNodes
2205
+ state.renderEdges = withMeshEdges(normalizedNodes, edges)
2206
+
2207
+ if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
2208
+ const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
2209
+ const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
2210
+ state.renderClusters = []
2211
+ state.renderNodes = fallbackNodes
2212
+ state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
2213
+ }
2214
+ }
2215
+
2216
+ const isNodeVisibleOnScreen = (node, width, height) => {
2217
+ const radius = nodeRadius(node) * state.transform.scale
2218
+ const screenX = node.x * state.transform.scale + state.transform.x
2219
+ const screenY = node.y * state.transform.scale + state.transform.y
2220
+
2221
+ return (
2222
+ screenX + radius >= 0 &&
2223
+ screenX - radius <= width &&
2224
+ screenY + radius >= 0 &&
2225
+ screenY - radius <= height
2226
+ )
2227
+ }
2228
+
2229
+ const hasValidTransform = () =>
2230
+ isFiniteNumber(state.transform.x) &&
2231
+ isFiniteNumber(state.transform.y) &&
2232
+ isFiniteNumber(state.transform.scale) &&
2233
+ Math.abs(state.transform.x) <= transformCoordinateLimit &&
2234
+ Math.abs(state.transform.y) <= transformCoordinateLimit &&
2235
+ state.transform.scale > 0
2236
+
2237
+ const sanitizeNodePosition = node => {
2238
+ if (!isReasonableCoordinate(node.x)) node.x = 0
2239
+ if (!isReasonableCoordinate(node.y)) node.y = 0
2240
+ if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
2241
+ if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
2242
+ }
2243
+
2244
+ const sanitizeAllNodePositions = () => {
2245
+ state.nodes.forEach(sanitizeNodePosition)
2246
+ state.visibleNodes.forEach(sanitizeNodePosition)
2247
+ }
2248
+
2249
+ const sanitizeGraphState = () => {
2250
+ state.renderNodes.forEach(sanitizeNodePosition)
2251
+ }
2252
+
219
2253
  const render = now => {
220
2254
  const delta = now - state.last
221
2255
  state.last = now
2256
+ const backgroundFrameIntervalMs =
2257
+ state.nodes.length > massiveGraphNodeThreshold
2258
+ ? (state.transform.scale < 0.035 ? 130 : state.transform.scale < 0.08 ? 110 : 86)
2259
+ : state.nodes.length > largeGraphNodeThreshold
2260
+ ? 64
2261
+ : 16
2262
+ const isInteracting =
2263
+ state.pointer.down ||
2264
+ state.renderVisibilityDirty ||
2265
+ state.recoveringViewport
2266
+ const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
2267
+ if (delta < minFrameIntervalMs) {
2268
+ requestAnimationFrame(render)
2269
+ return
2270
+ }
222
2271
  const rect = canvas.getBoundingClientRect()
223
2272
  const width = Math.max(rect.width, 320)
224
2273
  const height = Math.max(rect.height, 320)
2274
+ sanitizeGraphState()
2275
+ if (!hasValidTransform()) {
2276
+ resetView()
2277
+ }
225
2278
  ctx.clearRect(0, 0, width, height)
226
2279
  if (state.nodes.length === 0) {
227
2280
  ctx.fillStyle = '#99a5b5'
@@ -235,42 +2288,81 @@ const render = now => {
235
2288
  ctx.translate(state.transform.x, state.transform.y)
236
2289
  ctx.scale(state.transform.scale, state.transform.scale)
237
2290
 
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)
2291
+ computeRenderVisibility()
2292
+ tick(delta)
2293
+ const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
2294
+ const manualZoomGuardActive = now - state.lastManualZoomAt < zoomRecoveryGuardMs
2295
+ const allowViewportAutoRecovery = state.nodes.length <= massiveGraphNodeThreshold
2296
+ if (allowViewportAutoRecovery && !hasVisibleNodeOnScreen && state.renderNodes.length > 0 && !manualZoomGuardActive) {
2297
+ state.offscreenFrameCount += 1
2298
+ if (state.offscreenFrameCount >= 22 && !state.recoveringViewport) {
2299
+ state.recoveringViewport = true
2300
+ fitView({ useFiltered: true })
2301
+ state.offscreenFrameCount = 0
2302
+ requestAnimationFrame(() => {
2303
+ state.recoveringViewport = false
2304
+ })
270
2305
  }
271
- })
2306
+ } else {
2307
+ state.offscreenFrameCount = 0
2308
+ }
2309
+ const minimumEdgeScale =
2310
+ state.nodes.length > massiveGraphNodeThreshold
2311
+ ? 0
2312
+ : state.renderNodes.length > 1300
2313
+ ? 0.12
2314
+ : state.renderNodes.length > 900
2315
+ ? 0.085
2316
+ : state.renderNodes.length > 500
2317
+ ? 0.05
2318
+ : 0
2319
+ const drawEdges =
2320
+ state.renderClusters.length === 0 &&
2321
+ state.transform.scale >= minimumEdgeScale
2322
+ if (drawEdges) {
2323
+ drawGraphEdges()
2324
+ }
2325
+
2326
+ if (state.renderClusters.length > 0) {
2327
+ const safeScale = Math.max(state.transform.scale, 0.0001)
2328
+ state.renderClusters.forEach(cluster => {
2329
+ const isMacro = cluster.id === 'macro-galaxy'
2330
+ const radiusPx = isMacro
2331
+ ? 10
2332
+ : Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
2333
+ const radius = radiusPx / safeScale
2334
+ const haloRadius = (radiusPx + (isMacro ? 8 : 4)) / safeScale
2335
+ ctx.beginPath()
2336
+ ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
2337
+ ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
2338
+ ctx.fill()
2339
+ ctx.beginPath()
2340
+ ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
2341
+ ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
2342
+ ctx.fill()
2343
+ ctx.lineWidth = 1.4 / safeScale
2344
+ ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
2345
+ ctx.stroke()
2346
+ if (isMacro && cluster.representative?.title) {
2347
+ ctx.fillStyle = '#edf2f7'
2348
+ ctx.font = 12 / safeScale + 'px Inter, system-ui, sans-serif'
2349
+ ctx.textAlign = 'center'
2350
+ ctx.textBaseline = 'top'
2351
+ ctx.fillText(cluster.representative.title.slice(0, 28), cluster.x, cluster.y + (radiusPx + 9) / safeScale)
2352
+ }
2353
+ // Keep cluster markers minimal and faster to draw on large graphs.
2354
+ })
2355
+ } else {
2356
+ drawGraphNodes()
2357
+ }
272
2358
 
273
2359
  ctx.restore()
2360
+ if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
2361
+ ctx.fillStyle = '#99a5b5'
2362
+ ctx.font = '12px Inter, system-ui, sans-serif'
2363
+ ctx.textAlign = 'center'
2364
+ ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
2365
+ }
274
2366
  requestAnimationFrame(render)
275
2367
  }
276
2368
 
@@ -278,55 +2370,98 @@ const list = items => items.length
278
2370
  ? 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
2371
  : '<li><small>No links found.</small></li>'
280
2372
 
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
- }
2373
+ const linkedNodes = node => {
306
2374
  const nodeById = new Map(state.nodes.map(item => [item.id, item]))
307
2375
  const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
308
2376
  ...linkedNode,
309
2377
  weight: edge.weight,
310
2378
  priority: edge.priority
311
2379
  } : null
312
- const outgoing = state.graph.edges
2380
+ const outgoing = state.edges
313
2381
  .filter(edge => edge.source === node.id)
314
- .map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' }, edge))
2382
+ .map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
315
2383
  .filter(Boolean)
316
- const incoming = state.graph.edges
2384
+ const incoming = state.edges
317
2385
  .filter(edge => edge.target === node.id)
318
2386
  .map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
319
2387
  .filter(Boolean)
320
2388
 
321
- elements.title.textContent = node.title
322
- elements.path.textContent = node.path
323
- elements.tags.innerHTML = node.tags.length
2389
+ return { outgoing, incoming }
2390
+ }
2391
+
2392
+ const fetchNodeDetails = async node => {
2393
+ const cached = state.nodeDetails.get(node.id)
2394
+ if (cached) {
2395
+ return cached
2396
+ }
2397
+
2398
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
2399
+ if (!response.ok) {
2400
+ throw new Error('Failed to load graph node details')
2401
+ }
2402
+
2403
+ const payload = await response.json()
2404
+ const detail = payload?.node
2405
+ if (!detail || !detail.id) {
2406
+ throw new Error('Invalid graph node payload')
2407
+ }
2408
+ state.nodeDetails.set(detail.id, detail)
2409
+ return detail
2410
+ }
2411
+
2412
+ const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
2413
+
2414
+ const openContentDialog = async node => {
2415
+ if (!node) return
2416
+ elements.contentTitle.textContent = node.title || 'Loading...'
2417
+ elements.contentPath.textContent = node.path || 'Loading...'
2418
+ elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
324
2419
  ? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
325
2420
  : '<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)
2421
+ const initialLinks = linkedNodes(node)
2422
+ elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
2423
+ elements.contentIncoming.innerHTML = list(initialLinks.incoming)
2424
+ elements.contentBody.textContent = 'Loading note content...'
2425
+ if (!elements.contentDialog.open) {
2426
+ elements.contentDialog.showModal()
2427
+ }
2428
+
2429
+ const applyDetailToDialog = detail => {
2430
+ elements.contentTitle.textContent = detail.title
2431
+ elements.contentPath.textContent = detail.path
2432
+ elements.contentTags.innerHTML = detail.tags.length
2433
+ ? detail.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
2434
+ : '<span>No tags</span>'
2435
+ elements.contentBody.textContent = detail.content
2436
+ }
2437
+
2438
+ try {
2439
+ const detailedNode = await fetchNodeDetails(node)
2440
+ if (state.selected?.id !== node.id) {
2441
+ return
2442
+ }
2443
+ applyDetailToDialog(detailedNode)
2444
+ } catch {
2445
+ try {
2446
+ await wait(120)
2447
+ const retriedNode = await fetchNodeDetails(node)
2448
+ if (state.selected?.id !== node.id) {
2449
+ return
2450
+ }
2451
+ applyDetailToDialog(retriedNode)
2452
+ } catch {
2453
+ elements.contentBody.textContent = 'Unable to load note content.'
2454
+ }
2455
+ }
2456
+ }
2457
+
2458
+ const selectNode = (node, options = { openContent: false }) => {
2459
+ state.selected = node
2460
+ if (node && options.openContent) {
2461
+ openContentDialog(node).catch(() => {
2462
+ elements.contentBody.textContent = 'Unable to load note content.'
2463
+ })
2464
+ }
330
2465
  }
331
2466
 
332
2467
  const selectNodeById = id => {
@@ -334,45 +2469,142 @@ const selectNodeById = id => {
334
2469
  if (node) selectNode(node, { openContent: true })
335
2470
  }
336
2471
 
337
- const zoom = factor => {
338
- state.transform.scale = Math.max(0.25, Math.min(3.5, state.transform.scale * factor))
2472
+ const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
2473
+ const resolveZoomFactor = () => {
2474
+ if (state.nodes.length <= massiveGraphNodeThreshold) {
2475
+ return factor
2476
+ }
2477
+
2478
+ const scale = state.transform.scale
2479
+ if (factor > 1) {
2480
+ if (scale < 0.006) return Math.max(factor, 1.48)
2481
+ if (scale < 0.02) return Math.max(factor, 1.34)
2482
+ if (scale < 0.08) return Math.max(factor, 1.22)
2483
+ return factor
2484
+ }
2485
+
2486
+ if (scale < 0.006) return Math.min(factor, 0.68)
2487
+ if (scale < 0.02) return Math.min(factor, 0.78)
2488
+ if (scale < 0.08) return Math.min(factor, 0.86)
2489
+ return factor
2490
+ }
2491
+
2492
+ state.lastManualZoomAt = performance.now()
2493
+ const effectiveFactor = resolveZoomFactor()
2494
+ const nextScale = clampScale(state.transform.scale * effectiveFactor)
2495
+ if (nextScale === state.transform.scale) {
2496
+ return
2497
+ }
2498
+ const worldX = (screenX - state.transform.x) / state.transform.scale
2499
+ const worldY = (screenY - state.transform.y) / state.transform.scale
2500
+ state.lastZoomFocus = {
2501
+ x: worldX,
2502
+ y: worldY,
2503
+ at: performance.now()
2504
+ }
2505
+ state.transform.scale = clampScale(nextScale)
2506
+ state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
2507
+ state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
2508
+ state.offscreenFrameCount = 0
2509
+ markRenderDirty()
2510
+ }
2511
+
2512
+ const wheelZoomFactor = event => {
2513
+ const isModifierZoom = event.metaKey || event.ctrlKey
2514
+ const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
2515
+ const normalizedDelta = event.deltaY * deltaModeFactor
2516
+
2517
+ if (!Number.isFinite(normalizedDelta) || Math.abs(normalizedDelta) <= 0.0001) {
2518
+ return 1
2519
+ }
2520
+
2521
+ const sensitivity = wheelZoomExponent * (isModifierZoom ? wheelZoomModifierBoost : 1)
2522
+ const exponent = Math.max(
2523
+ -wheelZoomExponentCap,
2524
+ Math.min(wheelZoomExponentCap, -normalizedDelta * sensitivity)
2525
+ )
2526
+ return Math.exp(exponent)
2527
+ }
2528
+
2529
+ const handleWheelZoom = event => {
2530
+ if (elements.contentDialog?.open) {
2531
+ return
2532
+ }
2533
+
2534
+ event.preventDefault()
2535
+ const rect = canvas.getBoundingClientRect()
2536
+ const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
2537
+ const rawCursorY = Number.isFinite(event.offsetY) ? event.offsetY : event.clientY - rect.top
2538
+ const cursorX = Math.max(0, Math.min(Math.max(rect.width, 320), rawCursorX))
2539
+ const cursorY = Math.max(0, Math.min(Math.max(rect.height, 320), rawCursorY))
2540
+ const factor = wheelZoomFactor(event)
2541
+
2542
+ if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
2543
+ return
2544
+ }
2545
+
2546
+ zoomAtPoint(cursorX, cursorY, factor, 'wheel')
339
2547
  }
340
2548
 
341
2549
  const bindEvents = () => {
342
2550
  window.addEventListener('resize', resize)
343
2551
  elements.search.addEventListener('input', event => {
344
2552
  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'
2553
+ recomputeVisibility()
2554
+ scheduleContentFilterSync()
348
2555
  })
349
2556
  elements.agent.addEventListener('change', event => {
350
2557
  state.agentId = event.target.value
2558
+ writeStoredAgent(state.agentId)
2559
+ syncAgentInUrl(state.agentId)
351
2560
  state.selected = null
2561
+ state.nodeDetails = new Map()
2562
+ resetContentFilter()
2563
+ recomputeVisibility()
2564
+ scheduleContentFilterSync()
352
2565
  loadGraph({ reset: true }).catch(error => {
353
- elements.stats.textContent = 'Failed to load agent graph'
354
2566
  console.error(error)
355
2567
  })
356
2568
  })
357
- elements.zoomIn.addEventListener('click', () => zoom(1.18))
358
- elements.zoomOut.addEventListener('click', () => zoom(0.84))
359
- elements.reset.addEventListener('click', resetView)
2569
+ elements.zoomIn.addEventListener('click', () => {
2570
+ const rect = canvas.getBoundingClientRect()
2571
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.14, 'button')
2572
+ })
2573
+ elements.zoomOut.addEventListener('click', () => {
2574
+ const rect = canvas.getBoundingClientRect()
2575
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.88, 'button')
2576
+ })
2577
+ if (elements.fit) {
2578
+ elements.fit.addEventListener('click', () => {
2579
+ focusPrimaryHub()
2580
+ })
2581
+ }
2582
+ elements.reset.addEventListener('click', () => {
2583
+ resetView()
2584
+ })
360
2585
  elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
361
2586
  elements.contentDialog.addEventListener('click', event => {
2587
+ const target = event.target
2588
+ if (target instanceof HTMLElement && target.dataset.nodeId) {
2589
+ selectNodeById(target.dataset.nodeId)
2590
+ return
2591
+ }
362
2592
  if (event.target === elements.contentDialog) elements.contentDialog.close()
363
2593
  })
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
- })
2594
+ canvas.addEventListener('wheel', handleWheelZoom, { passive: false })
2595
+ canvas.addEventListener('dblclick', event => {
2596
+ const point = worldPoint(event)
2597
+ const node = hitNode(point)
2598
+ if (node) {
2599
+ selectNode(node, { openContent: true })
2600
+ return
2601
+ }
2602
+
2603
+ const rect = canvas.getBoundingClientRect()
2604
+ const cursorX = event.clientX - rect.left
2605
+ const cursorY = event.clientY - rect.top
2606
+ zoomAtPoint(cursorX, cursorY, 1.12)
371
2607
  })
372
- canvas.addEventListener('wheel', event => {
373
- event.preventDefault()
374
- zoom(event.deltaY < 0 ? 1.08 : 0.92)
375
- }, { passive: false })
376
2608
  canvas.addEventListener('pointerdown', event => {
377
2609
  const point = worldPoint(event)
378
2610
  const node = hitNode(point)
@@ -380,12 +2612,24 @@ const bindEvents = () => {
380
2612
  if (node) {
381
2613
  node.x = point.x
382
2614
  node.y = point.y
2615
+ markRenderDirty()
383
2616
  }
384
2617
  canvas.setPointerCapture(event.pointerId)
385
2618
  })
386
2619
  canvas.addEventListener('pointermove', event => {
387
2620
  const point = worldPoint(event)
388
- state.hovered = hitNode(point)
2621
+ const now = performance.now()
2622
+ const canHoverHitTest =
2623
+ !(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.06)
2624
+ const shouldHitTest = canHoverHitTest &&
2625
+ (state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
2626
+ if (shouldHitTest) {
2627
+ state.hovered = hitNode(point)
2628
+ state.lastHoverHitAt = now
2629
+ } else if (!canHoverHitTest) {
2630
+ state.hovered = null
2631
+ }
2632
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
389
2633
  if (!state.pointer.down) return
390
2634
  const dx = event.clientX - state.pointer.x
391
2635
  const dy = event.clientY - state.pointer.y
@@ -393,36 +2637,83 @@ const bindEvents = () => {
393
2637
  state.pointer.y = event.clientY
394
2638
  state.pointer.moved = state.pointer.moved || Math.abs(dx) + Math.abs(dy) > 3
395
2639
  if (state.pointer.dragNode) {
396
- state.pointer.dragNode.x = point.x
397
- state.pointer.dragNode.y = point.y
2640
+ const dragNode = state.pointer.dragNode
2641
+ const previousX = dragNode.x
2642
+ const previousY = dragNode.y
2643
+ dragNode.x = point.x
2644
+ dragNode.y = point.y
2645
+ applyDragNeighborhoodAdjustment(dragNode, dragNode.x - previousX, dragNode.y - previousY)
2646
+ markRenderDirty()
398
2647
  return
399
2648
  }
400
2649
  state.transform.x += dx
401
2650
  state.transform.y += dy
2651
+ state.transform.x = clampTransformCoordinate(state.transform.x)
2652
+ state.transform.y = clampTransformCoordinate(state.transform.y)
2653
+ state.offscreenFrameCount = 0
2654
+ markRenderDirty()
402
2655
  })
403
2656
  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 })
2657
+ const draggedNode = state.pointer.dragNode
2658
+ if (draggedNode && state.pointer.moved) {
2659
+ settleNeighborhoodAroundNode(draggedNode)
2660
+ markRenderDirty()
2661
+ }
2662
+ if (draggedNode && !state.pointer.moved) selectNode(draggedNode, { openContent: false })
2663
+ if (!draggedNode && !state.pointer.moved) selectNode(state.hovered, { openContent: false })
406
2664
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
407
2665
  canvas.releasePointerCapture(event.pointerId)
408
2666
  })
2667
+ canvas.addEventListener('pointercancel', () => {
2668
+ state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
2669
+ })
2670
+ canvas.addEventListener('pointerenter', event => {
2671
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
2672
+ })
2673
+ canvas.addEventListener('pointerleave', event => {
2674
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
2675
+ })
2676
+ window.addEventListener('keydown', event => {
2677
+ if (event.key === '+' || event.key === '=') {
2678
+ event.preventDefault()
2679
+ const rect = canvas.getBoundingClientRect()
2680
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.12)
2681
+ return
2682
+ }
2683
+
2684
+ if (event.key === '-' || event.key === '_') {
2685
+ event.preventDefault()
2686
+ const rect = canvas.getBoundingClientRect()
2687
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.89)
2688
+ return
2689
+ }
2690
+
2691
+ if (event.key === '0') {
2692
+ event.preventDefault()
2693
+ resetView()
2694
+ }
2695
+ })
409
2696
  }
410
2697
 
411
2698
  const loadAgents = async () => {
412
2699
  const response = await fetch('/api/agents')
413
2700
  const payload = await response.json()
414
2701
  const agents = Array.isArray(payload.agents) ? payload.agents : []
415
- const currentExists = agents.some(agent => agent.id === state.agentId)
2702
+ const preferredAgent = state.agentId || initialAgentFromUrl || readStoredAgent()
2703
+ const currentExists = agents.some(agent => agent.id === preferredAgent)
416
2704
  const selected = currentExists
417
- ? state.agentId
2705
+ ? preferredAgent
418
2706
  : (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
419
2707
  const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
420
2708
 
421
2709
  state.agentId = selected
2710
+ writeStoredAgent(selected)
2711
+ syncAgentInUrl(selected)
422
2712
  if (signature !== state.agentsSignature) {
2713
+ const formatAgentLabel = (agent) => agent.id
423
2714
  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>'
2715
+ ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
2716
+ : '<option value="shared">shared</option>'
426
2717
  state.agentsSignature = signature
427
2718
  }
428
2719
  elements.agent.value = selected
@@ -443,6 +2734,10 @@ const loadGraph = async (options = { reset: false }) => {
443
2734
 
444
2735
  const payload = await response.json()
445
2736
  const graph = payload?.layout ?? payload
2737
+ state.graphTotals = {
2738
+ nodes: Number.isFinite(payload?.totals?.nodes) ? payload.totals.nodes : (Array.isArray(graph.nodes) ? graph.nodes.length : 0),
2739
+ edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
2740
+ }
446
2741
  const signature = payload?.signature ?? graphSignature(graph)
447
2742
  if (!options.reset && signature === state.graphSignature) return
448
2743
  const selectedId = state.selected?.id
@@ -450,18 +2745,37 @@ const loadGraph = async (options = { reset: false }) => {
450
2745
  state.graphSignature = signature
451
2746
  state.graph = graph
452
2747
  state.nodes = layout.nodes
2748
+ state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
453
2749
  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
2750
+ state.nodeDegrees = state.edges.reduce((degrees, edge) => {
2751
+ degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
2752
+ if (edge.target) {
2753
+ degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
2754
+ }
2755
+ return degrees
2756
+ }, new Map())
2757
+ state.nodeDetails = new Map()
2758
+ pushNodesToFilterWorker()
2759
+ resetContentFilter()
2760
+ sanitizeAllNodePositions()
2761
+ recomputeVisibility()
2762
+ scheduleContentFilterSync()
2763
+ const tags = new Set(state.nodes.flatMap(node => node.tags))
2764
+ setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
2765
+ elements.nodeCount.textContent = state.graphTotals.nodes
2766
+ elements.edgeCount.textContent = state.graphTotals.edges
458
2767
  elements.tagCount.textContent = tags.size
459
2768
  resize()
460
2769
  if (options.reset) resetView()
461
- selectNode(state.nodes.find(node => node.id === selectedId) ?? null)
2770
+ const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
2771
+ selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
2772
+ if (!selectedNode && elements.contentDialog.open) {
2773
+ elements.contentDialog.close()
2774
+ }
462
2775
  }
463
2776
 
464
2777
  bindEvents()
2778
+ initFilterWorker()
465
2779
  requestAnimationFrame(() => {
466
2780
  resize()
467
2781
  resetView()
@@ -492,7 +2806,6 @@ loadAgents()
492
2806
  setInterval(refreshGraphLoop, pollIntervalMs)
493
2807
  })
494
2808
  .catch(error => {
495
- elements.stats.textContent = 'Failed to load graph'
496
2809
  console.error(error)
497
2810
  })
498
2811