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

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