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

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