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

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