@andespindola/brainlink 0.1.0-beta.12 → 0.1.0-beta.120

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