@andespindola/brainlink 0.1.0-beta.11 → 0.1.0-beta.110

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