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

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