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

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