@andespindola/brainlink 0.1.0-beta.9 → 0.1.0-beta.90

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