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

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