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

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