@andespindola/brainlink 0.1.0-beta.13 → 0.1.0-beta.131

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