@andespindola/brainlink 0.1.0-beta.1 → 0.1.0-beta.100

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 (64) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +72 -2
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +374 -24
  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 +230 -99
  12. package/dist/application/frontend/client-html.js +62 -44
  13. package/dist/application/frontend/client-js.js +3331 -163
  14. package/dist/application/frontend/client-worker-js.js +66 -0
  15. package/dist/application/get-graph-layout.js +22 -7
  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 +67 -16
  40. package/dist/domain/markdown.js +36 -4
  41. package/dist/domain/middle-out.js +18 -0
  42. package/dist/infrastructure/config.js +132 -8
  43. package/dist/infrastructure/file-index.js +358 -0
  44. package/dist/infrastructure/file-system-vault.js +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 +180 -16
  55. package/docs/ARCHITECTURE.md +37 -26
  56. package/docs/QUICKSTART.md +111 -0
  57. package/docs/RELEASE.md +3 -3
  58. package/package.json +6 -4
  59. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  60. package/dist/infrastructure/sqlite/graph-reader.js +0 -120
  61. package/dist/infrastructure/sqlite/schema.js +0 -111
  62. package/dist/infrastructure/sqlite/search-reader.js +0 -156
  63. package/dist/infrastructure/sqlite/types.js +0 -1
  64. package/dist/infrastructure/sqlite-index.js +0 -25
@@ -1,18 +1,96 @@
1
1
  export const createClientJs = () => `const canvas = document.getElementById('graph')
2
+ const glCanvas = document.getElementById('graphGl')
2
3
  const ctx = canvas.getContext('2d')
4
+ const largeGraphNodeThreshold = 4000
5
+ const massiveGraphNodeThreshold = 20000
6
+ const largeGraphEdgeRenderLimit = 120000
7
+ const renderNodeBudget = 900
8
+ const zoomedMassiveRenderNodeBudget = 2200
9
+ const renderEdgeBudget = 2400
10
+ const clusterActivationNodeThreshold = 600
11
+ const clusterZoomThreshold = 0.18
12
+ const macroGalaxyZoomThreshold = 0.012
13
+ const macroGalaxyEnterHysteresis = 0.86
14
+ const macroGalaxyExitHysteresis = 1.24
15
+ const galaxyDiscoveryEnabled = false
16
+ const massiveAutoFitMacroScale = 0.006
17
+ const defaultMacroScale = 0.006
18
+ const clusterCellPixelSize = 64
19
+ const minNodePixelRadius = 2.3
20
+ const viewportPaddingPx = 280
21
+ const worldCoordinateLimit = 5_000_000
22
+ const transformCoordinateLimit = 20_000_000
23
+ const hoverHitTestIntervalMs = 64
24
+ const ecosystemLevelNodeCap = 999
25
+ const ecosystemActivationNodeThreshold = 1000
26
+ const ecosystemClusterEdgeLimit = 520
27
+ const ecosystemHubEdgeLimit = 120
28
+ const ecosystemSiblingEdgeLimit = 180
29
+ const ecosystemClusterScaleThreshold = 0.78
30
+ const massiveEcosystemClusterScaleThreshold = 4.2
31
+ const ecosystemSubgraphScaleThreshold = 0.18
32
+ const ecosystemMicroScaleThreshold = 0.08
33
+ const ecosystemFocusedParentLimit = 2
34
+ const zoomRecoveryGuardMs = 4200
35
+ const zoomCapTargetViewportShare = 0.72
36
+ const meshEdgeScaleThreshold = 0.09
37
+ const meshEdgeMinBudget = 140
38
+ const meshEdgeMaxBudget = 1400
39
+ const layeredCoreScaleThreshold = 0.55
40
+ const dragNeighborhoodMaxAffected = 180
41
+ const dragSettleRounds = 3
42
+ const wheelZoomExponent = 0.0009
43
+ const wheelZoomExponentCap = 0.035
44
+ const wheelZoomModifierBoost = 1.08
3
45
  const state = {
4
46
  graph: { nodes: [], edges: [] },
5
47
  nodes: [],
48
+ nodeById: new Map(),
6
49
  edges: [],
50
+ visibleNodes: [],
51
+ visibleEdges: [],
52
+ renderNodes: [],
53
+ renderEdges: [],
54
+ renderClusters: [],
55
+ renderClusterEdges: [],
56
+ nodeDegrees: new Map(),
7
57
  selected: null,
8
58
  hovered: null,
9
59
  query: '',
60
+ contentFilter: { query: '', ids: null, token: 0, timer: null },
10
61
  agentId: '',
11
62
  agentsSignature: '',
63
+ nodeDetails: new Map(),
12
64
  transform: { x: 0, y: 0, scale: 1 },
13
65
  pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
66
+ cursor: { x: 0, y: 0, inCanvas: false },
14
67
  graphSignature: '',
15
- last: performance.now()
68
+ graphStatus: '',
69
+ graphTotals: { nodes: 0, edges: 0 },
70
+ last: performance.now(),
71
+ offscreenFrameCount: 0,
72
+ recoveringViewport: false,
73
+ renderVisibilityDirty: true,
74
+ lastViewportKey: '',
75
+ visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
76
+ visibleEdgeByNode: new Map(),
77
+ ecosystemClusters: [],
78
+ ecosystemClustersBySize: new Map(),
79
+ ecosystemNodeClusterBySize: new Map(),
80
+ ecosystemLevelSizes: [],
81
+ ecosystemExpansionLevels: [],
82
+ ecosystemBaseSize: ecosystemLevelNodeCap,
83
+ ecosystemHubCluster: null,
84
+ macroCenter: { x: 0, y: 0 },
85
+ macroRepresentative: null,
86
+ primaryHub: null,
87
+ hubNeighborDistance: Number.POSITIVE_INFINITY,
88
+ filterWorker: null,
89
+ filterReady: false,
90
+ lastHoverHitAt: 0,
91
+ lastManualZoomAt: 0,
92
+ lastZoomFocus: { x: 0, y: 0, at: 0 },
93
+ macroViewActive: false
16
94
  }
17
95
 
18
96
  const byId = id => document.getElementById(id)
@@ -23,25 +101,82 @@ const escapeHtml = value => String(value)
23
101
  .replaceAll('"', '"')
24
102
  .replaceAll("'", ''')
25
103
  const elements = {
26
- stats: byId('stats'),
27
104
  search: byId('search'),
28
105
  agent: byId('agent'),
29
- title: byId('title'),
30
- path: byId('path'),
31
- tags: byId('tags'),
32
- notes: byId('notes'),
33
- content: byId('content'),
34
- outgoing: byId('outgoing'),
35
- incoming: byId('incoming'),
36
106
  nodeCount: byId('nodeCount'),
37
107
  edgeCount: byId('edgeCount'),
38
108
  tagCount: byId('tagCount'),
39
109
  zoomIn: byId('zoomIn'),
40
110
  zoomOut: byId('zoomOut'),
41
- reset: byId('reset')
111
+ fit: byId('fit'),
112
+ reset: byId('reset'),
113
+ contentDialog: byId('contentDialog'),
114
+ contentTitle: byId('contentTitle'),
115
+ contentPath: byId('contentPath'),
116
+ contentTags: byId('contentTags'),
117
+ contentOutgoing: byId('contentOutgoing'),
118
+ contentIncoming: byId('contentIncoming'),
119
+ contentBody: byId('contentBody'),
120
+ contentClose: byId('contentClose')
42
121
  }
43
122
 
44
- const agentQuery = () => state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''
123
+ const zoomRange = {
124
+ min: 0.0002,
125
+ max: 4.5
126
+ }
127
+
128
+ const initialAgentFromUrl = (() => {
129
+ try {
130
+ const raw = new URL(window.location.href).searchParams.get('agent')
131
+ const value = raw?.trim() ?? ''
132
+ return value.length > 0 ? value : ''
133
+ } catch {
134
+ return ''
135
+ }
136
+ })()
137
+
138
+ const selectedAgentStorageKey = 'brainlink:selected-agent'
139
+
140
+ const readStoredAgent = () => {
141
+ try {
142
+ const value = window.localStorage.getItem(selectedAgentStorageKey)?.trim() ?? ''
143
+ return value.length > 0 ? value : ''
144
+ } catch {
145
+ return ''
146
+ }
147
+ }
148
+
149
+ const writeStoredAgent = (agentId) => {
150
+ try {
151
+ if (!agentId) {
152
+ window.localStorage.removeItem(selectedAgentStorageKey)
153
+ return
154
+ }
155
+ window.localStorage.setItem(selectedAgentStorageKey, agentId)
156
+ } catch {}
157
+ }
158
+
159
+ const syncAgentInUrl = (agentId) => {
160
+ try {
161
+ const url = new URL(window.location.href)
162
+ if (agentId && agentId.trim().length > 0) {
163
+ url.searchParams.set('agent', agentId)
164
+ } else {
165
+ url.searchParams.delete('agent')
166
+ }
167
+ window.history.replaceState({}, '', url.toString())
168
+ } catch {}
169
+ }
170
+
171
+ const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
172
+
173
+ const setGraphStatus = text => {
174
+ state.graphStatus = text
175
+ }
176
+
177
+ const handleGraphRefreshError = error => {
178
+ console.error(error)
179
+ }
45
180
 
46
181
  const graphTheme = {
47
182
  node: '#aeb8c5',
@@ -56,6 +191,238 @@ const graphTheme = {
56
191
  label: '#edf2f7'
57
192
  }
58
193
 
194
+ const parseRgb = color => {
195
+ const normalized = color.trim()
196
+ if (normalized.startsWith('#')) {
197
+ const value = normalized.slice(1)
198
+ const expanded = value.length === 3
199
+ ? value.split('').map(char => char + char).join('')
200
+ : value
201
+ const parsed = Number.parseInt(expanded, 16)
202
+ return [
203
+ ((parsed >> 16) & 255) / 255,
204
+ ((parsed >> 8) & 255) / 255,
205
+ (parsed & 255) / 255
206
+ ]
207
+ }
208
+
209
+ const match = normalized.match(/rgba?\\(([^)]+)\\)/)
210
+ if (!match) return [1, 1, 1]
211
+ const parts = match[1].split(',').map(part => Number.parseFloat(part.trim()))
212
+ return [
213
+ Math.max(0, Math.min(1, (parts[0] ?? 255) / 255)),
214
+ Math.max(0, Math.min(1, (parts[1] ?? 255) / 255)),
215
+ Math.max(0, Math.min(1, (parts[2] ?? 255) / 255))
216
+ ]
217
+ }
218
+
219
+ const rgba = (color, alpha = 1) => {
220
+ const [red, green, blue] = parseRgb(color)
221
+ return [red, green, blue, Math.max(0, Math.min(1, alpha))]
222
+ }
223
+
224
+ const createShader = (gl, type, source) => {
225
+ const shader = gl.createShader(type)
226
+ if (!shader) return null
227
+ gl.shaderSource(shader, source)
228
+ gl.compileShader(shader)
229
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
230
+ gl.deleteShader(shader)
231
+ return null
232
+ }
233
+ return shader
234
+ }
235
+
236
+ const createProgram = (gl, vertexSource, fragmentSource) => {
237
+ const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource)
238
+ const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)
239
+ if (!vertexShader || !fragmentShader) return null
240
+ const program = gl.createProgram()
241
+ if (!program) return null
242
+ gl.attachShader(program, vertexShader)
243
+ gl.attachShader(program, fragmentShader)
244
+ gl.linkProgram(program)
245
+ gl.deleteShader(vertexShader)
246
+ gl.deleteShader(fragmentShader)
247
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
248
+ gl.deleteProgram(program)
249
+ return null
250
+ }
251
+ return program
252
+ }
253
+
254
+ const createWebGlRenderer = targetCanvas => {
255
+ if (!targetCanvas) return null
256
+
257
+ const gl = targetCanvas.getContext('webgl2', { alpha: true, antialias: true }) ||
258
+ targetCanvas.getContext('webgl', { alpha: true, antialias: true })
259
+ if (!gl) return null
260
+
261
+ const lineProgram = createProgram(
262
+ gl,
263
+ 'attribute vec2 a_position; uniform vec2 u_resolution; void main() { vec2 zeroToOne = a_position / u_resolution; vec2 clip = zeroToOne * 2.0 - 1.0; gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); }',
264
+ 'precision mediump float; uniform vec4 u_color; void main() { gl_FragColor = u_color; }'
265
+ )
266
+ const pointProgram = createProgram(
267
+ gl,
268
+ 'attribute vec2 a_position; attribute float a_size; uniform vec2 u_resolution; void main() { vec2 zeroToOne = a_position / u_resolution; vec2 clip = zeroToOne * 2.0 - 1.0; gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); gl_PointSize = a_size; }',
269
+ 'precision mediump float; uniform vec4 u_color; void main() { vec2 center = gl_PointCoord - vec2(0.5); float distanceFromCenter = length(center); if (distanceFromCenter > 0.5) discard; float edge = smoothstep(0.5, 0.42, distanceFromCenter); gl_FragColor = vec4(u_color.rgb, u_color.a * edge); }'
270
+ )
271
+
272
+ if (!lineProgram || !pointProgram) return null
273
+
274
+ const lineBuffer = gl.createBuffer()
275
+ const pointPositionBuffer = gl.createBuffer()
276
+ const pointSizeBuffer = gl.createBuffer()
277
+ if (!lineBuffer || !pointPositionBuffer || !pointSizeBuffer) return null
278
+
279
+ const linePositionLocation = gl.getAttribLocation(lineProgram, 'a_position')
280
+ const lineResolutionLocation = gl.getUniformLocation(lineProgram, 'u_resolution')
281
+ const lineColorLocation = gl.getUniformLocation(lineProgram, 'u_color')
282
+ const pointPositionLocation = gl.getAttribLocation(pointProgram, 'a_position')
283
+ const pointSizeLocation = gl.getAttribLocation(pointProgram, 'a_size')
284
+ const pointResolutionLocation = gl.getUniformLocation(pointProgram, 'u_resolution')
285
+ const pointColorLocation = gl.getUniformLocation(pointProgram, 'u_color')
286
+
287
+ gl.enable(gl.BLEND)
288
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
289
+
290
+ const setViewport = (width, height) => {
291
+ gl.viewport(0, 0, targetCanvas.width, targetCanvas.height)
292
+ return [targetCanvas.width / Math.max(width, 1), targetCanvas.height / Math.max(height, 1)]
293
+ }
294
+
295
+ const screenPoint = (node, ratioX, ratioY) => [
296
+ (node.x * state.transform.scale + state.transform.x) * ratioX,
297
+ (node.y * state.transform.scale + state.transform.y) * ratioY
298
+ ]
299
+
300
+ const clear = (width, height) => {
301
+ setViewport(width, height)
302
+ gl.clearColor(0, 0, 0, 0)
303
+ gl.clear(gl.COLOR_BUFFER_BIT)
304
+ }
305
+
306
+ const drawLines = (edges, color, width, height) => {
307
+ if (edges.length === 0) return
308
+ const [ratioX, ratioY] = setViewport(width, height)
309
+ const positions = new Float32Array(edges.length * 4)
310
+ for (let index = 0; index < edges.length; index += 1) {
311
+ const edge = edges[index]
312
+ const source = screenPoint(edge.sourceNode, ratioX, ratioY)
313
+ const target = screenPoint(edge.targetNode, ratioX, ratioY)
314
+ const offset = index * 4
315
+ positions[offset] = source[0]
316
+ positions[offset + 1] = source[1]
317
+ positions[offset + 2] = target[0]
318
+ positions[offset + 3] = target[1]
319
+ }
320
+
321
+ gl.useProgram(lineProgram)
322
+ gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer)
323
+ gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STREAM_DRAW)
324
+ gl.enableVertexAttribArray(linePositionLocation)
325
+ gl.vertexAttribPointer(linePositionLocation, 2, gl.FLOAT, false, 0, 0)
326
+ gl.uniform2f(lineResolutionLocation, targetCanvas.width, targetCanvas.height)
327
+ gl.uniform4fv(lineColorLocation, color)
328
+ gl.lineWidth(1)
329
+ gl.drawArrays(gl.LINES, 0, edges.length * 2)
330
+ }
331
+
332
+ const drawPoints = (nodes, color, sizeForNode, width, height) => {
333
+ if (nodes.length === 0) return
334
+ const [ratioX, ratioY] = setViewport(width, height)
335
+ const positions = new Float32Array(nodes.length * 2)
336
+ const sizes = new Float32Array(nodes.length)
337
+ for (let index = 0; index < nodes.length; index += 1) {
338
+ const node = nodes[index]
339
+ const point = screenPoint(node, ratioX, ratioY)
340
+ const offset = index * 2
341
+ positions[offset] = point[0]
342
+ positions[offset + 1] = point[1]
343
+ sizes[index] = sizeForNode(node) * ((ratioX + ratioY) / 2)
344
+ }
345
+
346
+ gl.useProgram(pointProgram)
347
+ gl.bindBuffer(gl.ARRAY_BUFFER, pointPositionBuffer)
348
+ gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STREAM_DRAW)
349
+ gl.enableVertexAttribArray(pointPositionLocation)
350
+ gl.vertexAttribPointer(pointPositionLocation, 2, gl.FLOAT, false, 0, 0)
351
+ gl.bindBuffer(gl.ARRAY_BUFFER, pointSizeBuffer)
352
+ gl.bufferData(gl.ARRAY_BUFFER, sizes, gl.STREAM_DRAW)
353
+ gl.enableVertexAttribArray(pointSizeLocation)
354
+ gl.vertexAttribPointer(pointSizeLocation, 1, gl.FLOAT, false, 0, 0)
355
+ gl.uniform2f(pointResolutionLocation, targetCanvas.width, targetCanvas.height)
356
+ gl.uniform4fv(pointColorLocation, color)
357
+ gl.drawArrays(gl.POINTS, 0, nodes.length)
358
+ }
359
+
360
+ return { clear, drawLines, drawPoints }
361
+ }
362
+
363
+ const webGlRenderer = createWebGlRenderer(glCanvas)
364
+
365
+ const initFilterWorker = () => {
366
+ if (typeof Worker === 'undefined') {
367
+ return
368
+ }
369
+ try {
370
+ const worker = new Worker('/app-worker.js')
371
+ worker.onmessage = event => {
372
+ const payload = event.data
373
+ if (!payload || typeof payload !== 'object') return
374
+
375
+ if (payload.type === 'ready') {
376
+ state.filterReady = true
377
+ if (state.nodes.length > 0) {
378
+ worker.postMessage({
379
+ type: 'load-nodes',
380
+ nodes: state.nodes.map(node => ({
381
+ id: node.id,
382
+ title: node.title,
383
+ path: node.path || '',
384
+ tags: Array.isArray(node.tags) ? node.tags : []
385
+ }))
386
+ })
387
+ }
388
+ return
389
+ }
390
+
391
+ if (payload.type === 'filter-result') {
392
+ const token = payload.token
393
+ if (token !== state.contentFilter.token) {
394
+ return
395
+ }
396
+
397
+ const ids = Array.isArray(payload.ids) ? payload.ids.filter(id => typeof id === 'string') : []
398
+ state.contentFilter.query = normalizeQuery(state.query)
399
+ state.contentFilter.ids = new Set(ids)
400
+ recomputeVisibility()
401
+ }
402
+ }
403
+ state.filterWorker = worker
404
+ } catch {
405
+ state.filterWorker = null
406
+ state.filterReady = false
407
+ }
408
+ }
409
+
410
+ const pushNodesToFilterWorker = () => {
411
+ if (!state.filterWorker || !state.filterReady) {
412
+ return
413
+ }
414
+
415
+ state.filterWorker.postMessage({
416
+ type: 'load-nodes',
417
+ nodes: state.nodes.map(node => ({
418
+ id: node.id,
419
+ title: node.title,
420
+ path: node.path || '',
421
+ tags: Array.isArray(node.tags) ? node.tags : []
422
+ }))
423
+ })
424
+ }
425
+
59
426
  const resize = () => {
60
427
  const rect = canvas.getBoundingClientRect()
61
428
  const width = Math.max(rect.width, 320)
@@ -63,77 +430,2095 @@ const resize = () => {
63
430
  const ratio = window.devicePixelRatio || 1
64
431
  canvas.width = Math.floor(width * ratio)
65
432
  canvas.height = Math.floor(height * ratio)
433
+ if (glCanvas) {
434
+ glCanvas.width = Math.floor(width * ratio)
435
+ glCanvas.height = Math.floor(height * ratio)
436
+ }
66
437
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
438
+ markRenderDirty()
67
439
  }
68
440
 
69
- const filteredNodes = () => {
70
- const query = state.query.trim().toLowerCase()
71
- if (!query) return state.nodes
72
- return state.nodes.filter(node =>
441
+ const normalizeQuery = value => value.trim().toLowerCase()
442
+ const hubNodeRetentionLimit = 2
443
+ const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map|mapa)\b/i
444
+ const memoryHubPathPattern = /\bmemory[-_\s]*hub\b/i
445
+
446
+ const hubNodeScore = node => {
447
+ const title = node.title.trim().toLowerCase()
448
+ if (title === 'memory hub') return 6
449
+ if (title === 'knowledge hub') return 5
450
+ if (memoryHubPathPattern.test(node.path || '')) return 4
451
+ if (node.tags.some(tag => tag.trim().toLowerCase() === 'memory-hub')) return 3
452
+ if (/\bmoc\b/i.test(node.title)) return 2
453
+ return hubNodePattern.test(node.title) || hubNodePattern.test(node.path || '') || node.tags.some(tag => hubNodePattern.test(tag))
454
+ ? 1
455
+ : 0
456
+ }
457
+
458
+ const localFilteredNodes = query =>
459
+ state.nodes.filter(node =>
73
460
  node.title.toLowerCase().includes(query) ||
74
- node.path.toLowerCase().includes(query) ||
461
+ (node.path || '').toLowerCase().includes(query) ||
75
462
  node.tags.some(tag => tag.toLowerCase().includes(query))
76
463
  )
464
+
465
+ const rankedHubNodes = () => {
466
+ if (state.nodes.length === 0) {
467
+ return []
468
+ }
469
+
470
+ const byTitleAndDegree = [...state.nodes]
471
+ .filter(node => hubNodeScore(node) > 0)
472
+ .sort((left, right) => {
473
+ const byHubScore = hubNodeScore(right) - hubNodeScore(left)
474
+ if (byHubScore !== 0) return byHubScore
475
+ const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
476
+ if (byDegree !== 0) return byDegree
477
+ return left.title.localeCompare(right.title)
478
+ })
479
+
480
+ if (byTitleAndDegree.length > 0) {
481
+ return byTitleAndDegree.slice(0, hubNodeRetentionLimit)
482
+ }
483
+
484
+ return [...state.nodes]
485
+ .sort((left, right) => {
486
+ const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
487
+ if (byDegree !== 0) return byDegree
488
+ return left.title.localeCompare(right.title)
489
+ })
490
+ .slice(0, 1)
491
+ }
492
+
493
+ const withPersistentHubNodes = nodes => {
494
+ if (nodes.length === 0) {
495
+ return rankedHubNodes()
496
+ }
497
+
498
+ const ids = new Set(nodes.map(node => node.id))
499
+ const hubsToKeep = rankedHubNodes().filter(node => !ids.has(node.id))
500
+ return nodes.concat(hubsToKeep)
501
+ }
502
+
503
+ const filteredNodes = () => {
504
+ const query = normalizeQuery(state.query)
505
+ if (!query) return state.nodes
506
+ if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
507
+ const matched = state.nodes.filter(node => state.contentFilter.ids.has(node.id))
508
+ return withPersistentHubNodes(matched)
509
+ }
510
+
511
+ return withPersistentHubNodes(localFilteredNodes(query))
512
+ }
513
+
514
+ const resolveMacroRepresentative = (nodes) => {
515
+ if (nodes.length === 0) {
516
+ return null
517
+ }
518
+
519
+ const hubCandidate = state.primaryHub && nodes.some(node => node.id === state.primaryHub.id)
520
+ ? state.primaryHub
521
+ : null
522
+ let best = hubCandidate ?? nodes[0]
523
+ let bestDegree = state.nodeDegrees.get(best.id) ?? 0
524
+
525
+ for (let index = 1; index < nodes.length; index += 1) {
526
+ const node = nodes[index]
527
+ const degree = state.nodeDegrees.get(node.id) ?? 0
528
+ if (degree > bestDegree) {
529
+ best = node
530
+ bestDegree = degree
531
+ }
532
+ }
533
+
534
+ return best
535
+ }
536
+
537
+ const nearestHubNeighborDistance = (hub, nodes) => {
538
+ if (!hub || nodes.length <= 1) {
539
+ return Number.POSITIVE_INFINITY
540
+ }
541
+
542
+ let minimum = Number.POSITIVE_INFINITY
543
+ for (let index = 0; index < nodes.length; index += 1) {
544
+ const node = nodes[index]
545
+ if (node.id === hub.id) continue
546
+ const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
547
+ if (distance < minimum) {
548
+ minimum = distance
549
+ }
550
+ }
551
+
552
+ return minimum
553
+ }
554
+
555
+ const isDominantHub = (hub, nodeCount = state.visibleNodes.length) => {
556
+ if (!hub || nodeCount <= 0) {
557
+ return false
558
+ }
559
+
560
+ const degree = state.nodeDegrees.get(hub.id) ?? 0
561
+ const minimumDegree = Math.max(18, Math.sqrt(nodeCount) * 1.8)
562
+ const degreeRatio = degree / Math.max(nodeCount, 1)
563
+ return degree >= minimumDegree || degreeRatio >= 0.035
77
564
  }
78
565
 
79
- const visibleIds = () => new Set(filteredNodes().map(node => node.id))
566
+ const recomputeVisibility = () => {
567
+ const nodes = filteredNodes()
568
+ const ids = new Set(nodes.map(node => node.id))
569
+ const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
570
+ const limitedEdges = state.nodes.length > largeGraphNodeThreshold
571
+ ? [...edges]
572
+ .sort((left, right) => edgeWeight(right) - edgeWeight(left))
573
+ .slice(0, largeGraphEdgeRenderLimit)
574
+ : edges
80
575
 
81
- const visibleEdges = () => {
82
- const ids = visibleIds()
83
- return state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
576
+ state.visibleNodes = nodes
577
+ state.visibleEdges = limitedEdges
578
+ state.visibleNodeSpatial = createSpatialIndex(nodes)
579
+ state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
580
+ const primaryHub = rankedHubNodes()[0] ?? null
581
+ state.primaryHub = primaryHub
582
+ state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
583
+ const bounds = graphBounds(nodes)
584
+ const macroHub = isDominantHub(primaryHub, nodes.length) ? primaryHub : null
585
+ state.macroCenter = bounds
586
+ ? {
587
+ x: macroHub ? macroHub.x : (bounds.minX + bounds.maxX) / 2,
588
+ y: macroHub ? macroHub.y : (bounds.minY + bounds.maxY) / 2
589
+ }
590
+ : { x: 0, y: 0 }
591
+ const ecosystemGraph = nodes.length > ecosystemActivationNodeThreshold
592
+ ? buildEcosystemGraph(nodes, state.macroCenter, primaryHub)
593
+ : {
594
+ clusters: [],
595
+ clustersBySize: new Map(),
596
+ nodeClusterBySize: new Map(),
597
+ levelSizes: [],
598
+ expansionLevels: [],
599
+ baseSize: ecosystemLevelNodeCap,
600
+ hubCluster: null
601
+ }
602
+ state.ecosystemClusters = ecosystemGraph.clusters
603
+ state.ecosystemClustersBySize = ecosystemGraph.clustersBySize
604
+ state.ecosystemNodeClusterBySize = ecosystemGraph.nodeClusterBySize
605
+ state.ecosystemLevelSizes = ecosystemGraph.levelSizes
606
+ state.ecosystemExpansionLevels = ecosystemGraph.expansionLevels
607
+ state.ecosystemBaseSize = ecosystemGraph.baseSize
608
+ state.ecosystemHubCluster = ecosystemGraph.hubCluster
609
+ state.macroRepresentative = resolveMacroRepresentative(nodes)
610
+ markRenderDirty()
84
611
  }
85
612
 
86
613
  const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
614
+ const markRenderDirty = () => {
615
+ state.renderVisibilityDirty = true
616
+ }
87
617
 
88
- const resetView = () => {
89
- const rect = canvas.getBoundingClientRect()
90
- state.transform = { x: Math.max(rect.width, 320) / 2, y: Math.max(rect.height, 320) / 2, scale: 1 }
618
+ const createSpatialIndex = nodes => {
619
+ if (nodes.length === 0) {
620
+ return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
621
+ }
622
+
623
+ const bounds = graphBounds(nodes)
624
+ if (!bounds) {
625
+ return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
626
+ }
627
+
628
+ const targetNodesPerCell = 18
629
+ const approximateCellArea = Math.max((bounds.width * bounds.height) / Math.max(nodes.length / targetNodesPerCell, 1), 1)
630
+ const cellSize = Math.max(90, Math.min(2200, Math.sqrt(approximateCellArea)))
631
+ const buckets = new Map()
632
+
633
+ for (let index = 0; index < nodes.length; index += 1) {
634
+ const node = nodes[index]
635
+ const cellX = Math.floor((node.x - bounds.minX) / cellSize)
636
+ const cellY = Math.floor((node.y - bounds.minY) / cellSize)
637
+ const key = cellX + ':' + cellY
638
+ const bucket = buckets.get(key)
639
+ if (bucket) {
640
+ bucket.push(node)
641
+ continue
642
+ }
643
+ buckets.set(key, [node])
644
+ }
645
+
646
+ return {
647
+ cellSize,
648
+ minX: bounds.minX,
649
+ minY: bounds.minY,
650
+ maxX: bounds.maxX,
651
+ maxY: bounds.maxY,
652
+ buckets
653
+ }
91
654
  }
92
655
 
93
- const createLayout = graph => {
94
- const nodes = graph.nodes.map(node => ({
95
- ...node,
96
- x: Number.isFinite(node.x) ? node.x : 0,
97
- y: Number.isFinite(node.y) ? node.y : 0
98
- }))
99
- const nodeMap = new Map(nodes.map(node => [node.id, node]))
100
- const edges = graph.edges
101
- .filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
102
- .map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
103
- return { nodes, edges }
656
+ const viewportNodesFromSpatialIndex = viewport => {
657
+ if (state.visibleNodes.length <= 2500) {
658
+ return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
659
+ }
660
+
661
+ const spatial = state.visibleNodeSpatial
662
+ if (!spatial || spatial.buckets.size === 0) {
663
+ return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
664
+ }
665
+
666
+ const minCellX = Math.floor((viewport.minX - spatial.minX) / spatial.cellSize)
667
+ const maxCellX = Math.floor((viewport.maxX - spatial.minX) / spatial.cellSize)
668
+ const minCellY = Math.floor((viewport.minY - spatial.minY) / spatial.cellSize)
669
+ const maxCellY = Math.floor((viewport.maxY - spatial.minY) / spatial.cellSize)
670
+ const nodes = []
671
+
672
+ for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
673
+ for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
674
+ const bucket = spatial.buckets.get(cellX + ':' + cellY)
675
+ if (!bucket) continue
676
+
677
+ for (let index = 0; index < bucket.length; index += 1) {
678
+ const node = bucket[index]
679
+ if (isNodeInViewport(node, viewport)) {
680
+ nodes.push(node)
681
+ }
682
+ }
683
+ }
684
+ }
685
+
686
+ return nodes
687
+ }
688
+
689
+ const createVisibleEdgeLookup = edges => {
690
+ const lookup = new Map()
691
+
692
+ for (let index = 0; index < edges.length; index += 1) {
693
+ const edge = edges[index]
694
+ if (!edge.target) continue
695
+
696
+ const sourceList = lookup.get(edge.source)
697
+ if (sourceList) {
698
+ sourceList.push(edge)
699
+ } else {
700
+ lookup.set(edge.source, [edge])
701
+ }
702
+
703
+ const targetList = lookup.get(edge.target)
704
+ if (targetList) {
705
+ targetList.push(edge)
706
+ } else {
707
+ lookup.set(edge.target, [edge])
708
+ }
709
+ }
710
+
711
+ return lookup
712
+ }
713
+
714
+ const ecosystemKeyForNode = node => {
715
+ if (typeof node.segment === 'string' && node.segment.trim()) {
716
+ return node.segment.trim()
717
+ }
718
+ if (typeof node.group === 'string' && node.group.trim()) {
719
+ return node.group.trim()
720
+ }
721
+ const pathParts = String(node.path || '')
722
+ .split('/')
723
+ .filter(part => part.trim())
724
+ .slice(0, 2)
725
+ return pathParts.length > 0 ? pathParts.join('/') : 'root'
726
+ }
727
+
728
+ const compareNodesForEcosystem = (left, right) => {
729
+ const keyComparison = ecosystemKeyForNode(left).localeCompare(ecosystemKeyForNode(right))
730
+ if (keyComparison !== 0) return keyComparison
731
+ const leftDegree = state.nodeDegrees.get(left.id) ?? 0
732
+ const rightDegree = state.nodeDegrees.get(right.id) ?? 0
733
+ if (leftDegree !== rightDegree) return rightDegree - leftDegree
734
+ return String(left.title || left.id).localeCompare(String(right.title || right.id))
735
+ }
736
+
737
+ const selectEcosystemRepresentative = nodes => {
738
+ let representative = nodes[0] ?? null
739
+ let representativeScore = Number.NEGATIVE_INFINITY
740
+
741
+ for (let index = 0; index < nodes.length; index += 1) {
742
+ const node = nodes[index]
743
+ const score = (state.nodeDegrees.get(node.id) ?? 0) + hubNodeScore(node) * 1000
744
+ if (score > representativeScore) {
745
+ representative = node
746
+ representativeScore = score
747
+ }
748
+ }
749
+
750
+ return representative
751
+ }
752
+
753
+ const ecosystemLayoutSpacingForSize = size => {
754
+ if (size >= ecosystemLevelNodeCap) return 260
755
+ if (size >= 320) return 110
756
+ if (size >= 120) return 64
757
+ if (size >= 48) return 34
758
+ if (size >= 18) return 18
759
+ if (size >= 8) return 11
760
+ return 7
761
+ }
762
+
763
+ const buildEcosystemLevelSizes = nodeCount => {
764
+ if (nodeCount <= 0) return []
765
+ const sizes = []
766
+ let currentSize = Math.max(1, Math.ceil(nodeCount / ecosystemLevelNodeCap))
767
+ while (currentSize >= 1) {
768
+ sizes.push(currentSize)
769
+ if (currentSize === 1) {
770
+ break
771
+ }
772
+ const nextSize = Math.max(1, Math.ceil(currentSize / ecosystemLevelNodeCap))
773
+ if (nextSize === currentSize) {
774
+ break
775
+ }
776
+ currentSize = nextSize
777
+ }
778
+ return sizes
779
+ }
780
+
781
+ const buildEcosystemExpansionLevels = (levelSizes, nodeCount) => {
782
+ if (levelSizes.length <= 1) {
783
+ return []
784
+ }
785
+ const isMassive = nodeCount > massiveGraphNodeThreshold
786
+ const maxScale = isMassive
787
+ ? massiveEcosystemClusterScaleThreshold
788
+ : ecosystemClusterScaleThreshold
789
+ const startScale = isMassive ? 0.82 : 0.18
790
+ const transitionCount = levelSizes.length - 1
791
+ const usableScale = Math.max(0.08, maxScale - startScale)
792
+ const step = usableScale / transitionCount
793
+ const stride = isMassive ? 0.9 : 0.78
794
+ const overlap = isMassive ? 1.28 : 1.75
795
+ const levels = []
796
+ for (let index = 0; index < transitionCount; index += 1) {
797
+ const start = startScale + step * index * stride
798
+ const end = Math.min(maxScale, start + step * overlap)
799
+ levels.push({
800
+ parentSize: levelSizes[index],
801
+ childSize: levelSizes[index + 1],
802
+ start,
803
+ end
804
+ })
805
+ }
806
+ return levels
807
+ }
808
+
809
+ const ecosystemCompactPoint = (index, total, center, spacing) => {
810
+ if (total <= 1) {
811
+ return { x: center.x, y: center.y }
812
+ }
813
+ const angle = index * 2.399963229728653
814
+ const radius = spacing * Math.sqrt(index + 1)
815
+ return {
816
+ x: center.x + Math.cos(angle) * radius,
817
+ y: center.y + Math.sin(angle) * radius
818
+ }
819
+ }
820
+
821
+ const buildEcosystemCluster = (nodes, index, point) => {
822
+ const count = Math.max(nodes.length, 1)
823
+ const representative = selectEcosystemRepresentative(nodes)
824
+
825
+ return {
826
+ id: 'ecosystem-' + index,
827
+ x: point.x,
828
+ y: point.y,
829
+ count,
830
+ nodeIds: nodes.map(node => node.id),
831
+ representative,
832
+ label: ecosystemKeyForNode(nodes[0] ?? representative ?? { path: '' })
833
+ }
834
+ }
835
+
836
+ const buildEcosystemHubCluster = (hub, center) => hub
837
+ ? {
838
+ id: 'ecosystem-hub',
839
+ x: center.x,
840
+ y: center.y,
841
+ count: 1,
842
+ size: 1,
843
+ nodeIds: [hub.id],
844
+ representative: hub,
845
+ label: hub.title || 'Memory Hub',
846
+ parentId: null,
847
+ parentX: null,
848
+ parentY: null,
849
+ isHub: true
850
+ }
851
+ : null
852
+
853
+ const buildEcosystemLevel = (sortedNodes, size, parentLookup, center) => {
854
+ const clusters = []
855
+ const clusterByNodeId = new Map()
856
+ const parentChildIndex = new Map()
857
+
858
+ for (let offset = 0; offset < sortedNodes.length; offset += size) {
859
+ const clusterNodes = sortedNodes.slice(offset, offset + size)
860
+ const parentCluster = parentLookup?.get(clusterNodes[0]?.id)
861
+ const siblingIndex = parentCluster
862
+ ? (parentChildIndex.get(parentCluster.id) ?? 0)
863
+ : clusters.length
864
+ if (parentCluster) {
865
+ parentChildIndex.set(parentCluster.id, siblingIndex + 1)
866
+ }
867
+ const point = parentCluster
868
+ ? ecosystemCompactPoint(siblingIndex, Math.ceil((parentCluster.count || size) / size), parentCluster, ecosystemLayoutSpacingForSize(size))
869
+ : ecosystemCompactPoint(clusters.length, Math.ceil(sortedNodes.length / size), center, ecosystemLayoutSpacingForSize(size))
870
+ const cluster = {
871
+ ...buildEcosystemCluster(clusterNodes, clusters.length, point),
872
+ id: 'ecosystem-' + size + '-' + clusters.length,
873
+ size,
874
+ parentId: parentCluster?.id ?? null,
875
+ parentX: parentCluster?.x ?? null,
876
+ parentY: parentCluster?.y ?? null
877
+ }
878
+ clusters.push(cluster)
879
+ for (let index = 0; index < clusterNodes.length; index += 1) {
880
+ clusterByNodeId.set(clusterNodes[index].id, cluster)
881
+ }
882
+ }
883
+
884
+ return { clusters, clusterByNodeId }
885
+ }
886
+
887
+ const buildEcosystemGraph = (nodes, center, hub) => {
888
+ if (nodes.length === 0) {
889
+ return {
890
+ clusters: [],
891
+ clustersBySize: new Map(),
892
+ nodeClusterBySize: new Map(),
893
+ levelSizes: [],
894
+ expansionLevels: [],
895
+ baseSize: ecosystemLevelNodeCap,
896
+ hubCluster: null
897
+ }
898
+ }
899
+
900
+ const hubCluster = buildEcosystemHubCluster(hub, center)
901
+ const sortedNodes = nodes
902
+ .filter(node => node.id !== hub?.id)
903
+ .sort(compareNodesForEcosystem)
904
+ const levelSizes = buildEcosystemLevelSizes(sortedNodes.length)
905
+ const expansionLevels = buildEcosystemExpansionLevels(levelSizes, nodes.length)
906
+ const baseSize = levelSizes[0] ?? ecosystemLevelNodeCap
907
+ const clustersBySize = new Map()
908
+ const nodeClusterBySize = new Map()
909
+ let parentLookup = null
910
+
911
+ for (let index = 0; index < levelSizes.length; index += 1) {
912
+ const size = levelSizes[index]
913
+ const level = buildEcosystemLevel(sortedNodes, size, parentLookup, center)
914
+ clustersBySize.set(size, level.clusters)
915
+ nodeClusterBySize.set(size, level.clusterByNodeId)
916
+ parentLookup = level.clusterByNodeId
917
+ }
918
+
919
+ return {
920
+ clusters: clustersBySize.get(baseSize) ?? [],
921
+ clustersBySize,
922
+ nodeClusterBySize,
923
+ levelSizes,
924
+ expansionLevels,
925
+ baseSize,
926
+ hubCluster
927
+ }
928
+ }
929
+
930
+ const isClusterInViewport = (cluster, viewport) =>
931
+ cluster.x >= viewport.minX &&
932
+ cluster.x <= viewport.maxX &&
933
+ cluster.y >= viewport.minY &&
934
+ cluster.y <= viewport.maxY
935
+
936
+ const filterEcosystemClustersByViewport = (clusters, viewport) => {
937
+ const visible = clusters.filter(cluster => isClusterInViewport(cluster, viewport))
938
+ return visible.length > 0 ? visible : [...clusters]
939
+ }
940
+
941
+ const ecosystemFocusPoint = () => {
942
+ const cursorPoint = cursorWorldPoint()
943
+ if (cursorPoint) {
944
+ return cursorPoint
945
+ }
946
+ const now = performance.now()
947
+ if (now - state.lastZoomFocus.at <= 1800) {
948
+ return { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
949
+ }
950
+ return viewportCenterWorldPoint()
951
+ }
952
+
953
+ const nearestEcosystemParentIds = (clusters, focusPoint, limit) =>
954
+ clusters
955
+ .map(cluster => ({
956
+ cluster,
957
+ distance: Math.max(
958
+ 0,
959
+ Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y) -
960
+ clusterRadiusPx(cluster) / Math.max(state.transform.scale, 0.0001)
961
+ )
962
+ }))
963
+ .sort((left, right) => left.distance - right.distance)
964
+ .slice(0, limit)
965
+ .map(item => item.cluster.id)
966
+
967
+ const focusedParentCluster = (clusters, focusPoint) => {
968
+ if (clusters.length === 0) {
969
+ return null
970
+ }
971
+ let focused = clusters[0]
972
+ let nearestDistance = Number.POSITIVE_INFINITY
973
+ for (let index = 0; index < clusters.length; index += 1) {
974
+ const cluster = clusters[index]
975
+ const distance = Math.max(
976
+ 0,
977
+ Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y) -
978
+ clusterRadiusPx(cluster) / Math.max(state.transform.scale, 0.0001)
979
+ )
980
+ if (distance < nearestDistance) {
981
+ nearestDistance = distance
982
+ focused = cluster
983
+ }
984
+ }
985
+ return focused
986
+ }
987
+
988
+ const nearestSiblingScreenDistancePx = (focusedCluster, clusters) => {
989
+ let nearestDistancePx = Number.POSITIVE_INFINITY
990
+ for (let index = 0; index < clusters.length; index += 1) {
991
+ const cluster = clusters[index]
992
+ if (cluster.id === focusedCluster.id) continue
993
+ const distancePx = Math.hypot(
994
+ (cluster.x - focusedCluster.x) * state.transform.scale,
995
+ (cluster.y - focusedCluster.y) * state.transform.scale
996
+ )
997
+ if (distancePx < nearestDistancePx) {
998
+ nearestDistancePx = distancePx
999
+ }
1000
+ }
1001
+ return nearestDistancePx
1002
+ }
1003
+
1004
+ const ecosystemFocusReadiness = (parentClusters, focusPoint, childSize) => {
1005
+ if (parentClusters.length <= 1) {
1006
+ return 1
1007
+ }
1008
+ const focusedCluster = focusedParentCluster(parentClusters, focusPoint)
1009
+ if (!focusedCluster) {
1010
+ return 0
1011
+ }
1012
+ const nearestDistancePx = nearestSiblingScreenDistancePx(focusedCluster, parentClusters)
1013
+ const sizeHalfCap = Math.ceil(ecosystemLevelNodeCap / 2)
1014
+ const sizeEighthCap = Math.ceil(ecosystemLevelNodeCap / 8)
1015
+ const focusDistanceTargetPx = childSize >= sizeHalfCap
1016
+ ? 680
1017
+ : childSize >= sizeEighthCap
1018
+ ? 520
1019
+ : 380
1020
+ const focusDistanceRangePx = childSize >= sizeHalfCap ? 260 : 220
1021
+ return smoothStep((nearestDistancePx - focusDistanceTargetPx) / focusDistanceRangePx)
1022
+ }
1023
+ const smoothStep = value => {
1024
+ const clamped = Math.max(0, Math.min(1, value))
1025
+ return clamped * clamped * (3 - clamped * 2)
1026
+ }
1027
+
1028
+ const zoomProgress = (scale, start, end) =>
1029
+ smoothStep((scale - start) / Math.max(end - start, 0.0001))
1030
+
1031
+ const semanticZoomSpread = (progress, childSize) => {
1032
+ const spreadExponent = childSize <= Math.ceil(ecosystemLevelNodeCap / 12) ? 5.6 : 4.2
1033
+ const curve = Math.pow(progress, spreadExponent)
1034
+ if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
1035
+ return 0.12 + curve * 0.88
1036
+ }
1037
+ return curve
1038
+ }
1039
+
1040
+ const opacityForProgress = (progress, childSize) => {
1041
+ const opacityExponent = childSize <= Math.ceil(ecosystemLevelNodeCap / 12) ? 2.8 : 2.1
1042
+ const eased = Math.pow(progress, opacityExponent)
1043
+ if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
1044
+ return 0.22 + eased * 0.78
1045
+ }
1046
+ return eased
1047
+ }
1048
+
1049
+ const expandFocusedClusters = (parentClusters, focusPoint, childSize, progress, spread, viewport) => {
1050
+ const expandedParentIds = new Set(nearestEcosystemParentIds(
1051
+ parentClusters,
1052
+ focusPoint,
1053
+ ecosystemFocusedParentLimit
1054
+ ))
1055
+ const childClusters = state.ecosystemClustersBySize.get(childSize) ?? []
1056
+ const visibleChildClusters = childClusters
1057
+ .filter(cluster => expandedParentIds.has(cluster.parentId))
1058
+ .map(cluster => spreadChildClusterFromParent(cluster, childSize, progress, spread))
1059
+ .filter(cluster => isClusterInViewport(cluster, viewport))
1060
+
1061
+ return {
1062
+ expandedParentIds,
1063
+ childClusters: visibleChildClusters
1064
+ }
1065
+ }
1066
+
1067
+ const spreadChildClusterFromParent = (cluster, childSize, progress, spread) => {
1068
+ if (!Number.isFinite(cluster.parentX) || !Number.isFinite(cluster.parentY)) {
1069
+ return {
1070
+ ...cluster,
1071
+ lodOpacity: opacityForProgress(progress, childSize)
1072
+ }
1073
+ }
1074
+
1075
+ return {
1076
+ ...cluster,
1077
+ x: cluster.parentX + (cluster.x - cluster.parentX) * spread,
1078
+ y: cluster.parentY + (cluster.y - cluster.parentY) * spread,
1079
+ lodOpacity: opacityForProgress(progress, childSize)
1080
+ }
1081
+ }
1082
+
1083
+ const selectHierarchicalEcosystemClusters = viewport => {
1084
+ const baseClusters = state.ecosystemClustersBySize.get(state.ecosystemBaseSize) ?? state.ecosystemClusters
1085
+ const visibleBaseClusters = filterEcosystemClustersByViewport(baseClusters, viewport)
1086
+ const hubClusters = state.ecosystemHubCluster ? [state.ecosystemHubCluster] : []
1087
+ const visibleClusters = [...visibleBaseClusters]
1088
+ const focusPoint = ecosystemFocusPoint()
1089
+
1090
+ for (let index = 0; index < state.ecosystemExpansionLevels.length; index += 1) {
1091
+ const level = state.ecosystemExpansionLevels[index]
1092
+ const parentClusters = visibleClusters.filter(cluster => cluster.size === level.parentSize)
1093
+ if (parentClusters.length === 0) {
1094
+ continue
1095
+ }
1096
+ const zoomLevelProgress = zoomProgress(state.transform.scale, level.start, level.end)
1097
+ const focusReadiness = ecosystemFocusReadiness(parentClusters, focusPoint, level.childSize)
1098
+ const progress = zoomLevelProgress * focusReadiness
1099
+ if (progress <= 0.002) {
1100
+ continue
1101
+ }
1102
+ const spread = semanticZoomSpread(progress, level.childSize)
1103
+ const expansion = expandFocusedClusters(parentClusters, focusPoint, level.childSize, progress, spread, viewport)
1104
+ visibleClusters.push(...expansion.childClusters)
1105
+ }
1106
+
1107
+ return [...hubClusters, ...visibleClusters]
1108
+ }
1109
+
1110
+ const ecosystemSiblingEdgesForClusters = (clusters, existingEdges) => {
1111
+ const byParent = new Map()
1112
+ for (let index = 0; index < clusters.length; index += 1) {
1113
+ const cluster = clusters[index]
1114
+ if (cluster.isHub || !cluster.parentId) {
1115
+ continue
1116
+ }
1117
+ const siblings = byParent.get(cluster.parentId)
1118
+ if (siblings) {
1119
+ siblings.push(cluster)
1120
+ } else {
1121
+ byParent.set(cluster.parentId, [cluster])
1122
+ }
1123
+ }
1124
+
1125
+ const edges = []
1126
+ for (const siblings of byParent.values()) {
1127
+ const ordered = [...siblings]
1128
+ .sort((left, right) => Math.atan2(left.y - (left.parentY ?? 0), left.x - (left.parentX ?? 0)) - Math.atan2(right.y - (right.parentY ?? 0), right.x - (right.parentX ?? 0)))
1129
+ for (let index = 0; index < ordered.length && edges.length < ecosystemSiblingEdgeLimit; index += 1) {
1130
+ const sourceCluster = ordered[index]
1131
+ const targetCluster = ordered[(index + 1) % ordered.length]
1132
+ if (!targetCluster || sourceCluster.id === targetCluster.id) {
1133
+ continue
1134
+ }
1135
+ const orderedIds = sourceCluster.id < targetCluster.id
1136
+ ? [sourceCluster.id, targetCluster.id]
1137
+ : [targetCluster.id, sourceCluster.id]
1138
+ const key = orderedIds.join(':')
1139
+ if (existingEdges.has(key)) {
1140
+ continue
1141
+ }
1142
+ const edge = {
1143
+ id: key,
1144
+ sourceCluster,
1145
+ targetCluster,
1146
+ weight: 0.7,
1147
+ inferred: true
1148
+ }
1149
+ existingEdges.set(key, edge)
1150
+ edges.push(edge)
1151
+ }
1152
+ }
1153
+
1154
+ return edges
1155
+ }
1156
+
1157
+ const ecosystemEdgesForClusters = clusters => {
1158
+ const edgeClusters = clusters.filter(cluster => cluster.isHub || clusterOpacity(cluster) > 0.018)
1159
+ const clusterById = new Map(edgeClusters.map(cluster => [cluster.id, cluster]))
1160
+ const clusterIds = new Set(clusterById.keys())
1161
+ const levelsBySize = []
1162
+ for (let index = 0; index < edgeClusters.length; index += 1) {
1163
+ const cluster = edgeClusters[index]
1164
+ if (!cluster.size || cluster.isHub) continue
1165
+ if (!levelsBySize.some(level => level.size === cluster.size)) {
1166
+ levelsBySize.push({
1167
+ size: cluster.size,
1168
+ lookup: state.ecosystemNodeClusterBySize.get(cluster.size) ?? new Map()
1169
+ })
1170
+ }
1171
+ }
1172
+ levelsBySize.sort((left, right) => left.size - right.size)
1173
+ const resolveClusterForNode = nodeId => {
1174
+ if (state.ecosystemHubCluster?.nodeIds.includes(nodeId) && clusterIds.has(state.ecosystemHubCluster.id)) {
1175
+ return state.ecosystemHubCluster
1176
+ }
1177
+ for (let index = 0; index < levelsBySize.length; index += 1) {
1178
+ const lookup = levelsBySize[index].lookup
1179
+ const cluster = lookup.get(nodeId)
1180
+ if (cluster && clusterIds.has(cluster.id)) {
1181
+ return clusterById.get(cluster.id) ?? cluster
1182
+ }
1183
+ }
1184
+ return null
1185
+ }
1186
+
1187
+ const edgeByClusterPair = new Map()
1188
+ for (let index = 0; index < state.visibleEdges.length; index += 1) {
1189
+ const edge = state.visibleEdges[index]
1190
+ const sourceCluster = resolveClusterForNode(edge.source)
1191
+ const targetCluster = resolveClusterForNode(edge.target)
1192
+ if (!sourceCluster || !targetCluster || sourceCluster.id === targetCluster.id) {
1193
+ continue
1194
+ }
1195
+
1196
+ const orderedIds = sourceCluster.id < targetCluster.id
1197
+ ? [sourceCluster.id, targetCluster.id]
1198
+ : [targetCluster.id, sourceCluster.id]
1199
+ const key = orderedIds.join(':')
1200
+ const current = edgeByClusterPair.get(key)
1201
+ if (current) {
1202
+ current.weight += edgeWeight(edge)
1203
+ continue
1204
+ }
1205
+
1206
+ edgeByClusterPair.set(key, {
1207
+ id: key,
1208
+ sourceCluster,
1209
+ targetCluster,
1210
+ weight: edgeWeight(edge)
1211
+ })
1212
+ }
1213
+
1214
+ ecosystemSiblingEdgesForClusters(edgeClusters, edgeByClusterPair)
1215
+ const edges = Array.from(edgeByClusterPair.values())
1216
+ .sort((left, right) => right.weight - left.weight)
1217
+ .slice(0, ecosystemClusterEdgeLimit)
1218
+ const hubCluster = state.ecosystemHubCluster && clusterIds.has(state.ecosystemHubCluster.id)
1219
+ ? state.ecosystemHubCluster
1220
+ : null
1221
+ if (!hubCluster) {
1222
+ return edges
1223
+ }
1224
+
1225
+ const existingHubTargets = new Set(edges.flatMap(edge =>
1226
+ edge.sourceCluster.id === hubCluster.id
1227
+ ? [edge.targetCluster.id]
1228
+ : edge.targetCluster.id === hubCluster.id
1229
+ ? [edge.sourceCluster.id]
1230
+ : []
1231
+ ))
1232
+ const syntheticHubEdges = edgeClusters
1233
+ .filter(cluster => cluster.id !== hubCluster.id && !existingHubTargets.has(cluster.id))
1234
+ .slice(0, ecosystemHubEdgeLimit)
1235
+ .map(cluster => ({
1236
+ id: hubCluster.id + ':' + cluster.id,
1237
+ sourceCluster: hubCluster,
1238
+ targetCluster: cluster,
1239
+ weight: 1,
1240
+ inferred: true
1241
+ }))
1242
+ return edges.concat(syntheticHubEdges)
1243
+ }
1244
+
1245
+ const edgeBudgetForCurrentFrame = () => {
1246
+ const zoom = state.transform.scale
1247
+ if (zoom < 0.12) return 380
1248
+ if (zoom < 0.18) return 900
1249
+ if (zoom < 0.28) return 1700
1250
+ if (zoom < 0.45) return 2800
1251
+ if (zoom < 0.7) return 4200
1252
+ if (zoom < 1.05) return 5600
1253
+ return 7600
1254
+ }
1255
+
1256
+ const clusterBudgetForScale = (scale) => {
1257
+ if (scale < 0.008) return 90
1258
+ if (scale < 0.014) return 150
1259
+ if (scale < 0.022) return 240
1260
+ if (scale < 0.035) return 360
1261
+ return 520
1262
+ }
1263
+
1264
+ const nodeBudgetForScale = (scale) => {
1265
+ if (scale < 0.035) return 220
1266
+ if (scale < 0.06) return 360
1267
+ if (scale < 0.09) return 520
1268
+ if (scale < 0.14) return 720
1269
+ if (state.visibleNodes.length > massiveGraphNodeThreshold) {
1270
+ if (scale < 0.28) return renderNodeBudget
1271
+ if (scale < 0.45) return 1100
1272
+ if (scale < 0.7) return 1400
1273
+ if (scale < 1.05) return 1800
1274
+ return zoomedMassiveRenderNodeBudget
1275
+ }
1276
+ return renderNodeBudget
1277
+ }
1278
+
1279
+ const layerFocusForScale = (scale) => {
1280
+ const normalized = Math.max(0, Math.min(1, (scale - 0.06) / 0.94))
1281
+ const shellCenter = Math.max(0.08, 0.96 - normalized * 0.86)
1282
+ const shellWidth = Math.max(0.24, 0.46 - normalized * 0.16)
1283
+ const coreRadius = Math.max(0.06, 0.1 + normalized * 0.22)
1284
+ const coreRatio = Math.max(0.2, Math.min(0.72, 0.24 + normalized * 0.48))
1285
+
1286
+ return { shellCenter, shellWidth, coreRadius, coreRatio }
1287
+ }
1288
+
1289
+ const selectLayeredNodesForScale = (sourceNodes, targetCount) => {
1290
+ const hub = state.primaryHub
1291
+ if (!hub || sourceNodes.length <= 1200 || state.visibleNodes.length <= massiveGraphNodeThreshold) {
1292
+ return sourceNodes
1293
+ }
1294
+
1295
+ let maxDistance = 0
1296
+ const distances = sourceNodes.map((node) => {
1297
+ const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
1298
+ if (distance > maxDistance) {
1299
+ maxDistance = distance
1300
+ }
1301
+ return { node, distance }
1302
+ })
1303
+
1304
+ if (maxDistance <= 0.001) {
1305
+ return sourceNodes
1306
+ }
1307
+
1308
+ const focus = layerFocusForScale(state.transform.scale)
1309
+ const normalizedRows = distances.map((item) => ({
1310
+ ...item,
1311
+ normalized: item.distance / maxDistance
1312
+ }))
1313
+ const desired = Math.max(260, Math.min(sourceNodes.length, targetCount * 2))
1314
+ const coreTarget = Math.max(36, Math.min(desired - 8, Math.floor(desired * focus.coreRatio)))
1315
+ const shellTarget = Math.max(12, desired - coreTarget)
1316
+ const shellHalf = focus.shellWidth / 2
1317
+
1318
+ const coreNodes = normalizedRows
1319
+ .filter((item) => item.normalized <= focus.coreRadius)
1320
+ .sort((left, right) => {
1321
+ const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
1322
+ const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
1323
+ if (leftScore !== rightScore) return rightScore - leftScore
1324
+ return left.node.id.localeCompare(right.node.id)
1325
+ })
1326
+ .slice(0, coreTarget)
1327
+ .map((item) => item.node)
1328
+
1329
+ const shellNodes = normalizedRows
1330
+ .sort((left, right) => {
1331
+ const leftDelta = Math.abs(left.normalized - focus.shellCenter)
1332
+ const rightDelta = Math.abs(right.normalized - focus.shellCenter)
1333
+ const leftInside = leftDelta <= shellHalf ? 0 : 1
1334
+ const rightInside = rightDelta <= shellHalf ? 0 : 1
1335
+ if (leftInside !== rightInside) return leftInside - rightInside
1336
+ if (leftDelta !== rightDelta) return leftDelta - rightDelta
1337
+ const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
1338
+ const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
1339
+ if (leftScore !== rightScore) return rightScore - leftScore
1340
+ return left.node.id.localeCompare(right.node.id)
1341
+ })
1342
+ .slice(0, shellTarget)
1343
+ .map((item) => item.node)
1344
+
1345
+ const merged = []
1346
+ const ids = new Set()
1347
+ const pushUnique = (node) => {
1348
+ if (!node || ids.has(node.id)) return
1349
+ ids.add(node.id)
1350
+ merged.push(node)
1351
+ }
1352
+
1353
+ if (state.transform.scale >= layeredCoreScaleThreshold) {
1354
+ pushUnique(hub)
1355
+ }
1356
+ for (let index = 0; index < coreNodes.length; index += 1) pushUnique(coreNodes[index])
1357
+ for (let index = 0; index < shellNodes.length; index += 1) pushUnique(shellNodes[index])
1358
+
1359
+ return merged.length > 0 ? merged : sourceNodes
1360
+ }
1361
+
1362
+ const viewportCenterWorldPoint = () => {
1363
+ const viewport = worldViewportBounds()
1364
+ return {
1365
+ x: (viewport.minX + viewport.maxX) / 2,
1366
+ y: (viewport.minY + viewport.maxY) / 2
1367
+ }
1368
+ }
1369
+
1370
+ const screenToWorldPoint = (screenX, screenY) => ({
1371
+ x: (screenX - state.transform.x) / state.transform.scale,
1372
+ y: (screenY - state.transform.y) / state.transform.scale
1373
+ })
1374
+
1375
+ const cursorWorldPoint = () => {
1376
+ if (!state.cursor.inCanvas) {
1377
+ return null
1378
+ }
1379
+ const rect = canvas.getBoundingClientRect()
1380
+ const screenX = state.cursor.x - rect.left
1381
+ const screenY = state.cursor.y - rect.top
1382
+ const width = Math.max(rect.width, 320)
1383
+ const height = Math.max(rect.height, 320)
1384
+ if (!Number.isFinite(screenX) || !Number.isFinite(screenY)) {
1385
+ return null
1386
+ }
1387
+ if (screenX < 0 || screenX > width || screenY < 0 || screenY > height) {
1388
+ return null
1389
+ }
1390
+ return screenToWorldPoint(screenX, screenY)
1391
+ }
1392
+
1393
+ const visibilityScaleBucket = (scale) => {
1394
+ const safeScale = Math.max(zoomRange.min, scale)
1395
+ if (safeScale < 0.01) return Math.round(safeScale * 300_000)
1396
+ if (safeScale < 0.05) return Math.round(safeScale * 120_000)
1397
+ if (safeScale < 0.2) return Math.round(safeScale * 40_000)
1398
+ return Math.round(safeScale * 8_000)
1399
+ }
1400
+
1401
+ const shouldRenderMacroGalaxyView = () => {
1402
+ if (!galaxyDiscoveryEnabled) {
1403
+ state.macroViewActive = false
1404
+ return false
1405
+ }
1406
+ if (state.visibleNodes.length <= 1) {
1407
+ state.macroViewActive = false
1408
+ return false
1409
+ }
1410
+
1411
+ const enterThreshold = macroGalaxyZoomThreshold * macroGalaxyEnterHysteresis
1412
+ const exitThreshold = macroGalaxyZoomThreshold * macroGalaxyExitHysteresis
1413
+ const shouldRender = state.macroViewActive
1414
+ ? state.transform.scale <= exitThreshold
1415
+ : state.transform.scale <= enterThreshold
1416
+ state.macroViewActive = shouldRender
1417
+ return shouldRender
1418
+ }
1419
+
1420
+ const mergeUniqueNodes = (leftNodes, rightNodes, limit) => {
1421
+ const merged = []
1422
+ const ids = new Set()
1423
+
1424
+ const push = (node) => {
1425
+ if (!node || ids.has(node.id) || merged.length >= limit) {
1426
+ return
1427
+ }
1428
+ ids.add(node.id)
1429
+ merged.push(node)
1430
+ }
1431
+
1432
+ for (let index = 0; index < leftNodes.length && merged.length < limit; index += 1) {
1433
+ push(leftNodes[index])
1434
+ }
1435
+ for (let index = 0; index < rightNodes.length && merged.length < limit; index += 1) {
1436
+ push(rightNodes[index])
1437
+ }
1438
+
1439
+ return merged
1440
+ }
1441
+
1442
+ const selectStableSampleNodes = (sourceNodes, limit) => {
1443
+ if (sourceNodes.length <= limit) {
1444
+ return sourceNodes
1445
+ }
1446
+
1447
+ const now = performance.now()
1448
+ const cursorPoint = cursorWorldPoint()
1449
+ const recentZoomFocus =
1450
+ now - state.lastZoomFocus.at <= 1500
1451
+ ? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
1452
+ : null
1453
+ const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
1454
+ const previousIds = new Set(state.renderNodes.map((node) => node.id))
1455
+ const preferAnchorDistance = state.visibleNodes.length > massiveGraphNodeThreshold && state.transform.scale >= 0.28
1456
+
1457
+ return [...sourceNodes]
1458
+ .sort((left, right) => {
1459
+ const leftWasVisible = previousIds.has(left.id) ? 1 : 0
1460
+ const rightWasVisible = previousIds.has(right.id) ? 1 : 0
1461
+ const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
1462
+ const rightDistance = Math.hypot(right.x - anchor.x, right.y - anchor.y)
1463
+
1464
+ if (preferAnchorDistance) {
1465
+ if (leftDistance !== rightDistance) return leftDistance - rightDistance
1466
+ if (leftWasVisible !== rightWasVisible) return rightWasVisible - leftWasVisible
1467
+ } else {
1468
+ if (leftWasVisible !== rightWasVisible) return rightWasVisible - leftWasVisible
1469
+ if (leftDistance !== rightDistance) return leftDistance - rightDistance
1470
+ }
1471
+
1472
+ const leftDegree = state.nodeDegrees.get(left.id) ?? 0
1473
+ const rightDegree = state.nodeDegrees.get(right.id) ?? 0
1474
+ if (leftDegree !== rightDegree) return rightDegree - leftDegree
1475
+
1476
+ return left.id.localeCompare(right.id)
1477
+ })
1478
+ .slice(0, limit)
1479
+ }
1480
+
1481
+ const selectAccessBridgeNodes = (sourceNodes, limit) => {
1482
+ if (limit <= 0 || sourceNodes.length === 0) {
1483
+ return []
1484
+ }
1485
+
1486
+ const now = performance.now()
1487
+ const cursorPoint = cursorWorldPoint()
1488
+ const recentZoomFocus =
1489
+ now - state.lastZoomFocus.at <= 1200
1490
+ ? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
1491
+ : null
1492
+ const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
1493
+ return [...sourceNodes]
1494
+ .sort((left, right) => {
1495
+ const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
1496
+ const rightDistance = Math.hypot(right.x - anchor.x, right.y - anchor.y)
1497
+ if (leftDistance !== rightDistance) return leftDistance - rightDistance
1498
+ const leftDegree = state.nodeDegrees.get(left.id) ?? 0
1499
+ const rightDegree = state.nodeDegrees.get(right.id) ?? 0
1500
+ if (leftDegree !== rightDegree) return rightDegree - leftDegree
1501
+ return left.id.localeCompare(right.id)
1502
+ })
1503
+ .slice(0, limit)
1504
+ }
1505
+
1506
+ const edgeIdentityKey = edge => {
1507
+ if (!edge.target) return ''
1508
+ const pair = edge.source < edge.target
1509
+ ? edge.source + '|' + edge.target
1510
+ : edge.target + '|' + edge.source
1511
+ return pair + '|' + (edge.inferred ? 'mesh' : 'real')
1512
+ }
1513
+
1514
+ const edgeRelevanceScore = edge => {
1515
+ let score = edgeWeight(edge) * 10
1516
+ if (!edge.inferred) {
1517
+ score += 8
1518
+ }
1519
+
1520
+ const selectedId = state.selected?.id
1521
+ if (selectedId && (edge.source === selectedId || edge.target === selectedId)) {
1522
+ score += 120
1523
+ }
1524
+
1525
+ const hoveredId = state.hovered?.id
1526
+ if (hoveredId && (edge.source === hoveredId || edge.target === hoveredId)) {
1527
+ score += 70
1528
+ }
1529
+
1530
+ const hubId = state.primaryHub?.id
1531
+ if (hubId && (edge.source === hubId || edge.target === hubId)) {
1532
+ score += 42
1533
+ }
1534
+
1535
+ return score
1536
+ }
1537
+
1538
+ const collectVisibleEdgesForNodes = nodeIds => {
1539
+ if (nodeIds.size === 0) {
1540
+ return []
1541
+ }
1542
+
1543
+ const seen = new Set()
1544
+ const candidates = []
1545
+ const limit = edgeBudgetForCurrentFrame()
1546
+
1547
+ nodeIds.forEach(nodeId => {
1548
+ const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
1549
+ for (let index = 0; index < candidateEdges.length; index += 1) {
1550
+ const edge = candidateEdges[index]
1551
+ if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
1552
+ continue
1553
+ }
1554
+ const key = edgeIdentityKey(edge)
1555
+ if (seen.has(key)) continue
1556
+
1557
+ seen.add(key)
1558
+ candidates.push(edge)
1559
+ }
1560
+ })
1561
+
1562
+ if (candidates.length <= limit) {
1563
+ return candidates
1564
+ }
1565
+
1566
+ return candidates
1567
+ .sort((left, right) => {
1568
+ const scoreDelta = edgeRelevanceScore(right) - edgeRelevanceScore(left)
1569
+ if (scoreDelta !== 0) {
1570
+ return scoreDelta
1571
+ }
1572
+ const leftKey = edgeIdentityKey(left)
1573
+ const rightKey = edgeIdentityKey(right)
1574
+ return leftKey.localeCompare(rightKey)
1575
+ })
1576
+ .slice(0, limit)
1577
+ }
1578
+
1579
+ const edgeOpacityForScale = (edge, scale) => {
1580
+ if (edge.inferred) {
1581
+ if (scale < 0.2) return 0.06
1582
+ if (scale < 0.4) return 0.08
1583
+ if (scale < 0.7) return 0.1
1584
+ return 0.14
1585
+ }
1586
+
1587
+ if (scale < 0.2) return 0.14
1588
+ if (scale < 0.4) return 0.2
1589
+ if (scale < 0.7) return 0.28
1590
+ if (scale < 1.05) return 0.36
1591
+ return 0.46
1592
+ }
1593
+
1594
+ const edgeStrokeFor = (edge, selectedEdge) => {
1595
+ if (selectedEdge) {
1596
+ return graphTheme.edgeActive
1597
+ }
1598
+
1599
+ const opacity = edgeOpacityForScale(edge, state.transform.scale)
1600
+ return edge.inferred
1601
+ ? 'rgba(203, 213, 225, ' + opacity + ')'
1602
+ : 'rgba(153, 165, 181, ' + opacity + ')'
1603
+ }
1604
+
1605
+ const edgeWidthFor = (edge, selectedEdge) => {
1606
+ if (edge.inferred) {
1607
+ return selectedEdge ? 1.22 : 0.84
1608
+ }
1609
+
1610
+ return (selectedEdge ? 1.9 : 1.05) + Math.min(edgeWeight(edge) - 1, 8) * 0.24
1611
+ }
1612
+
1613
+ const drawGraphEdge = (edge) => {
1614
+ const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
1615
+ ctx.beginPath()
1616
+ ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
1617
+ ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
1618
+ ctx.strokeStyle = edgeStrokeFor(edge, selectedEdge)
1619
+ ctx.lineWidth = edgeWidthFor(edge, selectedEdge)
1620
+ ctx.stroke()
1621
+ }
1622
+
1623
+ const drawEdgeBatch = (edges, options) => {
1624
+ if (edges.length === 0) {
1625
+ return
1626
+ }
1627
+
1628
+ ctx.beginPath()
1629
+ for (let index = 0; index < edges.length; index += 1) {
1630
+ const edge = edges[index]
1631
+ ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
1632
+ ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
1633
+ }
1634
+ ctx.strokeStyle = options.strokeStyle
1635
+ ctx.lineWidth = options.lineWidth
1636
+ ctx.stroke()
1637
+ }
1638
+
1639
+ const regularEdgeBatchOptions = (edge) => ({
1640
+ strokeStyle: edgeStrokeFor(edge, false),
1641
+ lineWidth: edgeWidthFor(edge, false)
1642
+ })
1643
+
1644
+ const regularEdgeBatchKey = (edge) => {
1645
+ const options = regularEdgeBatchOptions(edge)
1646
+ return options.strokeStyle + '|' + options.lineWidth.toFixed(2)
1647
+ }
1648
+
1649
+ const drawGraphEdges = () => {
1650
+ const edgeBatches = new Map()
1651
+ const selectedEdges = []
1652
+
1653
+ for (let index = 0; index < state.renderEdges.length; index += 1) {
1654
+ const edge = state.renderEdges[index]
1655
+ const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
1656
+ if (isSelected) {
1657
+ selectedEdges.push(edge)
1658
+ continue
1659
+ }
1660
+
1661
+ const key = regularEdgeBatchKey(edge)
1662
+ const batch = edgeBatches.get(key)
1663
+ if (batch) {
1664
+ batch.edges.push(edge)
1665
+ } else {
1666
+ edgeBatches.set(key, {
1667
+ edges: [edge],
1668
+ options: regularEdgeBatchOptions(edge)
1669
+ })
1670
+ }
1671
+ }
1672
+
1673
+ edgeBatches.forEach((batch) => drawEdgeBatch(batch.edges, batch.options))
1674
+
1675
+ for (let index = 0; index < selectedEdges.length; index += 1) {
1676
+ drawGraphEdge(selectedEdges[index])
1677
+ }
1678
+ }
1679
+
1680
+ const shouldDrawNodeLabels = (node, isSelected, isHovered) =>
1681
+ isSelected ||
1682
+ isHovered ||
1683
+ (state.nodes.length > largeGraphNodeThreshold && state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) ||
1684
+ (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
1685
+
1686
+ const drawSingleNode = (node, options = { drawLabel: true }) => {
1687
+ const radius = nodeRadius(node)
1688
+ const isSelected = state.selected?.id === node.id
1689
+ const isHovered = state.hovered?.id === node.id
1690
+ ctx.beginPath()
1691
+ ctx.arc(node.x, node.y, radius + (isSelected ? 7 : isHovered ? 4 : 0), 0, Math.PI * 2)
1692
+ ctx.fillStyle = isSelected || isHovered ? graphTheme.nodeHaloActive : graphTheme.nodeHalo
1693
+ ctx.fill()
1694
+ ctx.beginPath()
1695
+ ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
1696
+ ctx.fillStyle = isSelected ? graphTheme.nodeSelected : isHovered ? graphTheme.nodeHover : graphTheme.node
1697
+ ctx.fill()
1698
+ ctx.lineWidth = isSelected ? 2.6 : 1.5
1699
+ ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
1700
+ ctx.stroke()
1701
+
1702
+ if (options.drawLabel && shouldDrawNodeLabels(node, isSelected, isHovered)) {
1703
+ ctx.fillStyle = graphTheme.label
1704
+ ctx.font = '12px Inter, system-ui, sans-serif'
1705
+ ctx.textAlign = 'center'
1706
+ ctx.textBaseline = 'top'
1707
+ ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
1708
+ }
1709
+ }
1710
+
1711
+ const drawNodeBatch = (nodes) => {
1712
+ if (nodes.length === 0) {
1713
+ return
1714
+ }
1715
+
1716
+ const drawHalos = state.renderNodes.length <= 1200 || state.transform.scale >= 0.45
1717
+ if (drawHalos) {
1718
+ ctx.beginPath()
1719
+ for (let index = 0; index < nodes.length; index += 1) {
1720
+ const node = nodes[index]
1721
+ ctx.moveTo(node.x + nodeRadius(node) + 3, node.y)
1722
+ ctx.arc(node.x, node.y, nodeRadius(node) + 3, 0, Math.PI * 2)
1723
+ }
1724
+ ctx.fillStyle = graphTheme.nodeHalo
1725
+ ctx.fill()
1726
+ }
1727
+
1728
+ ctx.beginPath()
1729
+ for (let index = 0; index < nodes.length; index += 1) {
1730
+ const node = nodes[index]
1731
+ const radius = nodeRadius(node)
1732
+ ctx.moveTo(node.x + radius, node.y)
1733
+ ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
1734
+ }
1735
+ ctx.fillStyle = graphTheme.node
1736
+ ctx.fill()
1737
+ ctx.lineWidth = 1.25
1738
+ ctx.strokeStyle = graphTheme.nodeStroke
1739
+ ctx.stroke()
1740
+ }
1741
+
1742
+ const drawGraphNodes = () => {
1743
+ const regularNodes = []
1744
+ const priorityNodes = []
1745
+
1746
+ for (let index = 0; index < state.renderNodes.length; index += 1) {
1747
+ const node = state.renderNodes[index]
1748
+ const isPriority =
1749
+ state.selected?.id === node.id ||
1750
+ state.hovered?.id === node.id
1751
+ if (isPriority) {
1752
+ priorityNodes.push(node)
1753
+ } else {
1754
+ regularNodes.push(node)
1755
+ }
1756
+ }
1757
+
1758
+ drawNodeBatch(regularNodes)
1759
+
1760
+ if (state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) {
1761
+ ctx.fillStyle = graphTheme.label
1762
+ ctx.font = '12px Inter, system-ui, sans-serif'
1763
+ ctx.textAlign = 'center'
1764
+ ctx.textBaseline = 'top'
1765
+ for (let index = 0; index < regularNodes.length; index += 1) {
1766
+ const node = regularNodes[index]
1767
+ ctx.fillText(node.title.slice(0, 34), node.x, node.y + nodeRadius(node) + 8)
1768
+ }
1769
+ }
1770
+
1771
+ priorityNodes.forEach(node => drawSingleNode(node))
1772
+ }
1773
+
1774
+ const partitionGraphForAcceleratedRenderer = () => {
1775
+ const regularNodes = []
1776
+ const priorityNodes = []
1777
+ const regularEdges = []
1778
+ const inferredEdges = []
1779
+ const selectedEdges = []
1780
+
1781
+ for (let index = 0; index < state.renderNodes.length; index += 1) {
1782
+ const node = state.renderNodes[index]
1783
+ const isPriority =
1784
+ state.selected?.id === node.id ||
1785
+ state.hovered?.id === node.id
1786
+ if (isPriority) {
1787
+ priorityNodes.push(node)
1788
+ } else {
1789
+ regularNodes.push(node)
1790
+ }
1791
+ }
1792
+
1793
+ for (let index = 0; index < state.renderEdges.length; index += 1) {
1794
+ const edge = state.renderEdges[index]
1795
+ const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
1796
+ if (isSelected) {
1797
+ selectedEdges.push(edge)
1798
+ } else if (edge.inferred) {
1799
+ inferredEdges.push(edge)
1800
+ } else {
1801
+ regularEdges.push(edge)
1802
+ }
1803
+ }
1804
+
1805
+ return { regularNodes, priorityNodes, regularEdges, inferredEdges, selectedEdges }
1806
+ }
1807
+
1808
+ const drawGraphLabels = nodes => {
1809
+ if (!(state.transform.scale >= 0.62 && state.renderNodes.length <= 1200)) {
1810
+ return
1811
+ }
1812
+
1813
+ ctx.fillStyle = graphTheme.label
1814
+ ctx.font = '12px Inter, system-ui, sans-serif'
1815
+ ctx.textAlign = 'center'
1816
+ ctx.textBaseline = 'top'
1817
+ for (let index = 0; index < nodes.length; index += 1) {
1818
+ const node = nodes[index]
1819
+ ctx.fillText(node.title.slice(0, 34), node.x, node.y + nodeRadius(node) + 8)
1820
+ }
1821
+ }
1822
+
1823
+ const drawAcceleratedGraph = (width, height, drawEdges) => {
1824
+ if (!webGlRenderer || state.renderClusters.length > 0) {
1825
+ return false
1826
+ }
1827
+
1828
+ const graphParts = partitionGraphForAcceleratedRenderer()
1829
+ const scale = state.transform.scale
1830
+ webGlRenderer.clear(width, height)
1831
+ if (drawEdges) {
1832
+ webGlRenderer.drawLines(
1833
+ graphParts.regularEdges,
1834
+ rgba('rgb(153, 165, 181)', edgeOpacityForScale({ inferred: false }, scale)),
1835
+ width,
1836
+ height
1837
+ )
1838
+ webGlRenderer.drawLines(
1839
+ graphParts.inferredEdges,
1840
+ rgba('rgb(203, 213, 225)', edgeOpacityForScale({ inferred: true }, scale)),
1841
+ width,
1842
+ height
1843
+ )
1844
+ }
1845
+ webGlRenderer.drawPoints(
1846
+ graphParts.regularNodes,
1847
+ rgba(graphTheme.nodeHalo, 0.28),
1848
+ node => Math.max((nodeRadius(node) + 3) * state.transform.scale * 2, 1.5),
1849
+ width,
1850
+ height
1851
+ )
1852
+ webGlRenderer.drawPoints(
1853
+ graphParts.regularNodes,
1854
+ rgba(graphTheme.node, 1),
1855
+ node => Math.max(nodeRadius(node) * state.transform.scale * 2, 1.2),
1856
+ width,
1857
+ height
1858
+ )
1859
+
1860
+ ctx.save()
1861
+ ctx.translate(state.transform.x, state.transform.y)
1862
+ ctx.scale(state.transform.scale, state.transform.scale)
1863
+ if (drawEdges) {
1864
+ graphParts.selectedEdges.forEach(edge => drawGraphEdge(edge))
1865
+ }
1866
+ drawGraphLabels(graphParts.regularNodes)
1867
+ graphParts.priorityNodes.forEach(node => drawSingleNode(node))
1868
+ ctx.restore()
1869
+
1870
+ return true
1871
+ }
1872
+
1873
+ const edgePairKey = (source, target) =>
1874
+ source < target ? source + '|' + target : target + '|' + source
1875
+
1876
+ const meshNeighborBuckets = (nodes, cellSize) => {
1877
+ const buckets = new Map()
1878
+
1879
+ for (let index = 0; index < nodes.length; index += 1) {
1880
+ const node = nodes[index]
1881
+ const cellX = Math.floor(node.x / cellSize)
1882
+ const cellY = Math.floor(node.y / cellSize)
1883
+ const key = cellX + ':' + cellY
1884
+ const bucket = buckets.get(key)
1885
+ if (bucket) {
1886
+ bucket.push(node)
1887
+ } else {
1888
+ buckets.set(key, [node])
1889
+ }
1890
+ }
1891
+
1892
+ return buckets
1893
+ }
1894
+
1895
+ const meshCandidatesForNode = (node, buckets, cellSize) => {
1896
+ const cellX = Math.floor(node.x / cellSize)
1897
+ const cellY = Math.floor(node.y / cellSize)
1898
+ const candidates = []
1899
+
1900
+ for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
1901
+ for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
1902
+ const bucket = buckets.get((cellX + offsetX) + ':' + (cellY + offsetY))
1903
+ if (!bucket) continue
1904
+ for (let index = 0; index < bucket.length; index += 1) {
1905
+ const candidate = bucket[index]
1906
+ if (candidate.id !== node.id) {
1907
+ candidates.push(candidate)
1908
+ }
1909
+ }
1910
+ }
1911
+ }
1912
+
1913
+ return candidates
1914
+ }
1915
+
1916
+ const buildMeshEdgesForNodes = (nodes, existingEdges) => {
1917
+ if (nodes.length < 2 || state.transform.scale < meshEdgeScaleThreshold) {
1918
+ return []
1919
+ }
1920
+
1921
+ const existingKeys = new Set()
1922
+ for (let index = 0; index < existingEdges.length; index += 1) {
1923
+ const edge = existingEdges[index]
1924
+ if (edge.target) {
1925
+ existingKeys.add(edgePairKey(edge.source, edge.target))
1926
+ }
1927
+ }
1928
+
1929
+ const desiredBudget = Math.min(
1930
+ meshEdgeMaxBudget,
1931
+ Math.max(meshEdgeMinBudget, Math.floor(edgeBudgetForCurrentFrame() * 0.62))
1932
+ )
1933
+ const perNodeNeighborCount =
1934
+ state.transform.scale >= 1.05 ? 4
1935
+ : state.transform.scale >= 0.62 ? 3
1936
+ : 2
1937
+ const cellSize = Math.max(120, 280 / Math.max(state.transform.scale, 0.0001))
1938
+ const maxDistance = 980
1939
+ const maxDistanceSquared = maxDistance * maxDistance
1940
+ const buckets = meshNeighborBuckets(nodes, cellSize)
1941
+ const meshEdges = []
1942
+ const meshKeys = new Set()
1943
+
1944
+ for (let index = 0; index < nodes.length && meshEdges.length < desiredBudget; index += 1) {
1945
+ const node = nodes[index]
1946
+ const candidates = meshCandidatesForNode(node, buckets, cellSize)
1947
+ .map((candidate) => ({
1948
+ node: candidate,
1949
+ distanceSquared: (candidate.x - node.x) ** 2 + (candidate.y - node.y) ** 2
1950
+ }))
1951
+ .filter((candidate) => candidate.distanceSquared <= maxDistanceSquared)
1952
+ .sort((left, right) => left.distanceSquared - right.distanceSquared)
1953
+
1954
+ let linked = 0
1955
+ for (let candidateIndex = 0; candidateIndex < candidates.length && linked < perNodeNeighborCount && meshEdges.length < desiredBudget; candidateIndex += 1) {
1956
+ const candidate = candidates[candidateIndex].node
1957
+ const key = edgePairKey(node.id, candidate.id)
1958
+ if (existingKeys.has(key) || meshKeys.has(key)) {
1959
+ continue
1960
+ }
1961
+
1962
+ meshKeys.add(key)
1963
+ meshEdges.push({
1964
+ source: node.id,
1965
+ target: candidate.id,
1966
+ targetTitle: candidate.title,
1967
+ weight: 1,
1968
+ priority: 'normal',
1969
+ sourceNode: node,
1970
+ targetNode: candidate,
1971
+ inferred: true
1972
+ })
1973
+ linked += 1
1974
+ }
1975
+ }
1976
+
1977
+ return meshEdges
1978
+ }
1979
+
1980
+ const withMeshEdges = (nodes, edges) => {
1981
+ if (nodes.length === 0 || state.visibleNodes.length <= largeGraphNodeThreshold || state.transform.scale < meshEdgeScaleThreshold) {
1982
+ return edges
1983
+ }
1984
+
1985
+ const meshEdges = buildMeshEdgesForNodes(nodes, edges)
1986
+ return meshEdges.length > 0 ? edges.concat(meshEdges) : edges
1987
+ }
1988
+
1989
+ const fallbackViewportNodes = () => {
1990
+ const nodes = []
1991
+ const maxNodes = Math.min(renderNodeBudget, 220)
1992
+ const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
1993
+
1994
+ for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
1995
+ nodes.push(state.visibleNodes[index])
1996
+ }
1997
+
1998
+ if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
1999
+ nodes.push(state.selected)
2000
+ }
2001
+
2002
+ return nodes
2003
+ }
2004
+
2005
+ const sampleVisibleNodes = (limit = renderNodeBudget, sourceNodes = state.visibleNodes) => {
2006
+ if (sourceNodes.length === 0 || limit <= 0) {
2007
+ return []
2008
+ }
2009
+
2010
+ const nodes = []
2011
+ const maxNodes = Math.min(Math.max(limit, 1), sourceNodes.length)
2012
+ const step = Math.max(1, Math.ceil(sourceNodes.length / maxNodes))
2013
+
2014
+ for (let index = 0; index < sourceNodes.length && nodes.length < maxNodes; index += step) {
2015
+ nodes.push(sourceNodes[index])
2016
+ }
2017
+
2018
+ if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
2019
+ nodes.push(state.selected)
2020
+ }
2021
+
2022
+ return nodes
2023
+ }
2024
+
2025
+ const enrichSampleWithNeighbors = (nodes) => {
2026
+ if (nodes.length === 0) {
2027
+ return {
2028
+ nodes,
2029
+ edges: []
2030
+ }
2031
+ }
2032
+
2033
+ const maxNodes = Math.min(renderNodeBudget, nodes.length + 200)
2034
+ const expanded = [...nodes]
2035
+ const ids = new Set(expanded.map((node) => node.id))
2036
+
2037
+ for (let index = 0; index < nodes.length && expanded.length < maxNodes; index += 1) {
2038
+ const node = nodes[index]
2039
+ const candidates = [...(state.visibleEdgeByNode.get(node.id) ?? [])]
2040
+ .filter((edge) => edge.target)
2041
+ .sort((left, right) => edgeWeight(right) - edgeWeight(left))
2042
+ .slice(0, 3)
2043
+
2044
+ for (let candidateIndex = 0; candidateIndex < candidates.length && expanded.length < maxNodes; candidateIndex += 1) {
2045
+ const edge = candidates[candidateIndex]
2046
+ const otherId = edge.source === node.id ? edge.target : edge.source
2047
+
2048
+ if (!otherId || ids.has(otherId)) {
2049
+ continue
2050
+ }
2051
+
2052
+ const otherNode = state.nodeById.get(otherId)
2053
+ if (!otherNode) {
2054
+ continue
2055
+ }
2056
+
2057
+ ids.add(otherId)
2058
+ expanded.push(otherNode)
2059
+ }
2060
+ }
2061
+
2062
+ const edges = collectVisibleEdgesForNodes(ids)
2063
+
2064
+ return {
2065
+ nodes: expanded,
2066
+ edges
2067
+ }
2068
+ }
2069
+
2070
+ const includeHubPreviewNeighborhood = (nodes, limit) => {
2071
+ const hub = state.primaryHub
2072
+ if (!hub) {
2073
+ return nodes
2074
+ }
2075
+
2076
+ const maxNodes = Math.max(1, Math.min(renderNodeBudget, limit))
2077
+ const merged = [...nodes]
2078
+ const ids = new Set(merged.map((node) => node.id))
2079
+ const protectedIds = new Set()
2080
+
2081
+ if (!ids.has(hub.id)) {
2082
+ if (merged.length < maxNodes) {
2083
+ merged.push(hub)
2084
+ ids.add(hub.id)
2085
+ } else {
2086
+ const replaceIndex = merged.findIndex((node) => node.id !== hub.id)
2087
+ if (replaceIndex >= 0) {
2088
+ ids.delete(merged[replaceIndex].id)
2089
+ merged[replaceIndex] = hub
2090
+ ids.add(hub.id)
2091
+ }
2092
+ }
2093
+ }
2094
+ protectedIds.add(hub.id)
2095
+
2096
+ const hubEdges = [...(state.visibleEdgeByNode.get(hub.id) ?? [])]
2097
+ .filter((edge) => edge.target && (edge.source === hub.id || edge.target === hub.id))
2098
+ .sort((left, right) => {
2099
+ const byWeight = edgeWeight(right) - edgeWeight(left)
2100
+ if (byWeight !== 0) return byWeight
2101
+
2102
+ const leftOtherId = left.source === hub.id ? left.target : left.source
2103
+ const rightOtherId = right.source === hub.id ? right.target : right.source
2104
+ const leftDegree = state.nodeDegrees.get(leftOtherId ?? '') ?? 0
2105
+ const rightDegree = state.nodeDegrees.get(rightOtherId ?? '') ?? 0
2106
+ if (leftDegree !== rightDegree) return rightDegree - leftDegree
2107
+
2108
+ return edgeIdentityKey(left).localeCompare(edgeIdentityKey(right))
2109
+ })
2110
+
2111
+ for (let index = 0; index < hubEdges.length && merged.length < maxNodes; index += 1) {
2112
+ const edge = hubEdges[index]
2113
+ const otherId = edge.source === hub.id ? edge.target : edge.source
2114
+ if (!otherId || ids.has(otherId)) {
2115
+ continue
2116
+ }
2117
+
2118
+ const otherNode = state.nodeById.get(otherId)
2119
+ if (!otherNode) {
2120
+ continue
2121
+ }
2122
+
2123
+ if (merged.length < maxNodes) {
2124
+ ids.add(otherId)
2125
+ merged.push(otherNode)
2126
+ protectedIds.add(otherId)
2127
+ continue
2128
+ }
2129
+
2130
+ const replaceIndex = (() => {
2131
+ for (let cursor = merged.length - 1; cursor >= 0; cursor -= 1) {
2132
+ const candidateId = merged[cursor]?.id
2133
+ if (candidateId && !protectedIds.has(candidateId)) {
2134
+ return cursor
2135
+ }
2136
+ }
2137
+ return -1
2138
+ })()
2139
+ if (replaceIndex >= 0) {
2140
+ const replacedId = merged[replaceIndex]?.id
2141
+ if (replacedId) {
2142
+ ids.delete(replacedId)
2143
+ }
2144
+ merged[replaceIndex] = otherNode
2145
+ ids.add(otherId)
2146
+ protectedIds.add(otherId)
2147
+ }
2148
+ }
2149
+
2150
+ return merged
2151
+ }
2152
+
2153
+ const ensureHubNodesInRenderedSet = (nodes) => {
2154
+ if (nodes.length === 0) {
2155
+ return nodes
2156
+ }
2157
+
2158
+ const maxNodes = Math.max(Math.min(renderNodeBudget, nodes.length), 1)
2159
+ const ids = new Set(nodes.map((node) => node.id))
2160
+ const hubs = rankedHubNodes()
2161
+ const merged = [...nodes]
2162
+
2163
+ for (let index = 0; index < hubs.length; index += 1) {
2164
+ const hub = hubs[index]
2165
+ if (ids.has(hub.id)) {
2166
+ continue
2167
+ }
2168
+
2169
+ if (merged.length < maxNodes) {
2170
+ merged.push(hub)
2171
+ ids.add(hub.id)
2172
+ continue
2173
+ }
2174
+
2175
+ const replacementIndex = merged.findIndex((node) => !hubs.some((candidate) => candidate.id === node.id))
2176
+ if (replacementIndex >= 0) {
2177
+ ids.delete(merged[replacementIndex].id)
2178
+ merged[replacementIndex] = hub
2179
+ ids.add(hub.id)
2180
+ }
2181
+ }
2182
+
2183
+ return merged
2184
+ }
2185
+
2186
+ const zoomCapByNodeCount = (nodeCount) => {
2187
+ if (nodeCount > 50000) return 5.4
2188
+ if (nodeCount > 20000) return 4.8
2189
+ if (nodeCount > 6000) return 4.2
2190
+ if (nodeCount > 2000) return 4
2191
+ return zoomRange.max
2192
+ }
2193
+
2194
+ const zoomCapByHubDistance = (distance) => {
2195
+ if (!Number.isFinite(distance) || distance <= 0) {
2196
+ return zoomRange.max
2197
+ }
2198
+
2199
+ const rect = canvas.getBoundingClientRect()
2200
+ const viewportWidth = Math.max(rect.width, 320)
2201
+ const viewportHeight = Math.max(rect.height, 320)
2202
+ const reference = Math.max(220, Math.min(viewportWidth, viewportHeight) * zoomCapTargetViewportShare)
2203
+ return Math.max(0.3, Math.min(zoomRange.max, reference / distance))
2204
+ }
2205
+
2206
+ const currentZoomMax = () => {
2207
+ const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
2208
+ const hubDistanceCap = isDominantHub(state.primaryHub, nodeCount)
2209
+ ? zoomCapByHubDistance(state.hubNeighborDistance)
2210
+ : zoomRange.max
2211
+ const minimumUsefulCap = nodeCount > massiveGraphNodeThreshold ? 1.9 : nodeCount > largeGraphNodeThreshold ? 1.35 : 0.8
2212
+ const capped = Math.min(zoomCapByNodeCount(nodeCount), Math.max(minimumUsefulCap, hubDistanceCap))
2213
+ return Math.max(zoomRange.min * 2, capped)
2214
+ }
2215
+
2216
+ const clampScale = value => Math.max(zoomRange.min, Math.min(currentZoomMax(), value))
2217
+ const isFiniteNumber = value => Number.isFinite(value)
2218
+ const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
2219
+ const clampTransformCoordinate = value => {
2220
+ if (!isFiniteNumber(value)) return 0
2221
+ if (value > transformCoordinateLimit) return transformCoordinateLimit
2222
+ if (value < -transformCoordinateLimit) return -transformCoordinateLimit
2223
+ return value
2224
+ }
2225
+
2226
+ const graphBounds = nodes => {
2227
+ if (nodes.length === 0) return null
2228
+ let minX = Number.POSITIVE_INFINITY
2229
+ let maxX = Number.NEGATIVE_INFINITY
2230
+ let minY = Number.POSITIVE_INFINITY
2231
+ let maxY = Number.NEGATIVE_INFINITY
2232
+
2233
+ nodes.forEach(node => {
2234
+ const radius = baseNodeRadius(node)
2235
+ minX = Math.min(minX, node.x - radius)
2236
+ maxX = Math.max(maxX, node.x + radius)
2237
+ minY = Math.min(minY, node.y - radius)
2238
+ maxY = Math.max(maxY, node.y + radius)
2239
+ })
2240
+
2241
+ return {
2242
+ minX,
2243
+ maxX,
2244
+ minY,
2245
+ maxY,
2246
+ width: Math.max(maxX - minX, 1),
2247
+ height: Math.max(maxY - minY, 1)
2248
+ }
2249
+ }
2250
+
2251
+ const fitScaleBiasByNodeCount = nodeCount => {
2252
+ if (nodeCount <= 6) return 1.22
2253
+ if (nodeCount <= 20) return 1.12
2254
+ if (nodeCount <= 60) return 1.04
2255
+ if (nodeCount <= 180) return 1
2256
+ if (nodeCount <= 600) return 0.94
2257
+ if (nodeCount <= 2000) return 0.82
2258
+ if (nodeCount <= 6000) return 0.68
2259
+ return 0.56
2260
+ }
2261
+
2262
+ const autoFitScaleRangeByNodeCount = nodeCount => {
2263
+ if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
2264
+ if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
2265
+ if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
2266
+ if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
2267
+ if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
2268
+ if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
2269
+ if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
2270
+ return { min: 0.0012, max: 0.24 }
2271
+ }
2272
+
2273
+ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
2274
+ const rect = canvas.getBoundingClientRect()
2275
+ const width = Math.max(rect.width, 320)
2276
+ const height = Math.max(rect.height, 320)
2277
+ const nodes = options.useFiltered ? filteredNodes() : state.nodes
2278
+ const bounds = graphBounds(nodes)
2279
+
2280
+ if (!bounds) {
2281
+ state.transform = { x: width / 2, y: height / 2, scale: 1 }
2282
+ state.offscreenFrameCount = 0
2283
+ state.recoveringViewport = false
2284
+ markRenderDirty()
2285
+ return
2286
+ }
2287
+
2288
+ const paddingByNodeCount = nodeCount => {
2289
+ if (nodeCount <= 6) return 28
2290
+ if (nodeCount <= 20) return 44
2291
+ if (nodeCount <= 60) return 68
2292
+ if (nodeCount <= 180) return 86
2293
+ if (nodeCount <= 600) return 110
2294
+ if (nodeCount <= 2000) return 140
2295
+ return 180
2296
+ }
2297
+ const padding = paddingByNodeCount(nodes.length)
2298
+ const scaleX = width / (bounds.width + padding * 2)
2299
+ const scaleY = height / (bounds.height + padding * 2)
2300
+ const fitScale = Math.min(scaleX, scaleY)
2301
+ const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
2302
+ const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
2303
+ const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
2304
+ const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
2305
+ const scale = options.macro && nodes.length > 1
2306
+ ? clampScale(Math.min(baselineScale, macroScale))
2307
+ : nodes.length > massiveGraphNodeThreshold
2308
+ ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
2309
+ : baselineScale
2310
+ const hubCenter =
2311
+ options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
2312
+ ? state.primaryHub
2313
+ : null
2314
+ const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
2315
+ const centerY = hubCenter ? hubCenter.y : (bounds.minY + bounds.maxY) / 2
2316
+
2317
+ state.transform = {
2318
+ x: clampTransformCoordinate(width / 2 - centerX * scale),
2319
+ y: clampTransformCoordinate(height / 2 - centerY * scale),
2320
+ scale: clampScale(scale)
2321
+ }
2322
+ state.offscreenFrameCount = 0
2323
+ state.recoveringViewport = false
2324
+ markRenderDirty()
2325
+ }
2326
+
2327
+ const resetView = () => fitView({ useFiltered: false, macro: true, preferHubCenter: true })
2328
+
2329
+ const focusPrimaryHub = () => {
2330
+ const hub = state.primaryHub
2331
+ if (!hub) {
2332
+ fitView({ useFiltered: true, macro: false, preferHubCenter: true })
2333
+ return
2334
+ }
2335
+
2336
+ const rect = canvas.getBoundingClientRect()
2337
+ const width = Math.max(rect.width, 320)
2338
+ const height = Math.max(rect.height, 320)
2339
+ const targetScale = clampScale(Math.max(0.78, state.transform.scale))
2340
+
2341
+ state.transform = {
2342
+ x: clampTransformCoordinate(width / 2 - hub.x * targetScale),
2343
+ y: clampTransformCoordinate(height / 2 - hub.y * targetScale),
2344
+ scale: targetScale
2345
+ }
2346
+ state.offscreenFrameCount = 0
2347
+ markRenderDirty()
2348
+ }
2349
+
2350
+ const layoutDensityScaleForNodeCount = (nodeCount) => {
2351
+ if (nodeCount > 50000) return 0.26
2352
+ if (nodeCount > 20000) return 0.3
2353
+ if (nodeCount > 6000) return 0.36
2354
+ if (nodeCount > 2000) return 0.42
2355
+ if (nodeCount > 600) return 0.5
2356
+ if (nodeCount > 180) return 0.58
2357
+ if (nodeCount > 60) return 0.68
2358
+ if (nodeCount > 20) return 0.78
2359
+ return 0.88
2360
+ }
2361
+
2362
+ const createLayout = graph => {
2363
+ const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
2364
+ const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
2365
+ const densityScale = layoutDensityScaleForNodeCount(nodeRows.length)
2366
+ const nodes = nodeRows.map(node => {
2367
+ if (Array.isArray(node)) {
2368
+ const [id, title, x, y, group, segment] = node
2369
+ return {
2370
+ id: typeof id === 'string' ? id : '',
2371
+ title: typeof title === 'string' ? title : 'Untitled',
2372
+ path: '',
2373
+ tags: [],
2374
+ group: typeof group === 'string' ? group : 'root',
2375
+ segment: typeof segment === 'string' ? segment : 'root',
2376
+ x: Number.isFinite(x) ? x * densityScale : 0,
2377
+ y: Number.isFinite(y) ? y * densityScale : 0,
2378
+ vx: 0,
2379
+ vy: 0
2380
+ }
2381
+ }
2382
+
2383
+ return {
2384
+ ...node,
2385
+ path: typeof node.path === 'string' ? node.path : '',
2386
+ tags: Array.isArray(node.tags) ? node.tags : [],
2387
+ x: Number.isFinite(node.x) ? node.x * densityScale : 0,
2388
+ y: Number.isFinite(node.y) ? node.y * densityScale : 0,
2389
+ vx: Number.isFinite(node.vx) ? node.vx : 0,
2390
+ vy: Number.isFinite(node.vy) ? node.vy : 0
2391
+ }
2392
+ })
2393
+ const nodeMap = new Map(nodes.map(node => [node.id, node]))
2394
+ const edges = edgeRows
2395
+ .map(edge => {
2396
+ if (Array.isArray(edge)) {
2397
+ const [source, target, weight, priority] = edge
2398
+ return {
2399
+ source: typeof source === 'string' ? source : '',
2400
+ target: typeof target === 'string' ? target : null,
2401
+ targetTitle: '',
2402
+ weight: Number.isFinite(weight) ? weight : 1,
2403
+ priority: typeof priority === 'string' ? priority : 'normal'
2404
+ }
2405
+ }
2406
+ return edge
2407
+ })
2408
+ .filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
2409
+ .map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
2410
+ return { nodes, edges }
2411
+ }
2412
+
2413
+ const encodeEntityTag = (value) => {
2414
+ const utf8 = new TextEncoder().encode(value)
2415
+ let binary = ''
2416
+
2417
+ for (let index = 0; index < utf8.length; index += 1) {
2418
+ binary += String.fromCharCode(utf8[index])
2419
+ }
2420
+
2421
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
2422
+ }
2423
+
2424
+ const graphSignature = graph => JSON.stringify({
2425
+ nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
2426
+ edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
2427
+ })
2428
+
2429
+ const resetContentFilter = () => {
2430
+ if (state.contentFilter.timer) {
2431
+ clearTimeout(state.contentFilter.timer)
2432
+ }
2433
+ state.contentFilter = {
2434
+ query: '',
2435
+ ids: null,
2436
+ token: state.contentFilter.token + 1,
2437
+ timer: null
2438
+ }
2439
+ recomputeVisibility()
2440
+ }
2441
+
2442
+ const syncContentFilter = async (query, token) => {
2443
+ const response = await fetch(
2444
+ '/api/graph-filter?q=' +
2445
+ encodeURIComponent(query) +
2446
+ '&limit=' +
2447
+ encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
2448
+ agentQuery('&')
2449
+ )
2450
+
2451
+ if (!response.ok || token !== state.contentFilter.token) {
2452
+ return
2453
+ }
2454
+
2455
+ const payload = await response.json()
2456
+ const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
2457
+ if (token !== state.contentFilter.token) {
2458
+ return
2459
+ }
2460
+
2461
+ state.contentFilter.query = query
2462
+ const merged = new Set([...(state.contentFilter.ids instanceof Set ? state.contentFilter.ids : []), ...nodeIds])
2463
+ state.contentFilter.ids = merged
2464
+ recomputeVisibility()
104
2465
  }
105
2466
 
106
- const encodeEntityTag = (value) => {
107
- const utf8 = new TextEncoder().encode(value)
108
- let binary = ''
2467
+ const scheduleContentFilterSync = () => {
2468
+ const query = normalizeQuery(state.query)
2469
+ if (!query) {
2470
+ resetContentFilter()
2471
+ return
2472
+ }
109
2473
 
110
- for (let index = 0; index < utf8.length; index += 1) {
111
- binary += String.fromCharCode(utf8[index])
2474
+ if (state.contentFilter.timer) {
2475
+ clearTimeout(state.contentFilter.timer)
112
2476
  }
113
2477
 
114
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
2478
+ const token = state.contentFilter.token + 1
2479
+ state.contentFilter = {
2480
+ query: state.contentFilter.query,
2481
+ ids: state.contentFilter.ids,
2482
+ token,
2483
+ timer: setTimeout(() => {
2484
+ if (state.filterWorker && state.filterReady) {
2485
+ state.filterWorker.postMessage({
2486
+ type: 'filter',
2487
+ query,
2488
+ token,
2489
+ limit: Math.max(state.nodes.length, 1)
2490
+ })
2491
+ }
2492
+ syncContentFilter(query, token).catch(() => {})
2493
+ }, 180)
2494
+ }
115
2495
  }
116
2496
 
117
- const graphSignature = graph => JSON.stringify({
118
- nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.content, node.tags]),
119
- edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
120
- })
121
-
122
2497
  const tick = delta => {
123
- const nodes = filteredNodes()
124
- const ids = new Set(nodes.map(node => node.id))
125
- const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
2498
+ const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
2499
+ const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
2500
+ const shouldRunPhysics =
2501
+ state.nodes.length <= 8000 &&
2502
+ nodes.length <= 320 &&
2503
+ state.transform.scale >= 0.08
2504
+ if (!shouldRunPhysics) {
2505
+ return
2506
+ }
126
2507
  const strength = Math.min(delta / 16, 2)
127
2508
 
128
2509
  edges.forEach(edge => {
129
2510
  const source = edge.sourceNode
130
2511
  const target = edge.targetNode
2512
+ source.vx = Number.isFinite(source.vx) ? source.vx : 0
2513
+ source.vy = Number.isFinite(source.vy) ? source.vy : 0
2514
+ target.vx = Number.isFinite(target.vx) ? target.vx : 0
2515
+ target.vy = Number.isFinite(target.vy) ? target.vy : 0
131
2516
  const dx = target.x - source.x
132
2517
  const dy = target.y - source.y
133
2518
  const distance = Math.max(Math.hypot(dx, dy), 1)
134
2519
  const force = (distance - 150) * 0.002 * strength
135
- const fx = dx * force
136
- const fy = dy * force
2520
+ const fx = (dx / distance) * force
2521
+ const fy = (dy / distance) * force
137
2522
  source.vx += fx
138
2523
  source.vy += fy
139
2524
  target.vx -= fx
@@ -144,6 +2529,10 @@ const tick = delta => {
144
2529
  for (let j = i + 1; j < nodes.length; j += 1) {
145
2530
  const a = nodes[i]
146
2531
  const b = nodes[j]
2532
+ a.vx = Number.isFinite(a.vx) ? a.vx : 0
2533
+ a.vy = Number.isFinite(a.vy) ? a.vy : 0
2534
+ b.vx = Number.isFinite(b.vx) ? b.vx : 0
2535
+ b.vy = Number.isFinite(b.vy) ? b.vy : 0
147
2536
  const dx = b.x - a.x
148
2537
  const dy = b.y - a.y
149
2538
  const distance = Math.max(Math.hypot(dx, dy), 1)
@@ -158,6 +2547,10 @@ const tick = delta => {
158
2547
  }
159
2548
 
160
2549
  nodes.forEach(node => {
2550
+ node.vx = Number.isFinite(node.vx) ? node.vx : 0
2551
+ node.vy = Number.isFinite(node.vy) ? node.vy : 0
2552
+ node.x = Number.isFinite(node.x) ? node.x : 0
2553
+ node.y = Number.isFinite(node.y) ? node.y : 0
161
2554
  if (state.pointer.dragNode === node) {
162
2555
  node.vx = 0
163
2556
  node.vy = 0
@@ -174,14 +2567,122 @@ const tick = delta => {
174
2567
 
175
2568
  const worldPoint = event => {
176
2569
  const rect = canvas.getBoundingClientRect()
177
- return {
178
- x: (event.clientX - rect.left - state.transform.x) / state.transform.scale,
179
- y: (event.clientY - rect.top - state.transform.y) / state.transform.scale
2570
+ return screenToWorldPoint(event.clientX - rect.left, event.clientY - rect.top)
2571
+ }
2572
+
2573
+ const connectedNodeIdsFor = (nodeId) => {
2574
+ const edges = state.visibleEdgeByNode.get(nodeId) ?? []
2575
+ const ids = new Set()
2576
+
2577
+ for (let index = 0; index < edges.length; index += 1) {
2578
+ const edge = edges[index]
2579
+ if (!edge.target) continue
2580
+ if (edge.source === nodeId) {
2581
+ ids.add(edge.target)
2582
+ } else if (edge.target === nodeId) {
2583
+ ids.add(edge.source)
2584
+ }
2585
+ }
2586
+
2587
+ return ids
2588
+ }
2589
+
2590
+ const applyDragNeighborhoodAdjustment = (dragNode, deltaX, deltaY) => {
2591
+ if (!dragNode) return
2592
+ if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) return
2593
+ if (Math.abs(deltaX) + Math.abs(deltaY) <= 0.001) return
2594
+
2595
+ const scale = Math.max(state.transform.scale, 0.0001)
2596
+ const influenceRadius = Math.max(220, Math.min(920, 440 / scale))
2597
+ const influenceRadiusSquared = influenceRadius * influenceRadius
2598
+ const connectedIds = connectedNodeIdsFor(dragNode.id)
2599
+ const candidates = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
2600
+ let adjusted = 0
2601
+
2602
+ for (let index = 0; index < candidates.length && adjusted < dragNeighborhoodMaxAffected; index += 1) {
2603
+ const node = candidates[index]
2604
+ if (node.id === dragNode.id) continue
2605
+
2606
+ const isConnected = connectedIds.has(node.id)
2607
+ const dx = node.x - dragNode.x
2608
+ const dy = node.y - dragNode.y
2609
+ const distanceSquared = dx * dx + dy * dy
2610
+ const withinRadius = distanceSquared <= influenceRadiusSquared
2611
+ if (!isConnected && !withinRadius) continue
2612
+
2613
+ const distance = Math.max(Math.sqrt(distanceSquared), 0.0001)
2614
+ const proximity = withinRadius ? 1 - (distance / influenceRadius) : 0
2615
+ const coupledStrength = isConnected ? 0.28 : 0.12
2616
+ const influence = Math.min(0.46, coupledStrength + proximity * 0.34)
2617
+ node.x += deltaX * influence
2618
+ node.y += deltaY * influence
2619
+ node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + deltaX * influence * 0.06
2620
+ node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + deltaY * influence * 0.06
2621
+ adjusted += 1
2622
+ }
2623
+ }
2624
+
2625
+ const settleNeighborhoodAroundNode = (dragNode) => {
2626
+ if (!dragNode) return
2627
+
2628
+ const scale = Math.max(state.transform.scale, 0.0001)
2629
+ const settleRadius = Math.max(240, Math.min(980, 520 / scale))
2630
+ const settleRadiusSquared = settleRadius * settleRadius
2631
+ const connectedIds = connectedNodeIdsFor(dragNode.id)
2632
+ const candidates = (state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes)
2633
+ .filter((node) => {
2634
+ if (node.id === dragNode.id) return true
2635
+ const dx = node.x - dragNode.x
2636
+ const dy = node.y - dragNode.y
2637
+ const distanceSquared = dx * dx + dy * dy
2638
+ return connectedIds.has(node.id) || distanceSquared <= settleRadiusSquared
2639
+ })
2640
+ .slice(0, dragNeighborhoodMaxAffected)
2641
+
2642
+ if (candidates.length <= 1) return
2643
+
2644
+ for (let round = 0; round < dragSettleRounds; round += 1) {
2645
+ for (let leftIndex = 0; leftIndex < candidates.length; leftIndex += 1) {
2646
+ const left = candidates[leftIndex]
2647
+ for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex += 1) {
2648
+ const right = candidates[rightIndex]
2649
+ const dx = right.x - left.x
2650
+ const dy = right.y - left.y
2651
+ const distance = Math.max(Math.hypot(dx, dy), 0.001)
2652
+ const minDistance = baseNodeRadius(left) + baseNodeRadius(right) + 10
2653
+ if (distance >= minDistance) continue
2654
+
2655
+ const push = (minDistance - distance) * 0.36
2656
+ const ux = dx / distance
2657
+ const uy = dy / distance
2658
+ if (left.id !== dragNode.id) {
2659
+ left.x -= ux * push
2660
+ left.y -= uy * push
2661
+ }
2662
+ if (right.id !== dragNode.id) {
2663
+ right.x += ux * push
2664
+ right.y += uy * push
2665
+ }
2666
+ }
2667
+ }
180
2668
  }
181
2669
  }
182
2670
 
183
2671
  const hitNode = point => {
184
- const nodes = filteredNodes()
2672
+ computeRenderVisibility()
2673
+ if (state.renderClusters.length > 0) {
2674
+ return null
2675
+ }
2676
+ const hitScaleFloor = state.nodes.length > massiveGraphNodeThreshold
2677
+ ? 0.2
2678
+ : state.nodes.length > largeGraphNodeThreshold
2679
+ ? 0.34
2680
+ : 0
2681
+ if (state.transform.scale < hitScaleFloor) {
2682
+ return null
2683
+ }
2684
+
2685
+ const nodes = state.renderNodes
185
2686
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
186
2687
  const node = nodes[index]
187
2688
  const radius = nodeRadius(node)
@@ -190,18 +2691,402 @@ const hitNode = point => {
190
2691
  return null
191
2692
  }
192
2693
 
193
- const nodeRadius = node => {
194
- const degree = state.edges.filter(edge => edge.source === node.id || edge.target === node.id).length
2694
+ const baseNodeRadius = node => {
2695
+ const degree = state.nodeDegrees.get(node.id) ?? 0
195
2696
  return 9 + Math.min(degree, 8) * 1.6
196
2697
  }
197
2698
 
2699
+ const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
2700
+
2701
+ const clusterRadiusPx = cluster => {
2702
+ if (cluster.id === 'macro-galaxy') {
2703
+ return 10
2704
+ }
2705
+ if (cluster.isHub) {
2706
+ return 3.8
2707
+ }
2708
+ if (String(cluster.id).startsWith('ecosystem-')) {
2709
+ const size = Math.max(1, Math.min(ecosystemLevelNodeCap, cluster.size || cluster.count || 1))
2710
+ const sizeBias = 0.56 + Math.log10(size + 1) * 0.28
2711
+ const densityBias = Math.log10((cluster.count || 1) + 1) * 0.12
2712
+ return Math.max(0.62, Math.min(2.4, sizeBias + densityBias))
2713
+ }
2714
+ return Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
2715
+ }
2716
+
2717
+ const clusterOpacity = cluster =>
2718
+ Math.max(0, Math.min(1, Number.isFinite(cluster.lodOpacity) ? cluster.lodOpacity : 1))
2719
+
2720
+ const worldViewportBounds = () => {
2721
+ const rect = canvas.getBoundingClientRect()
2722
+ const width = Math.max(rect.width, 320)
2723
+ const height = Math.max(rect.height, 320)
2724
+ const paddingMultiplier =
2725
+ state.nodes.length > massiveGraphNodeThreshold
2726
+ ? (state.transform.scale >= 0.6 ? 2.8 : state.transform.scale >= 0.25 ? 2.35 : 1.9)
2727
+ : state.nodes.length > largeGraphNodeThreshold
2728
+ ? 1.45
2729
+ : 1
2730
+ const padding = viewportPaddingPx * paddingMultiplier
2731
+
2732
+ return {
2733
+ minX: (-state.transform.x - padding) / state.transform.scale,
2734
+ maxX: (width - state.transform.x + padding) / state.transform.scale,
2735
+ minY: (-state.transform.y - padding) / state.transform.scale,
2736
+ maxY: (height - state.transform.y + padding) / state.transform.scale
2737
+ }
2738
+ }
2739
+
2740
+ const isNodeInViewport = (node, viewport) =>
2741
+ node.x >= viewport.minX &&
2742
+ node.x <= viewport.maxX &&
2743
+ node.y >= viewport.minY &&
2744
+ node.y <= viewport.maxY
2745
+
2746
+ const expandViewportBounds = (viewport, worldMargin) => ({
2747
+ minX: viewport.minX - worldMargin,
2748
+ maxX: viewport.maxX + worldMargin,
2749
+ minY: viewport.minY - worldMargin,
2750
+ maxY: viewport.maxY + worldMargin
2751
+ })
2752
+
2753
+ const viewportNodeStride = () => {
2754
+ if (state.nodes.length <= largeGraphNodeThreshold) {
2755
+ return 1
2756
+ }
2757
+
2758
+ if (state.transform.scale >= 0.95) {
2759
+ return 1
2760
+ }
2761
+ if (state.transform.scale >= 0.7) {
2762
+ return 2
2763
+ }
2764
+ if (state.transform.scale >= 0.48) {
2765
+ return 3
2766
+ }
2767
+ if (state.transform.scale >= 0.28) {
2768
+ return 5
2769
+ }
2770
+
2771
+ return 8
2772
+ }
2773
+
2774
+ const shouldRenderClusters = viewportNodes =>
2775
+ state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
2776
+
2777
+ const clusterViewportNodes = viewportNodes => {
2778
+ if (!shouldRenderClusters(viewportNodes)) {
2779
+ return []
2780
+ }
2781
+
2782
+ const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
2783
+ const buckets = new Map()
2784
+
2785
+ for (let index = 0; index < viewportNodes.length; index += 1) {
2786
+ const node = viewportNodes[index]
2787
+ const keyX = Math.floor(node.x / worldCellSize)
2788
+ const keyY = Math.floor(node.y / worldCellSize)
2789
+ const key = keyX + ':' + keyY
2790
+ const current = buckets.get(key)
2791
+ if (current) {
2792
+ current.count += 1
2793
+ current.sumX += node.x
2794
+ current.sumY += node.y
2795
+ if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
2796
+ current.representative = node
2797
+ current.degree = state.nodeDegrees.get(node.id) ?? 0
2798
+ }
2799
+ continue
2800
+ }
2801
+
2802
+ buckets.set(key, {
2803
+ id: key,
2804
+ count: 1,
2805
+ sumX: node.x,
2806
+ sumY: node.y,
2807
+ representative: node,
2808
+ degree: state.nodeDegrees.get(node.id) ?? 0
2809
+ })
2810
+ }
2811
+
2812
+ return Array.from(buckets.values())
2813
+ .sort((left, right) => right.count - left.count)
2814
+ .slice(0, Math.min(renderNodeBudget, 900))
2815
+ .map((cluster) => ({
2816
+ id: cluster.id,
2817
+ x: cluster.sumX / Math.max(cluster.count, 1),
2818
+ y: cluster.sumY / Math.max(cluster.count, 1),
2819
+ count: cluster.count,
2820
+ representative: cluster.representative
2821
+ }))
2822
+ }
2823
+
2824
+ const representativeNodesFromClusters = (clusters, limit) => {
2825
+ const representatives = clusters
2826
+ .map((cluster) => cluster.representative)
2827
+ .filter((node) => Boolean(node))
2828
+ const merged = mergeUniqueNodes(
2829
+ representatives,
2830
+ state.renderNodes ?? [],
2831
+ Math.max(1, limit)
2832
+ )
2833
+ return ensureHubNodesInRenderedSet(merged)
2834
+ }
2835
+
2836
+ const computeRenderVisibility = () => {
2837
+ if (!hasValidTransform()) {
2838
+ fitView({ useFiltered: true })
2839
+ }
2840
+ const viewport = worldViewportBounds()
2841
+ const viewportKey =
2842
+ Math.round(viewport.minX * 10) + ':' +
2843
+ Math.round(viewport.maxX * 10) + ':' +
2844
+ Math.round(viewport.minY * 10) + ':' +
2845
+ Math.round(viewport.maxY * 10) + ':' +
2846
+ visibilityScaleBucket(state.transform.scale)
2847
+
2848
+ if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
2849
+ return
2850
+ }
2851
+ state.lastViewportKey = viewportKey
2852
+ state.renderVisibilityDirty = false
2853
+ state.renderClusterEdges = []
2854
+
2855
+ const shouldRenderMacroGalaxy = shouldRenderMacroGalaxyView()
2856
+
2857
+ if (shouldRenderMacroGalaxy) {
2858
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2859
+ const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
2860
+ const representative = state.primaryHub ?? state.macroRepresentative ?? sourceNodes[0] ?? null
2861
+ if (representative) {
2862
+ state.renderClusters = [
2863
+ {
2864
+ id: 'macro-galaxy',
2865
+ x: state.macroCenter.x,
2866
+ y: state.macroCenter.y,
2867
+ count: sourceNodes.length,
2868
+ representative
2869
+ }
2870
+ ]
2871
+ state.renderNodes = [representative]
2872
+ } else {
2873
+ state.renderClusters = []
2874
+ state.renderNodes = []
2875
+ }
2876
+ state.renderEdges = []
2877
+ state.renderClusterEdges = []
2878
+ return
2879
+ }
2880
+
2881
+ const ecosystemScaleThreshold = state.visibleNodes.length > massiveGraphNodeThreshold
2882
+ ? massiveEcosystemClusterScaleThreshold
2883
+ : ecosystemClusterScaleThreshold
2884
+ if (
2885
+ state.visibleNodes.length > ecosystemActivationNodeThreshold &&
2886
+ state.transform.scale <= ecosystemScaleThreshold &&
2887
+ state.ecosystemClusters.length > 0
2888
+ ) {
2889
+ const clusters = selectHierarchicalEcosystemClusters(viewport)
2890
+ .sort((left, right) => right.count - left.count)
2891
+ state.renderClusters = clusters
2892
+ state.renderClusterEdges = ecosystemEdgesForClusters(clusters)
2893
+ state.renderNodes = []
2894
+ state.renderEdges = []
2895
+ return
2896
+ }
2897
+
2898
+ if (state.visibleNodes.length <= 2000) {
2899
+ state.renderNodes = state.visibleNodes
2900
+ state.renderClusters = []
2901
+ state.renderClusterEdges = []
2902
+ const ids = new Set(state.renderNodes.map((node) => node.id))
2903
+ state.renderEdges = withMeshEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
2904
+ return
2905
+ }
2906
+
2907
+ if (state.visibleNodes.length > massiveGraphNodeThreshold) {
2908
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2909
+ const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
2910
+ const sampleLimit = nodeBudgetForScale(state.transform.scale)
2911
+ const carryMargin = Math.max(240, Math.min(1200, 340 / Math.max(state.transform.scale, 0.0001)))
2912
+ const carryViewport = expandViewportBounds(viewport, carryMargin)
2913
+ const carryOverLimit = Math.max(180, Math.min(sampleLimit, Math.floor(sampleLimit * 0.5)))
2914
+ const carryOverNodes = (state.renderNodes ?? [])
2915
+ .filter((node) => isNodeInViewport(node, carryViewport))
2916
+ .slice(0, carryOverLimit)
2917
+ const sourceWithCarry = mergeUniqueNodes(
2918
+ sourceNodes,
2919
+ carryOverNodes,
2920
+ Math.max(sampleLimit * 7, carryOverLimit)
2921
+ )
2922
+ const sourceWithCarryIds = new Set(sourceWithCarry.map((node) => node.id))
2923
+ const sampledRaw = selectStableSampleNodes(
2924
+ sourceWithCarry,
2925
+ sampleLimit
2926
+ )
2927
+ const continuityBudget = Math.max(24, Math.min(sampleLimit - 8, Math.floor(sampleLimit * 0.42)))
2928
+ const previousVisibleNodes = (state.renderNodes ?? [])
2929
+ .filter((node) => sourceWithCarryIds.has(node.id))
2930
+ const continuityNodes = selectStableSampleNodes(previousVisibleNodes, continuityBudget)
2931
+ const sampled = mergeUniqueNodes(
2932
+ continuityNodes,
2933
+ sampledRaw,
2934
+ sampleLimit
2935
+ )
2936
+ let sampledNodes = ensureHubNodesInRenderedSet(sampled)
2937
+ if (state.transform.scale < 0.035) {
2938
+ sampledNodes = includeHubPreviewNeighborhood(
2939
+ sampledNodes,
2940
+ Math.min(renderNodeBudget, sampleLimit + 160)
2941
+ )
2942
+ }
2943
+ const sampledIds = new Set(sampledNodes.map((node) => node.id))
2944
+ let sampledEdges = collectVisibleEdgesForNodes(sampledIds)
2945
+
2946
+ if (state.transform.scale >= 0.035 && sampledEdges.length === 0) {
2947
+ const enriched = enrichSampleWithNeighbors(sampledNodes)
2948
+ sampledNodes = ensureHubNodesInRenderedSet(enriched.nodes)
2949
+ const sampledWithHubsIds = new Set(sampledNodes.map((node) => node.id))
2950
+ sampledEdges = collectVisibleEdgesForNodes(sampledWithHubsIds)
2951
+ }
2952
+
2953
+ state.renderClusters = []
2954
+ state.renderClusterEdges = []
2955
+ state.renderNodes = sampledNodes
2956
+ state.renderEdges = withMeshEdges(sampledNodes, sampledEdges)
2957
+ return
2958
+ }
2959
+
2960
+ if (state.transform.scale <= 0.0015) {
2961
+ const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
2962
+ const sampledIds = new Set(sampled.map((node) => node.id))
2963
+ state.renderClusters = []
2964
+ state.renderClusterEdges = []
2965
+ state.renderNodes = sampled
2966
+ state.renderEdges = withMeshEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
2967
+ return
2968
+ }
2969
+
2970
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2971
+ const clusters = clusterViewportNodes(viewportNodes)
2972
+ if (clusters.length > 0) {
2973
+ state.renderClusters = []
2974
+ state.renderClusterEdges = []
2975
+ state.renderNodes = representativeNodesFromClusters(clusters, Math.min(renderNodeBudget, 900))
2976
+ state.renderEdges = []
2977
+ return
2978
+ }
2979
+ state.renderClusters = []
2980
+ state.renderClusterEdges = []
2981
+ const stride = viewportNodeStride()
2982
+ const picked = []
2983
+
2984
+ for (let index = 0; index < viewportNodes.length; index += 1) {
2985
+ const node = viewportNodes[index]
2986
+
2987
+ const isPriority =
2988
+ node.id === state.selected?.id ||
2989
+ node.id === state.hovered?.id ||
2990
+ node.id === state.pointer.dragNode?.id
2991
+ if (isPriority || index % stride === 0) {
2992
+ picked.push(node)
2993
+ }
2994
+ }
2995
+
2996
+ const nodes = picked.length > renderNodeBudget
2997
+ ? picked.slice(0, renderNodeBudget)
2998
+ : picked
2999
+ if (nodes.length === 0 && state.visibleNodes.length > 0) {
3000
+ const fallbackNodes = fallbackViewportNodes()
3001
+ const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
3002
+ state.renderNodes = fallbackNodes
3003
+ state.renderClusters = []
3004
+ state.renderClusterEdges = []
3005
+ state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
3006
+ return
3007
+ }
3008
+
3009
+ const normalizedNodes = ensureHubNodesInRenderedSet(nodes)
3010
+ const nodeIds = new Set(normalizedNodes.map((node) => node.id))
3011
+ const edges = collectVisibleEdgesForNodes(nodeIds)
3012
+
3013
+ state.renderNodes = normalizedNodes
3014
+ state.renderEdges = withMeshEdges(normalizedNodes, edges)
3015
+
3016
+ if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
3017
+ const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
3018
+ const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
3019
+ state.renderClusters = []
3020
+ state.renderClusterEdges = []
3021
+ state.renderNodes = fallbackNodes
3022
+ state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
3023
+ }
3024
+ }
3025
+
3026
+ const isNodeVisibleOnScreen = (node, width, height) => {
3027
+ const radius = nodeRadius(node) * state.transform.scale
3028
+ const screenX = node.x * state.transform.scale + state.transform.x
3029
+ const screenY = node.y * state.transform.scale + state.transform.y
3030
+
3031
+ return (
3032
+ screenX + radius >= 0 &&
3033
+ screenX - radius <= width &&
3034
+ screenY + radius >= 0 &&
3035
+ screenY - radius <= height
3036
+ )
3037
+ }
3038
+
3039
+ const hasValidTransform = () =>
3040
+ isFiniteNumber(state.transform.x) &&
3041
+ isFiniteNumber(state.transform.y) &&
3042
+ isFiniteNumber(state.transform.scale) &&
3043
+ Math.abs(state.transform.x) <= transformCoordinateLimit &&
3044
+ Math.abs(state.transform.y) <= transformCoordinateLimit &&
3045
+ state.transform.scale > 0
3046
+
3047
+ const sanitizeNodePosition = node => {
3048
+ if (!isReasonableCoordinate(node.x)) node.x = 0
3049
+ if (!isReasonableCoordinate(node.y)) node.y = 0
3050
+ if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
3051
+ if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
3052
+ }
3053
+
3054
+ const sanitizeAllNodePositions = () => {
3055
+ state.nodes.forEach(sanitizeNodePosition)
3056
+ state.visibleNodes.forEach(sanitizeNodePosition)
3057
+ }
3058
+
3059
+ const sanitizeGraphState = () => {
3060
+ state.renderNodes.forEach(sanitizeNodePosition)
3061
+ }
3062
+
198
3063
  const render = now => {
199
3064
  const delta = now - state.last
200
3065
  state.last = now
3066
+ const backgroundFrameIntervalMs =
3067
+ state.nodes.length > massiveGraphNodeThreshold
3068
+ ? (state.transform.scale < 0.035 ? 130 : state.transform.scale < 0.08 ? 110 : 86)
3069
+ : state.nodes.length > largeGraphNodeThreshold
3070
+ ? 64
3071
+ : 16
3072
+ const isInteracting =
3073
+ state.pointer.down ||
3074
+ state.renderVisibilityDirty ||
3075
+ state.recoveringViewport
3076
+ const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
3077
+ if (delta < minFrameIntervalMs) {
3078
+ requestAnimationFrame(render)
3079
+ return
3080
+ }
201
3081
  const rect = canvas.getBoundingClientRect()
202
3082
  const width = Math.max(rect.width, 320)
203
3083
  const height = Math.max(rect.height, 320)
3084
+ sanitizeGraphState()
3085
+ if (!hasValidTransform()) {
3086
+ resetView()
3087
+ }
204
3088
  ctx.clearRect(0, 0, width, height)
3089
+ webGlRenderer?.clear(width, height)
205
3090
  if (state.nodes.length === 0) {
206
3091
  ctx.fillStyle = '#99a5b5'
207
3092
  ctx.font = '14px Inter, system-ui, sans-serif'
@@ -210,46 +3095,112 @@ const render = now => {
210
3095
  requestAnimationFrame(render)
211
3096
  return
212
3097
  }
213
- ctx.save()
214
- ctx.translate(state.transform.x, state.transform.y)
215
- ctx.scale(state.transform.scale, state.transform.scale)
216
-
217
- visibleEdges().forEach(edge => {
218
- const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
219
- ctx.beginPath()
220
- ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
221
- ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
222
- ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
223
- ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
224
- ctx.stroke()
225
- })
226
-
227
- filteredNodes().forEach(node => {
228
- const radius = nodeRadius(node)
229
- const isSelected = state.selected?.id === node.id
230
- const isHovered = state.hovered?.id === node.id
231
- ctx.beginPath()
232
- ctx.arc(node.x, node.y, radius + (isSelected ? 7 : isHovered ? 4 : 0), 0, Math.PI * 2)
233
- ctx.fillStyle = isSelected || isHovered ? graphTheme.nodeHaloActive : graphTheme.nodeHalo
234
- ctx.fill()
235
- ctx.beginPath()
236
- ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
237
- ctx.fillStyle = isSelected ? graphTheme.nodeSelected : isHovered ? graphTheme.nodeHover : graphTheme.node
238
- ctx.fill()
239
- ctx.lineWidth = isSelected ? 2.6 : 1.5
240
- ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
241
- ctx.stroke()
242
3098
 
243
- if (isSelected || isHovered || state.transform.scale > 1.18 || state.nodes.length <= 25) {
244
- ctx.fillStyle = graphTheme.label
245
- ctx.font = '12px Inter, system-ui, sans-serif'
246
- ctx.textAlign = 'center'
247
- ctx.textBaseline = 'top'
248
- ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
3099
+ computeRenderVisibility()
3100
+ tick(delta)
3101
+ const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
3102
+ const manualZoomGuardActive = now - state.lastManualZoomAt < zoomRecoveryGuardMs
3103
+ const allowViewportAutoRecovery = state.nodes.length <= massiveGraphNodeThreshold
3104
+ if (allowViewportAutoRecovery && !hasVisibleNodeOnScreen && state.renderNodes.length > 0 && !manualZoomGuardActive) {
3105
+ state.offscreenFrameCount += 1
3106
+ if (state.offscreenFrameCount >= 22 && !state.recoveringViewport) {
3107
+ state.recoveringViewport = true
3108
+ fitView({ useFiltered: true })
3109
+ state.offscreenFrameCount = 0
3110
+ requestAnimationFrame(() => {
3111
+ state.recoveringViewport = false
3112
+ })
249
3113
  }
250
- })
251
-
252
- ctx.restore()
3114
+ } else {
3115
+ state.offscreenFrameCount = 0
3116
+ }
3117
+ const minimumEdgeScale =
3118
+ state.nodes.length > massiveGraphNodeThreshold
3119
+ ? 0
3120
+ : state.renderNodes.length > 1300
3121
+ ? 0.12
3122
+ : state.renderNodes.length > 900
3123
+ ? 0.085
3124
+ : state.renderNodes.length > 500
3125
+ ? 0.05
3126
+ : 0
3127
+ const drawEdges =
3128
+ state.renderClusters.length === 0 &&
3129
+ state.transform.scale >= minimumEdgeScale
3130
+ if (drawAcceleratedGraph(width, height, drawEdges)) {
3131
+ // WebGL handles the dense node/edge layer; the 2D canvas remains the interaction overlay.
3132
+ } else if (state.renderClusters.length > 0) {
3133
+ ctx.save()
3134
+ ctx.translate(state.transform.x, state.transform.y)
3135
+ ctx.scale(state.transform.scale, state.transform.scale)
3136
+ const safeScale = Math.max(state.transform.scale, 0.0001)
3137
+ if (state.renderClusterEdges.length > 0) {
3138
+ for (let index = 0; index < state.renderClusterEdges.length; index += 1) {
3139
+ const edge = state.renderClusterEdges[index]
3140
+ const edgeOpacity = Math.min(clusterOpacity(edge.sourceCluster), clusterOpacity(edge.targetCluster))
3141
+ if (edgeOpacity <= 0.01) {
3142
+ continue
3143
+ }
3144
+ ctx.beginPath()
3145
+ ctx.moveTo(edge.sourceCluster.x, edge.sourceCluster.y)
3146
+ ctx.lineTo(edge.targetCluster.x, edge.targetCluster.y)
3147
+ ctx.lineWidth = 1.2 / safeScale
3148
+ ctx.strokeStyle = 'rgba(153, 165, 181, ' + (edge.inferred ? 0.14 : 0.22) * edgeOpacity + ')'
3149
+ ctx.stroke()
3150
+ }
3151
+ }
3152
+ state.renderClusters.forEach(cluster => {
3153
+ const isMacro = cluster.id === 'macro-galaxy'
3154
+ const isEcosystem = String(cluster.id).startsWith('ecosystem-')
3155
+ const isHub = Boolean(cluster.isHub)
3156
+ const opacity = clusterOpacity(cluster)
3157
+ if (opacity <= 0.01) {
3158
+ return
3159
+ }
3160
+ const radiusPx = clusterRadiusPx(cluster)
3161
+ const radius = radiusPx / safeScale
3162
+ const haloRadius = (radiusPx + (isMacro ? 8 : isHub ? 4 : isEcosystem ? 1.1 : 4)) / safeScale
3163
+ ctx.globalAlpha = opacity
3164
+ if (isHub || !isEcosystem || state.transform.scale >= ecosystemSubgraphScaleThreshold) {
3165
+ ctx.beginPath()
3166
+ ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
3167
+ ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
3168
+ ctx.fill()
3169
+ }
3170
+ ctx.beginPath()
3171
+ ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
3172
+ ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
3173
+ ctx.fill()
3174
+ ctx.lineWidth = (isEcosystem && !isHub ? 0.7 : 1.4) / safeScale
3175
+ ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
3176
+ ctx.stroke()
3177
+ if (isMacro && cluster.representative?.title) {
3178
+ ctx.fillStyle = '#edf2f7'
3179
+ ctx.font = 12 / safeScale + 'px Inter, system-ui, sans-serif'
3180
+ ctx.textAlign = 'center'
3181
+ ctx.textBaseline = 'top'
3182
+ ctx.fillText(cluster.representative.title.slice(0, 28), cluster.x, cluster.y + (radiusPx + 9) / safeScale)
3183
+ }
3184
+ ctx.globalAlpha = 1
3185
+ // Keep cluster markers minimal and faster to draw on large graphs.
3186
+ })
3187
+ ctx.restore()
3188
+ } else {
3189
+ ctx.save()
3190
+ ctx.translate(state.transform.x, state.transform.y)
3191
+ ctx.scale(state.transform.scale, state.transform.scale)
3192
+ if (drawEdges) {
3193
+ drawGraphEdges()
3194
+ }
3195
+ drawGraphNodes()
3196
+ ctx.restore()
3197
+ }
3198
+ if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
3199
+ ctx.fillStyle = '#99a5b5'
3200
+ ctx.font = '12px Inter, system-ui, sans-serif'
3201
+ ctx.textAlign = 'center'
3202
+ ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
3203
+ }
253
3204
  requestAnimationFrame(render)
254
3205
  }
255
3206
 
@@ -257,88 +3208,230 @@ const list = items => items.length
257
3208
  ? 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('')
258
3209
  : '<li><small>No links found.</small></li>'
259
3210
 
260
- const allNotesList = () => state.nodes.length
261
- ? 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('')
262
- : '<li><small>No notes indexed.</small></li>'
263
-
264
- const selectNode = node => {
265
- state.selected = node
266
- if (!node) {
267
- elements.title.textContent = 'Graph Overview'
268
- elements.path.textContent = state.nodes.length + ' notes and ' + state.graph.edges.length + ' links indexed.'
269
- elements.tags.innerHTML = ''
270
- elements.notes.innerHTML = allNotesList()
271
- elements.content.textContent = 'Selecione uma nota no grafo ou na lista para ver o Markdown completo, backlinks e links de saida.'
272
- elements.outgoing.innerHTML = '<li><small>Select a note to inspect outgoing links.</small></li>'
273
- elements.incoming.innerHTML = '<li><small>Select a note to inspect backlinks.</small></li>'
274
- return
275
- }
3211
+ const linkedNodes = node => {
276
3212
  const nodeById = new Map(state.nodes.map(item => [item.id, item]))
277
3213
  const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
278
3214
  ...linkedNode,
279
3215
  weight: edge.weight,
280
3216
  priority: edge.priority
281
3217
  } : null
282
- const outgoing = state.graph.edges
3218
+ const outgoing = state.edges
283
3219
  .filter(edge => edge.source === node.id)
284
- .map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' }, edge))
3220
+ .map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
285
3221
  .filter(Boolean)
286
- const incoming = state.graph.edges
3222
+ const incoming = state.edges
287
3223
  .filter(edge => edge.target === node.id)
288
3224
  .map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
289
3225
  .filter(Boolean)
290
3226
 
291
- elements.title.textContent = node.title
292
- elements.path.textContent = node.path
293
- elements.tags.innerHTML = node.tags.length
3227
+ return { outgoing, incoming }
3228
+ }
3229
+
3230
+ const fetchNodeDetails = async node => {
3231
+ const cached = state.nodeDetails.get(node.id)
3232
+ if (cached) {
3233
+ return cached
3234
+ }
3235
+
3236
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
3237
+ if (!response.ok) {
3238
+ throw new Error('Failed to load graph node details')
3239
+ }
3240
+
3241
+ const payload = await response.json()
3242
+ const detail = payload?.node
3243
+ if (!detail || !detail.id) {
3244
+ throw new Error('Invalid graph node payload')
3245
+ }
3246
+ state.nodeDetails.set(detail.id, detail)
3247
+ return detail
3248
+ }
3249
+
3250
+ const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
3251
+
3252
+ const openContentDialog = async node => {
3253
+ if (!node) return
3254
+ elements.contentTitle.textContent = node.title || 'Loading...'
3255
+ elements.contentPath.textContent = node.path || 'Loading...'
3256
+ elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
294
3257
  ? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
295
3258
  : '<span>No tags</span>'
296
- elements.notes.innerHTML = allNotesList()
297
- elements.content.textContent = node.content
298
- elements.outgoing.innerHTML = list(outgoing)
299
- elements.incoming.innerHTML = list(incoming)
3259
+ const initialLinks = linkedNodes(node)
3260
+ elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
3261
+ elements.contentIncoming.innerHTML = list(initialLinks.incoming)
3262
+ elements.contentBody.textContent = 'Loading note content...'
3263
+ if (!elements.contentDialog.open) {
3264
+ elements.contentDialog.showModal()
3265
+ }
3266
+
3267
+ const applyDetailToDialog = detail => {
3268
+ elements.contentTitle.textContent = detail.title
3269
+ elements.contentPath.textContent = detail.path
3270
+ elements.contentTags.innerHTML = detail.tags.length
3271
+ ? detail.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
3272
+ : '<span>No tags</span>'
3273
+ elements.contentBody.textContent = detail.content
3274
+ }
3275
+
3276
+ try {
3277
+ const detailedNode = await fetchNodeDetails(node)
3278
+ if (state.selected?.id !== node.id) {
3279
+ return
3280
+ }
3281
+ applyDetailToDialog(detailedNode)
3282
+ } catch {
3283
+ try {
3284
+ await wait(120)
3285
+ const retriedNode = await fetchNodeDetails(node)
3286
+ if (state.selected?.id !== node.id) {
3287
+ return
3288
+ }
3289
+ applyDetailToDialog(retriedNode)
3290
+ } catch {
3291
+ elements.contentBody.textContent = 'Unable to load note content.'
3292
+ }
3293
+ }
3294
+ }
3295
+
3296
+ const selectNode = (node, options = { openContent: false }) => {
3297
+ state.selected = node
3298
+ if (node && options.openContent) {
3299
+ openContentDialog(node).catch(() => {
3300
+ elements.contentBody.textContent = 'Unable to load note content.'
3301
+ })
3302
+ }
300
3303
  }
301
3304
 
302
3305
  const selectNodeById = id => {
303
3306
  const node = state.nodes.find(item => item.id === id)
304
- if (node) selectNode(node)
3307
+ if (node) selectNode(node, { openContent: true })
3308
+ }
3309
+
3310
+ const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
3311
+ state.lastManualZoomAt = performance.now()
3312
+ const effectiveFactor = factor
3313
+ const nextScale = clampScale(state.transform.scale * effectiveFactor)
3314
+ if (nextScale === state.transform.scale) {
3315
+ return
3316
+ }
3317
+ const worldPointAtCursor = screenToWorldPoint(screenX, screenY)
3318
+ const worldX = worldPointAtCursor.x
3319
+ const worldY = worldPointAtCursor.y
3320
+ state.lastZoomFocus = {
3321
+ x: worldX,
3322
+ y: worldY,
3323
+ at: performance.now()
3324
+ }
3325
+ state.transform.scale = clampScale(nextScale)
3326
+ state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
3327
+ state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
3328
+ state.offscreenFrameCount = 0
3329
+ markRenderDirty()
305
3330
  }
306
3331
 
307
- const zoom = factor => {
308
- state.transform.scale = Math.max(0.25, Math.min(3.5, state.transform.scale * factor))
3332
+ const wheelZoomFactor = event => {
3333
+ const isModifierZoom = event.metaKey || event.ctrlKey
3334
+ const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
3335
+ const normalizedDelta = event.deltaY * deltaModeFactor
3336
+
3337
+ if (!Number.isFinite(normalizedDelta) || Math.abs(normalizedDelta) <= 0.0001) {
3338
+ return 1
3339
+ }
3340
+
3341
+ const isMassiveEcosystemZoom =
3342
+ state.visibleNodes.length > massiveGraphNodeThreshold &&
3343
+ state.transform.scale <= massiveEcosystemClusterScaleThreshold
3344
+ const sensitivityMultiplier = isMassiveEcosystemZoom ? 0.48 : 1
3345
+ const capMultiplier = isMassiveEcosystemZoom ? 0.34 : 1
3346
+ const sensitivity = wheelZoomExponent * (isModifierZoom ? wheelZoomModifierBoost : 1) * sensitivityMultiplier
3347
+ const exponentCap = wheelZoomExponentCap * capMultiplier
3348
+ const exponent = Math.max(
3349
+ -exponentCap,
3350
+ Math.min(exponentCap, -normalizedDelta * sensitivity)
3351
+ )
3352
+ return Math.exp(exponent)
3353
+ }
3354
+
3355
+ const handleWheelZoom = event => {
3356
+ if (elements.contentDialog?.open) {
3357
+ return
3358
+ }
3359
+
3360
+ event.preventDefault()
3361
+ const rect = canvas.getBoundingClientRect()
3362
+ const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
3363
+ const rawCursorY = Number.isFinite(event.offsetY) ? event.offsetY : event.clientY - rect.top
3364
+ const cursorX = Math.max(0, Math.min(Math.max(rect.width, 320), rawCursorX))
3365
+ const cursorY = Math.max(0, Math.min(Math.max(rect.height, 320), rawCursorY))
3366
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
3367
+ const factor = wheelZoomFactor(event)
3368
+
3369
+ if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
3370
+ return
3371
+ }
3372
+
3373
+ zoomAtPoint(cursorX, cursorY, factor, 'wheel')
309
3374
  }
310
3375
 
311
3376
  const bindEvents = () => {
312
3377
  window.addEventListener('resize', resize)
313
3378
  elements.search.addEventListener('input', event => {
314
3379
  state.query = event.target.value
315
- elements.stats.textContent = state.query
316
- ? filteredNodes().length + ' filtered notes'
317
- : state.nodes.length + ' notes · ' + state.edges.length + ' links'
3380
+ recomputeVisibility()
3381
+ scheduleContentFilterSync()
318
3382
  })
319
3383
  elements.agent.addEventListener('change', event => {
320
3384
  state.agentId = event.target.value
3385
+ writeStoredAgent(state.agentId)
3386
+ syncAgentInUrl(state.agentId)
321
3387
  state.selected = null
3388
+ state.nodeDetails = new Map()
3389
+ resetContentFilter()
3390
+ recomputeVisibility()
3391
+ scheduleContentFilterSync()
322
3392
  loadGraph({ reset: true }).catch(error => {
323
- elements.stats.textContent = 'Failed to load agent graph'
324
3393
  console.error(error)
325
3394
  })
326
3395
  })
327
- elements.zoomIn.addEventListener('click', () => zoom(1.18))
328
- elements.zoomOut.addEventListener('click', () => zoom(0.84))
329
- elements.reset.addEventListener('click', resetView)
330
- ;[elements.notes, elements.outgoing, elements.incoming].forEach(element => {
331
- element.addEventListener('click', event => {
332
- const target = event.target
333
- if (!(target instanceof HTMLElement)) return
334
- const nodeId = target.dataset.nodeId
335
- if (nodeId) selectNodeById(nodeId)
3396
+ elements.zoomIn.addEventListener('click', () => {
3397
+ const rect = canvas.getBoundingClientRect()
3398
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.055, 'button')
3399
+ })
3400
+ elements.zoomOut.addEventListener('click', () => {
3401
+ const rect = canvas.getBoundingClientRect()
3402
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.948, 'button')
3403
+ })
3404
+ if (elements.fit) {
3405
+ elements.fit.addEventListener('click', () => {
3406
+ focusPrimaryHub()
336
3407
  })
3408
+ }
3409
+ elements.reset.addEventListener('click', () => {
3410
+ resetView()
3411
+ })
3412
+ elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
3413
+ elements.contentDialog.addEventListener('click', event => {
3414
+ const target = event.target
3415
+ if (target instanceof HTMLElement && target.dataset.nodeId) {
3416
+ selectNodeById(target.dataset.nodeId)
3417
+ return
3418
+ }
3419
+ if (event.target === elements.contentDialog) elements.contentDialog.close()
3420
+ })
3421
+ canvas.addEventListener('wheel', handleWheelZoom, { passive: false })
3422
+ canvas.addEventListener('dblclick', event => {
3423
+ const point = worldPoint(event)
3424
+ const node = hitNode(point)
3425
+ if (node) {
3426
+ selectNode(node, { openContent: true })
3427
+ return
3428
+ }
3429
+
3430
+ const rect = canvas.getBoundingClientRect()
3431
+ const cursorX = event.clientX - rect.left
3432
+ const cursorY = event.clientY - rect.top
3433
+ zoomAtPoint(cursorX, cursorY, 1.055)
337
3434
  })
338
- canvas.addEventListener('wheel', event => {
339
- event.preventDefault()
340
- zoom(event.deltaY < 0 ? 1.08 : 0.92)
341
- }, { passive: false })
342
3435
  canvas.addEventListener('pointerdown', event => {
343
3436
  const point = worldPoint(event)
344
3437
  const node = hitNode(point)
@@ -346,12 +3439,24 @@ const bindEvents = () => {
346
3439
  if (node) {
347
3440
  node.x = point.x
348
3441
  node.y = point.y
3442
+ markRenderDirty()
349
3443
  }
350
3444
  canvas.setPointerCapture(event.pointerId)
351
3445
  })
352
3446
  canvas.addEventListener('pointermove', event => {
353
3447
  const point = worldPoint(event)
354
- state.hovered = hitNode(point)
3448
+ const now = performance.now()
3449
+ const canHoverHitTest =
3450
+ !(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.06)
3451
+ const shouldHitTest = canHoverHitTest &&
3452
+ (state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
3453
+ if (shouldHitTest) {
3454
+ state.hovered = hitNode(point)
3455
+ state.lastHoverHitAt = now
3456
+ } else if (!canHoverHitTest) {
3457
+ state.hovered = null
3458
+ }
3459
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
355
3460
  if (!state.pointer.down) return
356
3461
  const dx = event.clientX - state.pointer.x
357
3462
  const dy = event.clientY - state.pointer.y
@@ -359,36 +3464,83 @@ const bindEvents = () => {
359
3464
  state.pointer.y = event.clientY
360
3465
  state.pointer.moved = state.pointer.moved || Math.abs(dx) + Math.abs(dy) > 3
361
3466
  if (state.pointer.dragNode) {
362
- state.pointer.dragNode.x = point.x
363
- state.pointer.dragNode.y = point.y
3467
+ const dragNode = state.pointer.dragNode
3468
+ const previousX = dragNode.x
3469
+ const previousY = dragNode.y
3470
+ dragNode.x = point.x
3471
+ dragNode.y = point.y
3472
+ applyDragNeighborhoodAdjustment(dragNode, dragNode.x - previousX, dragNode.y - previousY)
3473
+ markRenderDirty()
364
3474
  return
365
3475
  }
366
3476
  state.transform.x += dx
367
3477
  state.transform.y += dy
3478
+ state.transform.x = clampTransformCoordinate(state.transform.x)
3479
+ state.transform.y = clampTransformCoordinate(state.transform.y)
3480
+ state.offscreenFrameCount = 0
3481
+ markRenderDirty()
368
3482
  })
369
3483
  canvas.addEventListener('pointerup', event => {
370
- if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode)
371
- if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered)
3484
+ const draggedNode = state.pointer.dragNode
3485
+ if (draggedNode && state.pointer.moved) {
3486
+ settleNeighborhoodAroundNode(draggedNode)
3487
+ markRenderDirty()
3488
+ }
3489
+ if (draggedNode && !state.pointer.moved) selectNode(draggedNode, { openContent: false })
3490
+ if (!draggedNode && !state.pointer.moved) selectNode(state.hovered, { openContent: false })
372
3491
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
373
3492
  canvas.releasePointerCapture(event.pointerId)
374
3493
  })
3494
+ canvas.addEventListener('pointercancel', () => {
3495
+ state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
3496
+ })
3497
+ canvas.addEventListener('pointerenter', event => {
3498
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
3499
+ })
3500
+ canvas.addEventListener('pointerleave', event => {
3501
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
3502
+ })
3503
+ window.addEventListener('keydown', event => {
3504
+ if (event.key === '+' || event.key === '=') {
3505
+ event.preventDefault()
3506
+ const rect = canvas.getBoundingClientRect()
3507
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.05)
3508
+ return
3509
+ }
3510
+
3511
+ if (event.key === '-' || event.key === '_') {
3512
+ event.preventDefault()
3513
+ const rect = canvas.getBoundingClientRect()
3514
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.952)
3515
+ return
3516
+ }
3517
+
3518
+ if (event.key === '0') {
3519
+ event.preventDefault()
3520
+ resetView()
3521
+ }
3522
+ })
375
3523
  }
376
3524
 
377
3525
  const loadAgents = async () => {
378
3526
  const response = await fetch('/api/agents')
379
3527
  const payload = await response.json()
380
3528
  const agents = Array.isArray(payload.agents) ? payload.agents : []
381
- const currentExists = agents.some(agent => agent.id === state.agentId)
3529
+ const preferredAgent = state.agentId || initialAgentFromUrl || readStoredAgent()
3530
+ const currentExists = agents.some(agent => agent.id === preferredAgent)
382
3531
  const selected = currentExists
383
- ? state.agentId
3532
+ ? preferredAgent
384
3533
  : (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
385
3534
  const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
386
3535
 
387
3536
  state.agentId = selected
3537
+ writeStoredAgent(selected)
3538
+ syncAgentInUrl(selected)
388
3539
  if (signature !== state.agentsSignature) {
3540
+ const formatAgentLabel = (agent) => agent.id
389
3541
  elements.agent.innerHTML = agents.length
390
- ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent.id) + ' · ' + agent.documentCount + '</option>').join('')
391
- : '<option value="shared">shared · 0</option>'
3542
+ ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
3543
+ : '<option value="shared">shared</option>'
392
3544
  state.agentsSignature = signature
393
3545
  }
394
3546
  elements.agent.value = selected
@@ -409,6 +3561,10 @@ const loadGraph = async (options = { reset: false }) => {
409
3561
 
410
3562
  const payload = await response.json()
411
3563
  const graph = payload?.layout ?? payload
3564
+ state.graphTotals = {
3565
+ nodes: Number.isFinite(payload?.totals?.nodes) ? payload.totals.nodes : (Array.isArray(graph.nodes) ? graph.nodes.length : 0),
3566
+ edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
3567
+ }
412
3568
  const signature = payload?.signature ?? graphSignature(graph)
413
3569
  if (!options.reset && signature === state.graphSignature) return
414
3570
  const selectedId = state.selected?.id
@@ -416,18 +3572,37 @@ const loadGraph = async (options = { reset: false }) => {
416
3572
  state.graphSignature = signature
417
3573
  state.graph = graph
418
3574
  state.nodes = layout.nodes
3575
+ state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
419
3576
  state.edges = layout.edges
420
- const tags = new Set(graph.nodes.flatMap(node => node.tags))
421
- elements.stats.textContent = state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live'
422
- elements.nodeCount.textContent = graph.nodes.length
423
- elements.edgeCount.textContent = graph.edges.length
3577
+ state.nodeDegrees = state.edges.reduce((degrees, edge) => {
3578
+ degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
3579
+ if (edge.target) {
3580
+ degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
3581
+ }
3582
+ return degrees
3583
+ }, new Map())
3584
+ state.nodeDetails = new Map()
3585
+ pushNodesToFilterWorker()
3586
+ resetContentFilter()
3587
+ sanitizeAllNodePositions()
3588
+ recomputeVisibility()
3589
+ scheduleContentFilterSync()
3590
+ const tags = new Set(state.nodes.flatMap(node => node.tags))
3591
+ setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
3592
+ elements.nodeCount.textContent = state.graphTotals.nodes
3593
+ elements.edgeCount.textContent = state.graphTotals.edges
424
3594
  elements.tagCount.textContent = tags.size
425
3595
  resize()
426
3596
  if (options.reset) resetView()
427
- selectNode(state.nodes.find(node => node.id === selectedId) ?? null)
3597
+ const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
3598
+ selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
3599
+ if (!selectedNode && elements.contentDialog.open) {
3600
+ elements.contentDialog.close()
3601
+ }
428
3602
  }
429
3603
 
430
3604
  bindEvents()
3605
+ initFilterWorker()
431
3606
  requestAnimationFrame(() => {
432
3607
  resize()
433
3608
  resetView()
@@ -441,10 +3616,7 @@ const refreshGraphLoop = () => {
441
3616
  return
442
3617
  }
443
3618
 
444
- loadGraph().catch((error) => {
445
- elements.stats.textContent = 'Failed to refresh graph'
446
- console.error(error)
447
- })
3619
+ loadGraph().catch(handleGraphRefreshError)
448
3620
 
449
3621
  tickCounter += 1
450
3622
  if (tickCounter % 3 === 0) {
@@ -461,7 +3633,6 @@ loadAgents()
461
3633
  setInterval(refreshGraphLoop, pollIntervalMs)
462
3634
  })
463
3635
  .catch(error => {
464
- elements.stats.textContent = 'Failed to load graph'
465
3636
  console.error(error)
466
3637
  })
467
3638
 
@@ -470,9 +3641,6 @@ document.addEventListener('visibilitychange', () => {
470
3641
  return
471
3642
  }
472
3643
 
473
- loadGraph({ reset: true }).catch(error => {
474
- elements.stats.textContent = 'Failed to refresh graph'
475
- console.error(error)
476
- })
3644
+ loadGraph({ reset: true }).catch(handleGraphRefreshError)
477
3645
  })
478
3646
  `;