@andespindola/brainlink 0.1.0-beta.10 → 0.1.0-beta.101

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 (52) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +26 -2
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +138 -16
  6. package/SECURITY.md +1 -1
  7. package/dist/application/analyze-vault.js +1 -9
  8. package/dist/application/build-context.js +56 -1
  9. package/dist/application/dedupe-notes.js +226 -0
  10. package/dist/application/frontend/client-css.js +93 -45
  11. package/dist/application/frontend/client-html.js +34 -25
  12. package/dist/application/frontend/client-js.js +3153 -140
  13. package/dist/application/frontend/client-worker-js.js +66 -0
  14. package/dist/application/get-graph-layout.js +17 -5
  15. package/dist/application/get-graph-node.js +3 -3
  16. package/dist/application/get-graph-summary.js +3 -3
  17. package/dist/application/get-graph.js +3 -3
  18. package/dist/application/import-legacy-sqlite.js +296 -0
  19. package/dist/application/index-vault.js +252 -19
  20. package/dist/application/list-agents.js +3 -3
  21. package/dist/application/list-links.js +5 -5
  22. package/dist/application/offline-pack-backup.js +44 -0
  23. package/dist/application/search-graph-node-ids.js +3 -3
  24. package/dist/application/search-knowledge.js +25 -10
  25. package/dist/application/server/routes.js +76 -1
  26. package/dist/application/start-server.js +75 -4
  27. package/dist/application/watch-vault.js +23 -2
  28. package/dist/benchmarks/large-vault.js +1 -1
  29. package/dist/cli/commands/agent-commands.js +7 -0
  30. package/dist/cli/commands/write-commands.js +818 -8
  31. package/dist/domain/context.js +53 -11
  32. package/dist/domain/graph-layout.js +47 -2
  33. package/dist/domain/middle-out.js +18 -0
  34. package/dist/infrastructure/config.js +38 -0
  35. package/dist/infrastructure/file-index.js +358 -0
  36. package/dist/infrastructure/file-system-vault.js +15 -0
  37. package/dist/infrastructure/index-state.js +56 -0
  38. package/dist/infrastructure/private-pack-codec.js +134 -0
  39. package/dist/infrastructure/search-packs.js +452 -0
  40. package/dist/mcp/server.js +11 -1
  41. package/dist/mcp/tools.js +62 -0
  42. package/docs/AGENT_USAGE.md +97 -16
  43. package/docs/ARCHITECTURE.md +23 -26
  44. package/docs/QUICKSTART.md +7 -0
  45. package/package.json +6 -4
  46. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  47. package/dist/infrastructure/sqlite/graph-reader.js +0 -267
  48. package/dist/infrastructure/sqlite/recovery.js +0 -83
  49. package/dist/infrastructure/sqlite/schema.js +0 -114
  50. package/dist/infrastructure/sqlite/search-reader.js +0 -188
  51. package/dist/infrastructure/sqlite/types.js +0 -1
  52. package/dist/infrastructure/sqlite-index.js +0 -38
@@ -1,9 +1,59 @@
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: '',
@@ -13,9 +63,34 @@ const state = {
13
63
  nodeDetails: new Map(),
14
64
  transform: { x: 0, y: 0, scale: 1 },
15
65
  pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
66
+ cursor: { x: 0, y: 0, inCanvas: false },
16
67
  graphSignature: '',
17
68
  graphStatus: '',
18
- last: performance.now()
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
19
94
  }
20
95
 
21
96
  const byId = id => document.getElementById(id)
@@ -46,11 +121,54 @@ const elements = {
46
121
  }
47
122
 
48
123
  const zoomRange = {
49
- min: 0.05,
124
+ min: 0.0002,
50
125
  max: 4.5
51
126
  }
52
127
 
53
- const agentQuery = () => state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''
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) : ''
54
172
 
55
173
  const setGraphStatus = text => {
56
174
  state.graphStatus = text
@@ -73,45 +191,2049 @@ const graphTheme = {
73
191
  label: '#edf2f7'
74
192
  }
75
193
 
76
- const resize = () => {
77
- const rect = canvas.getBoundingClientRect()
78
- const width = Math.max(rect.width, 320)
79
- const height = Math.max(rect.height, 320)
80
- const ratio = window.devicePixelRatio || 1
81
- canvas.width = Math.floor(width * ratio)
82
- canvas.height = Math.floor(height * ratio)
83
- ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
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
+
426
+ const resize = () => {
427
+ const rect = canvas.getBoundingClientRect()
428
+ const width = Math.max(rect.width, 320)
429
+ const height = Math.max(rect.height, 320)
430
+ const ratio = window.devicePixelRatio || 1
431
+ canvas.width = Math.floor(width * ratio)
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
+ }
437
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
438
+ markRenderDirty()
439
+ }
440
+
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 =>
460
+ node.title.toLowerCase().includes(query) ||
461
+ (node.path || '').toLowerCase().includes(query) ||
462
+ node.tags.some(tag => tag.toLowerCase().includes(query))
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
564
+ }
565
+
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
575
+
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()
611
+ }
612
+
613
+ const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
614
+ const markRenderDirty = () => {
615
+ state.renderVisibilityDirty = true
616
+ }
617
+
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
+ }
654
+ }
655
+
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
84
2151
  }
85
2152
 
86
- const normalizeQuery = value => value.trim().toLowerCase()
2153
+ const ensureHubNodesInRenderedSet = (nodes) => {
2154
+ if (nodes.length === 0) {
2155
+ return nodes
2156
+ }
87
2157
 
88
- const localFilteredNodes = query =>
89
- state.nodes.filter(node =>
90
- node.title.toLowerCase().includes(query) ||
91
- node.path.toLowerCase().includes(query) ||
92
- node.tags.some(tag => tag.toLowerCase().includes(query))
93
- )
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]
94
2162
 
95
- const filteredNodes = () => {
96
- const query = normalizeQuery(state.query)
97
- if (!query) return state.nodes
98
- if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
99
- return state.nodes.filter(node => state.contentFilter.ids.has(node.id))
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
+ }
100
2181
  }
101
2182
 
102
- return localFilteredNodes(query)
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
103
2192
  }
104
2193
 
105
- const visibleIds = () => new Set(filteredNodes().map(node => node.id))
2194
+ const zoomCapByHubDistance = (distance) => {
2195
+ if (!Number.isFinite(distance) || distance <= 0) {
2196
+ return zoomRange.max
2197
+ }
106
2198
 
107
- const visibleEdges = () => {
108
- const ids = visibleIds()
109
- return state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
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))
110
2204
  }
111
2205
 
112
- const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
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
+ }
113
2215
 
114
- const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
2216
+ const zoomFloorByNodeCount = (nodeCount) => {
2217
+ if (nodeCount > massiveGraphNodeThreshold) return 0.0016
2218
+ if (nodeCount > largeGraphNodeThreshold) return 0.001
2219
+ if (nodeCount > ecosystemActivationNodeThreshold) return 0.0006
2220
+ return zoomRange.min
2221
+ }
2222
+
2223
+ const currentZoomMin = () => {
2224
+ const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
2225
+ return Math.max(zoomRange.min, zoomFloorByNodeCount(nodeCount))
2226
+ }
2227
+
2228
+ const clampScale = value => Math.max(currentZoomMin(), Math.min(currentZoomMax(), value))
2229
+ const isFiniteNumber = value => Number.isFinite(value)
2230
+ const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
2231
+ const clampTransformCoordinate = value => {
2232
+ if (!isFiniteNumber(value)) return 0
2233
+ if (value > transformCoordinateLimit) return transformCoordinateLimit
2234
+ if (value < -transformCoordinateLimit) return -transformCoordinateLimit
2235
+ return value
2236
+ }
115
2237
 
116
2238
  const graphBounds = nodes => {
117
2239
  if (nodes.length === 0) return null
@@ -121,7 +2243,7 @@ const graphBounds = nodes => {
121
2243
  let maxY = Number.NEGATIVE_INFINITY
122
2244
 
123
2245
  nodes.forEach(node => {
124
- const radius = nodeRadius(node)
2246
+ const radius = baseNodeRadius(node)
125
2247
  minX = Math.min(minX, node.x - radius)
126
2248
  maxX = Math.max(maxX, node.x + radius)
127
2249
  minY = Math.min(minY, node.y - radius)
@@ -138,7 +2260,41 @@ const graphBounds = nodes => {
138
2260
  }
139
2261
  }
140
2262
 
141
- const fitView = (options = { useFiltered: true }) => {
2263
+ const fitScaleBiasByNodeCount = nodeCount => {
2264
+ if (nodeCount <= 6) return 1.22
2265
+ if (nodeCount <= 20) return 1.12
2266
+ if (nodeCount <= 60) return 1.04
2267
+ if (nodeCount <= 180) return 1
2268
+ if (nodeCount <= 600) return 0.94
2269
+ if (nodeCount <= 2000) return 0.82
2270
+ if (nodeCount <= 6000) return 0.68
2271
+ return 0.56
2272
+ }
2273
+
2274
+ const autoFitScaleRangeByNodeCount = nodeCount => {
2275
+ if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
2276
+ if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
2277
+ if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
2278
+ if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
2279
+ if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
2280
+ if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
2281
+ if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
2282
+ return { min: 0.0012, max: 0.24 }
2283
+ }
2284
+
2285
+ const macroFaceToFaceScale = (nodeCount, hubDistance) => {
2286
+ if (!Number.isFinite(hubDistance) || hubDistance <= 0 || nodeCount <= ecosystemActivationNodeThreshold) {
2287
+ return 0
2288
+ }
2289
+
2290
+ const rect = canvas.getBoundingClientRect()
2291
+ const viewportReference = Math.max(320, Math.min(rect.width, rect.height))
2292
+ const share = nodeCount > massiveGraphNodeThreshold ? 0.052 : 0.046
2293
+ const targetPx = Math.max(18, viewportReference * share)
2294
+ return targetPx / hubDistance
2295
+ }
2296
+
2297
+ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
142
2298
  const rect = canvas.getBoundingClientRect()
143
2299
  const width = Math.max(rect.width, 320)
144
2300
  const height = Math.max(rect.height, 320)
@@ -147,33 +2303,138 @@ const fitView = (options = { useFiltered: true }) => {
147
2303
 
148
2304
  if (!bounds) {
149
2305
  state.transform = { x: width / 2, y: height / 2, scale: 1 }
2306
+ state.offscreenFrameCount = 0
2307
+ state.recoveringViewport = false
2308
+ markRenderDirty()
150
2309
  return
151
2310
  }
152
2311
 
153
- const padding = 100
2312
+ const paddingByNodeCount = nodeCount => {
2313
+ if (nodeCount <= 6) return 28
2314
+ if (nodeCount <= 20) return 44
2315
+ if (nodeCount <= 60) return 68
2316
+ if (nodeCount <= 180) return 86
2317
+ if (nodeCount <= 600) return 110
2318
+ if (nodeCount <= 2000) return 140
2319
+ return 180
2320
+ }
2321
+ const padding = paddingByNodeCount(nodes.length)
154
2322
  const scaleX = width / (bounds.width + padding * 2)
155
2323
  const scaleY = height / (bounds.height + padding * 2)
156
- const scale = clampScale(Math.min(scaleX, scaleY))
157
- const centerX = (bounds.minX + bounds.maxX) / 2
158
- const centerY = (bounds.minY + bounds.maxY) / 2
2324
+ const fitScale = Math.min(scaleX, scaleY)
2325
+ const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
2326
+ const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
2327
+ const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
2328
+ const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
2329
+ const scale = options.macro && nodes.length > 1
2330
+ ? clampScale(Math.min(baselineScale, macroScale))
2331
+ : nodes.length > massiveGraphNodeThreshold
2332
+ ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
2333
+ : baselineScale
2334
+ const macroFloorScale = options.macro
2335
+ ? clampScale(macroFaceToFaceScale(nodes.length, state.hubNeighborDistance))
2336
+ : 0
2337
+ const resolvedScale = options.macro
2338
+ ? clampScale(Math.max(scale, macroFloorScale))
2339
+ : scale
2340
+ const hubCenter =
2341
+ options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
2342
+ ? state.primaryHub
2343
+ : null
2344
+ const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
2345
+ const centerY = hubCenter ? hubCenter.y : (bounds.minY + bounds.maxY) / 2
159
2346
 
160
2347
  state.transform = {
161
- x: width / 2 - centerX * scale,
162
- y: height / 2 - centerY * scale,
163
- scale
2348
+ x: clampTransformCoordinate(width / 2 - centerX * resolvedScale),
2349
+ y: clampTransformCoordinate(height / 2 - centerY * resolvedScale),
2350
+ scale: clampScale(resolvedScale)
164
2351
  }
2352
+ state.offscreenFrameCount = 0
2353
+ state.recoveringViewport = false
2354
+ markRenderDirty()
165
2355
  }
166
2356
 
167
- const resetView = () => fitView({ useFiltered: false })
2357
+ const resetView = () => fitView({ useFiltered: false, macro: true, preferHubCenter: true })
2358
+
2359
+ const focusPrimaryHub = () => {
2360
+ const hub = state.primaryHub
2361
+ if (!hub) {
2362
+ fitView({ useFiltered: true, macro: false, preferHubCenter: true })
2363
+ return
2364
+ }
2365
+
2366
+ const rect = canvas.getBoundingClientRect()
2367
+ const width = Math.max(rect.width, 320)
2368
+ const height = Math.max(rect.height, 320)
2369
+ const targetScale = clampScale(Math.max(0.78, state.transform.scale))
2370
+
2371
+ state.transform = {
2372
+ x: clampTransformCoordinate(width / 2 - hub.x * targetScale),
2373
+ y: clampTransformCoordinate(height / 2 - hub.y * targetScale),
2374
+ scale: targetScale
2375
+ }
2376
+ state.offscreenFrameCount = 0
2377
+ markRenderDirty()
2378
+ }
2379
+
2380
+ const layoutDensityScaleForNodeCount = (nodeCount) => {
2381
+ if (nodeCount > 50000) return 0.26
2382
+ if (nodeCount > 20000) return 0.3
2383
+ if (nodeCount > 6000) return 0.36
2384
+ if (nodeCount > 2000) return 0.42
2385
+ if (nodeCount > 600) return 0.5
2386
+ if (nodeCount > 180) return 0.58
2387
+ if (nodeCount > 60) return 0.68
2388
+ if (nodeCount > 20) return 0.78
2389
+ return 0.88
2390
+ }
168
2391
 
169
2392
  const createLayout = graph => {
170
- const nodes = graph.nodes.map(node => ({
171
- ...node,
172
- x: Number.isFinite(node.x) ? node.x : 0,
173
- y: Number.isFinite(node.y) ? node.y : 0
174
- }))
2393
+ const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
2394
+ const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
2395
+ const densityScale = layoutDensityScaleForNodeCount(nodeRows.length)
2396
+ const nodes = nodeRows.map(node => {
2397
+ if (Array.isArray(node)) {
2398
+ const [id, title, x, y, group, segment] = node
2399
+ return {
2400
+ id: typeof id === 'string' ? id : '',
2401
+ title: typeof title === 'string' ? title : 'Untitled',
2402
+ path: '',
2403
+ tags: [],
2404
+ group: typeof group === 'string' ? group : 'root',
2405
+ segment: typeof segment === 'string' ? segment : 'root',
2406
+ x: Number.isFinite(x) ? x * densityScale : 0,
2407
+ y: Number.isFinite(y) ? y * densityScale : 0,
2408
+ vx: 0,
2409
+ vy: 0
2410
+ }
2411
+ }
2412
+
2413
+ return {
2414
+ ...node,
2415
+ path: typeof node.path === 'string' ? node.path : '',
2416
+ tags: Array.isArray(node.tags) ? node.tags : [],
2417
+ x: Number.isFinite(node.x) ? node.x * densityScale : 0,
2418
+ y: Number.isFinite(node.y) ? node.y * densityScale : 0,
2419
+ vx: Number.isFinite(node.vx) ? node.vx : 0,
2420
+ vy: Number.isFinite(node.vy) ? node.vy : 0
2421
+ }
2422
+ })
175
2423
  const nodeMap = new Map(nodes.map(node => [node.id, node]))
176
- const edges = graph.edges
2424
+ const edges = edgeRows
2425
+ .map(edge => {
2426
+ if (Array.isArray(edge)) {
2427
+ const [source, target, weight, priority] = edge
2428
+ return {
2429
+ source: typeof source === 'string' ? source : '',
2430
+ target: typeof target === 'string' ? target : null,
2431
+ targetTitle: '',
2432
+ weight: Number.isFinite(weight) ? weight : 1,
2433
+ priority: typeof priority === 'string' ? priority : 'normal'
2434
+ }
2435
+ }
2436
+ return edge
2437
+ })
177
2438
  .filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
178
2439
  .map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
179
2440
  return { nodes, edges }
@@ -205,15 +2466,16 @@ const resetContentFilter = () => {
205
2466
  token: state.contentFilter.token + 1,
206
2467
  timer: null
207
2468
  }
2469
+ recomputeVisibility()
208
2470
  }
209
2471
 
210
2472
  const syncContentFilter = async (query, token) => {
211
2473
  const response = await fetch(
212
- '/api/graph-filter?q=' +
2474
+ '/api/graph-filter?q=' +
213
2475
  encodeURIComponent(query) +
214
2476
  '&limit=' +
215
2477
  encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
216
- agentQuery()
2478
+ agentQuery('&')
217
2479
  )
218
2480
 
219
2481
  if (!response.ok || token !== state.contentFilter.token) {
@@ -227,7 +2489,9 @@ const syncContentFilter = async (query, token) => {
227
2489
  }
228
2490
 
229
2491
  state.contentFilter.query = query
230
- state.contentFilter.ids = new Set(nodeIds)
2492
+ const merged = new Set([...(state.contentFilter.ids instanceof Set ? state.contentFilter.ids : []), ...nodeIds])
2493
+ state.contentFilter.ids = merged
2494
+ recomputeVisibility()
231
2495
  }
232
2496
 
233
2497
  const scheduleContentFilterSync = () => {
@@ -247,26 +2511,44 @@ const scheduleContentFilterSync = () => {
247
2511
  ids: state.contentFilter.ids,
248
2512
  token,
249
2513
  timer: setTimeout(() => {
2514
+ if (state.filterWorker && state.filterReady) {
2515
+ state.filterWorker.postMessage({
2516
+ type: 'filter',
2517
+ query,
2518
+ token,
2519
+ limit: Math.max(state.nodes.length, 1)
2520
+ })
2521
+ }
250
2522
  syncContentFilter(query, token).catch(() => {})
251
2523
  }, 180)
252
2524
  }
253
2525
  }
254
2526
 
255
2527
  const tick = delta => {
256
- const nodes = filteredNodes()
257
- const ids = new Set(nodes.map(node => node.id))
258
- const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
2528
+ const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
2529
+ const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
2530
+ const shouldRunPhysics =
2531
+ state.nodes.length <= 8000 &&
2532
+ nodes.length <= 320 &&
2533
+ state.transform.scale >= 0.08
2534
+ if (!shouldRunPhysics) {
2535
+ return
2536
+ }
259
2537
  const strength = Math.min(delta / 16, 2)
260
2538
 
261
2539
  edges.forEach(edge => {
262
2540
  const source = edge.sourceNode
263
2541
  const target = edge.targetNode
2542
+ source.vx = Number.isFinite(source.vx) ? source.vx : 0
2543
+ source.vy = Number.isFinite(source.vy) ? source.vy : 0
2544
+ target.vx = Number.isFinite(target.vx) ? target.vx : 0
2545
+ target.vy = Number.isFinite(target.vy) ? target.vy : 0
264
2546
  const dx = target.x - source.x
265
2547
  const dy = target.y - source.y
266
2548
  const distance = Math.max(Math.hypot(dx, dy), 1)
267
2549
  const force = (distance - 150) * 0.002 * strength
268
- const fx = dx * force
269
- const fy = dy * force
2550
+ const fx = (dx / distance) * force
2551
+ const fy = (dy / distance) * force
270
2552
  source.vx += fx
271
2553
  source.vy += fy
272
2554
  target.vx -= fx
@@ -277,6 +2559,10 @@ const tick = delta => {
277
2559
  for (let j = i + 1; j < nodes.length; j += 1) {
278
2560
  const a = nodes[i]
279
2561
  const b = nodes[j]
2562
+ a.vx = Number.isFinite(a.vx) ? a.vx : 0
2563
+ a.vy = Number.isFinite(a.vy) ? a.vy : 0
2564
+ b.vx = Number.isFinite(b.vx) ? b.vx : 0
2565
+ b.vy = Number.isFinite(b.vy) ? b.vy : 0
280
2566
  const dx = b.x - a.x
281
2567
  const dy = b.y - a.y
282
2568
  const distance = Math.max(Math.hypot(dx, dy), 1)
@@ -291,6 +2577,10 @@ const tick = delta => {
291
2577
  }
292
2578
 
293
2579
  nodes.forEach(node => {
2580
+ node.vx = Number.isFinite(node.vx) ? node.vx : 0
2581
+ node.vy = Number.isFinite(node.vy) ? node.vy : 0
2582
+ node.x = Number.isFinite(node.x) ? node.x : 0
2583
+ node.y = Number.isFinite(node.y) ? node.y : 0
294
2584
  if (state.pointer.dragNode === node) {
295
2585
  node.vx = 0
296
2586
  node.vy = 0
@@ -307,14 +2597,122 @@ const tick = delta => {
307
2597
 
308
2598
  const worldPoint = event => {
309
2599
  const rect = canvas.getBoundingClientRect()
310
- return {
311
- x: (event.clientX - rect.left - state.transform.x) / state.transform.scale,
312
- y: (event.clientY - rect.top - state.transform.y) / state.transform.scale
2600
+ return screenToWorldPoint(event.clientX - rect.left, event.clientY - rect.top)
2601
+ }
2602
+
2603
+ const connectedNodeIdsFor = (nodeId) => {
2604
+ const edges = state.visibleEdgeByNode.get(nodeId) ?? []
2605
+ const ids = new Set()
2606
+
2607
+ for (let index = 0; index < edges.length; index += 1) {
2608
+ const edge = edges[index]
2609
+ if (!edge.target) continue
2610
+ if (edge.source === nodeId) {
2611
+ ids.add(edge.target)
2612
+ } else if (edge.target === nodeId) {
2613
+ ids.add(edge.source)
2614
+ }
2615
+ }
2616
+
2617
+ return ids
2618
+ }
2619
+
2620
+ const applyDragNeighborhoodAdjustment = (dragNode, deltaX, deltaY) => {
2621
+ if (!dragNode) return
2622
+ if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) return
2623
+ if (Math.abs(deltaX) + Math.abs(deltaY) <= 0.001) return
2624
+
2625
+ const scale = Math.max(state.transform.scale, 0.0001)
2626
+ const influenceRadius = Math.max(220, Math.min(920, 440 / scale))
2627
+ const influenceRadiusSquared = influenceRadius * influenceRadius
2628
+ const connectedIds = connectedNodeIdsFor(dragNode.id)
2629
+ const candidates = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
2630
+ let adjusted = 0
2631
+
2632
+ for (let index = 0; index < candidates.length && adjusted < dragNeighborhoodMaxAffected; index += 1) {
2633
+ const node = candidates[index]
2634
+ if (node.id === dragNode.id) continue
2635
+
2636
+ const isConnected = connectedIds.has(node.id)
2637
+ const dx = node.x - dragNode.x
2638
+ const dy = node.y - dragNode.y
2639
+ const distanceSquared = dx * dx + dy * dy
2640
+ const withinRadius = distanceSquared <= influenceRadiusSquared
2641
+ if (!isConnected && !withinRadius) continue
2642
+
2643
+ const distance = Math.max(Math.sqrt(distanceSquared), 0.0001)
2644
+ const proximity = withinRadius ? 1 - (distance / influenceRadius) : 0
2645
+ const coupledStrength = isConnected ? 0.28 : 0.12
2646
+ const influence = Math.min(0.46, coupledStrength + proximity * 0.34)
2647
+ node.x += deltaX * influence
2648
+ node.y += deltaY * influence
2649
+ node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + deltaX * influence * 0.06
2650
+ node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + deltaY * influence * 0.06
2651
+ adjusted += 1
2652
+ }
2653
+ }
2654
+
2655
+ const settleNeighborhoodAroundNode = (dragNode) => {
2656
+ if (!dragNode) return
2657
+
2658
+ const scale = Math.max(state.transform.scale, 0.0001)
2659
+ const settleRadius = Math.max(240, Math.min(980, 520 / scale))
2660
+ const settleRadiusSquared = settleRadius * settleRadius
2661
+ const connectedIds = connectedNodeIdsFor(dragNode.id)
2662
+ const candidates = (state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes)
2663
+ .filter((node) => {
2664
+ if (node.id === dragNode.id) return true
2665
+ const dx = node.x - dragNode.x
2666
+ const dy = node.y - dragNode.y
2667
+ const distanceSquared = dx * dx + dy * dy
2668
+ return connectedIds.has(node.id) || distanceSquared <= settleRadiusSquared
2669
+ })
2670
+ .slice(0, dragNeighborhoodMaxAffected)
2671
+
2672
+ if (candidates.length <= 1) return
2673
+
2674
+ for (let round = 0; round < dragSettleRounds; round += 1) {
2675
+ for (let leftIndex = 0; leftIndex < candidates.length; leftIndex += 1) {
2676
+ const left = candidates[leftIndex]
2677
+ for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex += 1) {
2678
+ const right = candidates[rightIndex]
2679
+ const dx = right.x - left.x
2680
+ const dy = right.y - left.y
2681
+ const distance = Math.max(Math.hypot(dx, dy), 0.001)
2682
+ const minDistance = baseNodeRadius(left) + baseNodeRadius(right) + 10
2683
+ if (distance >= minDistance) continue
2684
+
2685
+ const push = (minDistance - distance) * 0.36
2686
+ const ux = dx / distance
2687
+ const uy = dy / distance
2688
+ if (left.id !== dragNode.id) {
2689
+ left.x -= ux * push
2690
+ left.y -= uy * push
2691
+ }
2692
+ if (right.id !== dragNode.id) {
2693
+ right.x += ux * push
2694
+ right.y += uy * push
2695
+ }
2696
+ }
2697
+ }
313
2698
  }
314
2699
  }
315
2700
 
316
2701
  const hitNode = point => {
317
- const nodes = filteredNodes()
2702
+ computeRenderVisibility()
2703
+ if (state.renderClusters.length > 0) {
2704
+ return null
2705
+ }
2706
+ const hitScaleFloor = state.nodes.length > massiveGraphNodeThreshold
2707
+ ? 0.2
2708
+ : state.nodes.length > largeGraphNodeThreshold
2709
+ ? 0.34
2710
+ : 0
2711
+ if (state.transform.scale < hitScaleFloor) {
2712
+ return null
2713
+ }
2714
+
2715
+ const nodes = state.renderNodes
318
2716
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
319
2717
  const node = nodes[index]
320
2718
  const radius = nodeRadius(node)
@@ -323,18 +2721,402 @@ const hitNode = point => {
323
2721
  return null
324
2722
  }
325
2723
 
326
- const nodeRadius = node => {
327
- const degree = state.edges.filter(edge => edge.source === node.id || edge.target === node.id).length
2724
+ const baseNodeRadius = node => {
2725
+ const degree = state.nodeDegrees.get(node.id) ?? 0
328
2726
  return 9 + Math.min(degree, 8) * 1.6
329
2727
  }
330
2728
 
2729
+ const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
2730
+
2731
+ const clusterRadiusPx = cluster => {
2732
+ if (cluster.id === 'macro-galaxy') {
2733
+ return 10
2734
+ }
2735
+ if (cluster.isHub) {
2736
+ return 3.8
2737
+ }
2738
+ if (String(cluster.id).startsWith('ecosystem-')) {
2739
+ const size = Math.max(1, Math.min(ecosystemLevelNodeCap, cluster.size || cluster.count || 1))
2740
+ const sizeBias = 0.56 + Math.log10(size + 1) * 0.28
2741
+ const densityBias = Math.log10((cluster.count || 1) + 1) * 0.12
2742
+ return Math.max(0.62, Math.min(2.4, sizeBias + densityBias))
2743
+ }
2744
+ return Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
2745
+ }
2746
+
2747
+ const clusterOpacity = cluster =>
2748
+ Math.max(0, Math.min(1, Number.isFinite(cluster.lodOpacity) ? cluster.lodOpacity : 1))
2749
+
2750
+ const worldViewportBounds = () => {
2751
+ const rect = canvas.getBoundingClientRect()
2752
+ const width = Math.max(rect.width, 320)
2753
+ const height = Math.max(rect.height, 320)
2754
+ const paddingMultiplier =
2755
+ state.nodes.length > massiveGraphNodeThreshold
2756
+ ? (state.transform.scale >= 0.6 ? 2.8 : state.transform.scale >= 0.25 ? 2.35 : 1.9)
2757
+ : state.nodes.length > largeGraphNodeThreshold
2758
+ ? 1.45
2759
+ : 1
2760
+ const padding = viewportPaddingPx * paddingMultiplier
2761
+
2762
+ return {
2763
+ minX: (-state.transform.x - padding) / state.transform.scale,
2764
+ maxX: (width - state.transform.x + padding) / state.transform.scale,
2765
+ minY: (-state.transform.y - padding) / state.transform.scale,
2766
+ maxY: (height - state.transform.y + padding) / state.transform.scale
2767
+ }
2768
+ }
2769
+
2770
+ const isNodeInViewport = (node, viewport) =>
2771
+ node.x >= viewport.minX &&
2772
+ node.x <= viewport.maxX &&
2773
+ node.y >= viewport.minY &&
2774
+ node.y <= viewport.maxY
2775
+
2776
+ const expandViewportBounds = (viewport, worldMargin) => ({
2777
+ minX: viewport.minX - worldMargin,
2778
+ maxX: viewport.maxX + worldMargin,
2779
+ minY: viewport.minY - worldMargin,
2780
+ maxY: viewport.maxY + worldMargin
2781
+ })
2782
+
2783
+ const viewportNodeStride = () => {
2784
+ if (state.nodes.length <= largeGraphNodeThreshold) {
2785
+ return 1
2786
+ }
2787
+
2788
+ if (state.transform.scale >= 0.95) {
2789
+ return 1
2790
+ }
2791
+ if (state.transform.scale >= 0.7) {
2792
+ return 2
2793
+ }
2794
+ if (state.transform.scale >= 0.48) {
2795
+ return 3
2796
+ }
2797
+ if (state.transform.scale >= 0.28) {
2798
+ return 5
2799
+ }
2800
+
2801
+ return 8
2802
+ }
2803
+
2804
+ const shouldRenderClusters = viewportNodes =>
2805
+ state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
2806
+
2807
+ const clusterViewportNodes = viewportNodes => {
2808
+ if (!shouldRenderClusters(viewportNodes)) {
2809
+ return []
2810
+ }
2811
+
2812
+ const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
2813
+ const buckets = new Map()
2814
+
2815
+ for (let index = 0; index < viewportNodes.length; index += 1) {
2816
+ const node = viewportNodes[index]
2817
+ const keyX = Math.floor(node.x / worldCellSize)
2818
+ const keyY = Math.floor(node.y / worldCellSize)
2819
+ const key = keyX + ':' + keyY
2820
+ const current = buckets.get(key)
2821
+ if (current) {
2822
+ current.count += 1
2823
+ current.sumX += node.x
2824
+ current.sumY += node.y
2825
+ if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
2826
+ current.representative = node
2827
+ current.degree = state.nodeDegrees.get(node.id) ?? 0
2828
+ }
2829
+ continue
2830
+ }
2831
+
2832
+ buckets.set(key, {
2833
+ id: key,
2834
+ count: 1,
2835
+ sumX: node.x,
2836
+ sumY: node.y,
2837
+ representative: node,
2838
+ degree: state.nodeDegrees.get(node.id) ?? 0
2839
+ })
2840
+ }
2841
+
2842
+ return Array.from(buckets.values())
2843
+ .sort((left, right) => right.count - left.count)
2844
+ .slice(0, Math.min(renderNodeBudget, 900))
2845
+ .map((cluster) => ({
2846
+ id: cluster.id,
2847
+ x: cluster.sumX / Math.max(cluster.count, 1),
2848
+ y: cluster.sumY / Math.max(cluster.count, 1),
2849
+ count: cluster.count,
2850
+ representative: cluster.representative
2851
+ }))
2852
+ }
2853
+
2854
+ const representativeNodesFromClusters = (clusters, limit) => {
2855
+ const representatives = clusters
2856
+ .map((cluster) => cluster.representative)
2857
+ .filter((node) => Boolean(node))
2858
+ const merged = mergeUniqueNodes(
2859
+ representatives,
2860
+ state.renderNodes ?? [],
2861
+ Math.max(1, limit)
2862
+ )
2863
+ return ensureHubNodesInRenderedSet(merged)
2864
+ }
2865
+
2866
+ const computeRenderVisibility = () => {
2867
+ if (!hasValidTransform()) {
2868
+ fitView({ useFiltered: true })
2869
+ }
2870
+ const viewport = worldViewportBounds()
2871
+ const viewportKey =
2872
+ Math.round(viewport.minX * 10) + ':' +
2873
+ Math.round(viewport.maxX * 10) + ':' +
2874
+ Math.round(viewport.minY * 10) + ':' +
2875
+ Math.round(viewport.maxY * 10) + ':' +
2876
+ visibilityScaleBucket(state.transform.scale)
2877
+
2878
+ if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
2879
+ return
2880
+ }
2881
+ state.lastViewportKey = viewportKey
2882
+ state.renderVisibilityDirty = false
2883
+ state.renderClusterEdges = []
2884
+
2885
+ const shouldRenderMacroGalaxy = shouldRenderMacroGalaxyView()
2886
+
2887
+ if (shouldRenderMacroGalaxy) {
2888
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2889
+ const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
2890
+ const representative = state.primaryHub ?? state.macroRepresentative ?? sourceNodes[0] ?? null
2891
+ if (representative) {
2892
+ state.renderClusters = [
2893
+ {
2894
+ id: 'macro-galaxy',
2895
+ x: state.macroCenter.x,
2896
+ y: state.macroCenter.y,
2897
+ count: sourceNodes.length,
2898
+ representative
2899
+ }
2900
+ ]
2901
+ state.renderNodes = [representative]
2902
+ } else {
2903
+ state.renderClusters = []
2904
+ state.renderNodes = []
2905
+ }
2906
+ state.renderEdges = []
2907
+ state.renderClusterEdges = []
2908
+ return
2909
+ }
2910
+
2911
+ const ecosystemScaleThreshold = state.visibleNodes.length > massiveGraphNodeThreshold
2912
+ ? massiveEcosystemClusterScaleThreshold
2913
+ : ecosystemClusterScaleThreshold
2914
+ if (
2915
+ state.visibleNodes.length > ecosystemActivationNodeThreshold &&
2916
+ state.transform.scale <= ecosystemScaleThreshold &&
2917
+ state.ecosystemClusters.length > 0
2918
+ ) {
2919
+ const clusters = selectHierarchicalEcosystemClusters(viewport)
2920
+ .sort((left, right) => right.count - left.count)
2921
+ state.renderClusters = clusters
2922
+ state.renderClusterEdges = ecosystemEdgesForClusters(clusters)
2923
+ state.renderNodes = []
2924
+ state.renderEdges = []
2925
+ return
2926
+ }
2927
+
2928
+ if (state.visibleNodes.length <= 2000) {
2929
+ state.renderNodes = state.visibleNodes
2930
+ state.renderClusters = []
2931
+ state.renderClusterEdges = []
2932
+ const ids = new Set(state.renderNodes.map((node) => node.id))
2933
+ state.renderEdges = withMeshEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
2934
+ return
2935
+ }
2936
+
2937
+ if (state.visibleNodes.length > massiveGraphNodeThreshold) {
2938
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2939
+ const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
2940
+ const sampleLimit = nodeBudgetForScale(state.transform.scale)
2941
+ const carryMargin = Math.max(240, Math.min(1200, 340 / Math.max(state.transform.scale, 0.0001)))
2942
+ const carryViewport = expandViewportBounds(viewport, carryMargin)
2943
+ const carryOverLimit = Math.max(180, Math.min(sampleLimit, Math.floor(sampleLimit * 0.5)))
2944
+ const carryOverNodes = (state.renderNodes ?? [])
2945
+ .filter((node) => isNodeInViewport(node, carryViewport))
2946
+ .slice(0, carryOverLimit)
2947
+ const sourceWithCarry = mergeUniqueNodes(
2948
+ sourceNodes,
2949
+ carryOverNodes,
2950
+ Math.max(sampleLimit * 7, carryOverLimit)
2951
+ )
2952
+ const sourceWithCarryIds = new Set(sourceWithCarry.map((node) => node.id))
2953
+ const sampledRaw = selectStableSampleNodes(
2954
+ sourceWithCarry,
2955
+ sampleLimit
2956
+ )
2957
+ const continuityBudget = Math.max(24, Math.min(sampleLimit - 8, Math.floor(sampleLimit * 0.42)))
2958
+ const previousVisibleNodes = (state.renderNodes ?? [])
2959
+ .filter((node) => sourceWithCarryIds.has(node.id))
2960
+ const continuityNodes = selectStableSampleNodes(previousVisibleNodes, continuityBudget)
2961
+ const sampled = mergeUniqueNodes(
2962
+ continuityNodes,
2963
+ sampledRaw,
2964
+ sampleLimit
2965
+ )
2966
+ let sampledNodes = ensureHubNodesInRenderedSet(sampled)
2967
+ if (state.transform.scale < 0.035) {
2968
+ sampledNodes = includeHubPreviewNeighborhood(
2969
+ sampledNodes,
2970
+ Math.min(renderNodeBudget, sampleLimit + 160)
2971
+ )
2972
+ }
2973
+ const sampledIds = new Set(sampledNodes.map((node) => node.id))
2974
+ let sampledEdges = collectVisibleEdgesForNodes(sampledIds)
2975
+
2976
+ if (state.transform.scale >= 0.035 && sampledEdges.length === 0) {
2977
+ const enriched = enrichSampleWithNeighbors(sampledNodes)
2978
+ sampledNodes = ensureHubNodesInRenderedSet(enriched.nodes)
2979
+ const sampledWithHubsIds = new Set(sampledNodes.map((node) => node.id))
2980
+ sampledEdges = collectVisibleEdgesForNodes(sampledWithHubsIds)
2981
+ }
2982
+
2983
+ state.renderClusters = []
2984
+ state.renderClusterEdges = []
2985
+ state.renderNodes = sampledNodes
2986
+ state.renderEdges = withMeshEdges(sampledNodes, sampledEdges)
2987
+ return
2988
+ }
2989
+
2990
+ if (state.transform.scale <= 0.0015) {
2991
+ const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
2992
+ const sampledIds = new Set(sampled.map((node) => node.id))
2993
+ state.renderClusters = []
2994
+ state.renderClusterEdges = []
2995
+ state.renderNodes = sampled
2996
+ state.renderEdges = withMeshEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
2997
+ return
2998
+ }
2999
+
3000
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
3001
+ const clusters = clusterViewportNodes(viewportNodes)
3002
+ if (clusters.length > 0) {
3003
+ state.renderClusters = []
3004
+ state.renderClusterEdges = []
3005
+ state.renderNodes = representativeNodesFromClusters(clusters, Math.min(renderNodeBudget, 900))
3006
+ state.renderEdges = []
3007
+ return
3008
+ }
3009
+ state.renderClusters = []
3010
+ state.renderClusterEdges = []
3011
+ const stride = viewportNodeStride()
3012
+ const picked = []
3013
+
3014
+ for (let index = 0; index < viewportNodes.length; index += 1) {
3015
+ const node = viewportNodes[index]
3016
+
3017
+ const isPriority =
3018
+ node.id === state.selected?.id ||
3019
+ node.id === state.hovered?.id ||
3020
+ node.id === state.pointer.dragNode?.id
3021
+ if (isPriority || index % stride === 0) {
3022
+ picked.push(node)
3023
+ }
3024
+ }
3025
+
3026
+ const nodes = picked.length > renderNodeBudget
3027
+ ? picked.slice(0, renderNodeBudget)
3028
+ : picked
3029
+ if (nodes.length === 0 && state.visibleNodes.length > 0) {
3030
+ const fallbackNodes = fallbackViewportNodes()
3031
+ const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
3032
+ state.renderNodes = fallbackNodes
3033
+ state.renderClusters = []
3034
+ state.renderClusterEdges = []
3035
+ state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
3036
+ return
3037
+ }
3038
+
3039
+ const normalizedNodes = ensureHubNodesInRenderedSet(nodes)
3040
+ const nodeIds = new Set(normalizedNodes.map((node) => node.id))
3041
+ const edges = collectVisibleEdgesForNodes(nodeIds)
3042
+
3043
+ state.renderNodes = normalizedNodes
3044
+ state.renderEdges = withMeshEdges(normalizedNodes, edges)
3045
+
3046
+ if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
3047
+ const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
3048
+ const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
3049
+ state.renderClusters = []
3050
+ state.renderClusterEdges = []
3051
+ state.renderNodes = fallbackNodes
3052
+ state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
3053
+ }
3054
+ }
3055
+
3056
+ const isNodeVisibleOnScreen = (node, width, height) => {
3057
+ const radius = nodeRadius(node) * state.transform.scale
3058
+ const screenX = node.x * state.transform.scale + state.transform.x
3059
+ const screenY = node.y * state.transform.scale + state.transform.y
3060
+
3061
+ return (
3062
+ screenX + radius >= 0 &&
3063
+ screenX - radius <= width &&
3064
+ screenY + radius >= 0 &&
3065
+ screenY - radius <= height
3066
+ )
3067
+ }
3068
+
3069
+ const hasValidTransform = () =>
3070
+ isFiniteNumber(state.transform.x) &&
3071
+ isFiniteNumber(state.transform.y) &&
3072
+ isFiniteNumber(state.transform.scale) &&
3073
+ Math.abs(state.transform.x) <= transformCoordinateLimit &&
3074
+ Math.abs(state.transform.y) <= transformCoordinateLimit &&
3075
+ state.transform.scale > 0
3076
+
3077
+ const sanitizeNodePosition = node => {
3078
+ if (!isReasonableCoordinate(node.x)) node.x = 0
3079
+ if (!isReasonableCoordinate(node.y)) node.y = 0
3080
+ if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
3081
+ if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
3082
+ }
3083
+
3084
+ const sanitizeAllNodePositions = () => {
3085
+ state.nodes.forEach(sanitizeNodePosition)
3086
+ state.visibleNodes.forEach(sanitizeNodePosition)
3087
+ }
3088
+
3089
+ const sanitizeGraphState = () => {
3090
+ state.renderNodes.forEach(sanitizeNodePosition)
3091
+ }
3092
+
331
3093
  const render = now => {
332
3094
  const delta = now - state.last
333
3095
  state.last = now
3096
+ const backgroundFrameIntervalMs =
3097
+ state.nodes.length > massiveGraphNodeThreshold
3098
+ ? (state.transform.scale < 0.035 ? 130 : state.transform.scale < 0.08 ? 110 : 86)
3099
+ : state.nodes.length > largeGraphNodeThreshold
3100
+ ? 64
3101
+ : 16
3102
+ const isInteracting =
3103
+ state.pointer.down ||
3104
+ state.renderVisibilityDirty ||
3105
+ state.recoveringViewport
3106
+ const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
3107
+ if (delta < minFrameIntervalMs) {
3108
+ requestAnimationFrame(render)
3109
+ return
3110
+ }
334
3111
  const rect = canvas.getBoundingClientRect()
335
3112
  const width = Math.max(rect.width, 320)
336
3113
  const height = Math.max(rect.height, 320)
3114
+ sanitizeGraphState()
3115
+ if (!hasValidTransform()) {
3116
+ resetView()
3117
+ }
337
3118
  ctx.clearRect(0, 0, width, height)
3119
+ webGlRenderer?.clear(width, height)
338
3120
  if (state.nodes.length === 0) {
339
3121
  ctx.fillStyle = '#99a5b5'
340
3122
  ctx.font = '14px Inter, system-ui, sans-serif'
@@ -343,46 +3125,112 @@ const render = now => {
343
3125
  requestAnimationFrame(render)
344
3126
  return
345
3127
  }
346
- ctx.save()
347
- ctx.translate(state.transform.x, state.transform.y)
348
- ctx.scale(state.transform.scale, state.transform.scale)
349
-
350
- visibleEdges().forEach(edge => {
351
- const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
352
- ctx.beginPath()
353
- ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
354
- ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
355
- ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
356
- ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
357
- ctx.stroke()
358
- })
359
-
360
- filteredNodes().forEach(node => {
361
- const radius = nodeRadius(node)
362
- const isSelected = state.selected?.id === node.id
363
- const isHovered = state.hovered?.id === node.id
364
- ctx.beginPath()
365
- ctx.arc(node.x, node.y, radius + (isSelected ? 7 : isHovered ? 4 : 0), 0, Math.PI * 2)
366
- ctx.fillStyle = isSelected || isHovered ? graphTheme.nodeHaloActive : graphTheme.nodeHalo
367
- ctx.fill()
368
- ctx.beginPath()
369
- ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
370
- ctx.fillStyle = isSelected ? graphTheme.nodeSelected : isHovered ? graphTheme.nodeHover : graphTheme.node
371
- ctx.fill()
372
- ctx.lineWidth = isSelected ? 2.6 : 1.5
373
- ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
374
- ctx.stroke()
375
3128
 
376
- if (isSelected || isHovered || state.transform.scale > 1.18 || state.nodes.length <= 25) {
377
- ctx.fillStyle = graphTheme.label
378
- ctx.font = '12px Inter, system-ui, sans-serif'
379
- ctx.textAlign = 'center'
380
- ctx.textBaseline = 'top'
381
- ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
3129
+ computeRenderVisibility()
3130
+ tick(delta)
3131
+ const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
3132
+ const manualZoomGuardActive = now - state.lastManualZoomAt < zoomRecoveryGuardMs
3133
+ const allowViewportAutoRecovery = state.nodes.length <= massiveGraphNodeThreshold
3134
+ if (allowViewportAutoRecovery && !hasVisibleNodeOnScreen && state.renderNodes.length > 0 && !manualZoomGuardActive) {
3135
+ state.offscreenFrameCount += 1
3136
+ if (state.offscreenFrameCount >= 22 && !state.recoveringViewport) {
3137
+ state.recoveringViewport = true
3138
+ fitView({ useFiltered: true })
3139
+ state.offscreenFrameCount = 0
3140
+ requestAnimationFrame(() => {
3141
+ state.recoveringViewport = false
3142
+ })
382
3143
  }
383
- })
384
-
385
- ctx.restore()
3144
+ } else {
3145
+ state.offscreenFrameCount = 0
3146
+ }
3147
+ const minimumEdgeScale =
3148
+ state.nodes.length > massiveGraphNodeThreshold
3149
+ ? 0
3150
+ : state.renderNodes.length > 1300
3151
+ ? 0.12
3152
+ : state.renderNodes.length > 900
3153
+ ? 0.085
3154
+ : state.renderNodes.length > 500
3155
+ ? 0.05
3156
+ : 0
3157
+ const drawEdges =
3158
+ state.renderClusters.length === 0 &&
3159
+ state.transform.scale >= minimumEdgeScale
3160
+ if (drawAcceleratedGraph(width, height, drawEdges)) {
3161
+ // WebGL handles the dense node/edge layer; the 2D canvas remains the interaction overlay.
3162
+ } else if (state.renderClusters.length > 0) {
3163
+ ctx.save()
3164
+ ctx.translate(state.transform.x, state.transform.y)
3165
+ ctx.scale(state.transform.scale, state.transform.scale)
3166
+ const safeScale = Math.max(state.transform.scale, 0.0001)
3167
+ if (state.renderClusterEdges.length > 0) {
3168
+ for (let index = 0; index < state.renderClusterEdges.length; index += 1) {
3169
+ const edge = state.renderClusterEdges[index]
3170
+ const edgeOpacity = Math.min(clusterOpacity(edge.sourceCluster), clusterOpacity(edge.targetCluster))
3171
+ if (edgeOpacity <= 0.01) {
3172
+ continue
3173
+ }
3174
+ ctx.beginPath()
3175
+ ctx.moveTo(edge.sourceCluster.x, edge.sourceCluster.y)
3176
+ ctx.lineTo(edge.targetCluster.x, edge.targetCluster.y)
3177
+ ctx.lineWidth = 1.2 / safeScale
3178
+ ctx.strokeStyle = 'rgba(153, 165, 181, ' + (edge.inferred ? 0.14 : 0.22) * edgeOpacity + ')'
3179
+ ctx.stroke()
3180
+ }
3181
+ }
3182
+ state.renderClusters.forEach(cluster => {
3183
+ const isMacro = cluster.id === 'macro-galaxy'
3184
+ const isEcosystem = String(cluster.id).startsWith('ecosystem-')
3185
+ const isHub = Boolean(cluster.isHub)
3186
+ const opacity = clusterOpacity(cluster)
3187
+ if (opacity <= 0.01) {
3188
+ return
3189
+ }
3190
+ const radiusPx = clusterRadiusPx(cluster)
3191
+ const radius = radiusPx / safeScale
3192
+ const haloRadius = (radiusPx + (isMacro ? 8 : isHub ? 4 : isEcosystem ? 1.1 : 4)) / safeScale
3193
+ ctx.globalAlpha = opacity
3194
+ if (isHub || !isEcosystem || state.transform.scale >= ecosystemSubgraphScaleThreshold) {
3195
+ ctx.beginPath()
3196
+ ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
3197
+ ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
3198
+ ctx.fill()
3199
+ }
3200
+ ctx.beginPath()
3201
+ ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
3202
+ ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
3203
+ ctx.fill()
3204
+ ctx.lineWidth = (isEcosystem && !isHub ? 0.7 : 1.4) / safeScale
3205
+ ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
3206
+ ctx.stroke()
3207
+ if (isMacro && cluster.representative?.title) {
3208
+ ctx.fillStyle = '#edf2f7'
3209
+ ctx.font = 12 / safeScale + 'px Inter, system-ui, sans-serif'
3210
+ ctx.textAlign = 'center'
3211
+ ctx.textBaseline = 'top'
3212
+ ctx.fillText(cluster.representative.title.slice(0, 28), cluster.x, cluster.y + (radiusPx + 9) / safeScale)
3213
+ }
3214
+ ctx.globalAlpha = 1
3215
+ // Keep cluster markers minimal and faster to draw on large graphs.
3216
+ })
3217
+ ctx.restore()
3218
+ } else {
3219
+ ctx.save()
3220
+ ctx.translate(state.transform.x, state.transform.y)
3221
+ ctx.scale(state.transform.scale, state.transform.scale)
3222
+ if (drawEdges) {
3223
+ drawGraphEdges()
3224
+ }
3225
+ drawGraphNodes()
3226
+ ctx.restore()
3227
+ }
3228
+ if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
3229
+ ctx.fillStyle = '#99a5b5'
3230
+ ctx.font = '12px Inter, system-ui, sans-serif'
3231
+ ctx.textAlign = 'center'
3232
+ ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
3233
+ }
386
3234
  requestAnimationFrame(render)
387
3235
  }
388
3236
 
@@ -397,11 +3245,11 @@ const linkedNodes = node => {
397
3245
  weight: edge.weight,
398
3246
  priority: edge.priority
399
3247
  } : null
400
- const outgoing = state.graph.edges
3248
+ const outgoing = state.edges
401
3249
  .filter(edge => edge.source === node.id)
402
- .map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' }, edge))
3250
+ .map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
403
3251
  .filter(Boolean)
404
- const incoming = state.graph.edges
3252
+ const incoming = state.edges
405
3253
  .filter(edge => edge.target === node.id)
406
3254
  .map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
407
3255
  .filter(Boolean)
@@ -415,7 +3263,7 @@ const fetchNodeDetails = async node => {
415
3263
  return cached
416
3264
  }
417
3265
 
418
- const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery())
3266
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
419
3267
  if (!response.ok) {
420
3268
  throw new Error('Failed to load graph node details')
421
3269
  }
@@ -429,29 +3277,49 @@ const fetchNodeDetails = async node => {
429
3277
  return detail
430
3278
  }
431
3279
 
3280
+ const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
3281
+
432
3282
  const openContentDialog = async node => {
433
3283
  if (!node) return
434
- const { outgoing, incoming } = linkedNodes(node)
435
- elements.contentTitle.textContent = node.title
436
- elements.contentPath.textContent = node.path
437
- elements.contentTags.innerHTML = node.tags.length
3284
+ elements.contentTitle.textContent = node.title || 'Loading...'
3285
+ elements.contentPath.textContent = node.path || 'Loading...'
3286
+ elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
438
3287
  ? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
439
3288
  : '<span>No tags</span>'
440
- elements.contentOutgoing.innerHTML = list(outgoing)
441
- elements.contentIncoming.innerHTML = list(incoming)
3289
+ const initialLinks = linkedNodes(node)
3290
+ elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
3291
+ elements.contentIncoming.innerHTML = list(initialLinks.incoming)
442
3292
  elements.contentBody.textContent = 'Loading note content...'
443
3293
  if (!elements.contentDialog.open) {
444
3294
  elements.contentDialog.showModal()
445
3295
  }
446
3296
 
3297
+ const applyDetailToDialog = detail => {
3298
+ elements.contentTitle.textContent = detail.title
3299
+ elements.contentPath.textContent = detail.path
3300
+ elements.contentTags.innerHTML = detail.tags.length
3301
+ ? detail.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
3302
+ : '<span>No tags</span>'
3303
+ elements.contentBody.textContent = detail.content
3304
+ }
3305
+
447
3306
  try {
448
3307
  const detailedNode = await fetchNodeDetails(node)
449
3308
  if (state.selected?.id !== node.id) {
450
3309
  return
451
3310
  }
452
- elements.contentBody.textContent = detailedNode.content
3311
+ applyDetailToDialog(detailedNode)
453
3312
  } catch {
454
- elements.contentBody.textContent = 'Unable to load note content.'
3313
+ try {
3314
+ await wait(120)
3315
+ const retriedNode = await fetchNodeDetails(node)
3316
+ if (state.selected?.id !== node.id) {
3317
+ return
3318
+ }
3319
+ applyDetailToDialog(retriedNode)
3320
+ } catch {
3321
+ elements.contentBody.textContent = 'Unable to load note content.'
3322
+ }
455
3323
  }
456
3324
  }
457
3325
 
@@ -469,27 +3337,87 @@ const selectNodeById = id => {
469
3337
  if (node) selectNode(node, { openContent: true })
470
3338
  }
471
3339
 
472
- const zoomAtPoint = (screenX, screenY, factor) => {
473
- const nextScale = clampScale(state.transform.scale * factor)
474
- if (nextScale === state.transform.scale) return
475
- const worldX = (screenX - state.transform.x) / state.transform.scale
476
- const worldY = (screenY - state.transform.y) / state.transform.scale
477
- state.transform.scale = nextScale
478
- state.transform.x = screenX - worldX * nextScale
479
- state.transform.y = screenY - worldY * nextScale
3340
+ const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
3341
+ state.lastManualZoomAt = performance.now()
3342
+ const effectiveFactor = factor
3343
+ const nextScale = clampScale(state.transform.scale * effectiveFactor)
3344
+ if (nextScale === state.transform.scale) {
3345
+ return
3346
+ }
3347
+ const worldPointAtCursor = screenToWorldPoint(screenX, screenY)
3348
+ const worldX = worldPointAtCursor.x
3349
+ const worldY = worldPointAtCursor.y
3350
+ state.lastZoomFocus = {
3351
+ x: worldX,
3352
+ y: worldY,
3353
+ at: performance.now()
3354
+ }
3355
+ state.transform.scale = clampScale(nextScale)
3356
+ state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
3357
+ state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
3358
+ state.offscreenFrameCount = 0
3359
+ markRenderDirty()
3360
+ }
3361
+
3362
+ const wheelZoomFactor = event => {
3363
+ const isModifierZoom = event.metaKey || event.ctrlKey
3364
+ const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
3365
+ const normalizedDelta = event.deltaY * deltaModeFactor
3366
+
3367
+ if (!Number.isFinite(normalizedDelta) || Math.abs(normalizedDelta) <= 0.0001) {
3368
+ return 1
3369
+ }
3370
+
3371
+ const isMassiveEcosystemZoom =
3372
+ state.visibleNodes.length > massiveGraphNodeThreshold &&
3373
+ state.transform.scale <= massiveEcosystemClusterScaleThreshold
3374
+ const sensitivityMultiplier = isMassiveEcosystemZoom ? 0.48 : 1
3375
+ const capMultiplier = isMassiveEcosystemZoom ? 0.34 : 1
3376
+ const sensitivity = wheelZoomExponent * (isModifierZoom ? wheelZoomModifierBoost : 1) * sensitivityMultiplier
3377
+ const exponentCap = wheelZoomExponentCap * capMultiplier
3378
+ const exponent = Math.max(
3379
+ -exponentCap,
3380
+ Math.min(exponentCap, -normalizedDelta * sensitivity)
3381
+ )
3382
+ return Math.exp(exponent)
3383
+ }
3384
+
3385
+ const handleWheelZoom = event => {
3386
+ if (elements.contentDialog?.open) {
3387
+ return
3388
+ }
3389
+
3390
+ event.preventDefault()
3391
+ const rect = canvas.getBoundingClientRect()
3392
+ const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
3393
+ const rawCursorY = Number.isFinite(event.offsetY) ? event.offsetY : event.clientY - rect.top
3394
+ const cursorX = Math.max(0, Math.min(Math.max(rect.width, 320), rawCursorX))
3395
+ const cursorY = Math.max(0, Math.min(Math.max(rect.height, 320), rawCursorY))
3396
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
3397
+ const factor = wheelZoomFactor(event)
3398
+
3399
+ if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
3400
+ return
3401
+ }
3402
+
3403
+ zoomAtPoint(cursorX, cursorY, factor, 'wheel')
480
3404
  }
481
3405
 
482
3406
  const bindEvents = () => {
483
3407
  window.addEventListener('resize', resize)
484
3408
  elements.search.addEventListener('input', event => {
485
3409
  state.query = event.target.value
3410
+ recomputeVisibility()
486
3411
  scheduleContentFilterSync()
487
3412
  })
488
3413
  elements.agent.addEventListener('change', event => {
489
3414
  state.agentId = event.target.value
3415
+ writeStoredAgent(state.agentId)
3416
+ syncAgentInUrl(state.agentId)
490
3417
  state.selected = null
491
3418
  state.nodeDetails = new Map()
492
3419
  resetContentFilter()
3420
+ recomputeVisibility()
493
3421
  scheduleContentFilterSync()
494
3422
  loadGraph({ reset: true }).catch(error => {
495
3423
  console.error(error)
@@ -497,16 +3425,20 @@ const bindEvents = () => {
497
3425
  })
498
3426
  elements.zoomIn.addEventListener('click', () => {
499
3427
  const rect = canvas.getBoundingClientRect()
500
- zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.18)
3428
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.055, 'button')
501
3429
  })
502
3430
  elements.zoomOut.addEventListener('click', () => {
503
3431
  const rect = canvas.getBoundingClientRect()
504
- zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.84)
3432
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.948, 'button')
505
3433
  })
506
3434
  if (elements.fit) {
507
- elements.fit.addEventListener('click', () => fitView({ useFiltered: true }))
3435
+ elements.fit.addEventListener('click', () => {
3436
+ focusPrimaryHub()
3437
+ })
508
3438
  }
509
- elements.reset.addEventListener('click', resetView)
3439
+ elements.reset.addEventListener('click', () => {
3440
+ resetView()
3441
+ })
510
3442
  elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
511
3443
  elements.contentDialog.addEventListener('click', event => {
512
3444
  const target = event.target
@@ -516,14 +3448,20 @@ const bindEvents = () => {
516
3448
  }
517
3449
  if (event.target === elements.contentDialog) elements.contentDialog.close()
518
3450
  })
519
- canvas.addEventListener('wheel', event => {
520
- event.preventDefault()
3451
+ canvas.addEventListener('wheel', handleWheelZoom, { passive: false })
3452
+ canvas.addEventListener('dblclick', event => {
3453
+ const point = worldPoint(event)
3454
+ const node = hitNode(point)
3455
+ if (node) {
3456
+ selectNode(node, { openContent: true })
3457
+ return
3458
+ }
3459
+
521
3460
  const rect = canvas.getBoundingClientRect()
522
3461
  const cursorX = event.clientX - rect.left
523
3462
  const cursorY = event.clientY - rect.top
524
- const factor = event.deltaY < 0 ? 1.08 : 0.92
525
- zoomAtPoint(cursorX, cursorY, factor)
526
- }, { passive: false })
3463
+ zoomAtPoint(cursorX, cursorY, 1.055)
3464
+ })
527
3465
  canvas.addEventListener('pointerdown', event => {
528
3466
  const point = worldPoint(event)
529
3467
  const node = hitNode(point)
@@ -531,12 +3469,24 @@ const bindEvents = () => {
531
3469
  if (node) {
532
3470
  node.x = point.x
533
3471
  node.y = point.y
3472
+ markRenderDirty()
534
3473
  }
535
3474
  canvas.setPointerCapture(event.pointerId)
536
3475
  })
537
3476
  canvas.addEventListener('pointermove', event => {
538
3477
  const point = worldPoint(event)
539
- state.hovered = hitNode(point)
3478
+ const now = performance.now()
3479
+ const canHoverHitTest =
3480
+ !(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.06)
3481
+ const shouldHitTest = canHoverHitTest &&
3482
+ (state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
3483
+ if (shouldHitTest) {
3484
+ state.hovered = hitNode(point)
3485
+ state.lastHoverHitAt = now
3486
+ } else if (!canHoverHitTest) {
3487
+ state.hovered = null
3488
+ }
3489
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
540
3490
  if (!state.pointer.down) return
541
3491
  const dx = event.clientX - state.pointer.x
542
3492
  const dy = event.clientY - state.pointer.y
@@ -544,36 +3494,83 @@ const bindEvents = () => {
544
3494
  state.pointer.y = event.clientY
545
3495
  state.pointer.moved = state.pointer.moved || Math.abs(dx) + Math.abs(dy) > 3
546
3496
  if (state.pointer.dragNode) {
547
- state.pointer.dragNode.x = point.x
548
- state.pointer.dragNode.y = point.y
3497
+ const dragNode = state.pointer.dragNode
3498
+ const previousX = dragNode.x
3499
+ const previousY = dragNode.y
3500
+ dragNode.x = point.x
3501
+ dragNode.y = point.y
3502
+ applyDragNeighborhoodAdjustment(dragNode, dragNode.x - previousX, dragNode.y - previousY)
3503
+ markRenderDirty()
549
3504
  return
550
3505
  }
551
3506
  state.transform.x += dx
552
3507
  state.transform.y += dy
3508
+ state.transform.x = clampTransformCoordinate(state.transform.x)
3509
+ state.transform.y = clampTransformCoordinate(state.transform.y)
3510
+ state.offscreenFrameCount = 0
3511
+ markRenderDirty()
553
3512
  })
554
3513
  canvas.addEventListener('pointerup', event => {
555
- if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
556
- if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
3514
+ const draggedNode = state.pointer.dragNode
3515
+ if (draggedNode && state.pointer.moved) {
3516
+ settleNeighborhoodAroundNode(draggedNode)
3517
+ markRenderDirty()
3518
+ }
3519
+ if (draggedNode && !state.pointer.moved) selectNode(draggedNode, { openContent: false })
3520
+ if (!draggedNode && !state.pointer.moved) selectNode(state.hovered, { openContent: false })
557
3521
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
558
3522
  canvas.releasePointerCapture(event.pointerId)
559
3523
  })
3524
+ canvas.addEventListener('pointercancel', () => {
3525
+ state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
3526
+ })
3527
+ canvas.addEventListener('pointerenter', event => {
3528
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
3529
+ })
3530
+ canvas.addEventListener('pointerleave', event => {
3531
+ state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
3532
+ })
3533
+ window.addEventListener('keydown', event => {
3534
+ if (event.key === '+' || event.key === '=') {
3535
+ event.preventDefault()
3536
+ const rect = canvas.getBoundingClientRect()
3537
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.05)
3538
+ return
3539
+ }
3540
+
3541
+ if (event.key === '-' || event.key === '_') {
3542
+ event.preventDefault()
3543
+ const rect = canvas.getBoundingClientRect()
3544
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.952)
3545
+ return
3546
+ }
3547
+
3548
+ if (event.key === '0') {
3549
+ event.preventDefault()
3550
+ resetView()
3551
+ }
3552
+ })
560
3553
  }
561
3554
 
562
3555
  const loadAgents = async () => {
563
3556
  const response = await fetch('/api/agents')
564
3557
  const payload = await response.json()
565
3558
  const agents = Array.isArray(payload.agents) ? payload.agents : []
566
- const currentExists = agents.some(agent => agent.id === state.agentId)
3559
+ const preferredAgent = state.agentId || initialAgentFromUrl || readStoredAgent()
3560
+ const currentExists = agents.some(agent => agent.id === preferredAgent)
567
3561
  const selected = currentExists
568
- ? state.agentId
3562
+ ? preferredAgent
569
3563
  : (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
570
3564
  const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
571
3565
 
572
3566
  state.agentId = selected
3567
+ writeStoredAgent(selected)
3568
+ syncAgentInUrl(selected)
573
3569
  if (signature !== state.agentsSignature) {
3570
+ const formatAgentLabel = (agent) => agent.id
574
3571
  elements.agent.innerHTML = agents.length
575
- ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent.id) + ' · ' + agent.documentCount + '</option>').join('')
576
- : '<option value="shared">shared · 0</option>'
3572
+ ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
3573
+ : '<option value="shared">shared</option>'
577
3574
  state.agentsSignature = signature
578
3575
  }
579
3576
  elements.agent.value = selected
@@ -594,6 +3591,10 @@ const loadGraph = async (options = { reset: false }) => {
594
3591
 
595
3592
  const payload = await response.json()
596
3593
  const graph = payload?.layout ?? payload
3594
+ state.graphTotals = {
3595
+ nodes: Number.isFinite(payload?.totals?.nodes) ? payload.totals.nodes : (Array.isArray(graph.nodes) ? graph.nodes.length : 0),
3596
+ edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
3597
+ }
597
3598
  const signature = payload?.signature ?? graphSignature(graph)
598
3599
  if (!options.reset && signature === state.graphSignature) return
599
3600
  const selectedId = state.selected?.id
@@ -601,14 +3602,25 @@ const loadGraph = async (options = { reset: false }) => {
601
3602
  state.graphSignature = signature
602
3603
  state.graph = graph
603
3604
  state.nodes = layout.nodes
3605
+ state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
604
3606
  state.edges = layout.edges
3607
+ state.nodeDegrees = state.edges.reduce((degrees, edge) => {
3608
+ degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
3609
+ if (edge.target) {
3610
+ degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
3611
+ }
3612
+ return degrees
3613
+ }, new Map())
605
3614
  state.nodeDetails = new Map()
3615
+ pushNodesToFilterWorker()
606
3616
  resetContentFilter()
3617
+ sanitizeAllNodePositions()
3618
+ recomputeVisibility()
607
3619
  scheduleContentFilterSync()
608
- const tags = new Set(graph.nodes.flatMap(node => node.tags))
609
- setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
610
- elements.nodeCount.textContent = graph.nodes.length
611
- elements.edgeCount.textContent = graph.edges.length
3620
+ const tags = new Set(state.nodes.flatMap(node => node.tags))
3621
+ setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
3622
+ elements.nodeCount.textContent = state.graphTotals.nodes
3623
+ elements.edgeCount.textContent = state.graphTotals.edges
612
3624
  elements.tagCount.textContent = tags.size
613
3625
  resize()
614
3626
  if (options.reset) resetView()
@@ -620,6 +3632,7 @@ const loadGraph = async (options = { reset: false }) => {
620
3632
  }
621
3633
 
622
3634
  bindEvents()
3635
+ initFilterWorker()
623
3636
  requestAnimationFrame(() => {
624
3637
  resize()
625
3638
  resetView()