@andespindola/brainlink 0.1.0-beta.14 → 0.1.0-beta.140

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