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

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