@andespindola/brainlink 0.1.0-beta.99 → 1.0.0

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 (46) hide show
  1. package/AGENTS.md +6 -6
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +186 -38
  4. package/dist/application/add-note.js +13 -44
  5. package/dist/application/analyze-vault.js +1 -1
  6. package/dist/application/auto-migrate-configured-vault.js +37 -0
  7. package/dist/application/build-context.js +119 -20
  8. package/dist/application/canonical-context-links.js +209 -0
  9. package/dist/application/frontend/client-css.js +212 -42
  10. package/dist/application/frontend/client-html.js +42 -28
  11. package/dist/application/frontend/client-js.js +1294 -3222
  12. package/dist/application/frontend/client-render-worker-js.js +676 -0
  13. package/dist/application/get-graph-contexts.js +33 -0
  14. package/dist/application/get-graph-layout.js +62 -8
  15. package/dist/application/get-graph-stream-chunk.js +326 -0
  16. package/dist/application/get-graph-view.js +246 -0
  17. package/dist/application/graph-view-state.js +66 -0
  18. package/dist/application/import-legacy-sqlite.js +3 -33
  19. package/dist/application/index-vault.js +35 -22
  20. package/dist/application/migrate-context-links.js +79 -0
  21. package/dist/application/search-graph-node-ids.js +63 -3
  22. package/dist/application/server/routes.js +197 -12
  23. package/dist/cli/commands/read-commands.js +39 -3
  24. package/dist/cli/commands/vault-commands.js +182 -0
  25. package/dist/cli/commands/write-commands.js +147 -12
  26. package/dist/cli/main.js +2 -0
  27. package/dist/cli/runtime.js +10 -2
  28. package/dist/domain/context.js +1 -0
  29. package/dist/domain/graph-contexts.js +180 -0
  30. package/dist/domain/graph-layout.js +347 -21
  31. package/dist/domain/markdown.js +53 -9
  32. package/dist/infrastructure/config.js +105 -6
  33. package/dist/infrastructure/context-packs.js +122 -0
  34. package/dist/infrastructure/file-index.js +6 -3
  35. package/dist/infrastructure/index-state.js +2 -0
  36. package/dist/infrastructure/vault-migration-state.js +69 -0
  37. package/dist/infrastructure/volatile-memory.js +100 -0
  38. package/dist/mcp/http-server.js +97 -0
  39. package/dist/mcp/runtime.js +20 -0
  40. package/dist/mcp/server.js +36 -13
  41. package/dist/mcp/tools.js +203 -14
  42. package/docs/AGENT_USAGE.md +50 -5
  43. package/docs/ARCHITECTURE.md +11 -0
  44. package/docs/QUICKSTART.md +3 -1
  45. package/docs/RELEASE.md +4 -3
  46. package/package.json +3 -1
@@ -1,118 +1,26 @@
1
1
  export const createClientJs = () => `const canvas = document.getElementById('graph')
2
- const glCanvas = document.getElementById('graphGl')
3
- const ctx = canvas.getContext('2d')
4
- const largeGraphNodeThreshold = 4000
5
- const massiveGraphNodeThreshold = 20000
6
- const largeGraphEdgeRenderLimit = 120000
7
- const renderNodeBudget = 900
8
- const zoomedMassiveRenderNodeBudget = 2200
9
- const renderEdgeBudget = 2400
10
- const clusterActivationNodeThreshold = 600
11
- const clusterZoomThreshold = 0.18
12
- const macroGalaxyZoomThreshold = 0.012
13
- const macroGalaxyEnterHysteresis = 0.86
14
- const macroGalaxyExitHysteresis = 1.24
15
- const galaxyDiscoveryEnabled = false
16
- const massiveAutoFitMacroScale = 0.006
17
- const defaultMacroScale = 0.006
18
- const clusterCellPixelSize = 64
19
- const minNodePixelRadius = 2.3
20
- const viewportPaddingPx = 280
21
- const worldCoordinateLimit = 5_000_000
22
- const transformCoordinateLimit = 20_000_000
23
- const hoverHitTestIntervalMs = 64
24
- const ecosystemLevelNodeCap = 999
25
- const ecosystemActivationNodeThreshold = 1000
26
- const ecosystemClusterEdgeLimit = 520
27
- const ecosystemHubEdgeLimit = 120
28
- const ecosystemSiblingEdgeLimit = 180
29
- const ecosystemClusterScaleThreshold = 0.78
30
- const massiveEcosystemClusterScaleThreshold = 4.2
31
- const ecosystemSubgraphScaleThreshold = 0.18
32
- const ecosystemMicroScaleThreshold = 0.08
33
- const ecosystemFocusedParentLimit = 2
34
- const zoomRecoveryGuardMs = 4200
35
- const zoomCapTargetViewportShare = 0.72
36
- const meshEdgeScaleThreshold = 0.09
37
- const meshEdgeMinBudget = 140
38
- const meshEdgeMaxBudget = 1400
39
- const layeredCoreScaleThreshold = 0.55
40
- const dragNeighborhoodMaxAffected = 180
41
- const dragSettleRounds = 3
42
- const wheelZoomExponent = 0.0009
43
- const wheelZoomExponentCap = 0.035
44
- const wheelZoomModifierBoost = 1.08
45
- const state = {
46
- graph: { nodes: [], edges: [] },
47
- nodes: [],
48
- nodeById: new Map(),
49
- edges: [],
50
- visibleNodes: [],
51
- visibleEdges: [],
52
- renderNodes: [],
53
- renderEdges: [],
54
- renderClusters: [],
55
- renderClusterEdges: [],
56
- nodeDegrees: new Map(),
57
- selected: null,
58
- hovered: null,
59
- query: '',
60
- contentFilter: { query: '', ids: null, token: 0, timer: null },
61
- agentId: '',
62
- agentsSignature: '',
63
- nodeDetails: new Map(),
64
- transform: { x: 0, y: 0, scale: 1 },
65
- pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
66
- cursor: { x: 0, y: 0, inCanvas: false },
67
- graphSignature: '',
68
- graphStatus: '',
69
- graphTotals: { nodes: 0, edges: 0 },
70
- last: performance.now(),
71
- offscreenFrameCount: 0,
72
- recoveringViewport: false,
73
- renderVisibilityDirty: true,
74
- lastViewportKey: '',
75
- visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
76
- visibleEdgeByNode: new Map(),
77
- ecosystemClusters: [],
78
- ecosystemClustersBySize: new Map(),
79
- ecosystemNodeClusterBySize: new Map(),
80
- ecosystemLevelSizes: [],
81
- ecosystemExpansionLevels: [],
82
- ecosystemBaseSize: ecosystemLevelNodeCap,
83
- ecosystemHubCluster: null,
84
- macroCenter: { x: 0, y: 0 },
85
- macroRepresentative: null,
86
- primaryHub: null,
87
- hubNeighborDistance: Number.POSITIVE_INFINITY,
88
- filterWorker: null,
89
- filterReady: false,
90
- lastHoverHitAt: 0,
91
- lastManualZoomAt: 0,
92
- lastZoomFocus: { x: 0, y: 0, at: 0 },
93
- macroViewActive: false
94
- }
95
-
96
- const byId = id => document.getElementById(id)
97
- const escapeHtml = value => String(value)
98
- .replaceAll('&', '&')
99
- .replaceAll('<', '&lt;')
100
- .replaceAll('>', '&gt;')
101
- .replaceAll('"', '&quot;')
102
- .replaceAll("'", '&#039;')
2
+ let ctx2dFallback = null
3
+ const byId = (id) => document.getElementById(id)
103
4
  const elements = {
104
5
  search: byId('search'),
105
6
  agent: byId('agent'),
7
+ context: byId('context'),
106
8
  nodeCount: byId('nodeCount'),
107
9
  edgeCount: byId('edgeCount'),
108
10
  tagCount: byId('tagCount'),
109
11
  zoomIn: byId('zoomIn'),
110
12
  zoomOut: byId('zoomOut'),
111
13
  fit: byId('fit'),
14
+ releaseNode: byId('releaseNode'),
112
15
  reset: byId('reset'),
16
+ labels: byId('graphLabels'),
17
+ tooltip: byId('graphTooltip'),
18
+ miniMap: byId('miniMap'),
113
19
  contentDialog: byId('contentDialog'),
114
20
  contentTitle: byId('contentTitle'),
115
21
  contentPath: byId('contentPath'),
22
+ contentFacts: byId('contentFacts'),
23
+ contentContextLinks: byId('contentContextLinks'),
116
24
  contentTags: byId('contentTags'),
117
25
  contentOutgoing: byId('contentOutgoing'),
118
26
  contentIncoming: byId('contentIncoming'),
@@ -120,22 +28,91 @@ const elements = {
120
28
  contentClose: byId('contentClose')
121
29
  }
122
30
 
31
+ const state = {
32
+ camera: {
33
+ x: 0,
34
+ y: 0,
35
+ scale: 0.22
36
+ },
37
+ pointer: {
38
+ down: false,
39
+ moved: false,
40
+ dragging: false,
41
+ dragNodeId: '',
42
+ x: 0,
43
+ y: 0,
44
+ startX: 0,
45
+ startY: 0,
46
+ startWorldX: 0,
47
+ startWorldY: 0,
48
+ nodeStartX: 0,
49
+ nodeStartY: 0,
50
+ worldAnchorX: 0,
51
+ worldAnchorY: 0
52
+ },
53
+ viewport: {
54
+ width: 320,
55
+ height: 320,
56
+ ratio: window.devicePixelRatio || 1
57
+ },
58
+ workerReady: false,
59
+ rendererMode: 'worker',
60
+ renderWorker: null,
61
+ agentId: '',
62
+ contextId: '',
63
+ graphSignature: '',
64
+ graphMode: 'near',
65
+ nodePositionsSignature: '',
66
+ nodePositionsScope: '',
67
+ serverNodePositionsScope: '',
68
+ nodePositions: new Map(),
69
+ hoveredNodeId: '',
70
+ focusedNodeIds: new Set(),
71
+ spatialIndex: {
72
+ key: '',
73
+ cells: new Map()
74
+ },
75
+ miniMapView: null,
76
+ miniMapDirty: true,
77
+ overlayScheduled: false,
78
+ overlayIdleTimer: null,
79
+ chunk: {
80
+ nodes: [],
81
+ edges: []
82
+ },
83
+ selectedNodeId: null,
84
+ searchToken: 0,
85
+ searchTimer: null,
86
+ searchResultIds: new Set(),
87
+ fetchToken: 0,
88
+ fetchTimer: null,
89
+ fetchAbortController: null,
90
+ lastChunkRequestKey: '',
91
+ cameraSyncScheduled: false,
92
+ lastWheelAt: 0,
93
+ lastVisibleNodes: 0,
94
+ lastVisibleEdges: 0,
95
+ totals: {
96
+ nodes: 0,
97
+ edges: 0
98
+ }
99
+ }
100
+
123
101
  const zoomRange = {
124
102
  min: 0.0002,
125
103
  max: 4.5
126
104
  }
127
105
 
128
- const initialAgentFromUrl = (() => {
129
- try {
130
- const raw = new URL(window.location.href).searchParams.get('agent')
131
- const value = raw?.trim() ?? ''
132
- return value.length > 0 ? value : ''
133
- } catch {
134
- return ''
135
- }
136
- })()
137
-
138
106
  const selectedAgentStorageKey = 'brainlink:selected-agent'
107
+ const selectedContextStorageKey = 'brainlink:selected-context'
108
+ const nodePositionsStoragePrefix = 'brainlink:graph-node-positions:'
109
+
110
+ const escapeHtml = (value) => String(value)
111
+ .replaceAll('&', '&amp;')
112
+ .replaceAll('<', '&lt;')
113
+ .replaceAll('>', '&gt;')
114
+ .replaceAll('"', '&quot;')
115
+ .replaceAll("'", '&#039;')
139
116
 
140
117
  const readStoredAgent = () => {
141
118
  try {
@@ -156,3430 +133,1525 @@ const writeStoredAgent = (agentId) => {
156
133
  } catch {}
157
134
  }
158
135
 
159
- const syncAgentInUrl = (agentId) => {
136
+ const readStoredContext = () => {
160
137
  try {
161
- const url = new URL(window.location.href)
162
- if (agentId && agentId.trim().length > 0) {
163
- url.searchParams.set('agent', agentId)
164
- } else {
165
- url.searchParams.delete('agent')
166
- }
167
- window.history.replaceState({}, '', url.toString())
168
- } catch {}
169
- }
170
-
171
- const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
172
-
173
- const setGraphStatus = text => {
174
- state.graphStatus = text
138
+ const value = window.localStorage.getItem(selectedContextStorageKey)?.trim() ?? ''
139
+ return value.length > 0 ? value : ''
140
+ } catch {
141
+ return ''
142
+ }
175
143
  }
176
144
 
177
- const handleGraphRefreshError = error => {
178
- console.error(error)
145
+ const writeStoredContext = (contextId) => {
146
+ try {
147
+ if (!contextId) {
148
+ window.localStorage.removeItem(selectedContextStorageKey)
149
+ return
150
+ }
151
+ window.localStorage.setItem(selectedContextStorageKey, contextId)
152
+ } catch {}
179
153
  }
180
154
 
181
- const graphTheme = {
182
- node: '#aeb8c5',
183
- nodeSelected: '#f3f7fb',
184
- nodeHover: '#cbd5e1',
185
- nodeHalo: 'rgba(203, 213, 225, 0.14)',
186
- nodeHaloActive: 'rgba(243, 247, 251, 0.2)',
187
- nodeStroke: '#0d0f12',
188
- nodeStrokeActive: '#ffffff',
189
- edge: 'rgba(153, 165, 181, 0.16)',
190
- edgeActive: 'rgba(226, 232, 240, 0.52)',
191
- label: '#edf2f7'
192
- }
193
-
194
- const parseRgb = color => {
195
- const normalized = color.trim()
196
- if (normalized.startsWith('#')) {
197
- const value = normalized.slice(1)
198
- const expanded = value.length === 3
199
- ? value.split('').map(char => char + char).join('')
200
- : value
201
- const parsed = Number.parseInt(expanded, 16)
202
- return [
203
- ((parsed >> 16) & 255) / 255,
204
- ((parsed >> 8) & 255) / 255,
205
- (parsed & 255) / 255
206
- ]
207
- }
208
-
209
- const match = normalized.match(/rgba?\\(([^)]+)\\)/)
210
- if (!match) return [1, 1, 1]
211
- const parts = match[1].split(',').map(part => Number.parseFloat(part.trim()))
212
- return [
213
- Math.max(0, Math.min(1, (parts[0] ?? 255) / 255)),
214
- Math.max(0, Math.min(1, (parts[1] ?? 255) / 255)),
215
- Math.max(0, Math.min(1, (parts[2] ?? 255) / 255))
216
- ]
217
- }
155
+ const nodePositionsStorageKey = () => [
156
+ nodePositionsStoragePrefix,
157
+ state.graphSignature || 'unknown',
158
+ state.agentId || 'all-agents',
159
+ state.contextId || 'all-contexts'
160
+ ].join(':')
218
161
 
219
- const rgba = (color, alpha = 1) => {
220
- const [red, green, blue] = parseRgb(color)
221
- return [red, green, blue, Math.max(0, Math.min(1, alpha))]
222
- }
162
+ const readStoredNodePositions = () => {
163
+ try {
164
+ const raw = window.localStorage.getItem(nodePositionsStorageKey())
165
+ const parsed = raw ? JSON.parse(raw) : []
166
+ if (!Array.isArray(parsed)) {
167
+ return new Map()
168
+ }
223
169
 
224
- const createShader = (gl, type, source) => {
225
- const shader = gl.createShader(type)
226
- if (!shader) return null
227
- gl.shaderSource(shader, source)
228
- gl.compileShader(shader)
229
- if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
230
- gl.deleteShader(shader)
231
- return null
232
- }
233
- return shader
234
- }
235
-
236
- const createProgram = (gl, vertexSource, fragmentSource) => {
237
- const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource)
238
- const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)
239
- if (!vertexShader || !fragmentShader) return null
240
- const program = gl.createProgram()
241
- if (!program) return null
242
- gl.attachShader(program, vertexShader)
243
- gl.attachShader(program, fragmentShader)
244
- gl.linkProgram(program)
245
- gl.deleteShader(vertexShader)
246
- gl.deleteShader(fragmentShader)
247
- if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
248
- gl.deleteProgram(program)
249
- return null
170
+ return new Map(parsed.flatMap((entry) => {
171
+ const id = typeof entry?.[0] === 'string' ? entry[0] : ''
172
+ const x = Number(entry?.[1])
173
+ const y = Number(entry?.[2])
174
+ return id && Number.isFinite(x) && Number.isFinite(y) ? [[id, { x, y }]] : []
175
+ }))
176
+ } catch {
177
+ return new Map()
250
178
  }
251
- return program
252
179
  }
253
180
 
254
- const createWebGlRenderer = targetCanvas => {
255
- if (!targetCanvas) return null
256
-
257
- const gl = targetCanvas.getContext('webgl2', { alpha: true, antialias: true }) ||
258
- targetCanvas.getContext('webgl', { alpha: true, antialias: true })
259
- if (!gl) return null
260
-
261
- const lineProgram = createProgram(
262
- gl,
263
- 'attribute vec2 a_position; uniform vec2 u_resolution; void main() { vec2 zeroToOne = a_position / u_resolution; vec2 clip = zeroToOne * 2.0 - 1.0; gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); }',
264
- 'precision mediump float; uniform vec4 u_color; void main() { gl_FragColor = u_color; }'
265
- )
266
- const pointProgram = createProgram(
267
- gl,
268
- 'attribute vec2 a_position; attribute float a_size; uniform vec2 u_resolution; void main() { vec2 zeroToOne = a_position / u_resolution; vec2 clip = zeroToOne * 2.0 - 1.0; gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); gl_PointSize = a_size; }',
269
- 'precision mediump float; uniform vec4 u_color; void main() { vec2 center = gl_PointCoord - vec2(0.5); float distanceFromCenter = length(center); if (distanceFromCenter > 0.5) discard; float edge = smoothstep(0.5, 0.42, distanceFromCenter); gl_FragColor = vec4(u_color.rgb, u_color.a * edge); }'
270
- )
271
-
272
- if (!lineProgram || !pointProgram) return null
273
-
274
- const lineBuffer = gl.createBuffer()
275
- const pointPositionBuffer = gl.createBuffer()
276
- const pointSizeBuffer = gl.createBuffer()
277
- if (!lineBuffer || !pointPositionBuffer || !pointSizeBuffer) return null
278
-
279
- const linePositionLocation = gl.getAttribLocation(lineProgram, 'a_position')
280
- const lineResolutionLocation = gl.getUniformLocation(lineProgram, 'u_resolution')
281
- const lineColorLocation = gl.getUniformLocation(lineProgram, 'u_color')
282
- const pointPositionLocation = gl.getAttribLocation(pointProgram, 'a_position')
283
- const pointSizeLocation = gl.getAttribLocation(pointProgram, 'a_size')
284
- const pointResolutionLocation = gl.getUniformLocation(pointProgram, 'u_resolution')
285
- const pointColorLocation = gl.getUniformLocation(pointProgram, 'u_color')
286
-
287
- gl.enable(gl.BLEND)
288
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
289
-
290
- const setViewport = (width, height) => {
291
- gl.viewport(0, 0, targetCanvas.width, targetCanvas.height)
292
- return [targetCanvas.width / Math.max(width, 1), targetCanvas.height / Math.max(height, 1)]
181
+ const ensureNodePositionsLoaded = () => {
182
+ const storageKey = nodePositionsStorageKey()
183
+ if (!state.graphSignature || (state.nodePositionsSignature === state.graphSignature && state.nodePositionsScope === storageKey)) {
184
+ return
293
185
  }
294
186
 
295
- const screenPoint = (node, ratioX, ratioY) => [
296
- (node.x * state.transform.scale + state.transform.x) * ratioX,
297
- (node.y * state.transform.scale + state.transform.y) * ratioY
298
- ]
187
+ state.nodePositions = readStoredNodePositions()
188
+ state.nodePositionsSignature = state.graphSignature
189
+ state.nodePositionsScope = storageKey
190
+ }
299
191
 
300
- const clear = (width, height) => {
301
- setViewport(width, height)
302
- gl.clearColor(0, 0, 0, 0)
303
- gl.clear(gl.COLOR_BUFFER_BIT)
304
- }
305
-
306
- const drawLines = (edges, color, width, height) => {
307
- if (edges.length === 0) return
308
- const [ratioX, ratioY] = setViewport(width, height)
309
- const positions = new Float32Array(edges.length * 4)
310
- for (let index = 0; index < edges.length; index += 1) {
311
- const edge = edges[index]
312
- const source = screenPoint(edge.sourceNode, ratioX, ratioY)
313
- const target = screenPoint(edge.targetNode, ratioX, ratioY)
314
- const offset = index * 4
315
- positions[offset] = source[0]
316
- positions[offset + 1] = source[1]
317
- positions[offset + 2] = target[0]
318
- positions[offset + 3] = target[1]
192
+ const writeStoredNodePositions = () => {
193
+ try {
194
+ if (!state.graphSignature) {
195
+ return
319
196
  }
320
197
 
321
- gl.useProgram(lineProgram)
322
- gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer)
323
- gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STREAM_DRAW)
324
- gl.enableVertexAttribArray(linePositionLocation)
325
- gl.vertexAttribPointer(linePositionLocation, 2, gl.FLOAT, false, 0, 0)
326
- gl.uniform2f(lineResolutionLocation, targetCanvas.width, targetCanvas.height)
327
- gl.uniform4fv(lineColorLocation, color)
328
- gl.lineWidth(1)
329
- gl.drawArrays(gl.LINES, 0, edges.length * 2)
330
- }
331
-
332
- const drawPoints = (nodes, color, sizeForNode, width, height) => {
333
- if (nodes.length === 0) return
334
- const [ratioX, ratioY] = setViewport(width, height)
335
- const positions = new Float32Array(nodes.length * 2)
336
- const sizes = new Float32Array(nodes.length)
337
- for (let index = 0; index < nodes.length; index += 1) {
338
- const node = nodes[index]
339
- const point = screenPoint(node, ratioX, ratioY)
340
- const offset = index * 2
341
- positions[offset] = point[0]
342
- positions[offset + 1] = point[1]
343
- sizes[index] = sizeForNode(node) * ((ratioX + ratioY) / 2)
344
- }
198
+ const entries = Array.from(state.nodePositions.entries())
199
+ .filter((entry) => Number.isFinite(entry[1]?.x) && Number.isFinite(entry[1]?.y))
200
+ .map((entry) => [entry[0], entry[1].x, entry[1].y])
345
201
 
346
- gl.useProgram(pointProgram)
347
- gl.bindBuffer(gl.ARRAY_BUFFER, pointPositionBuffer)
348
- gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STREAM_DRAW)
349
- gl.enableVertexAttribArray(pointPositionLocation)
350
- gl.vertexAttribPointer(pointPositionLocation, 2, gl.FLOAT, false, 0, 0)
351
- gl.bindBuffer(gl.ARRAY_BUFFER, pointSizeBuffer)
352
- gl.bufferData(gl.ARRAY_BUFFER, sizes, gl.STREAM_DRAW)
353
- gl.enableVertexAttribArray(pointSizeLocation)
354
- gl.vertexAttribPointer(pointSizeLocation, 1, gl.FLOAT, false, 0, 0)
355
- gl.uniform2f(pointResolutionLocation, targetCanvas.width, targetCanvas.height)
356
- gl.uniform4fv(pointColorLocation, color)
357
- gl.drawArrays(gl.POINTS, 0, nodes.length)
358
- }
202
+ if (entries.length === 0) {
203
+ window.localStorage.removeItem(nodePositionsStorageKey())
204
+ return
205
+ }
359
206
 
360
- return { clear, drawLines, drawPoints }
207
+ window.localStorage.setItem(nodePositionsStorageKey(), JSON.stringify(entries))
208
+ } catch {}
361
209
  }
362
210
 
363
- const webGlRenderer = createWebGlRenderer(glCanvas)
364
-
365
- const initFilterWorker = () => {
366
- if (typeof Worker === 'undefined') {
367
- return
368
- }
211
+ const clearStoredNodePositions = () => {
369
212
  try {
370
- const worker = new Worker('/app-worker.js')
371
- worker.onmessage = event => {
372
- const payload = event.data
373
- if (!payload || typeof payload !== 'object') return
374
-
375
- if (payload.type === 'ready') {
376
- state.filterReady = true
377
- if (state.nodes.length > 0) {
378
- worker.postMessage({
379
- type: 'load-nodes',
380
- nodes: state.nodes.map(node => ({
381
- id: node.id,
382
- title: node.title,
383
- path: node.path || '',
384
- tags: Array.isArray(node.tags) ? node.tags : []
385
- }))
386
- })
387
- }
388
- return
389
- }
390
-
391
- if (payload.type === 'filter-result') {
392
- const token = payload.token
393
- if (token !== state.contentFilter.token) {
394
- return
395
- }
396
-
397
- const ids = Array.isArray(payload.ids) ? payload.ids.filter(id => typeof id === 'string') : []
398
- state.contentFilter.query = normalizeQuery(state.query)
399
- state.contentFilter.ids = new Set(ids)
400
- recomputeVisibility()
401
- }
213
+ if (state.graphSignature) {
214
+ window.localStorage.removeItem(nodePositionsStorageKey())
402
215
  }
403
- state.filterWorker = worker
404
- } catch {
405
- state.filterWorker = null
406
- state.filterReady = false
216
+ } catch {}
217
+ state.nodePositions = new Map()
218
+ state.nodePositionsSignature = state.graphSignature
219
+ state.nodePositionsScope = nodePositionsStorageKey()
220
+ }
221
+
222
+ const graphViewStateQuery = () => {
223
+ const params = new URLSearchParams({ signature: state.graphSignature })
224
+ if (state.agentId) {
225
+ params.set('agent', state.agentId)
226
+ }
227
+ if (state.contextId) {
228
+ params.set('context', state.contextId)
407
229
  }
230
+ return params.toString()
408
231
  }
409
232
 
410
- const pushNodesToFilterWorker = () => {
411
- if (!state.filterWorker || !state.filterReady) {
233
+ const syncNodePositionsFromServer = async () => {
234
+ if (!state.graphSignature) {
235
+ return
236
+ }
237
+ const scope = nodePositionsStorageKey()
238
+ if (state.serverNodePositionsScope === scope) {
412
239
  return
413
240
  }
241
+ state.serverNodePositionsScope = scope
414
242
 
415
- state.filterWorker.postMessage({
416
- type: 'load-nodes',
417
- nodes: state.nodes.map(node => ({
418
- id: node.id,
419
- title: node.title,
420
- path: node.path || '',
421
- tags: Array.isArray(node.tags) ? node.tags : []
243
+ try {
244
+ const response = await fetch('/api/graph-view-state?' + graphViewStateQuery())
245
+ if (!response.ok) {
246
+ return
247
+ }
248
+ const payload = await response.json()
249
+ const positions = Array.isArray(payload?.positions) ? payload.positions : []
250
+ if (positions.length === 0) {
251
+ return
252
+ }
253
+ state.nodePositions = new Map(positions.flatMap((position) => {
254
+ const id = typeof position?.id === 'string' ? position.id : ''
255
+ const x = Number(position?.x)
256
+ const y = Number(position?.y)
257
+ return id && Number.isFinite(x) && Number.isFinite(y) ? [[id, { x, y }]] : []
422
258
  }))
423
- })
259
+ writeStoredNodePositions()
260
+ } catch {}
424
261
  }
425
262
 
426
- const resize = () => {
427
- const rect = canvas.getBoundingClientRect()
428
- const width = Math.max(rect.width, 320)
429
- const height = Math.max(rect.height, 320)
430
- const ratio = window.devicePixelRatio || 1
431
- canvas.width = Math.floor(width * ratio)
432
- canvas.height = Math.floor(height * ratio)
433
- if (glCanvas) {
434
- glCanvas.width = Math.floor(width * ratio)
435
- glCanvas.height = Math.floor(height * ratio)
263
+ const persistNodePositionsToServer = () => {
264
+ if (!state.graphSignature) {
265
+ return
436
266
  }
437
- ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
438
- markRenderDirty()
439
- }
440
267
 
441
- const normalizeQuery = value => value.trim().toLowerCase()
442
- const hubNodeRetentionLimit = 2
443
- const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map|mapa)\b/i
444
- const memoryHubPathPattern = /\bmemory[-_\s]*hub\b/i
268
+ const positions = Array.from(state.nodePositions.entries()).map(([id, position]) => ({
269
+ id,
270
+ x: position.x,
271
+ y: position.y
272
+ }))
445
273
 
446
- const hubNodeScore = node => {
447
- const title = node.title.trim().toLowerCase()
448
- if (title === 'memory hub') return 6
449
- if (title === 'knowledge hub') return 5
450
- if (memoryHubPathPattern.test(node.path || '')) return 4
451
- if (node.tags.some(tag => tag.trim().toLowerCase() === 'memory-hub')) return 3
452
- if (/\bmoc\b/i.test(node.title)) return 2
453
- return hubNodePattern.test(node.title) || hubNodePattern.test(node.path || '') || node.tags.some(tag => hubNodePattern.test(tag))
454
- ? 1
455
- : 0
274
+ fetch('/api/graph-view-state?' + graphViewStateQuery(), {
275
+ method: 'POST',
276
+ headers: { 'content-type': 'application/json' },
277
+ body: JSON.stringify({ positions })
278
+ }).catch(() => {})
456
279
  }
457
280
 
458
- const localFilteredNodes = query =>
459
- state.nodes.filter(node =>
460
- node.title.toLowerCase().includes(query) ||
461
- (node.path || '').toLowerCase().includes(query) ||
462
- node.tags.some(tag => tag.toLowerCase().includes(query))
463
- )
464
-
465
- const rankedHubNodes = () => {
466
- if (state.nodes.length === 0) {
467
- return []
468
- }
469
-
470
- const byTitleAndDegree = [...state.nodes]
471
- .filter(node => hubNodeScore(node) > 0)
472
- .sort((left, right) => {
473
- const byHubScore = hubNodeScore(right) - hubNodeScore(left)
474
- if (byHubScore !== 0) return byHubScore
475
- const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
476
- if (byDegree !== 0) return byDegree
477
- return left.title.localeCompare(right.title)
478
- })
479
-
480
- if (byTitleAndDegree.length > 0) {
481
- return byTitleAndDegree.slice(0, hubNodeRetentionLimit)
281
+ const clearNodePositionsOnServer = () => {
282
+ if (!state.graphSignature) {
283
+ return
482
284
  }
483
285
 
484
- return [...state.nodes]
485
- .sort((left, right) => {
486
- const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
487
- if (byDegree !== 0) return byDegree
488
- return left.title.localeCompare(right.title)
489
- })
490
- .slice(0, 1)
286
+ fetch('/api/graph-view-state?' + graphViewStateQuery(), { method: 'DELETE' }).catch(() => {})
491
287
  }
492
288
 
493
- const withPersistentHubNodes = nodes => {
494
- if (nodes.length === 0) {
495
- return rankedHubNodes()
289
+ const releaseSelectedNodePosition = () => {
290
+ if (!state.selectedNodeId || !state.nodePositions.has(state.selectedNodeId)) {
291
+ return
496
292
  }
497
293
 
498
- const ids = new Set(nodes.map(node => node.id))
499
- const hubsToKeep = rankedHubNodes().filter(node => !ids.has(node.id))
500
- return nodes.concat(hubsToKeep)
294
+ state.nodePositions.delete(state.selectedNodeId)
295
+ writeStoredNodePositions()
296
+ persistNodePositionsToServer()
297
+ scheduleChunkFetch({ fit: false })
501
298
  }
502
299
 
503
- const filteredNodes = () => {
504
- const query = normalizeQuery(state.query)
505
- if (!query) return state.nodes
506
- if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
507
- const matched = state.nodes.filter(node => state.contentFilter.ids.has(node.id))
508
- return withPersistentHubNodes(matched)
509
- }
510
-
511
- return withPersistentHubNodes(localFilteredNodes(query))
300
+ const syncAgentInUrl = (agentId) => {
301
+ try {
302
+ const url = new URL(window.location.href)
303
+ if (agentId && agentId.trim().length > 0) {
304
+ url.searchParams.set('agent', agentId)
305
+ } else {
306
+ url.searchParams.delete('agent')
307
+ }
308
+ window.history.replaceState({}, '', url.toString())
309
+ } catch {}
512
310
  }
513
311
 
514
- const resolveMacroRepresentative = (nodes) => {
515
- if (nodes.length === 0) {
516
- return null
517
- }
518
-
519
- const hubCandidate = state.primaryHub && nodes.some(node => node.id === state.primaryHub.id)
520
- ? state.primaryHub
521
- : null
522
- let best = hubCandidate ?? nodes[0]
523
- let bestDegree = state.nodeDegrees.get(best.id) ?? 0
524
-
525
- for (let index = 1; index < nodes.length; index += 1) {
526
- const node = nodes[index]
527
- const degree = state.nodeDegrees.get(node.id) ?? 0
528
- if (degree > bestDegree) {
529
- best = node
530
- bestDegree = degree
312
+ const syncContextInUrl = (contextId) => {
313
+ try {
314
+ const url = new URL(window.location.href)
315
+ if (contextId && contextId.trim().length > 0) {
316
+ url.searchParams.set('context', contextId)
317
+ } else {
318
+ url.searchParams.delete('context')
531
319
  }
532
- }
533
-
534
- return best
320
+ window.history.replaceState({}, '', url.toString())
321
+ } catch {}
535
322
  }
536
323
 
537
- const nearestHubNeighborDistance = (hub, nodes) => {
538
- if (!hub || nodes.length <= 1) {
539
- return Number.POSITIVE_INFINITY
324
+ const initialAgentFromUrl = (() => {
325
+ try {
326
+ const raw = new URL(window.location.href).searchParams.get('agent')
327
+ const value = raw?.trim() ?? ''
328
+ return value.length > 0 ? value : ''
329
+ } catch {
330
+ return ''
540
331
  }
332
+ })()
541
333
 
542
- let minimum = Number.POSITIVE_INFINITY
543
- for (let index = 0; index < nodes.length; index += 1) {
544
- const node = nodes[index]
545
- if (node.id === hub.id) continue
546
- const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
547
- if (distance < minimum) {
548
- minimum = distance
549
- }
334
+ const initialContextFromUrl = (() => {
335
+ try {
336
+ const raw = new URL(window.location.href).searchParams.get('context')
337
+ const value = raw?.trim() ?? ''
338
+ return value.length > 0 ? value : ''
339
+ } catch {
340
+ return ''
550
341
  }
342
+ })()
551
343
 
552
- return minimum
553
- }
554
-
555
- const isDominantHub = (hub, nodeCount = state.visibleNodes.length) => {
556
- if (!hub || nodeCount <= 0) {
557
- return false
344
+ const scopeQuery = (separator = '?') => {
345
+ const params = new URLSearchParams()
346
+ if (state.agentId) {
347
+ params.set('agent', state.agentId)
348
+ }
349
+ if (state.contextId) {
350
+ params.set('context', state.contextId)
558
351
  }
352
+ const query = params.toString()
559
353
 
560
- const degree = state.nodeDegrees.get(hub.id) ?? 0
561
- const minimumDegree = Math.max(18, Math.sqrt(nodeCount) * 1.8)
562
- const degreeRatio = degree / Math.max(nodeCount, 1)
563
- return degree >= minimumDegree || degreeRatio >= 0.035
564
- }
565
-
566
- const recomputeVisibility = () => {
567
- const nodes = filteredNodes()
568
- const ids = new Set(nodes.map(node => node.id))
569
- const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
570
- const limitedEdges = state.nodes.length > largeGraphNodeThreshold
571
- ? [...edges]
572
- .sort((left, right) => edgeWeight(right) - edgeWeight(left))
573
- .slice(0, largeGraphEdgeRenderLimit)
574
- : edges
575
-
576
- state.visibleNodes = nodes
577
- state.visibleEdges = limitedEdges
578
- state.visibleNodeSpatial = createSpatialIndex(nodes)
579
- state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
580
- const primaryHub = rankedHubNodes()[0] ?? null
581
- state.primaryHub = primaryHub
582
- state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
583
- const bounds = graphBounds(nodes)
584
- const macroHub = isDominantHub(primaryHub, nodes.length) ? primaryHub : null
585
- state.macroCenter = bounds
586
- ? {
587
- x: macroHub ? macroHub.x : (bounds.minX + bounds.maxX) / 2,
588
- y: macroHub ? macroHub.y : (bounds.minY + bounds.maxY) / 2
589
- }
590
- : { x: 0, y: 0 }
591
- const ecosystemGraph = nodes.length > ecosystemActivationNodeThreshold
592
- ? buildEcosystemGraph(nodes, state.macroCenter, primaryHub)
593
- : {
594
- clusters: [],
595
- clustersBySize: new Map(),
596
- nodeClusterBySize: new Map(),
597
- levelSizes: [],
598
- expansionLevels: [],
599
- baseSize: ecosystemLevelNodeCap,
600
- hubCluster: null
601
- }
602
- state.ecosystemClusters = ecosystemGraph.clusters
603
- state.ecosystemClustersBySize = ecosystemGraph.clustersBySize
604
- state.ecosystemNodeClusterBySize = ecosystemGraph.nodeClusterBySize
605
- state.ecosystemLevelSizes = ecosystemGraph.levelSizes
606
- state.ecosystemExpansionLevels = ecosystemGraph.expansionLevels
607
- state.ecosystemBaseSize = ecosystemGraph.baseSize
608
- state.ecosystemHubCluster = ecosystemGraph.hubCluster
609
- state.macroRepresentative = resolveMacroRepresentative(nodes)
610
- markRenderDirty()
354
+ return query ? separator + query : ''
611
355
  }
612
356
 
613
- const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
614
- const markRenderDirty = () => {
615
- state.renderVisibilityDirty = true
357
+ const parseColor = (hex) => {
358
+ const normalized = String(hex || '#ffffff').replace('#', '')
359
+ const expanded = normalized.length === 3
360
+ ? normalized.split('').map((char) => char + char).join('')
361
+ : normalized.padEnd(6, 'f')
362
+ const value = Number.parseInt(expanded, 16)
363
+ return [
364
+ ((value >> 16) & 255) / 255,
365
+ ((value >> 8) & 255) / 255,
366
+ (value & 255) / 255,
367
+ 1
368
+ ]
616
369
  }
617
370
 
618
- const createSpatialIndex = nodes => {
619
- if (nodes.length === 0) {
620
- return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
621
- }
371
+ const graphTheme = {
372
+ node: parseColor('#5aa8ff'),
373
+ nodeCluster: parseColor('#3f7fbd'),
374
+ nodeHighlight: parseColor('#ffcb67'),
375
+ nodeSelected: parseColor('#edf4ff'),
376
+ nodePalette: [
377
+ parseColor('#5aa8ff'),
378
+ parseColor('#5ecf92'),
379
+ parseColor('#ffb65c'),
380
+ parseColor('#ff7dac'),
381
+ parseColor('#a88fff'),
382
+ parseColor('#59d0dd'),
383
+ parseColor('#ff8f6a'),
384
+ parseColor('#a4b3c3'),
385
+ parseColor('#c9945f'),
386
+ parseColor('#7cb6ff')
387
+ ],
388
+ edge: [0.59, 0.71, 0.83, 0.14],
389
+ edgeHeavy: [0.59, 0.71, 0.83, 0.3],
390
+ clear: parseColor('#08131d')
391
+ }
392
+
393
+ const segmentPalette = ['#5aa8ff', '#5ecf92', '#ffb65c', '#ff7dac', '#a88fff', '#59d0dd', '#ff8f6a', '#a4b3c3', '#c9945f', '#7cb6ff']
394
+
395
+ const segmentColorIndex = (segment) => {
396
+ const value = String(segment || '')
397
+ let hash = 0
398
+ for (let index = 0; index < value.length; index += 1) {
399
+ hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0
400
+ }
401
+ return Math.abs(hash) % segmentPalette.length
402
+ }
403
+
404
+ const segmentColor = (segment) => segmentPalette[segmentColorIndex(segment)] || segmentPalette[0]
405
+ const nodeKind = (node) => node?.[6] === 'cluster' ? 'cluster' : 'node'
406
+ const isRealGraphNode = (node) => nodeKind(node) === 'node'
407
+
408
+ const clampScale = (scale) => Math.max(zoomRange.min, Math.min(zoomRange.max, scale))
409
+
410
+ const getZoomNodeBudget = () => {
411
+ const scale = state.camera.scale
412
+ if (scale < 0.06) return 900
413
+ if (scale < 0.12) return 1600
414
+ if (scale < 0.24) return 2600
415
+ if (scale < 0.7) return 4000
416
+ return 6000
417
+ }
418
+
419
+ const getZoomEdgeBudget = () => {
420
+ const scale = state.camera.scale
421
+ if (scale < 0.06) return 2000
422
+ if (scale < 0.12) return 4800
423
+ if (scale < 0.24) return 9000
424
+ if (scale < 0.7) return 15000
425
+ return 26000
426
+ }
427
+
428
+ const zoomDetailBand = () => {
429
+ const scale = state.camera.scale
430
+ if (scale < 0.06) return 'far'
431
+ if (scale < 0.12) return 'wide'
432
+ if (scale < 0.24) return 'mid'
433
+ if (scale < 0.7) return 'near'
434
+ return 'detail'
435
+ }
436
+
437
+ const graphStreamRequestKey = ({ x, y, w, h }) => {
438
+ const grid = Math.max(80, Math.min(720, Math.max(w, h) / 6))
439
+ return [
440
+ state.agentId || '*',
441
+ state.contextId || '*',
442
+ zoomDetailBand(),
443
+ getZoomNodeBudget(),
444
+ getZoomEdgeBudget(),
445
+ Math.round(x / grid),
446
+ Math.round(y / grid),
447
+ Math.round(w / grid),
448
+ Math.round(h / grid)
449
+ ].join(':')
450
+ }
451
+
452
+ const screenToWorld = (screenX, screenY) => ({
453
+ x: (screenX - state.camera.x) / state.camera.scale,
454
+ y: (screenY - state.camera.y) / state.camera.scale
455
+ })
622
456
 
623
- const bounds = graphBounds(nodes)
624
- if (!bounds) {
625
- return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
626
- }
457
+ const worldToScreen = (x, y) => ({
458
+ x: x * state.camera.scale + state.camera.x,
459
+ y: y * state.camera.scale + state.camera.y
460
+ })
627
461
 
628
- const targetNodesPerCell = 18
629
- const approximateCellArea = Math.max((bounds.width * bounds.height) / Math.max(nodes.length / targetNodesPerCell, 1), 1)
630
- const cellSize = Math.max(90, Math.min(2200, Math.sqrt(approximateCellArea)))
631
- const buckets = new Map()
462
+ const spatialIndexKey = () => [
463
+ state.graphSignature,
464
+ state.camera.x.toFixed(1),
465
+ state.camera.y.toFixed(1),
466
+ state.camera.scale.toFixed(4),
467
+ normalizeList(state.chunk.nodes).length
468
+ ].join(':')
469
+
470
+ const rebuildSpatialIndex = () => {
471
+ const key = spatialIndexKey()
472
+ if (state.spatialIndex.key === key) {
473
+ return
474
+ }
632
475
 
633
- for (let index = 0; index < nodes.length; index += 1) {
634
- const node = nodes[index]
635
- const cellX = Math.floor((node.x - bounds.minX) / cellSize)
636
- const cellY = Math.floor((node.y - bounds.minY) / cellSize)
637
- const key = cellX + ':' + cellY
638
- const bucket = buckets.get(key)
476
+ const cellSize = 44
477
+ const cells = new Map()
478
+ normalizeList(state.chunk.nodes).forEach((node) => {
479
+ const id = typeof node?.[0] === 'string' ? node[0] : ''
480
+ if (!id) return
481
+ const x = Number(node?.[2])
482
+ const y = Number(node?.[3])
483
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return
484
+ const point = worldToScreen(x, y)
485
+ const cellX = Math.floor(point.x / cellSize)
486
+ const cellY = Math.floor(point.y / cellSize)
487
+ const key = cellX + ',' + cellY
488
+ const bucket = cells.get(key)
639
489
  if (bucket) {
640
490
  bucket.push(node)
641
- continue
491
+ return
642
492
  }
643
- buckets.set(key, [node])
644
- }
493
+ cells.set(key, [node])
494
+ })
645
495
 
646
- return {
647
- cellSize,
648
- minX: bounds.minX,
649
- minY: bounds.minY,
650
- maxX: bounds.maxX,
651
- maxY: bounds.maxY,
652
- buckets
653
- }
496
+ state.spatialIndex = { key, cells }
654
497
  }
655
498
 
656
- const viewportNodesFromSpatialIndex = viewport => {
657
- if (state.visibleNodes.length <= 2500) {
658
- return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
659
- }
660
-
661
- const spatial = state.visibleNodeSpatial
662
- if (!spatial || spatial.buckets.size === 0) {
663
- return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
664
- }
665
-
666
- const minCellX = Math.floor((viewport.minX - spatial.minX) / spatial.cellSize)
667
- const maxCellX = Math.floor((viewport.maxX - spatial.minX) / spatial.cellSize)
668
- const minCellY = Math.floor((viewport.minY - spatial.minY) / spatial.cellSize)
669
- const maxCellY = Math.floor((viewport.maxY - spatial.minY) / spatial.cellSize)
499
+ const spatialCandidates = (screenX, screenY) => {
500
+ rebuildSpatialIndex()
501
+ const cellSize = 44
502
+ const cellX = Math.floor(screenX / cellSize)
503
+ const cellY = Math.floor(screenY / cellSize)
670
504
  const nodes = []
671
505
 
672
- for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
673
- for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
674
- const bucket = spatial.buckets.get(cellX + ':' + cellY)
675
- if (!bucket) continue
676
-
677
- for (let index = 0; index < bucket.length; index += 1) {
678
- const node = bucket[index]
679
- if (isNodeInViewport(node, viewport)) {
680
- nodes.push(node)
681
- }
682
- }
506
+ for (let y = cellY - 1; y <= cellY + 1; y += 1) {
507
+ for (let x = cellX - 1; x <= cellX + 1; x += 1) {
508
+ nodes.push(...(state.spatialIndex.cells.get(x + ',' + y) ?? []))
683
509
  }
684
510
  }
685
511
 
686
512
  return nodes
687
513
  }
688
514
 
689
- const createVisibleEdgeLookup = edges => {
690
- const lookup = new Map()
515
+ const nodeByIdFromChunk = () => new Map(normalizeList(state.chunk.nodes).map((node) => [node[0], node]))
691
516
 
692
- for (let index = 0; index < edges.length; index += 1) {
693
- const edge = edges[index]
694
- if (!edge.target) continue
695
-
696
- const sourceList = lookup.get(edge.source)
697
- if (sourceList) {
698
- sourceList.push(edge)
699
- } else {
700
- lookup.set(edge.source, [edge])
701
- }
517
+ const linkedNodeIds = (nodeId) => {
518
+ const ids = new Set(nodeId ? [nodeId] : [])
519
+ normalizeList(state.chunk.edges).forEach((edge) => {
520
+ if (edge?.[0] === nodeId && typeof edge?.[1] === 'string') ids.add(edge[1])
521
+ if (edge?.[1] === nodeId && typeof edge?.[0] === 'string') ids.add(edge[0])
522
+ })
523
+ return ids
524
+ }
702
525
 
703
- const targetList = lookup.get(edge.target)
704
- if (targetList) {
705
- targetList.push(edge)
706
- } else {
707
- lookup.set(edge.target, [edge])
708
- }
526
+ const setFocusedNodeIds = (ids) => {
527
+ state.focusedNodeIds = ids
528
+ if (state.renderWorker && state.workerReady) {
529
+ state.renderWorker.postMessage({ type: 'focus', ids: Array.from(ids) })
709
530
  }
710
-
711
- return lookup
531
+ updateGraphOverlays()
712
532
  }
713
533
 
714
- const ecosystemKeyForNode = node => {
715
- if (typeof node.segment === 'string' && node.segment.trim()) {
716
- return node.segment.trim()
534
+ const drawFallback = () => {
535
+ if (state.rendererMode !== 'fallback') {
536
+ return
717
537
  }
718
- if (typeof node.group === 'string' && node.group.trim()) {
719
- return node.group.trim()
538
+ ctx2dFallback = ctx2dFallback ?? canvas.getContext('2d')
539
+ if (!ctx2dFallback) {
540
+ return
720
541
  }
721
- const pathParts = String(node.path || '')
722
- .split('/')
723
- .filter(part => part.trim())
724
- .slice(0, 2)
725
- return pathParts.length > 0 ? pathParts.join('/') : 'root'
726
- }
542
+ const width = state.viewport.width
543
+ const height = state.viewport.height
544
+ const ratio = state.viewport.ratio
545
+ canvas.width = Math.floor(width * ratio)
546
+ canvas.height = Math.floor(height * ratio)
547
+ ctx2dFallback.setTransform(ratio, 0, 0, ratio, 0, 0)
548
+ ctx2dFallback.fillStyle = '#08131d'
549
+ ctx2dFallback.fillRect(0, 0, width, height)
727
550
 
728
- const compareNodesForEcosystem = (left, right) => {
729
- const keyComparison = ecosystemKeyForNode(left).localeCompare(ecosystemKeyForNode(right))
730
- if (keyComparison !== 0) return keyComparison
731
- const leftDegree = state.nodeDegrees.get(left.id) ?? 0
732
- const rightDegree = state.nodeDegrees.get(right.id) ?? 0
733
- if (leftDegree !== rightDegree) return rightDegree - leftDegree
734
- return String(left.title || left.id).localeCompare(String(right.title || right.id))
735
- }
551
+ const nodes = Array.isArray(state.chunk.nodes) ? state.chunk.nodes : []
552
+ const edges = Array.isArray(state.chunk.edges) ? state.chunk.edges : []
553
+ const nodeById = new Map()
554
+ for (let i = 0; i < nodes.length; i += 1) {
555
+ nodeById.set(nodes[i][0], nodes[i])
556
+ }
736
557
 
737
- const selectEcosystemRepresentative = nodes => {
738
- let representative = nodes[0] ?? null
739
- let representativeScore = Number.NEGATIVE_INFINITY
558
+ ctx2dFallback.strokeStyle = 'rgba(151,181,212,0.18)'
559
+ ctx2dFallback.lineWidth = 1
560
+ for (let i = 0; i < edges.length; i += 1) {
561
+ const edge = edges[i]
562
+ const source = nodeById.get(edge[0])
563
+ const target = nodeById.get(edge[1])
564
+ if (!source || !target) continue
565
+ const from = worldToScreen(source[2], source[3])
566
+ const to = worldToScreen(target[2], target[3])
567
+ ctx2dFallback.beginPath()
568
+ ctx2dFallback.moveTo(from.x, from.y)
569
+ ctx2dFallback.lineTo(to.x, to.y)
570
+ ctx2dFallback.stroke()
571
+ }
740
572
 
741
- for (let index = 0; index < nodes.length; index += 1) {
742
- const node = nodes[index]
743
- const score = (state.nodeDegrees.get(node.id) ?? 0) + hubNodeScore(node) * 1000
744
- if (score > representativeScore) {
745
- representative = node
746
- representativeScore = score
747
- }
573
+ for (let i = 0; i < nodes.length; i += 1) {
574
+ const node = nodes[i]
575
+ const p = worldToScreen(node[2], node[3])
576
+ const selected = state.selectedNodeId === node[0]
577
+ const color = segmentColor(node[5] || node[4] || node[1])
578
+ const radius = Math.max(3.2, Math.min(16.5, 5 + node[7] * 0.65))
579
+
580
+ ctx2dFallback.beginPath()
581
+ ctx2dFallback.fillStyle = selected ? '#edf4ff' : color
582
+ ctx2dFallback.arc(p.x, p.y, radius, 0, Math.PI * 2)
583
+ ctx2dFallback.fill()
748
584
  }
749
585
 
750
- return representative
586
+ ctx2dFallback.fillStyle = '#97a9bd'
587
+ ctx2dFallback.font = '12px Inter, system-ui, sans-serif'
588
+ ctx2dFallback.textAlign = 'center'
589
+ ctx2dFallback.fillText('Fallback canvas mode', Math.max(width, 320) / 2, 24)
751
590
  }
752
591
 
753
- const ecosystemLayoutSpacingForSize = size => {
754
- if (size >= ecosystemLevelNodeCap) return 260
755
- if (size >= 320) return 110
756
- if (size >= 120) return 64
757
- if (size >= 48) return 34
758
- if (size >= 18) return 18
759
- if (size >= 8) return 11
760
- return 7
592
+ const updateTotals = () => {
593
+ elements.nodeCount.textContent = String(state.totals.nodes)
594
+ elements.edgeCount.textContent = String(state.totals.edges)
761
595
  }
762
596
 
763
- const buildEcosystemLevelSizes = nodeCount => {
764
- if (nodeCount <= 0) return []
765
- const sizes = []
766
- let currentSize = Math.max(1, Math.ceil(nodeCount / ecosystemLevelNodeCap))
767
- while (currentSize >= 1) {
768
- sizes.push(currentSize)
769
- if (currentSize === 1) {
770
- break
771
- }
772
- const nextSize = Math.max(1, Math.ceil(currentSize / ecosystemLevelNodeCap))
773
- if (nextSize === currentSize) {
774
- break
597
+ const updateTagCount = () => {
598
+ elements.tagCount.textContent = state.graphMode === 'far' ? 'clusters' : state.graphMode
599
+ }
600
+
601
+ const updateWorkerCamera = () => {
602
+ updateGraphOverlays()
603
+ if (!state.renderWorker || !state.workerReady) {
604
+ return
605
+ }
606
+ if (state.cameraSyncScheduled) {
607
+ return
608
+ }
609
+ state.cameraSyncScheduled = true
610
+ requestAnimationFrame(() => {
611
+ state.cameraSyncScheduled = false
612
+ if (!state.renderWorker || !state.workerReady) {
613
+ return
775
614
  }
776
- currentSize = nextSize
777
- }
778
- return sizes
779
- }
780
-
781
- const buildEcosystemExpansionLevels = (levelSizes, nodeCount) => {
782
- if (levelSizes.length <= 1) {
783
- return []
784
- }
785
- const isMassive = nodeCount > massiveGraphNodeThreshold
786
- const maxScale = isMassive
787
- ? massiveEcosystemClusterScaleThreshold
788
- : ecosystemClusterScaleThreshold
789
- const startScale = isMassive ? 0.82 : 0.18
790
- const transitionCount = levelSizes.length - 1
791
- const usableScale = Math.max(0.08, maxScale - startScale)
792
- const step = usableScale / transitionCount
793
- const stride = isMassive ? 0.9 : 0.78
794
- const overlap = isMassive ? 1.28 : 1.75
795
- const levels = []
796
- for (let index = 0; index < transitionCount; index += 1) {
797
- const start = startScale + step * index * stride
798
- const end = Math.min(maxScale, start + step * overlap)
799
- levels.push({
800
- parentSize: levelSizes[index],
801
- childSize: levelSizes[index + 1],
802
- start,
803
- end
615
+ state.renderWorker.postMessage({
616
+ type: 'camera',
617
+ camera: state.camera
804
618
  })
805
- }
806
- return levels
619
+ })
807
620
  }
808
621
 
809
- const ecosystemCompactPoint = (index, total, center, spacing) => {
810
- if (total <= 1) {
811
- return { x: center.x, y: center.y }
812
- }
813
- const angle = index * 2.399963229728653
814
- const radius = spacing * Math.sqrt(index + 1)
815
- return {
816
- x: center.x + Math.cos(angle) * radius,
817
- y: center.y + Math.sin(angle) * radius
622
+ const updateWorkerSize = () => {
623
+ updateGraphOverlays()
624
+ if (!state.renderWorker || !state.workerReady) {
625
+ return
818
626
  }
627
+ state.renderWorker.postMessage({
628
+ type: 'resize',
629
+ width: state.viewport.width,
630
+ height: state.viewport.height,
631
+ devicePixelRatio: state.viewport.ratio
632
+ })
819
633
  }
820
634
 
821
- const buildEcosystemCluster = (nodes, index, point) => {
822
- const count = Math.max(nodes.length, 1)
823
- const representative = selectEcosystemRepresentative(nodes)
635
+ const normalizeList = (items) => Array.isArray(items) ? items : []
824
636
 
825
- return {
826
- id: 'ecosystem-' + index,
827
- x: point.x,
828
- y: point.y,
829
- count,
830
- nodeIds: nodes.map(node => node.id),
831
- representative,
832
- label: ecosystemKeyForNode(nodes[0] ?? representative ?? { path: '' })
833
- }
834
- }
835
-
836
- const buildEcosystemHubCluster = (hub, center) => hub
837
- ? {
838
- id: 'ecosystem-hub',
839
- x: center.x,
840
- y: center.y,
841
- count: 1,
842
- size: 1,
843
- nodeIds: [hub.id],
844
- representative: hub,
845
- label: hub.title || 'Memory Hub',
846
- parentId: null,
847
- parentX: null,
848
- parentY: null,
849
- isHub: true
850
- }
851
- : null
852
-
853
- const buildEcosystemLevel = (sortedNodes, size, parentLookup, center) => {
854
- const clusters = []
855
- const clusterByNodeId = new Map()
856
- const parentChildIndex = new Map()
857
-
858
- for (let offset = 0; offset < sortedNodes.length; offset += size) {
859
- const clusterNodes = sortedNodes.slice(offset, offset + size)
860
- const parentCluster = parentLookup?.get(clusterNodes[0]?.id)
861
- const siblingIndex = parentCluster
862
- ? (parentChildIndex.get(parentCluster.id) ?? 0)
863
- : clusters.length
864
- if (parentCluster) {
865
- parentChildIndex.set(parentCluster.id, siblingIndex + 1)
866
- }
867
- const point = parentCluster
868
- ? ecosystemCompactPoint(siblingIndex, Math.ceil((parentCluster.count || size) / size), parentCluster, ecosystemLayoutSpacingForSize(size))
869
- : ecosystemCompactPoint(clusters.length, Math.ceil(sortedNodes.length / size), center, ecosystemLayoutSpacingForSize(size))
870
- const cluster = {
871
- ...buildEcosystemCluster(clusterNodes, clusters.length, point),
872
- id: 'ecosystem-' + size + '-' + clusters.length,
873
- size,
874
- parentId: parentCluster?.id ?? null,
875
- parentX: parentCluster?.x ?? null,
876
- parentY: parentCluster?.y ?? null
877
- }
878
- clusters.push(cluster)
879
- for (let index = 0; index < clusterNodes.length; index += 1) {
880
- clusterByNodeId.set(clusterNodes[index].id, cluster)
881
- }
637
+ const applyManualNodePositions = (nodes) => normalizeList(nodes).map((node) => {
638
+ const id = typeof node?.[0] === 'string' ? node[0] : ''
639
+ const position = id ? state.nodePositions.get(id) : null
640
+ if (!position || !Number.isFinite(position.x) || !Number.isFinite(position.y)) {
641
+ return node
882
642
  }
883
643
 
884
- return { clusters, clusterByNodeId }
885
- }
644
+ const next = [...node]
645
+ next[2] = position.x
646
+ next[3] = position.y
647
+ return next
648
+ })
886
649
 
887
- const buildEcosystemGraph = (nodes, center, hub) => {
888
- if (nodes.length === 0) {
889
- return {
890
- clusters: [],
891
- clustersBySize: new Map(),
892
- nodeClusterBySize: new Map(),
893
- levelSizes: [],
894
- expansionLevels: [],
895
- baseSize: ecosystemLevelNodeCap,
896
- hubCluster: null
897
- }
650
+ const updateNodePositionInChunk = (nodeId, x, y) => {
651
+ if (!nodeId || !Number.isFinite(x) || !Number.isFinite(y)) {
652
+ return
898
653
  }
899
654
 
900
- const hubCluster = buildEcosystemHubCluster(hub, center)
901
- const sortedNodes = nodes
902
- .filter(node => node.id !== hub?.id)
903
- .sort(compareNodesForEcosystem)
904
- const levelSizes = buildEcosystemLevelSizes(sortedNodes.length)
905
- const expansionLevels = buildEcosystemExpansionLevels(levelSizes, nodes.length)
906
- const baseSize = levelSizes[0] ?? ecosystemLevelNodeCap
907
- const clustersBySize = new Map()
908
- const nodeClusterBySize = new Map()
909
- let parentLookup = null
910
-
911
- for (let index = 0; index < levelSizes.length; index += 1) {
912
- const size = levelSizes[index]
913
- const level = buildEcosystemLevel(sortedNodes, size, parentLookup, center)
914
- clustersBySize.set(size, level.clusters)
915
- nodeClusterBySize.set(size, level.clusterByNodeId)
916
- parentLookup = level.clusterByNodeId
655
+ state.chunk = {
656
+ ...state.chunk,
657
+ nodes: normalizeList(state.chunk.nodes).map((node) => {
658
+ if (node?.[0] !== nodeId) {
659
+ return node
660
+ }
661
+ const next = [...node]
662
+ next[2] = x
663
+ next[3] = y
664
+ return next
665
+ })
917
666
  }
667
+ state.spatialIndex.key = ''
918
668
 
919
- return {
920
- clusters: clustersBySize.get(baseSize) ?? [],
921
- clustersBySize,
922
- nodeClusterBySize,
923
- levelSizes,
924
- expansionLevels,
925
- baseSize,
926
- hubCluster
669
+ if (state.renderWorker && state.workerReady) {
670
+ state.renderWorker.postMessage({ type: 'move-node', id: nodeId, x, y })
927
671
  }
672
+ updateGraphOverlays()
928
673
  }
929
674
 
930
- const isClusterInViewport = (cluster, viewport) =>
931
- cluster.x >= viewport.minX &&
932
- cluster.x <= viewport.maxX &&
933
- cluster.y >= viewport.minY &&
934
- cluster.y <= viewport.maxY
675
+ const focusNodeInViewport = (nodeId, nextScale = null) => {
676
+ const node = nodeByIdFromChunk().get(nodeId)
677
+ if (!node) {
678
+ return false
679
+ }
935
680
 
936
- const filterEcosystemClustersByViewport = (clusters, viewport) => {
937
- const visible = clusters.filter(cluster => isClusterInViewport(cluster, viewport))
938
- return visible.length > 0 ? visible : [...clusters]
939
- }
940
-
941
- const ecosystemFocusPoint = () => {
942
- const cursorPoint = cursorWorldPoint()
943
- if (cursorPoint) {
944
- return cursorPoint
945
- }
946
- const now = performance.now()
947
- if (now - state.lastZoomFocus.at <= 1800) {
948
- return { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
681
+ const x = Number(node[2])
682
+ const y = Number(node[3])
683
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
684
+ return false
949
685
  }
950
- return viewportCenterWorldPoint()
951
- }
952
-
953
- const nearestEcosystemParentIds = (clusters, focusPoint, limit) =>
954
- clusters
955
- .map(cluster => ({
956
- cluster,
957
- distance: Math.max(
958
- 0,
959
- Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y) -
960
- clusterRadiusPx(cluster) / Math.max(state.transform.scale, 0.0001)
961
- )
962
- }))
963
- .sort((left, right) => left.distance - right.distance)
964
- .slice(0, limit)
965
- .map(item => item.cluster.id)
966
-
967
- const smoothStep = value => {
968
- const clamped = Math.max(0, Math.min(1, value))
969
- return clamped * clamped * (3 - clamped * 2)
970
- }
971
-
972
- const zoomProgress = (scale, start, end) =>
973
- smoothStep((scale - start) / Math.max(end - start, 0.0001))
974
686
 
975
- const semanticZoomSpread = (progress, childSize) => {
976
- const spreadExponent = childSize <= Math.ceil(ecosystemLevelNodeCap / 12) ? 5.6 : 4.2
977
- const curve = Math.pow(progress, spreadExponent)
978
- if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
979
- return 0.12 + curve * 0.88
687
+ if (Number.isFinite(nextScale)) {
688
+ state.camera.scale = clampScale(Number(nextScale))
980
689
  }
981
- return curve
690
+ state.camera.x = state.viewport.width / 2 - x * state.camera.scale
691
+ state.camera.y = state.viewport.height / 2 - y * state.camera.scale
692
+ updateWorkerCamera()
693
+ scheduleChunkFetch()
694
+ return true
982
695
  }
983
696
 
984
- const opacityForProgress = (progress, childSize) => {
985
- const opacityExponent = childSize <= Math.ceil(ecosystemLevelNodeCap / 12) ? 2.8 : 2.1
986
- const eased = Math.pow(progress, opacityExponent)
987
- if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
988
- return 0.22 + eased * 0.78
697
+ const showTooltip = (node, pointer) => {
698
+ if (!elements.tooltip || !node) {
699
+ return
989
700
  }
990
- return eased
991
- }
992
701
 
993
- const expandFocusedClusters = (parentClusters, childSize, progress, spread, viewport) => {
994
- const focusPoint = ecosystemFocusPoint()
995
- const expandedParentIds = new Set(nearestEcosystemParentIds(
996
- parentClusters,
997
- focusPoint,
998
- ecosystemFocusedParentLimit
999
- ))
1000
- const childClusters = state.ecosystemClustersBySize.get(childSize) ?? []
1001
- const visibleChildClusters = childClusters
1002
- .filter(cluster => expandedParentIds.has(cluster.parentId))
1003
- .map(cluster => spreadChildClusterFromParent(cluster, childSize, progress, spread))
1004
- .filter(cluster => isClusterInViewport(cluster, viewport))
702
+ elements.tooltip.hidden = false
703
+ elements.tooltip.innerHTML =
704
+ '<strong>' + escapeHtml(node[1] || node[0]) + '</strong>' +
705
+ '<small>' + escapeHtml(node[4] || node[5] || '') + '</small>'
706
+ elements.tooltip.style.left = Math.min(state.viewport.width - 24, pointer.x + 14) + 'px'
707
+ elements.tooltip.style.top = Math.min(state.viewport.height - 24, pointer.y + 14) + 'px'
708
+ }
1005
709
 
1006
- return {
1007
- expandedParentIds,
1008
- childClusters: visibleChildClusters
710
+ const hideTooltip = () => {
711
+ if (elements.tooltip) {
712
+ elements.tooltip.hidden = true
1009
713
  }
1010
714
  }
1011
715
 
1012
- const spreadChildClusterFromParent = (cluster, childSize, progress, spread) => {
1013
- if (!Number.isFinite(cluster.parentX) || !Number.isFinite(cluster.parentY)) {
1014
- return {
1015
- ...cluster,
1016
- lodOpacity: opacityForProgress(progress, childSize)
1017
- }
1018
- }
716
+ const labelCandidates = () => {
717
+ const nodes = normalizeList(state.chunk.nodes)
718
+ const visible = nodes.filter((node) => {
719
+ const x = Number(node?.[2])
720
+ const y = Number(node?.[3])
721
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return false
722
+ const point = worldToScreen(x, y)
723
+ return point.x >= -80 && point.x <= state.viewport.width + 80 && point.y >= -80 && point.y <= state.viewport.height + 80
724
+ })
725
+ const shouldShowMany = state.camera.scale >= 0.72 || visible.length <= 120
726
+ const focused = state.focusedNodeIds
1019
727
 
1020
- return {
1021
- ...cluster,
1022
- x: cluster.parentX + (cluster.x - cluster.parentX) * spread,
1023
- y: cluster.parentY + (cluster.y - cluster.parentY) * spread,
1024
- lodOpacity: opacityForProgress(progress, childSize)
1025
- }
728
+ return visible
729
+ .filter((node) => shouldShowMany || focused.has(node[0]) || node[0] === state.hoveredNodeId || node[0] === state.selectedNodeId || Number(node?.[7]) > 5.5)
730
+ .sort((left, right) => {
731
+ const leftFocused = focused.has(left[0]) || left[0] === state.hoveredNodeId || left[0] === state.selectedNodeId ? 1 : 0
732
+ const rightFocused = focused.has(right[0]) || right[0] === state.hoveredNodeId || right[0] === state.selectedNodeId ? 1 : 0
733
+ if (rightFocused !== leftFocused) return rightFocused - leftFocused
734
+ return Number(right?.[7] ?? 0) - Number(left?.[7] ?? 0)
735
+ })
736
+ .slice(0, state.camera.scale >= 0.72 ? 160 : 48)
1026
737
  }
1027
738
 
1028
- const selectHierarchicalEcosystemClusters = viewport => {
1029
- const baseClusters = state.ecosystemClustersBySize.get(state.ecosystemBaseSize) ?? state.ecosystemClusters
1030
- const visibleBaseClusters = filterEcosystemClustersByViewport(baseClusters, viewport)
1031
- const hubClusters = state.ecosystemHubCluster ? [state.ecosystemHubCluster] : []
1032
- const visibleClusters = [...visibleBaseClusters]
1033
-
1034
- for (let index = 0; index < state.ecosystemExpansionLevels.length; index += 1) {
1035
- const level = state.ecosystemExpansionLevels[index]
1036
- const parentClusters = visibleClusters.filter(cluster => cluster.size === level.parentSize)
1037
- if (parentClusters.length === 0) {
1038
- continue
1039
- }
1040
- const progress = zoomProgress(state.transform.scale, level.start, level.end)
1041
- const spread = semanticZoomSpread(progress, level.childSize)
1042
- const expansion = expandFocusedClusters(parentClusters, level.childSize, progress, spread, viewport)
1043
- visibleClusters.push(...expansion.childClusters)
739
+ const drawLabels = () => {
740
+ if (!elements.labels) {
741
+ return
1044
742
  }
1045
743
 
1046
- return [...hubClusters, ...visibleClusters]
744
+ elements.labels.innerHTML = labelCandidates().map((node) => {
745
+ const point = worldToScreen(Number(node[2]), Number(node[3]))
746
+ const focused = state.focusedNodeIds.has(node[0]) || node[0] === state.hoveredNodeId || node[0] === state.selectedNodeId
747
+ return '<span class="graph-label' + (focused ? ' is-focused' : '') + '" style="left:' +
748
+ point.x.toFixed(1) + 'px;top:' + point.y.toFixed(1) + 'px">' + escapeHtml(node[1] || node[0]) + '</span>'
749
+ }).join('')
1047
750
  }
1048
751
 
1049
- const ecosystemSiblingEdgesForClusters = (clusters, existingEdges) => {
1050
- const byParent = new Map()
1051
- for (let index = 0; index < clusters.length; index += 1) {
1052
- const cluster = clusters[index]
1053
- if (cluster.isHub || !cluster.parentId) {
1054
- continue
1055
- }
1056
- const siblings = byParent.get(cluster.parentId)
1057
- if (siblings) {
1058
- siblings.push(cluster)
1059
- } else {
1060
- byParent.set(cluster.parentId, [cluster])
1061
- }
752
+ const drawMiniMap = () => {
753
+ const miniMap = elements.miniMap
754
+ if (!(miniMap instanceof HTMLCanvasElement)) {
755
+ return
1062
756
  }
1063
-
1064
- const edges = []
1065
- for (const siblings of byParent.values()) {
1066
- const ordered = [...siblings]
1067
- .sort((left, right) => Math.atan2(left.y - (left.parentY ?? 0), left.x - (left.parentX ?? 0)) - Math.atan2(right.y - (right.parentY ?? 0), right.x - (right.parentX ?? 0)))
1068
- for (let index = 0; index < ordered.length && edges.length < ecosystemSiblingEdgeLimit; index += 1) {
1069
- const sourceCluster = ordered[index]
1070
- const targetCluster = ordered[(index + 1) % ordered.length]
1071
- if (!targetCluster || sourceCluster.id === targetCluster.id) {
1072
- continue
1073
- }
1074
- const orderedIds = sourceCluster.id < targetCluster.id
1075
- ? [sourceCluster.id, targetCluster.id]
1076
- : [targetCluster.id, sourceCluster.id]
1077
- const key = orderedIds.join(':')
1078
- if (existingEdges.has(key)) {
1079
- continue
1080
- }
1081
- const edge = {
1082
- id: key,
1083
- sourceCluster,
1084
- targetCluster,
1085
- weight: 0.7,
1086
- inferred: true
1087
- }
1088
- existingEdges.set(key, edge)
1089
- edges.push(edge)
1090
- }
757
+ const nodes = normalizeList(state.chunk.nodes)
758
+ const ctx = miniMap.getContext('2d')
759
+ if (!ctx || nodes.length === 0) {
760
+ return
1091
761
  }
1092
762
 
1093
- return edges
763
+ const ratio = window.devicePixelRatio || 1
764
+ const width = miniMap.clientWidth || 180
765
+ const height = miniMap.clientHeight || 120
766
+ miniMap.width = Math.floor(width * ratio)
767
+ miniMap.height = Math.floor(height * ratio)
768
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
769
+ ctx.clearRect(0, 0, width, height)
770
+ ctx.fillStyle = 'rgba(8, 19, 29, 0.88)'
771
+ ctx.fillRect(0, 0, width, height)
772
+
773
+ const xs = nodes.map((node) => Number(node[2])).filter(Number.isFinite)
774
+ const ys = nodes.map((node) => Number(node[3])).filter(Number.isFinite)
775
+ const minX = Math.min(...xs)
776
+ const maxX = Math.max(...xs)
777
+ const minY = Math.min(...ys)
778
+ const maxY = Math.max(...ys)
779
+ const graphWidth = Math.max(1, maxX - minX)
780
+ const graphHeight = Math.max(1, maxY - minY)
781
+ const scale = Math.min((width - 18) / graphWidth, (height - 18) / graphHeight)
782
+ const offsetX = (width - graphWidth * scale) / 2
783
+ const offsetY = (height - graphHeight * scale) / 2
784
+ const toMini = (x, y) => ({
785
+ x: offsetX + (x - minX) * scale,
786
+ y: offsetY + (y - minY) * scale
787
+ })
788
+ state.miniMapView = { minX, minY, scale, offsetX, offsetY, width, height }
789
+
790
+ ctx.fillStyle = 'rgba(90, 168, 255, 0.62)'
791
+ nodes.forEach((node) => {
792
+ const point = toMini(Number(node[2]), Number(node[3]))
793
+ ctx.fillRect(point.x - 1, point.y - 1, 2, 2)
794
+ })
795
+
796
+ const worldTopLeft = screenToWorld(0, 0)
797
+ const worldBottomRight = screenToWorld(state.viewport.width, state.viewport.height)
798
+ const topLeft = toMini(Math.min(worldTopLeft.x, worldBottomRight.x), Math.min(worldTopLeft.y, worldBottomRight.y))
799
+ const bottomRight = toMini(Math.max(worldTopLeft.x, worldBottomRight.x), Math.max(worldTopLeft.y, worldBottomRight.y))
800
+ ctx.strokeStyle = 'rgba(90, 168, 255, 0.86)'
801
+ ctx.lineWidth = 1
802
+ ctx.strokeRect(topLeft.x, topLeft.y, Math.max(3, bottomRight.x - topLeft.x), Math.max(3, bottomRight.y - topLeft.y))
1094
803
  }
1095
804
 
1096
- const ecosystemEdgesForClusters = clusters => {
1097
- const edgeClusters = clusters.filter(cluster => cluster.isHub || clusterOpacity(cluster) > 0.018)
1098
- const clusterById = new Map(edgeClusters.map(cluster => [cluster.id, cluster]))
1099
- const clusterIds = new Set(clusterById.keys())
1100
- const levelsBySize = []
1101
- for (let index = 0; index < edgeClusters.length; index += 1) {
1102
- const cluster = edgeClusters[index]
1103
- if (!cluster.size || cluster.isHub) continue
1104
- if (!levelsBySize.some(level => level.size === cluster.size)) {
1105
- levelsBySize.push({
1106
- size: cluster.size,
1107
- lookup: state.ecosystemNodeClusterBySize.get(cluster.size) ?? new Map()
1108
- })
1109
- }
805
+ const shouldDeferGraphOverlays = () => state.pointer.down || performance.now() - state.lastWheelAt < 150
806
+
807
+ const updateGraphOverlays = () => {
808
+ if (state.overlayScheduled) {
809
+ return
1110
810
  }
1111
- levelsBySize.sort((left, right) => left.size - right.size)
1112
- const resolveClusterForNode = nodeId => {
1113
- if (state.ecosystemHubCluster?.nodeIds.includes(nodeId) && clusterIds.has(state.ecosystemHubCluster.id)) {
1114
- return state.ecosystemHubCluster
1115
- }
1116
- for (let index = 0; index < levelsBySize.length; index += 1) {
1117
- const lookup = levelsBySize[index].lookup
1118
- const cluster = lookup.get(nodeId)
1119
- if (cluster && clusterIds.has(cluster.id)) {
1120
- return clusterById.get(cluster.id) ?? cluster
811
+ state.overlayScheduled = true
812
+ requestAnimationFrame(() => {
813
+ state.overlayScheduled = false
814
+ if (shouldDeferGraphOverlays()) {
815
+ elements.labels?.classList.add('is-stale')
816
+ if (!state.overlayIdleTimer) {
817
+ state.overlayIdleTimer = setTimeout(() => {
818
+ state.overlayIdleTimer = null
819
+ updateGraphOverlays()
820
+ }, 170)
1121
821
  }
822
+ return
1122
823
  }
1123
- return null
1124
- }
1125
-
1126
- const edgeByClusterPair = new Map()
1127
- for (let index = 0; index < state.visibleEdges.length; index += 1) {
1128
- const edge = state.visibleEdges[index]
1129
- const sourceCluster = resolveClusterForNode(edge.source)
1130
- const targetCluster = resolveClusterForNode(edge.target)
1131
- if (!sourceCluster || !targetCluster || sourceCluster.id === targetCluster.id) {
1132
- continue
1133
- }
1134
-
1135
- const orderedIds = sourceCluster.id < targetCluster.id
1136
- ? [sourceCluster.id, targetCluster.id]
1137
- : [targetCluster.id, sourceCluster.id]
1138
- const key = orderedIds.join(':')
1139
- const current = edgeByClusterPair.get(key)
1140
- if (current) {
1141
- current.weight += edgeWeight(edge)
1142
- continue
824
+ elements.labels?.classList.remove('is-stale')
825
+ drawLabels()
826
+ if (state.miniMapDirty) {
827
+ drawMiniMap()
828
+ state.miniMapDirty = false
1143
829
  }
830
+ })
831
+ }
1144
832
 
1145
- edgeByClusterPair.set(key, {
1146
- id: key,
1147
- sourceCluster,
1148
- targetCluster,
1149
- weight: edgeWeight(edge)
833
+ const list = (items) => {
834
+ const rows = normalizeList(items)
835
+ if (rows.length === 0) {
836
+ return '<li><small>No links found.</small></li>'
837
+ }
838
+ return rows
839
+ .map((item) => {
840
+ const title = typeof item?.title === 'string' ? item.title : 'Untitled'
841
+ const id = typeof item?.id === 'string' ? item.id : ''
842
+ const path = typeof item?.path === 'string' ? item.path : ''
843
+ const meta = item?.weight ? ' · weight ' + escapeHtml(item.weight) + ' · ' + escapeHtml(item.priority || 'normal') : ''
844
+ return '<li>' +
845
+ (id ? '<button type="button" data-node-id="' + escapeHtml(id) + '">' + escapeHtml(title) + '</button>' : escapeHtml(title)) +
846
+ '<small>' + escapeHtml(path) + meta + '</small>' +
847
+ '</li>'
1150
848
  })
1151
- }
1152
-
1153
- ecosystemSiblingEdgesForClusters(edgeClusters, edgeByClusterPair)
1154
- const edges = Array.from(edgeByClusterPair.values())
1155
- .sort((left, right) => right.weight - left.weight)
1156
- .slice(0, ecosystemClusterEdgeLimit)
1157
- const hubCluster = state.ecosystemHubCluster && clusterIds.has(state.ecosystemHubCluster.id)
1158
- ? state.ecosystemHubCluster
1159
- : null
1160
- if (!hubCluster) {
1161
- return edges
1162
- }
1163
-
1164
- const existingHubTargets = new Set(edges.flatMap(edge =>
1165
- edge.sourceCluster.id === hubCluster.id
1166
- ? [edge.targetCluster.id]
1167
- : edge.targetCluster.id === hubCluster.id
1168
- ? [edge.sourceCluster.id]
1169
- : []
1170
- ))
1171
- const syntheticHubEdges = edgeClusters
1172
- .filter(cluster => cluster.id !== hubCluster.id && !existingHubTargets.has(cluster.id))
1173
- .slice(0, ecosystemHubEdgeLimit)
1174
- .map(cluster => ({
1175
- id: hubCluster.id + ':' + cluster.id,
1176
- sourceCluster: hubCluster,
1177
- targetCluster: cluster,
1178
- weight: 1,
1179
- inferred: true
1180
- }))
1181
- return edges.concat(syntheticHubEdges)
849
+ .join('')
1182
850
  }
1183
851
 
1184
- const edgeBudgetForCurrentFrame = () => {
1185
- const zoom = state.transform.scale
1186
- if (zoom < 0.12) return 380
1187
- if (zoom < 0.18) return 900
1188
- if (zoom < 0.28) return 1700
1189
- if (zoom < 0.45) return 2800
1190
- if (zoom < 0.7) return 4200
1191
- if (zoom < 1.05) return 5600
1192
- return 7600
852
+ const buildFacts = (node, outgoingCount, incomingCount) => {
853
+ const content = typeof node?.content === 'string' ? node.content : ''
854
+ const words = content.trim().length > 0 ? content.trim().split(/\\s+/).length : 0
855
+ return [
856
+ { label: 'Agent', value: typeof node?.agentId === 'string' && node.agentId ? node.agentId : 'shared' },
857
+ { label: 'Words', value: String(words) },
858
+ { label: 'Chars', value: String(content.length) },
859
+ { label: 'Outgoing', value: String(outgoingCount) },
860
+ { label: 'Backlinks', value: String(incomingCount) }
861
+ ]
1193
862
  }
1194
863
 
1195
- const clusterBudgetForScale = (scale) => {
1196
- if (scale < 0.008) return 90
1197
- if (scale < 0.014) return 150
1198
- if (scale < 0.022) return 240
1199
- if (scale < 0.035) return 360
1200
- return 520
1201
- }
864
+ const listFacts = (facts) => facts
865
+ .map((fact) => '<li><strong>' + escapeHtml(fact.label) + ':</strong> <small>' + escapeHtml(fact.value) + '</small></li>')
866
+ .join('')
1202
867
 
1203
- const nodeBudgetForScale = (scale) => {
1204
- if (scale < 0.035) return 220
1205
- if (scale < 0.06) return 360
1206
- if (scale < 0.09) return 520
1207
- if (scale < 0.14) return 720
1208
- if (state.visibleNodes.length > massiveGraphNodeThreshold) {
1209
- if (scale < 0.28) return renderNodeBudget
1210
- if (scale < 0.45) return 1100
1211
- if (scale < 0.7) return 1400
1212
- if (scale < 1.05) return 1800
1213
- return zoomedMassiveRenderNodeBudget
868
+ const listContextLinks = (links) => {
869
+ if (!Array.isArray(links) || links.length === 0) {
870
+ return '<li><small>No context links found.</small></li>'
1214
871
  }
1215
- return renderNodeBudget
872
+ return links
873
+ .map((link) => '<li><span>' + escapeHtml(link.title) + '</span><small>' + escapeHtml(link.priority || 'normal') + '</small></li>')
874
+ .join('')
1216
875
  }
1217
876
 
1218
- const layerFocusForScale = (scale) => {
1219
- const normalized = Math.max(0, Math.min(1, (scale - 0.06) / 0.94))
1220
- const shellCenter = Math.max(0.08, 0.96 - normalized * 0.86)
1221
- const shellWidth = Math.max(0.24, 0.46 - normalized * 0.16)
1222
- const coreRadius = Math.max(0.06, 0.1 + normalized * 0.22)
1223
- const coreRatio = Math.max(0.2, Math.min(0.72, 0.24 + normalized * 0.48))
877
+ const nodeContextLinks = (node, outgoing) => {
878
+ const titles = Array.isArray(node?.contextLinks) ? node.contextLinks : []
879
+ const outgoingByTitle = new Map(normalizeList(outgoing).map((link) => [String(link.title || '').toLowerCase(), link]))
1224
880
 
1225
- return { shellCenter, shellWidth, coreRadius, coreRatio }
881
+ return titles
882
+ .map((title) => {
883
+ const match = outgoingByTitle.get(String(title).toLowerCase())
884
+ return {
885
+ title,
886
+ priority: match?.priority || 'normal'
887
+ }
888
+ })
889
+ .filter((link) => typeof link.title === 'string' && link.title.trim().length > 0)
1226
890
  }
1227
891
 
1228
- const selectLayeredNodesForScale = (sourceNodes, targetCount) => {
1229
- const hub = state.primaryHub
1230
- if (!hub || sourceNodes.length <= 1200 || state.visibleNodes.length <= massiveGraphNodeThreshold) {
1231
- return sourceNodes
1232
- }
892
+ const linkedNodes = (node) => {
893
+ const nodeById = new Map((state.chunk.nodes || []).map((item) => [item[0], item]))
894
+ const edges = normalizeList(state.chunk.edges)
1233
895
 
1234
- let maxDistance = 0
1235
- const distances = sourceNodes.map((node) => {
1236
- const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
1237
- if (distance > maxDistance) {
1238
- maxDistance = distance
896
+ const outgoing = []
897
+ const incoming = []
898
+ for (let index = 0; index < edges.length; index += 1) {
899
+ const edge = edges[index]
900
+ if (edge[0] === node.id) {
901
+ const target = nodeById.get(edge[1])
902
+ if (target) {
903
+ outgoing.push({ id: target[0], title: target[1], path: target[4] || '', weight: edge[2], priority: edge[3] })
904
+ }
905
+ }
906
+ if (edge[1] === node.id) {
907
+ const source = nodeById.get(edge[0])
908
+ if (source) {
909
+ incoming.push({ id: source[0], title: source[1], path: source[4] || '', weight: edge[2], priority: edge[3] })
910
+ }
1239
911
  }
1240
- return { node, distance }
1241
- })
1242
-
1243
- if (maxDistance <= 0.001) {
1244
- return sourceNodes
1245
912
  }
1246
913
 
1247
- const focus = layerFocusForScale(state.transform.scale)
1248
- const normalizedRows = distances.map((item) => ({
1249
- ...item,
1250
- normalized: item.distance / maxDistance
1251
- }))
1252
- const desired = Math.max(260, Math.min(sourceNodes.length, targetCount * 2))
1253
- const coreTarget = Math.max(36, Math.min(desired - 8, Math.floor(desired * focus.coreRatio)))
1254
- const shellTarget = Math.max(12, desired - coreTarget)
1255
- const shellHalf = focus.shellWidth / 2
914
+ return { outgoing, incoming }
915
+ }
1256
916
 
1257
- const coreNodes = normalizedRows
1258
- .filter((item) => item.normalized <= focus.coreRadius)
1259
- .sort((left, right) => {
1260
- const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
1261
- const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
1262
- if (leftScore !== rightScore) return rightScore - leftScore
1263
- return left.node.id.localeCompare(right.node.id)
1264
- })
1265
- .slice(0, coreTarget)
1266
- .map((item) => item.node)
917
+ const openContentDialog = () => {
918
+ elements.contentDialog.hidden = false
919
+ }
1267
920
 
1268
- const shellNodes = normalizedRows
1269
- .sort((left, right) => {
1270
- const leftDelta = Math.abs(left.normalized - focus.shellCenter)
1271
- const rightDelta = Math.abs(right.normalized - focus.shellCenter)
1272
- const leftInside = leftDelta <= shellHalf ? 0 : 1
1273
- const rightInside = rightDelta <= shellHalf ? 0 : 1
1274
- if (leftInside !== rightInside) return leftInside - rightInside
1275
- if (leftDelta !== rightDelta) return leftDelta - rightDelta
1276
- const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
1277
- const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
1278
- if (leftScore !== rightScore) return rightScore - leftScore
1279
- return left.node.id.localeCompare(right.node.id)
1280
- })
1281
- .slice(0, shellTarget)
1282
- .map((item) => item.node)
921
+ const closeContentDialog = () => {
922
+ elements.contentDialog.hidden = true
923
+ }
1283
924
 
1284
- const merged = []
1285
- const ids = new Set()
1286
- const pushUnique = (node) => {
1287
- if (!node || ids.has(node.id)) return
1288
- ids.add(node.id)
1289
- merged.push(node)
925
+ const loadNodeDetails = async (nodeId) => {
926
+ if (!nodeId) {
927
+ return
1290
928
  }
1291
929
 
1292
- if (state.transform.scale >= layeredCoreScaleThreshold) {
1293
- pushUnique(hub)
930
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(nodeId) + scopeQuery('&'))
931
+ if (!response.ok) {
932
+ throw new Error('Failed to load graph node details')
1294
933
  }
1295
- for (let index = 0; index < coreNodes.length; index += 1) pushUnique(coreNodes[index])
1296
- for (let index = 0; index < shellNodes.length; index += 1) pushUnique(shellNodes[index])
1297
-
1298
- return merged.length > 0 ? merged : sourceNodes
1299
- }
1300
934
 
1301
- const viewportCenterWorldPoint = () => {
1302
- const viewport = worldViewportBounds()
1303
- return {
1304
- x: (viewport.minX + viewport.maxX) / 2,
1305
- y: (viewport.minY + viewport.maxY) / 2
935
+ const payload = await response.json()
936
+ if (!payload || typeof payload !== 'object' || !payload.node) {
937
+ throw new Error('Invalid graph node payload')
1306
938
  }
1307
- }
1308
939
 
1309
- const screenToWorldPoint = (screenX, screenY) => ({
1310
- x: (screenX - state.transform.x) / state.transform.scale,
1311
- y: (screenY - state.transform.y) / state.transform.scale
1312
- })
940
+ const node = payload.node
941
+ state.selectedNodeId = node.id
942
+ setFocusedNodeIds(linkedNodeIds(node.id))
1313
943
 
1314
- const cursorWorldPoint = () => {
1315
- if (!state.cursor.inCanvas) {
1316
- return null
1317
- }
1318
- const rect = canvas.getBoundingClientRect()
1319
- const screenX = state.cursor.x - rect.left
1320
- const screenY = state.cursor.y - rect.top
1321
- const width = Math.max(rect.width, 320)
1322
- const height = Math.max(rect.height, 320)
1323
- if (!Number.isFinite(screenX) || !Number.isFinite(screenY)) {
1324
- return null
1325
- }
1326
- if (screenX < 0 || screenX > width || screenY < 0 || screenY > height) {
1327
- return null
944
+ if (state.renderWorker && state.workerReady) {
945
+ state.renderWorker.postMessage({ type: 'select', id: node.id })
1328
946
  }
1329
- return screenToWorldPoint(screenX, screenY)
1330
- }
1331
947
 
1332
- const visibilityScaleBucket = (scale) => {
1333
- const safeScale = Math.max(zoomRange.min, scale)
1334
- if (safeScale < 0.01) return Math.round(safeScale * 300_000)
1335
- if (safeScale < 0.05) return Math.round(safeScale * 120_000)
1336
- if (safeScale < 0.2) return Math.round(safeScale * 40_000)
1337
- return Math.round(safeScale * 8_000)
1338
- }
948
+ elements.contentTitle.textContent = node.title || 'Untitled'
949
+ elements.contentPath.textContent = node.path || ''
1339
950
 
1340
- const shouldRenderMacroGalaxyView = () => {
1341
- if (!galaxyDiscoveryEnabled) {
1342
- state.macroViewActive = false
1343
- return false
1344
- }
1345
- if (state.visibleNodes.length <= 1) {
1346
- state.macroViewActive = false
1347
- return false
1348
- }
951
+ const tags = Array.isArray(node.tags) ? node.tags : []
952
+ elements.contentTags.innerHTML = tags.length > 0
953
+ ? tags.map((tag) => '<span>' + escapeHtml(tag) + '</span>').join('')
954
+ : '<span>No tags</span>'
955
+
956
+ const related = linkedNodes(node)
957
+ const contextLinks = nodeContextLinks(node, related.outgoing)
958
+ const facts = buildFacts(node, related.outgoing.length, related.incoming.length)
959
+ elements.contentFacts.innerHTML = listFacts(facts)
960
+ elements.contentContextLinks.innerHTML = listContextLinks(contextLinks)
961
+ elements.contentOutgoing.innerHTML = list(related.outgoing)
962
+ elements.contentIncoming.innerHTML = list(related.incoming)
963
+ elements.contentBody.textContent = typeof node.content === 'string' ? node.content : ''
1349
964
 
1350
- const enterThreshold = macroGalaxyZoomThreshold * macroGalaxyEnterHysteresis
1351
- const exitThreshold = macroGalaxyZoomThreshold * macroGalaxyExitHysteresis
1352
- const shouldRender = state.macroViewActive
1353
- ? state.transform.scale <= exitThreshold
1354
- : state.transform.scale <= enterThreshold
1355
- state.macroViewActive = shouldRender
1356
- return shouldRender
965
+ openContentDialog()
1357
966
  }
1358
967
 
1359
- const mergeUniqueNodes = (leftNodes, rightNodes, limit) => {
1360
- const merged = []
1361
- const ids = new Set()
968
+ const fitFromChunk = () => {
969
+ const nodes = normalizeList(state.chunk.nodes)
970
+ if (nodes.length === 0) {
971
+ return
972
+ }
1362
973
 
1363
- const push = (node) => {
1364
- if (!node || ids.has(node.id) || merged.length >= limit) {
1365
- return
974
+ let minX = Infinity
975
+ let minY = Infinity
976
+ let maxX = -Infinity
977
+ let maxY = -Infinity
978
+
979
+ for (let index = 0; index < nodes.length; index += 1) {
980
+ const node = nodes[index]
981
+ const x = Number(node[2])
982
+ const y = Number(node[3])
983
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
984
+ continue
1366
985
  }
1367
- ids.add(node.id)
1368
- merged.push(node)
986
+ if (x < minX) minX = x
987
+ if (y < minY) minY = y
988
+ if (x > maxX) maxX = x
989
+ if (y > maxY) maxY = y
1369
990
  }
1370
991
 
1371
- for (let index = 0; index < leftNodes.length && merged.length < limit; index += 1) {
1372
- push(leftNodes[index])
1373
- }
1374
- for (let index = 0; index < rightNodes.length && merged.length < limit; index += 1) {
1375
- push(rightNodes[index])
992
+ if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
993
+ return
1376
994
  }
1377
995
 
1378
- return merged
1379
- }
996
+ const width = Math.max(1, maxX - minX)
997
+ const height = Math.max(1, maxY - minY)
998
+ const scaleX = state.viewport.width / width
999
+ const scaleY = state.viewport.height / height
1000
+ const scale = clampScale(Math.min(scaleX, scaleY) * 0.72)
1001
+
1002
+ state.camera.scale = scale
1003
+ state.camera.x = state.viewport.width / 2 - (minX + width / 2) * scale
1004
+ state.camera.y = state.viewport.height / 2 - (minY + height / 2) * scale
1005
+ updateWorkerCamera()
1006
+ }
1007
+
1008
+ const fetchChunk = async ({ fit } = { fit: false }) => {
1009
+ const token = ++state.fetchToken
1010
+ if (state.fetchAbortController) {
1011
+ state.fetchAbortController.abort()
1012
+ }
1013
+ const controller = new AbortController()
1014
+ state.fetchAbortController = controller
1015
+ const worldTopLeft = screenToWorld(0, 0)
1016
+ const worldBottomRight = screenToWorld(state.viewport.width, state.viewport.height)
1017
+ const x = Math.min(worldTopLeft.x, worldBottomRight.x)
1018
+ const y = Math.min(worldTopLeft.y, worldBottomRight.y)
1019
+ const w = Math.abs(worldBottomRight.x - worldTopLeft.x)
1020
+ const h = Math.abs(worldBottomRight.y - worldTopLeft.y)
1021
+
1022
+ const params = new URLSearchParams({
1023
+ x: String(x),
1024
+ y: String(y),
1025
+ w: String(Math.max(1, w)),
1026
+ h: String(Math.max(1, h)),
1027
+ scale: String(state.camera.scale),
1028
+ nodeBudget: String(getZoomNodeBudget()),
1029
+ edgeBudget: String(getZoomEdgeBudget())
1030
+ })
1380
1031
 
1381
- const selectStableSampleNodes = (sourceNodes, limit) => {
1382
- if (sourceNodes.length <= limit) {
1383
- return sourceNodes
1032
+ if (state.agentId) {
1033
+ params.set('agent', state.agentId)
1384
1034
  }
1385
-
1386
- const now = performance.now()
1387
- const cursorPoint = cursorWorldPoint()
1388
- const recentZoomFocus =
1389
- now - state.lastZoomFocus.at <= 1500
1390
- ? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
1391
- : null
1392
- const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
1393
- const previousIds = new Set(state.renderNodes.map((node) => node.id))
1394
- const preferAnchorDistance = state.visibleNodes.length > massiveGraphNodeThreshold && state.transform.scale >= 0.28
1395
-
1396
- return [...sourceNodes]
1397
- .sort((left, right) => {
1398
- const leftWasVisible = previousIds.has(left.id) ? 1 : 0
1399
- const rightWasVisible = previousIds.has(right.id) ? 1 : 0
1400
- const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
1401
- const rightDistance = Math.hypot(right.x - anchor.x, right.y - anchor.y)
1402
-
1403
- if (preferAnchorDistance) {
1404
- if (leftDistance !== rightDistance) return leftDistance - rightDistance
1405
- if (leftWasVisible !== rightWasVisible) return rightWasVisible - leftWasVisible
1406
- } else {
1407
- if (leftWasVisible !== rightWasVisible) return rightWasVisible - leftWasVisible
1408
- if (leftDistance !== rightDistance) return leftDistance - rightDistance
1409
- }
1410
-
1411
- const leftDegree = state.nodeDegrees.get(left.id) ?? 0
1412
- const rightDegree = state.nodeDegrees.get(right.id) ?? 0
1413
- if (leftDegree !== rightDegree) return rightDegree - leftDegree
1414
-
1415
- return left.id.localeCompare(right.id)
1416
- })
1417
- .slice(0, limit)
1418
- }
1419
-
1420
- const selectAccessBridgeNodes = (sourceNodes, limit) => {
1421
- if (limit <= 0 || sourceNodes.length === 0) {
1422
- return []
1035
+ if (state.contextId) {
1036
+ params.set('context', state.contextId)
1423
1037
  }
1424
1038
 
1425
- const now = performance.now()
1426
- const cursorPoint = cursorWorldPoint()
1427
- const recentZoomFocus =
1428
- now - state.lastZoomFocus.at <= 1200
1429
- ? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
1430
- : null
1431
- const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
1432
- return [...sourceNodes]
1433
- .sort((left, right) => {
1434
- const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
1435
- const rightDistance = Math.hypot(right.x - anchor.x, right.y - anchor.y)
1436
- if (leftDistance !== rightDistance) return leftDistance - rightDistance
1437
- const leftDegree = state.nodeDegrees.get(left.id) ?? 0
1438
- const rightDegree = state.nodeDegrees.get(right.id) ?? 0
1439
- if (leftDegree !== rightDegree) return rightDegree - leftDegree
1440
- return left.id.localeCompare(right.id)
1441
- })
1442
- .slice(0, limit)
1443
- }
1444
-
1445
- const edgeIdentityKey = edge => {
1446
- if (!edge.target) return ''
1447
- const pair = edge.source < edge.target
1448
- ? edge.source + '|' + edge.target
1449
- : edge.target + '|' + edge.source
1450
- return pair + '|' + (edge.inferred ? 'mesh' : 'real')
1451
- }
1452
-
1453
- const edgeRelevanceScore = edge => {
1454
- let score = edgeWeight(edge) * 10
1455
- if (!edge.inferred) {
1456
- score += 8
1039
+ const requestKey = graphStreamRequestKey({ x, y, w, h })
1040
+ if (!fit && state.lastChunkRequestKey === requestKey && state.chunk.nodes.length > 0) {
1041
+ return
1457
1042
  }
1458
1043
 
1459
- const selectedId = state.selected?.id
1460
- if (selectedId && (edge.source === selectedId || edge.target === selectedId)) {
1461
- score += 120
1044
+ const response = await fetch('/api/graph-stream?' + params.toString(), { signal: controller.signal })
1045
+ if (!response.ok) {
1046
+ throw new Error('Failed to fetch graph stream chunk')
1462
1047
  }
1463
1048
 
1464
- const hoveredId = state.hovered?.id
1465
- if (hoveredId && (edge.source === hoveredId || edge.target === hoveredId)) {
1466
- score += 70
1049
+ const chunk = await response.json()
1050
+ if (controller.signal.aborted) {
1051
+ return
1052
+ }
1053
+ if (token !== state.fetchToken) {
1054
+ return
1467
1055
  }
1468
1056
 
1469
- const hubId = state.primaryHub?.id
1470
- if (hubId && (edge.source === hubId || edge.target === hubId)) {
1471
- score += 42
1057
+ state.graphSignature = typeof chunk.signature === 'string' ? chunk.signature : ''
1058
+ state.lastChunkRequestKey = requestKey
1059
+ ensureNodePositionsLoaded()
1060
+ await syncNodePositionsFromServer()
1061
+ state.graphMode = typeof chunk.mode === 'string' ? chunk.mode : 'near'
1062
+ const chunkNodes = applyManualNodePositions(chunk.nodes)
1063
+ state.chunk = {
1064
+ nodes: chunkNodes,
1065
+ edges: normalizeList(chunk.edges)
1066
+ }
1067
+ state.miniMapDirty = true
1068
+ state.spatialIndex.key = ''
1069
+ const renderChunk = { ...chunk, nodes: chunkNodes }
1070
+ state.totals = {
1071
+ nodes: Number.isFinite(chunk?.totals?.nodes) ? Number(chunk.totals.nodes) : state.chunk.nodes.length,
1072
+ edges: Number.isFinite(chunk?.totals?.edges) ? Number(chunk.totals.edges) : state.chunk.edges.length
1472
1073
  }
1473
1074
 
1474
- return score
1475
- }
1075
+ updateTotals()
1076
+ updateTagCount()
1476
1077
 
1477
- const collectVisibleEdgesForNodes = nodeIds => {
1478
- if (nodeIds.size === 0) {
1479
- return []
1078
+ if (fit) {
1079
+ fitFromChunk()
1480
1080
  }
1481
1081
 
1482
- const seen = new Set()
1483
- const candidates = []
1484
- const limit = edgeBudgetForCurrentFrame()
1485
-
1486
- nodeIds.forEach(nodeId => {
1487
- const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
1488
- for (let index = 0; index < candidateEdges.length; index += 1) {
1489
- const edge = candidateEdges[index]
1490
- if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
1491
- continue
1492
- }
1493
- const key = edgeIdentityKey(edge)
1494
- if (seen.has(key)) continue
1082
+ if (state.renderWorker && state.workerReady) {
1083
+ state.renderWorker.postMessage({ type: 'chunk', chunk: renderChunk })
1084
+ state.renderWorker.postMessage({ type: 'select', id: state.selectedNodeId })
1085
+ state.renderWorker.postMessage({ type: 'highlight', ids: Array.from(state.searchResultIds) })
1086
+ }
1495
1087
 
1496
- seen.add(key)
1497
- candidates.push(edge)
1498
- }
1499
- })
1088
+ updateGraphOverlays()
1089
+ drawFallback()
1090
+ }
1500
1091
 
1501
- if (candidates.length <= limit) {
1502
- return candidates
1092
+ const scheduleChunkFetch = ({ fit } = { fit: false }) => {
1093
+ if (state.fetchTimer) {
1094
+ clearTimeout(state.fetchTimer)
1503
1095
  }
1504
1096
 
1505
- return candidates
1506
- .sort((left, right) => {
1507
- const scoreDelta = edgeRelevanceScore(right) - edgeRelevanceScore(left)
1508
- if (scoreDelta !== 0) {
1509
- return scoreDelta
1097
+ const now = performance.now()
1098
+ const recentlyWheeling = now - state.lastWheelAt < 320
1099
+ const heavyScene = state.lastVisibleNodes > 1200 || state.lastVisibleEdges > 3500
1100
+ const delay = fit ? 0 : (state.pointer.down ? 320 : (recentlyWheeling ? (heavyScene ? 420 : 300) : (heavyScene ? 120 : 72)))
1101
+ state.fetchTimer = setTimeout(() => {
1102
+ state.fetchTimer = null
1103
+ fetchChunk({ fit }).catch((error) => {
1104
+ if (error && error.name === 'AbortError') {
1105
+ return
1510
1106
  }
1511
- const leftKey = edgeIdentityKey(left)
1512
- const rightKey = edgeIdentityKey(right)
1513
- return leftKey.localeCompare(rightKey)
1107
+ console.error(error)
1514
1108
  })
1515
- .slice(0, limit)
1109
+ }, delay)
1516
1110
  }
1517
1111
 
1518
- const edgeOpacityForScale = (edge, scale) => {
1519
- if (edge.inferred) {
1520
- if (scale < 0.2) return 0.06
1521
- if (scale < 0.4) return 0.08
1522
- if (scale < 0.7) return 0.1
1523
- return 0.14
1524
- }
1525
-
1526
- if (scale < 0.2) return 0.14
1527
- if (scale < 0.4) return 0.2
1528
- if (scale < 0.7) return 0.28
1529
- if (scale < 1.05) return 0.36
1530
- return 0.46
1112
+ const setViewportFromCanvas = () => {
1113
+ const rect = canvas.getBoundingClientRect()
1114
+ state.viewport.width = Math.max(320, rect.width)
1115
+ state.viewport.height = Math.max(320, rect.height)
1116
+ state.viewport.ratio = window.devicePixelRatio || 1
1117
+ state.miniMapDirty = true
1118
+ updateWorkerSize()
1119
+ drawFallback()
1531
1120
  }
1532
1121
 
1533
- const edgeStrokeFor = (edge, selectedEdge) => {
1534
- if (selectedEdge) {
1535
- return graphTheme.edgeActive
1122
+ const pickFallbackNode = (screenX, screenY) => {
1123
+ const nodes = spatialCandidates(screenX, screenY)
1124
+ if (nodes.length === 0) {
1125
+ return null
1536
1126
  }
1537
1127
 
1538
- const opacity = edgeOpacityForScale(edge, state.transform.scale)
1539
- return edge.inferred
1540
- ? 'rgba(203, 213, 225, ' + opacity + ')'
1541
- : 'rgba(153, 165, 181, ' + opacity + ')'
1542
- }
1543
-
1544
- const edgeWidthFor = (edge, selectedEdge) => {
1545
- if (edge.inferred) {
1546
- return selectedEdge ? 1.22 : 0.84
1128
+ let bestNode = null
1129
+ let bestDistance = Infinity
1130
+ for (let index = 0; index < nodes.length; index += 1) {
1131
+ const node = nodes[index]
1132
+ const id = typeof node[0] === 'string' ? node[0] : ''
1133
+ if (!id) continue
1134
+ const x = Number(node[2])
1135
+ const y = Number(node[3])
1136
+ const weight = Number(node[7])
1137
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue
1138
+ const point = worldToScreen(x, y)
1139
+ const radius = Math.max(3.2, Math.min(16.5, 5 + (Number.isFinite(weight) ? weight : 0) * 0.65))
1140
+ const distance = Math.hypot(screenX - point.x, screenY - point.y)
1141
+ if (distance <= radius && distance < bestDistance) {
1142
+ bestDistance = distance
1143
+ bestNode = node
1144
+ }
1547
1145
  }
1548
1146
 
1549
- return (selectedEdge ? 1.9 : 1.05) + Math.min(edgeWeight(edge) - 1, 8) * 0.24
1147
+ return bestNode
1550
1148
  }
1551
1149
 
1552
- const drawGraphEdge = (edge) => {
1553
- const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
1554
- ctx.beginPath()
1555
- ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
1556
- ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
1557
- ctx.strokeStyle = edgeStrokeFor(edge, selectedEdge)
1558
- ctx.lineWidth = edgeWidthFor(edge, selectedEdge)
1559
- ctx.stroke()
1150
+ const pickFallbackNodeId = (screenX, screenY) => {
1151
+ const node = pickFallbackNode(screenX, screenY)
1152
+ return typeof node?.[0] === 'string' ? node[0] : ''
1560
1153
  }
1561
1154
 
1562
- const drawEdgeBatch = (edges, options) => {
1563
- if (edges.length === 0) {
1155
+ const handlePickedNode = (node) => {
1156
+ const nodeId = typeof node?.id === 'string' ? node.id : typeof node?.[0] === 'string' ? node[0] : ''
1157
+ if (!nodeId) {
1564
1158
  return
1565
1159
  }
1566
1160
 
1567
- ctx.beginPath()
1568
- for (let index = 0; index < edges.length; index += 1) {
1569
- const edge = edges[index]
1570
- ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
1571
- ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
1161
+ const kind = typeof node?.kind === 'string' ? node.kind : nodeKind(node)
1162
+ if (kind === 'cluster') {
1163
+ const currentScale = state.camera.scale
1164
+ const targetScale = currentScale < 0.22 ? 0.28 : Math.min(1.1, currentScale * 1.6)
1165
+ focusNodeInViewport(nodeId, targetScale)
1166
+ return
1572
1167
  }
1573
- ctx.strokeStyle = options.strokeStyle
1574
- ctx.lineWidth = options.lineWidth
1575
- ctx.stroke()
1576
- }
1577
-
1578
- const regularEdgeBatchOptions = (edge) => ({
1579
- strokeStyle: edgeStrokeFor(edge, false),
1580
- lineWidth: edgeWidthFor(edge, false)
1581
- })
1582
1168
 
1583
- const regularEdgeBatchKey = (edge) => {
1584
- const options = regularEdgeBatchOptions(edge)
1585
- return options.strokeStyle + '|' + options.lineWidth.toFixed(2)
1169
+ loadNodeDetails(nodeId).catch((error) => console.error(error))
1586
1170
  }
1587
1171
 
1588
- const drawGraphEdges = () => {
1589
- const edgeBatches = new Map()
1590
- const selectedEdges = []
1591
-
1592
- for (let index = 0; index < state.renderEdges.length; index += 1) {
1593
- const edge = state.renderEdges[index]
1594
- const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
1595
- if (isSelected) {
1596
- selectedEdges.push(edge)
1597
- continue
1598
- }
1599
-
1600
- const key = regularEdgeBatchKey(edge)
1601
- const batch = edgeBatches.get(key)
1602
- if (batch) {
1603
- batch.edges.push(edge)
1604
- } else {
1605
- edgeBatches.set(key, {
1606
- edges: [edge],
1607
- options: regularEdgeBatchOptions(edge)
1608
- })
1172
+ const pickAt = (screenX, screenY) => {
1173
+ if (state.rendererMode === 'fallback') {
1174
+ const node = pickFallbackNode(screenX, screenY)
1175
+ if (node) {
1176
+ handlePickedNode(node)
1609
1177
  }
1178
+ return
1610
1179
  }
1611
1180
 
1612
- edgeBatches.forEach((batch) => drawEdgeBatch(batch.edges, batch.options))
1613
-
1614
- for (let index = 0; index < selectedEdges.length; index += 1) {
1615
- drawGraphEdge(selectedEdges[index])
1181
+ if (!state.renderWorker || !state.workerReady) {
1182
+ return
1616
1183
  }
1617
- }
1618
-
1619
- const shouldDrawNodeLabels = (node, isSelected, isHovered) =>
1620
- isSelected ||
1621
- isHovered ||
1622
- (state.nodes.length > largeGraphNodeThreshold && state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) ||
1623
- (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
1624
1184
 
1625
- const drawSingleNode = (node, options = { drawLabel: true }) => {
1626
- const radius = nodeRadius(node)
1627
- const isSelected = state.selected?.id === node.id
1628
- const isHovered = state.hovered?.id === node.id
1629
- ctx.beginPath()
1630
- ctx.arc(node.x, node.y, radius + (isSelected ? 7 : isHovered ? 4 : 0), 0, Math.PI * 2)
1631
- ctx.fillStyle = isSelected || isHovered ? graphTheme.nodeHaloActive : graphTheme.nodeHalo
1632
- ctx.fill()
1633
- ctx.beginPath()
1634
- ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
1635
- ctx.fillStyle = isSelected ? graphTheme.nodeSelected : isHovered ? graphTheme.nodeHover : graphTheme.node
1636
- ctx.fill()
1637
- ctx.lineWidth = isSelected ? 2.6 : 1.5
1638
- ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
1639
- ctx.stroke()
1185
+ const requestId = Math.random().toString(36).slice(2)
1186
+ state.renderWorker.postMessage({
1187
+ type: 'pick',
1188
+ requestId,
1189
+ x: screenX,
1190
+ y: screenY
1191
+ })
1192
+ }
1640
1193
 
1641
- if (options.drawLabel && shouldDrawNodeLabels(node, isSelected, isHovered)) {
1642
- ctx.fillStyle = graphTheme.label
1643
- ctx.font = '12px Inter, system-ui, sans-serif'
1644
- ctx.textAlign = 'center'
1645
- ctx.textBaseline = 'top'
1646
- ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
1647
- }
1194
+ const zoomAtPoint = (screenX, screenY, factor) => {
1195
+ const clamped = Math.max(0.92, Math.min(1.09, factor))
1196
+ const before = screenToWorld(screenX, screenY)
1197
+ state.camera.scale = clampScale(state.camera.scale * clamped)
1198
+ state.camera.x = screenX - before.x * state.camera.scale
1199
+ state.camera.y = screenY - before.y * state.camera.scale
1200
+ updateWorkerCamera()
1201
+ scheduleChunkFetch()
1648
1202
  }
1649
1203
 
1650
- const drawNodeBatch = (nodes) => {
1651
- if (nodes.length === 0) {
1652
- return
1653
- }
1204
+ const resolvePointer = (event) => {
1205
+ const rect = canvas.getBoundingClientRect()
1206
+ return {
1207
+ x: event.clientX - rect.left,
1208
+ y: event.clientY - rect.top
1209
+ }
1210
+ }
1211
+
1212
+ const setupInput = () => {
1213
+ const dragActivationDistance = 6
1214
+ const resetPointerState = (pointerId = null) => {
1215
+ state.pointer.down = false
1216
+ state.pointer.dragging = false
1217
+ state.pointer.dragNodeId = ''
1218
+ canvas.classList.remove('is-node-dragging')
1219
+ if (pointerId !== null) {
1220
+ try {
1221
+ if (canvas.hasPointerCapture(pointerId)) {
1222
+ canvas.releasePointerCapture(pointerId)
1223
+ }
1224
+ } catch {}
1225
+ }
1226
+ }
1227
+
1228
+ canvas.addEventListener('wheel', (event) => {
1229
+ event.preventDefault()
1230
+ state.lastWheelAt = performance.now()
1231
+ const pointer = resolvePointer(event)
1232
+ const exponent = Math.max(-0.05, Math.min(0.05, -event.deltaY * 0.001))
1233
+ zoomAtPoint(pointer.x, pointer.y, Math.exp(exponent))
1234
+ }, { passive: false })
1235
+
1236
+ canvas.addEventListener('pointerdown', (event) => {
1237
+ event.preventDefault()
1238
+ const pointer = resolvePointer(event)
1239
+ const candidateNode = pickFallbackNode(pointer.x, pointer.y)
1240
+ const candidateNodeId = isRealGraphNode(candidateNode) && typeof candidateNode?.[0] === 'string' ? candidateNode[0] : ''
1241
+ const candidateX = Number(candidateNode?.[2])
1242
+ const candidateY = Number(candidateNode?.[3])
1243
+ const world = screenToWorld(pointer.x, pointer.y)
1244
+ state.pointer.down = true
1245
+ state.pointer.moved = false
1246
+ state.pointer.dragging = false
1247
+ state.pointer.dragNodeId = candidateNodeId
1248
+ state.pointer.x = pointer.x
1249
+ state.pointer.y = pointer.y
1250
+ state.pointer.startX = pointer.x
1251
+ state.pointer.startY = pointer.y
1252
+ state.pointer.startWorldX = world.x
1253
+ state.pointer.startWorldY = world.y
1254
+ state.pointer.nodeStartX = candidateNodeId && Number.isFinite(candidateX) ? candidateX : 0
1255
+ state.pointer.nodeStartY = candidateNodeId && Number.isFinite(candidateY) ? candidateY : 0
1256
+ state.pointer.worldAnchorX = world.x
1257
+ state.pointer.worldAnchorY = world.y
1258
+ try {
1259
+ canvas.setPointerCapture(event.pointerId)
1260
+ } catch {}
1261
+ })
1262
+
1263
+ canvas.addEventListener('pointermove', (event) => {
1264
+ if (state.pointer.down) {
1265
+ event.preventDefault()
1266
+ }
1267
+ const pointer = resolvePointer(event)
1654
1268
 
1655
- const drawHalos = state.renderNodes.length <= 1200 || state.transform.scale >= 0.45
1656
- if (drawHalos) {
1657
- ctx.beginPath()
1658
- for (let index = 0; index < nodes.length; index += 1) {
1659
- const node = nodes[index]
1660
- ctx.moveTo(node.x + nodeRadius(node) + 3, node.y)
1661
- ctx.arc(node.x, node.y, nodeRadius(node) + 3, 0, Math.PI * 2)
1269
+ if (state.pointer.down) {
1270
+ const dx = pointer.x - state.pointer.x
1271
+ const dy = pointer.y - state.pointer.y
1272
+ const distanceFromStart = Math.hypot(pointer.x - state.pointer.startX, pointer.y - state.pointer.startY)
1273
+ if (distanceFromStart >= dragActivationDistance) {
1274
+ state.pointer.moved = true
1275
+ state.pointer.dragging = true
1276
+ canvas.classList.toggle('is-node-dragging', Boolean(state.pointer.dragNodeId))
1277
+ }
1278
+ if (!state.pointer.dragging) {
1279
+ state.pointer.x = pointer.x
1280
+ state.pointer.y = pointer.y
1281
+ return
1282
+ }
1283
+ if (state.pointer.dragNodeId) {
1284
+ const world = screenToWorld(pointer.x, pointer.y)
1285
+ const x = state.pointer.nodeStartX + world.x - state.pointer.startWorldX
1286
+ const y = state.pointer.nodeStartY + world.y - state.pointer.startWorldY
1287
+ state.nodePositions.set(state.pointer.dragNodeId, { x, y })
1288
+ updateNodePositionInChunk(state.pointer.dragNodeId, x, y)
1289
+ state.pointer.x = pointer.x
1290
+ state.pointer.y = pointer.y
1291
+ drawFallback()
1292
+ return
1293
+ }
1294
+ state.camera.x += dx
1295
+ state.camera.y += dy
1296
+ state.pointer.x = pointer.x
1297
+ state.pointer.y = pointer.y
1298
+ updateWorkerCamera()
1299
+ drawFallback()
1300
+ return
1662
1301
  }
1663
- ctx.fillStyle = graphTheme.nodeHalo
1664
- ctx.fill()
1665
- }
1666
1302
 
1667
- ctx.beginPath()
1668
- for (let index = 0; index < nodes.length; index += 1) {
1669
- const node = nodes[index]
1670
- const radius = nodeRadius(node)
1671
- ctx.moveTo(node.x + radius, node.y)
1672
- ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
1673
- }
1674
- ctx.fillStyle = graphTheme.node
1675
- ctx.fill()
1676
- ctx.lineWidth = 1.25
1677
- ctx.strokeStyle = graphTheme.nodeStroke
1678
- ctx.stroke()
1679
- }
1680
-
1681
- const drawGraphNodes = () => {
1682
- const regularNodes = []
1683
- const priorityNodes = []
1684
-
1685
- for (let index = 0; index < state.renderNodes.length; index += 1) {
1686
- const node = state.renderNodes[index]
1687
- const isPriority =
1688
- state.selected?.id === node.id ||
1689
- state.hovered?.id === node.id
1690
- if (isPriority) {
1691
- priorityNodes.push(node)
1303
+ const hovered = pickFallbackNode(pointer.x, pointer.y)
1304
+ const hoveredId = isRealGraphNode(hovered) && typeof hovered?.[0] === 'string' ? hovered[0] : ''
1305
+ if (state.hoveredNodeId !== hoveredId) {
1306
+ state.hoveredNodeId = hoveredId
1307
+ canvas.classList.toggle('is-node-hover', Boolean(hoveredId))
1308
+ updateGraphOverlays()
1309
+ }
1310
+ if (hoveredId) {
1311
+ showTooltip(hovered, pointer)
1692
1312
  } else {
1693
- regularNodes.push(node)
1313
+ hideTooltip()
1694
1314
  }
1695
- }
1315
+ })
1696
1316
 
1697
- drawNodeBatch(regularNodes)
1317
+ canvas.addEventListener('pointerup', (event) => {
1318
+ const pointer = resolvePointer(event)
1319
+ const distanceFromStart = Math.hypot(pointer.x - state.pointer.startX, pointer.y - state.pointer.startY)
1320
+ const shouldPick = !state.pointer.dragging && distanceFromStart < dragActivationDistance
1321
+ const shouldRefreshAfterDrag = state.pointer.dragging
1322
+ const shouldPersistNodePosition = state.pointer.dragging && Boolean(state.pointer.dragNodeId)
1323
+ resetPointerState(event.pointerId)
1698
1324
 
1699
- if (state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) {
1700
- ctx.fillStyle = graphTheme.label
1701
- ctx.font = '12px Inter, system-ui, sans-serif'
1702
- ctx.textAlign = 'center'
1703
- ctx.textBaseline = 'top'
1704
- for (let index = 0; index < regularNodes.length; index += 1) {
1705
- const node = regularNodes[index]
1706
- ctx.fillText(node.title.slice(0, 34), node.x, node.y + nodeRadius(node) + 8)
1325
+ if (shouldPick) {
1326
+ pickAt(pointer.x, pointer.y)
1327
+ return
1707
1328
  }
1708
- }
1329
+ if (shouldPersistNodePosition) {
1330
+ writeStoredNodePositions()
1331
+ persistNodePositionsToServer()
1332
+ return
1333
+ }
1334
+ if (shouldRefreshAfterDrag) {
1335
+ scheduleChunkFetch()
1336
+ }
1337
+ })
1709
1338
 
1710
- priorityNodes.forEach(node => drawSingleNode(node))
1711
- }
1339
+ canvas.addEventListener('pointerleave', () => {
1340
+ state.hoveredNodeId = ''
1341
+ canvas.classList.remove('is-node-hover')
1342
+ hideTooltip()
1343
+ updateGraphOverlays()
1344
+ })
1712
1345
 
1713
- const partitionGraphForAcceleratedRenderer = () => {
1714
- const regularNodes = []
1715
- const priorityNodes = []
1716
- const regularEdges = []
1717
- const inferredEdges = []
1718
- const selectedEdges = []
1346
+ canvas.addEventListener('pointercancel', (event) => {
1347
+ resetPointerState(event.pointerId)
1348
+ hideTooltip()
1349
+ updateGraphOverlays()
1350
+ })
1719
1351
 
1720
- for (let index = 0; index < state.renderNodes.length; index += 1) {
1721
- const node = state.renderNodes[index]
1722
- const isPriority =
1723
- state.selected?.id === node.id ||
1724
- state.hovered?.id === node.id
1725
- if (isPriority) {
1726
- priorityNodes.push(node)
1727
- } else {
1728
- regularNodes.push(node)
1729
- }
1730
- }
1352
+ canvas.addEventListener('lostpointercapture', () => {
1353
+ resetPointerState()
1354
+ })
1731
1355
 
1732
- for (let index = 0; index < state.renderEdges.length; index += 1) {
1733
- const edge = state.renderEdges[index]
1734
- const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
1735
- if (isSelected) {
1736
- selectedEdges.push(edge)
1737
- } else if (edge.inferred) {
1738
- inferredEdges.push(edge)
1739
- } else {
1740
- regularEdges.push(edge)
1356
+ elements.miniMap.addEventListener('click', (event) => {
1357
+ if (!state.miniMapView) {
1358
+ return
1741
1359
  }
1742
- }
1360
+ const rect = elements.miniMap.getBoundingClientRect()
1361
+ const x = event.clientX - rect.left
1362
+ const y = event.clientY - rect.top
1363
+ const worldX = state.miniMapView.minX + (x - state.miniMapView.offsetX) / state.miniMapView.scale
1364
+ const worldY = state.miniMapView.minY + (y - state.miniMapView.offsetY) / state.miniMapView.scale
1365
+ state.camera.x = state.viewport.width / 2 - worldX * state.camera.scale
1366
+ state.camera.y = state.viewport.height / 2 - worldY * state.camera.scale
1367
+ updateWorkerCamera()
1368
+ scheduleChunkFetch()
1369
+ })
1370
+
1371
+ canvas.addEventListener('dblclick', (event) => {
1372
+ const pointer = resolvePointer(event)
1373
+ zoomAtPoint(pointer.x, pointer.y, 1.065)
1374
+ })
1743
1375
 
1744
- return { regularNodes, priorityNodes, regularEdges, inferredEdges, selectedEdges }
1376
+ window.addEventListener('keydown', (event) => {
1377
+ if (event.key === 'Escape' && !elements.contentDialog.hidden) {
1378
+ closeContentDialog()
1379
+ return
1380
+ }
1381
+ if (event.key === '+') {
1382
+ zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
1383
+ return
1384
+ }
1385
+ if (event.key === '-') {
1386
+ zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
1387
+ return
1388
+ }
1389
+ if (event.key === '0') {
1390
+ scheduleChunkFetch({ fit: true })
1391
+ }
1392
+ })
1745
1393
  }
1746
1394
 
1747
- const drawGraphLabels = nodes => {
1748
- if (!(state.transform.scale >= 0.62 && state.renderNodes.length <= 1200)) {
1749
- return
1750
- }
1395
+ const setupControls = () => {
1396
+ elements.zoomIn.addEventListener('click', () => {
1397
+ zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
1398
+ })
1751
1399
 
1752
- ctx.fillStyle = graphTheme.label
1753
- ctx.font = '12px Inter, system-ui, sans-serif'
1754
- ctx.textAlign = 'center'
1755
- ctx.textBaseline = 'top'
1756
- for (let index = 0; index < nodes.length; index += 1) {
1757
- const node = nodes[index]
1758
- ctx.fillText(node.title.slice(0, 34), node.x, node.y + nodeRadius(node) + 8)
1759
- }
1760
- }
1400
+ elements.zoomOut.addEventListener('click', () => {
1401
+ zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
1402
+ })
1761
1403
 
1762
- const drawAcceleratedGraph = (width, height, drawEdges) => {
1763
- if (!webGlRenderer || state.renderClusters.length > 0) {
1764
- return false
1765
- }
1404
+ elements.fit.addEventListener('click', () => {
1405
+ fitFromChunk()
1406
+ scheduleChunkFetch()
1407
+ })
1766
1408
 
1767
- const graphParts = partitionGraphForAcceleratedRenderer()
1768
- const scale = state.transform.scale
1769
- webGlRenderer.clear(width, height)
1770
- if (drawEdges) {
1771
- webGlRenderer.drawLines(
1772
- graphParts.regularEdges,
1773
- rgba('rgb(153, 165, 181)', edgeOpacityForScale({ inferred: false }, scale)),
1774
- width,
1775
- height
1776
- )
1777
- webGlRenderer.drawLines(
1778
- graphParts.inferredEdges,
1779
- rgba('rgb(203, 213, 225)', edgeOpacityForScale({ inferred: true }, scale)),
1780
- width,
1781
- height
1782
- )
1783
- }
1784
- webGlRenderer.drawPoints(
1785
- graphParts.regularNodes,
1786
- rgba(graphTheme.nodeHalo, 0.28),
1787
- node => Math.max((nodeRadius(node) + 3) * state.transform.scale * 2, 1.5),
1788
- width,
1789
- height
1790
- )
1791
- webGlRenderer.drawPoints(
1792
- graphParts.regularNodes,
1793
- rgba(graphTheme.node, 1),
1794
- node => Math.max(nodeRadius(node) * state.transform.scale * 2, 1.2),
1795
- width,
1796
- height
1797
- )
1798
-
1799
- ctx.save()
1800
- ctx.translate(state.transform.x, state.transform.y)
1801
- ctx.scale(state.transform.scale, state.transform.scale)
1802
- if (drawEdges) {
1803
- graphParts.selectedEdges.forEach(edge => drawGraphEdge(edge))
1804
- }
1805
- drawGraphLabels(graphParts.regularNodes)
1806
- graphParts.priorityNodes.forEach(node => drawSingleNode(node))
1807
- ctx.restore()
1409
+ elements.releaseNode.addEventListener('click', () => {
1410
+ releaseSelectedNodePosition()
1411
+ })
1808
1412
 
1809
- return true
1810
- }
1413
+ elements.reset.addEventListener('click', () => {
1414
+ clearStoredNodePositions()
1415
+ clearNodePositionsOnServer()
1416
+ state.camera = { x: 0, y: 0, scale: 0.22 }
1417
+ updateWorkerCamera()
1418
+ scheduleChunkFetch({ fit: true })
1419
+ })
1811
1420
 
1812
- const edgePairKey = (source, target) =>
1813
- source < target ? source + '|' + target : target + '|' + source
1421
+ elements.contentClose.addEventListener('click', () => {
1422
+ closeContentDialog()
1423
+ })
1814
1424
 
1815
- const meshNeighborBuckets = (nodes, cellSize) => {
1816
- const buckets = new Map()
1425
+ elements.contentDialog.addEventListener('click', (event) => {
1426
+ if (event.target === elements.contentDialog) {
1427
+ closeContentDialog()
1428
+ }
1429
+ })
1817
1430
 
1818
- for (let index = 0; index < nodes.length; index += 1) {
1819
- const node = nodes[index]
1820
- const cellX = Math.floor(node.x / cellSize)
1821
- const cellY = Math.floor(node.y / cellSize)
1822
- const key = cellX + ':' + cellY
1823
- const bucket = buckets.get(key)
1824
- if (bucket) {
1825
- bucket.push(node)
1826
- } else {
1827
- buckets.set(key, [node])
1431
+ elements.search.addEventListener('input', () => {
1432
+ if (state.searchTimer) {
1433
+ clearTimeout(state.searchTimer)
1828
1434
  }
1829
- }
1830
-
1831
- return buckets
1832
- }
1833
-
1834
- const meshCandidatesForNode = (node, buckets, cellSize) => {
1835
- const cellX = Math.floor(node.x / cellSize)
1836
- const cellY = Math.floor(node.y / cellSize)
1837
- const candidates = []
1838
-
1839
- for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
1840
- for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
1841
- const bucket = buckets.get((cellX + offsetX) + ':' + (cellY + offsetY))
1842
- if (!bucket) continue
1843
- for (let index = 0; index < bucket.length; index += 1) {
1844
- const candidate = bucket[index]
1845
- if (candidate.id !== node.id) {
1846
- candidates.push(candidate)
1847
- }
1848
- }
1849
- }
1850
- }
1851
-
1852
- return candidates
1435
+ state.searchTimer = setTimeout(() => {
1436
+ state.searchTimer = null
1437
+ runGraphSearch().catch((error) => console.error(error))
1438
+ }, 160)
1439
+ })
1853
1440
  }
1854
1441
 
1855
- const buildMeshEdgesForNodes = (nodes, existingEdges) => {
1856
- if (nodes.length < 2 || state.transform.scale < meshEdgeScaleThreshold) {
1857
- return []
1858
- }
1859
-
1860
- const existingKeys = new Set()
1861
- for (let index = 0; index < existingEdges.length; index += 1) {
1862
- const edge = existingEdges[index]
1863
- if (edge.target) {
1864
- existingKeys.add(edgePairKey(edge.source, edge.target))
1865
- }
1866
- }
1867
-
1868
- const desiredBudget = Math.min(
1869
- meshEdgeMaxBudget,
1870
- Math.max(meshEdgeMinBudget, Math.floor(edgeBudgetForCurrentFrame() * 0.62))
1871
- )
1872
- const perNodeNeighborCount =
1873
- state.transform.scale >= 1.05 ? 4
1874
- : state.transform.scale >= 0.62 ? 3
1875
- : 2
1876
- const cellSize = Math.max(120, 280 / Math.max(state.transform.scale, 0.0001))
1877
- const maxDistance = 980
1878
- const maxDistanceSquared = maxDistance * maxDistance
1879
- const buckets = meshNeighborBuckets(nodes, cellSize)
1880
- const meshEdges = []
1881
- const meshKeys = new Set()
1882
-
1883
- for (let index = 0; index < nodes.length && meshEdges.length < desiredBudget; index += 1) {
1884
- const node = nodes[index]
1885
- const candidates = meshCandidatesForNode(node, buckets, cellSize)
1886
- .map((candidate) => ({
1887
- node: candidate,
1888
- distanceSquared: (candidate.x - node.x) ** 2 + (candidate.y - node.y) ** 2
1889
- }))
1890
- .filter((candidate) => candidate.distanceSquared <= maxDistanceSquared)
1891
- .sort((left, right) => left.distanceSquared - right.distanceSquared)
1892
-
1893
- let linked = 0
1894
- for (let candidateIndex = 0; candidateIndex < candidates.length && linked < perNodeNeighborCount && meshEdges.length < desiredBudget; candidateIndex += 1) {
1895
- const candidate = candidates[candidateIndex].node
1896
- const key = edgePairKey(node.id, candidate.id)
1897
- if (existingKeys.has(key) || meshKeys.has(key)) {
1898
- continue
1899
- }
1900
-
1901
- meshKeys.add(key)
1902
- meshEdges.push({
1903
- source: node.id,
1904
- target: candidate.id,
1905
- targetTitle: candidate.title,
1906
- weight: 1,
1907
- priority: 'normal',
1908
- sourceNode: node,
1909
- targetNode: candidate,
1910
- inferred: true
1911
- })
1912
- linked += 1
1442
+ const runGraphSearch = async () => {
1443
+ const token = ++state.searchToken
1444
+ const query = (elements.search.value || '').trim()
1445
+ if (!query) {
1446
+ state.searchResultIds = new Set()
1447
+ setFocusedNodeIds(new Set())
1448
+ if (state.renderWorker && state.workerReady) {
1449
+ state.renderWorker.postMessage({ type: 'highlight', ids: [] })
1913
1450
  }
1451
+ return
1914
1452
  }
1915
1453
 
1916
- return meshEdges
1917
- }
1918
-
1919
- const withMeshEdges = (nodes, edges) => {
1920
- if (nodes.length === 0 || state.visibleNodes.length <= largeGraphNodeThreshold || state.transform.scale < meshEdgeScaleThreshold) {
1921
- return edges
1922
- }
1923
-
1924
- const meshEdges = buildMeshEdgesForNodes(nodes, edges)
1925
- return meshEdges.length > 0 ? edges.concat(meshEdges) : edges
1926
- }
1927
-
1928
- const fallbackViewportNodes = () => {
1929
- const nodes = []
1930
- const maxNodes = Math.min(renderNodeBudget, 220)
1931
- const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
1932
-
1933
- for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
1934
- nodes.push(state.visibleNodes[index])
1935
- }
1936
-
1937
- if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
1938
- nodes.push(state.selected)
1939
- }
1940
-
1941
- return nodes
1942
- }
1943
-
1944
- const sampleVisibleNodes = (limit = renderNodeBudget, sourceNodes = state.visibleNodes) => {
1945
- if (sourceNodes.length === 0 || limit <= 0) {
1946
- return []
1947
- }
1948
-
1949
- const nodes = []
1950
- const maxNodes = Math.min(Math.max(limit, 1), sourceNodes.length)
1951
- const step = Math.max(1, Math.ceil(sourceNodes.length / maxNodes))
1952
-
1953
- for (let index = 0; index < sourceNodes.length && nodes.length < maxNodes; index += step) {
1954
- nodes.push(sourceNodes[index])
1955
- }
1956
-
1957
- if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
1958
- nodes.push(state.selected)
1454
+ const response = await fetch('/api/graph-filter?q=' + encodeURIComponent(query) + '&limit=1800' + scopeQuery('&'))
1455
+ if (!response.ok) {
1456
+ throw new Error('Failed to search graph')
1959
1457
  }
1960
-
1961
- return nodes
1962
- }
1963
-
1964
- const enrichSampleWithNeighbors = (nodes) => {
1965
- if (nodes.length === 0) {
1966
- return {
1967
- nodes,
1968
- edges: []
1969
- }
1458
+ const payload = await response.json()
1459
+ if (token !== state.searchToken) {
1460
+ return
1970
1461
  }
1971
1462
 
1972
- const maxNodes = Math.min(renderNodeBudget, nodes.length + 200)
1973
- const expanded = [...nodes]
1974
- const ids = new Set(expanded.map((node) => node.id))
1975
-
1976
- for (let index = 0; index < nodes.length && expanded.length < maxNodes; index += 1) {
1977
- const node = nodes[index]
1978
- const candidates = [...(state.visibleEdgeByNode.get(node.id) ?? [])]
1979
- .filter((edge) => edge.target)
1980
- .sort((left, right) => edgeWeight(right) - edgeWeight(left))
1981
- .slice(0, 3)
1982
-
1983
- for (let candidateIndex = 0; candidateIndex < candidates.length && expanded.length < maxNodes; candidateIndex += 1) {
1984
- const edge = candidates[candidateIndex]
1985
- const otherId = edge.source === node.id ? edge.target : edge.source
1986
-
1987
- if (!otherId || ids.has(otherId)) {
1988
- continue
1989
- }
1990
-
1991
- const otherNode = state.nodeById.get(otherId)
1992
- if (!otherNode) {
1993
- continue
1994
- }
1995
-
1996
- ids.add(otherId)
1997
- expanded.push(otherNode)
1998
- }
1463
+ const ids = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter((id) => typeof id === 'string' && id.length > 0) : []
1464
+ state.searchResultIds = new Set(ids)
1465
+ setFocusedNodeIds(state.searchResultIds)
1466
+ if (state.renderWorker && state.workerReady) {
1467
+ state.renderWorker.postMessage({ type: 'highlight', ids })
1999
1468
  }
2000
-
2001
- const edges = collectVisibleEdgesForNodes(ids)
2002
-
2003
- return {
2004
- nodes: expanded,
2005
- edges
1469
+ if (ids.length > 0 && state.graphMode === 'far') {
1470
+ state.camera.scale = Math.max(state.camera.scale, 0.82)
1471
+ updateWorkerCamera()
1472
+ scheduleChunkFetch()
2006
1473
  }
2007
1474
  }
2008
1475
 
2009
- const includeHubPreviewNeighborhood = (nodes, limit) => {
2010
- const hub = state.primaryHub
2011
- if (!hub) {
2012
- return nodes
2013
- }
2014
-
2015
- const maxNodes = Math.max(1, Math.min(renderNodeBudget, limit))
2016
- const merged = [...nodes]
2017
- const ids = new Set(merged.map((node) => node.id))
2018
- const protectedIds = new Set()
2019
-
2020
- if (!ids.has(hub.id)) {
2021
- if (merged.length < maxNodes) {
2022
- merged.push(hub)
2023
- ids.add(hub.id)
2024
- } else {
2025
- const replaceIndex = merged.findIndex((node) => node.id !== hub.id)
2026
- if (replaceIndex >= 0) {
2027
- ids.delete(merged[replaceIndex].id)
2028
- merged[replaceIndex] = hub
2029
- ids.add(hub.id)
2030
- }
2031
- }
1476
+ const loadAgents = async () => {
1477
+ const response = await fetch('/api/agents')
1478
+ if (!response.ok) {
1479
+ throw new Error('Failed to load agents')
2032
1480
  }
2033
- protectedIds.add(hub.id)
2034
1481
 
2035
- const hubEdges = [...(state.visibleEdgeByNode.get(hub.id) ?? [])]
2036
- .filter((edge) => edge.target && (edge.source === hub.id || edge.target === hub.id))
2037
- .sort((left, right) => {
2038
- const byWeight = edgeWeight(right) - edgeWeight(left)
2039
- if (byWeight !== 0) return byWeight
2040
-
2041
- const leftOtherId = left.source === hub.id ? left.target : left.source
2042
- const rightOtherId = right.source === hub.id ? right.target : right.source
2043
- const leftDegree = state.nodeDegrees.get(leftOtherId ?? '') ?? 0
2044
- const rightDegree = state.nodeDegrees.get(rightOtherId ?? '') ?? 0
2045
- if (leftDegree !== rightDegree) return rightDegree - leftDegree
2046
-
2047
- return edgeIdentityKey(left).localeCompare(edgeIdentityKey(right))
1482
+ const payload = await response.json()
1483
+ const agents = Array.isArray(payload?.agents) ? payload.agents : []
1484
+
1485
+ elements.agent.innerHTML = agents
1486
+ .map((agent) => {
1487
+ const id = String(agent?.id || '')
1488
+ const count = Number.isFinite(agent?.documentCount) ? agent.documentCount : 0
1489
+ const label = id === 'shared' ? 'shared' : id
1490
+ return '<option value="' + escapeHtml(id) + '">' + escapeHtml(label) + ' (' + count + ')</option>'
2048
1491
  })
1492
+ .join('')
2049
1493
 
2050
- for (let index = 0; index < hubEdges.length && merged.length < maxNodes; index += 1) {
2051
- const edge = hubEdges[index]
2052
- const otherId = edge.source === hub.id ? edge.target : edge.source
2053
- if (!otherId || ids.has(otherId)) {
2054
- continue
2055
- }
1494
+ const preferredAgent = initialAgentFromUrl || readStoredAgent()
1495
+ const hasPreferred = preferredAgent && agents.some((agent) => agent?.id === preferredAgent)
1496
+ state.agentId = hasPreferred ? preferredAgent : String(agents[0]?.id || '')
1497
+ elements.agent.value = state.agentId
2056
1498
 
2057
- const otherNode = state.nodeById.get(otherId)
2058
- if (!otherNode) {
2059
- continue
2060
- }
2061
-
2062
- if (merged.length < maxNodes) {
2063
- ids.add(otherId)
2064
- merged.push(otherNode)
2065
- protectedIds.add(otherId)
2066
- continue
2067
- }
2068
-
2069
- const replaceIndex = (() => {
2070
- for (let cursor = merged.length - 1; cursor >= 0; cursor -= 1) {
2071
- const candidateId = merged[cursor]?.id
2072
- if (candidateId && !protectedIds.has(candidateId)) {
2073
- return cursor
2074
- }
2075
- }
2076
- return -1
2077
- })()
2078
- if (replaceIndex >= 0) {
2079
- const replacedId = merged[replaceIndex]?.id
2080
- if (replacedId) {
2081
- ids.delete(replacedId)
2082
- }
2083
- merged[replaceIndex] = otherNode
2084
- ids.add(otherId)
2085
- protectedIds.add(otherId)
2086
- }
2087
- }
2088
-
2089
- return merged
2090
- }
2091
-
2092
- const ensureHubNodesInRenderedSet = (nodes) => {
2093
- if (nodes.length === 0) {
2094
- return nodes
2095
- }
2096
-
2097
- const maxNodes = Math.max(Math.min(renderNodeBudget, nodes.length), 1)
2098
- const ids = new Set(nodes.map((node) => node.id))
2099
- const hubs = rankedHubNodes()
2100
- const merged = [...nodes]
2101
-
2102
- for (let index = 0; index < hubs.length; index += 1) {
2103
- const hub = hubs[index]
2104
- if (ids.has(hub.id)) {
2105
- continue
2106
- }
2107
-
2108
- if (merged.length < maxNodes) {
2109
- merged.push(hub)
2110
- ids.add(hub.id)
2111
- continue
2112
- }
2113
-
2114
- const replacementIndex = merged.findIndex((node) => !hubs.some((candidate) => candidate.id === node.id))
2115
- if (replacementIndex >= 0) {
2116
- ids.delete(merged[replacementIndex].id)
2117
- merged[replacementIndex] = hub
2118
- ids.add(hub.id)
2119
- }
2120
- }
2121
-
2122
- return merged
2123
- }
2124
-
2125
- const zoomCapByNodeCount = (nodeCount) => {
2126
- if (nodeCount > 50000) return 5.4
2127
- if (nodeCount > 20000) return 4.8
2128
- if (nodeCount > 6000) return 4.2
2129
- if (nodeCount > 2000) return 4
2130
- return zoomRange.max
2131
- }
2132
-
2133
- const zoomCapByHubDistance = (distance) => {
2134
- if (!Number.isFinite(distance) || distance <= 0) {
2135
- return zoomRange.max
2136
- }
2137
-
2138
- const rect = canvas.getBoundingClientRect()
2139
- const viewportWidth = Math.max(rect.width, 320)
2140
- const viewportHeight = Math.max(rect.height, 320)
2141
- const reference = Math.max(220, Math.min(viewportWidth, viewportHeight) * zoomCapTargetViewportShare)
2142
- return Math.max(0.3, Math.min(zoomRange.max, reference / distance))
2143
- }
2144
-
2145
- const currentZoomMax = () => {
2146
- const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
2147
- const hubDistanceCap = isDominantHub(state.primaryHub, nodeCount)
2148
- ? zoomCapByHubDistance(state.hubNeighborDistance)
2149
- : zoomRange.max
2150
- const minimumUsefulCap = nodeCount > massiveGraphNodeThreshold ? 1.9 : nodeCount > largeGraphNodeThreshold ? 1.35 : 0.8
2151
- const capped = Math.min(zoomCapByNodeCount(nodeCount), Math.max(minimumUsefulCap, hubDistanceCap))
2152
- return Math.max(zoomRange.min * 2, capped)
2153
- }
2154
-
2155
- const clampScale = value => Math.max(zoomRange.min, Math.min(currentZoomMax(), value))
2156
- const isFiniteNumber = value => Number.isFinite(value)
2157
- const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
2158
- const clampTransformCoordinate = value => {
2159
- if (!isFiniteNumber(value)) return 0
2160
- if (value > transformCoordinateLimit) return transformCoordinateLimit
2161
- if (value < -transformCoordinateLimit) return -transformCoordinateLimit
2162
- return value
2163
- }
2164
-
2165
- const graphBounds = nodes => {
2166
- if (nodes.length === 0) return null
2167
- let minX = Number.POSITIVE_INFINITY
2168
- let maxX = Number.NEGATIVE_INFINITY
2169
- let minY = Number.POSITIVE_INFINITY
2170
- let maxY = Number.NEGATIVE_INFINITY
2171
-
2172
- nodes.forEach(node => {
2173
- const radius = baseNodeRadius(node)
2174
- minX = Math.min(minX, node.x - radius)
2175
- maxX = Math.max(maxX, node.x + radius)
2176
- minY = Math.min(minY, node.y - radius)
2177
- maxY = Math.max(maxY, node.y + radius)
2178
- })
2179
-
2180
- return {
2181
- minX,
2182
- maxX,
2183
- minY,
2184
- maxY,
2185
- width: Math.max(maxX - minX, 1),
2186
- height: Math.max(maxY - minY, 1)
2187
- }
2188
- }
2189
-
2190
- const fitScaleBiasByNodeCount = nodeCount => {
2191
- if (nodeCount <= 6) return 1.22
2192
- if (nodeCount <= 20) return 1.12
2193
- if (nodeCount <= 60) return 1.04
2194
- if (nodeCount <= 180) return 1
2195
- if (nodeCount <= 600) return 0.94
2196
- if (nodeCount <= 2000) return 0.82
2197
- if (nodeCount <= 6000) return 0.68
2198
- return 0.56
2199
- }
2200
-
2201
- const autoFitScaleRangeByNodeCount = nodeCount => {
2202
- if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
2203
- if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
2204
- if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
2205
- if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
2206
- if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
2207
- if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
2208
- if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
2209
- return { min: 0.0012, max: 0.24 }
2210
- }
2211
-
2212
- const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
2213
- const rect = canvas.getBoundingClientRect()
2214
- const width = Math.max(rect.width, 320)
2215
- const height = Math.max(rect.height, 320)
2216
- const nodes = options.useFiltered ? filteredNodes() : state.nodes
2217
- const bounds = graphBounds(nodes)
2218
-
2219
- if (!bounds) {
2220
- state.transform = { x: width / 2, y: height / 2, scale: 1 }
2221
- state.offscreenFrameCount = 0
2222
- state.recoveringViewport = false
2223
- markRenderDirty()
2224
- return
2225
- }
2226
-
2227
- const paddingByNodeCount = nodeCount => {
2228
- if (nodeCount <= 6) return 28
2229
- if (nodeCount <= 20) return 44
2230
- if (nodeCount <= 60) return 68
2231
- if (nodeCount <= 180) return 86
2232
- if (nodeCount <= 600) return 110
2233
- if (nodeCount <= 2000) return 140
2234
- return 180
2235
- }
2236
- const padding = paddingByNodeCount(nodes.length)
2237
- const scaleX = width / (bounds.width + padding * 2)
2238
- const scaleY = height / (bounds.height + padding * 2)
2239
- const fitScale = Math.min(scaleX, scaleY)
2240
- const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
2241
- const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
2242
- const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
2243
- const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
2244
- const scale = options.macro && nodes.length > 1
2245
- ? clampScale(Math.min(baselineScale, macroScale))
2246
- : nodes.length > massiveGraphNodeThreshold
2247
- ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
2248
- : baselineScale
2249
- const hubCenter =
2250
- options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
2251
- ? state.primaryHub
2252
- : null
2253
- const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
2254
- const centerY = hubCenter ? hubCenter.y : (bounds.minY + bounds.maxY) / 2
2255
-
2256
- state.transform = {
2257
- x: clampTransformCoordinate(width / 2 - centerX * scale),
2258
- y: clampTransformCoordinate(height / 2 - centerY * scale),
2259
- scale: clampScale(scale)
2260
- }
2261
- state.offscreenFrameCount = 0
2262
- state.recoveringViewport = false
2263
- markRenderDirty()
2264
- }
2265
-
2266
- const resetView = () => fitView({ useFiltered: false, macro: true, preferHubCenter: true })
2267
-
2268
- const focusPrimaryHub = () => {
2269
- const hub = state.primaryHub
2270
- if (!hub) {
2271
- fitView({ useFiltered: true, macro: false, preferHubCenter: true })
2272
- return
2273
- }
2274
-
2275
- const rect = canvas.getBoundingClientRect()
2276
- const width = Math.max(rect.width, 320)
2277
- const height = Math.max(rect.height, 320)
2278
- const targetScale = clampScale(Math.max(0.78, state.transform.scale))
2279
-
2280
- state.transform = {
2281
- x: clampTransformCoordinate(width / 2 - hub.x * targetScale),
2282
- y: clampTransformCoordinate(height / 2 - hub.y * targetScale),
2283
- scale: targetScale
2284
- }
2285
- state.offscreenFrameCount = 0
2286
- markRenderDirty()
2287
- }
2288
-
2289
- const layoutDensityScaleForNodeCount = (nodeCount) => {
2290
- if (nodeCount > 50000) return 0.26
2291
- if (nodeCount > 20000) return 0.3
2292
- if (nodeCount > 6000) return 0.36
2293
- if (nodeCount > 2000) return 0.42
2294
- if (nodeCount > 600) return 0.5
2295
- if (nodeCount > 180) return 0.58
2296
- if (nodeCount > 60) return 0.68
2297
- if (nodeCount > 20) return 0.78
2298
- return 0.88
2299
- }
2300
-
2301
- const createLayout = graph => {
2302
- const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
2303
- const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
2304
- const densityScale = layoutDensityScaleForNodeCount(nodeRows.length)
2305
- const nodes = nodeRows.map(node => {
2306
- if (Array.isArray(node)) {
2307
- const [id, title, x, y, group, segment] = node
2308
- return {
2309
- id: typeof id === 'string' ? id : '',
2310
- title: typeof title === 'string' ? title : 'Untitled',
2311
- path: '',
2312
- tags: [],
2313
- group: typeof group === 'string' ? group : 'root',
2314
- segment: typeof segment === 'string' ? segment : 'root',
2315
- x: Number.isFinite(x) ? x * densityScale : 0,
2316
- y: Number.isFinite(y) ? y * densityScale : 0,
2317
- vx: 0,
2318
- vy: 0
2319
- }
2320
- }
2321
-
2322
- return {
2323
- ...node,
2324
- path: typeof node.path === 'string' ? node.path : '',
2325
- tags: Array.isArray(node.tags) ? node.tags : [],
2326
- x: Number.isFinite(node.x) ? node.x * densityScale : 0,
2327
- y: Number.isFinite(node.y) ? node.y * densityScale : 0,
2328
- vx: Number.isFinite(node.vx) ? node.vx : 0,
2329
- vy: Number.isFinite(node.vy) ? node.vy : 0
2330
- }
1499
+ elements.agent.addEventListener('change', () => {
1500
+ state.agentId = elements.agent.value || ''
1501
+ writeStoredAgent(state.agentId)
1502
+ syncAgentInUrl(state.agentId)
1503
+ loadContexts().then(() => scheduleChunkFetch({ fit: true })).catch((error) => console.error(error))
2331
1504
  })
2332
- const nodeMap = new Map(nodes.map(node => [node.id, node]))
2333
- const edges = edgeRows
2334
- .map(edge => {
2335
- if (Array.isArray(edge)) {
2336
- const [source, target, weight, priority] = edge
2337
- return {
2338
- source: typeof source === 'string' ? source : '',
2339
- target: typeof target === 'string' ? target : null,
2340
- targetTitle: '',
2341
- weight: Number.isFinite(weight) ? weight : 1,
2342
- priority: typeof priority === 'string' ? priority : 'normal'
2343
- }
2344
- }
2345
- return edge
2346
- })
2347
- .filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
2348
- .map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
2349
- return { nodes, edges }
2350
- }
2351
-
2352
- const encodeEntityTag = (value) => {
2353
- const utf8 = new TextEncoder().encode(value)
2354
- let binary = ''
2355
1505
 
2356
- for (let index = 0; index < utf8.length; index += 1) {
2357
- binary += String.fromCharCode(utf8[index])
2358
- }
2359
-
2360
- return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
2361
- }
2362
-
2363
- const graphSignature = graph => JSON.stringify({
2364
- nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
2365
- edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
2366
- })
2367
-
2368
- const resetContentFilter = () => {
2369
- if (state.contentFilter.timer) {
2370
- clearTimeout(state.contentFilter.timer)
2371
- }
2372
- state.contentFilter = {
2373
- query: '',
2374
- ids: null,
2375
- token: state.contentFilter.token + 1,
2376
- timer: null
2377
- }
2378
- recomputeVisibility()
1506
+ syncAgentInUrl(state.agentId)
2379
1507
  }
2380
1508
 
2381
- const syncContentFilter = async (query, token) => {
2382
- const response = await fetch(
2383
- '/api/graph-filter?q=' +
2384
- encodeURIComponent(query) +
2385
- '&limit=' +
2386
- encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
2387
- agentQuery('&')
2388
- )
2389
-
2390
- if (!response.ok || token !== state.contentFilter.token) {
2391
- return
1509
+ const loadContexts = async () => {
1510
+ const response = await fetch('/api/graph-contexts' + (state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''))
1511
+ if (!response.ok) {
1512
+ throw new Error('Failed to load graph contexts')
2392
1513
  }
2393
1514
 
2394
1515
  const payload = await response.json()
2395
- const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
2396
- if (token !== state.contentFilter.token) {
2397
- return
2398
- }
2399
-
2400
- state.contentFilter.query = query
2401
- const merged = new Set([...(state.contentFilter.ids instanceof Set ? state.contentFilter.ids : []), ...nodeIds])
2402
- state.contentFilter.ids = merged
2403
- recomputeVisibility()
2404
- }
2405
-
2406
- const scheduleContentFilterSync = () => {
2407
- const query = normalizeQuery(state.query)
2408
- if (!query) {
2409
- resetContentFilter()
2410
- return
2411
- }
1516
+ const contexts = Array.isArray(payload?.contexts) ? payload.contexts : []
1517
+ const options = [
1518
+ '<option value="">All contexts</option>',
1519
+ ...contexts.map((context) => {
1520
+ const id = String(context?.id || '')
1521
+ const title = String(context?.title || id || 'Untitled')
1522
+ const count = Number.isFinite(context?.nodeCount) ? context.nodeCount : 0
1523
+ return '<option value="' + escapeHtml(id) + '">' + escapeHtml(title) + ' (' + count + ')</option>'
1524
+ })
1525
+ ]
2412
1526
 
2413
- if (state.contentFilter.timer) {
2414
- clearTimeout(state.contentFilter.timer)
2415
- }
1527
+ elements.context.innerHTML = options.join('')
2416
1528
 
2417
- const token = state.contentFilter.token + 1
2418
- state.contentFilter = {
2419
- query: state.contentFilter.query,
2420
- ids: state.contentFilter.ids,
2421
- token,
2422
- timer: setTimeout(() => {
2423
- if (state.filterWorker && state.filterReady) {
2424
- state.filterWorker.postMessage({
2425
- type: 'filter',
2426
- query,
2427
- token,
2428
- limit: Math.max(state.nodes.length, 1)
2429
- })
2430
- }
2431
- syncContentFilter(query, token).catch(() => {})
2432
- }, 180)
2433
- }
1529
+ const preferredContext = initialContextFromUrl || readStoredContext()
1530
+ const hasPreferred = preferredContext && contexts.some((context) => context?.id === preferredContext)
1531
+ state.contextId = hasPreferred ? preferredContext : ''
1532
+ elements.context.value = state.contextId
1533
+ writeStoredContext(state.contextId)
1534
+ syncContextInUrl(state.contextId)
2434
1535
  }
2435
1536
 
2436
- const tick = delta => {
2437
- const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
2438
- const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
2439
- const shouldRunPhysics =
2440
- state.nodes.length <= 8000 &&
2441
- nodes.length <= 320 &&
2442
- state.transform.scale >= 0.08
2443
- if (!shouldRunPhysics) {
2444
- return
2445
- }
2446
- const strength = Math.min(delta / 16, 2)
2447
-
2448
- edges.forEach(edge => {
2449
- const source = edge.sourceNode
2450
- const target = edge.targetNode
2451
- source.vx = Number.isFinite(source.vx) ? source.vx : 0
2452
- source.vy = Number.isFinite(source.vy) ? source.vy : 0
2453
- target.vx = Number.isFinite(target.vx) ? target.vx : 0
2454
- target.vy = Number.isFinite(target.vy) ? target.vy : 0
2455
- const dx = target.x - source.x
2456
- const dy = target.y - source.y
2457
- const distance = Math.max(Math.hypot(dx, dy), 1)
2458
- const force = (distance - 150) * 0.002 * strength
2459
- const fx = (dx / distance) * force
2460
- const fy = (dy / distance) * force
2461
- source.vx += fx
2462
- source.vy += fy
2463
- target.vx -= fx
2464
- target.vy -= fy
2465
- })
2466
-
2467
- for (let i = 0; i < nodes.length; i += 1) {
2468
- for (let j = i + 1; j < nodes.length; j += 1) {
2469
- const a = nodes[i]
2470
- const b = nodes[j]
2471
- a.vx = Number.isFinite(a.vx) ? a.vx : 0
2472
- a.vy = Number.isFinite(a.vy) ? a.vy : 0
2473
- b.vx = Number.isFinite(b.vx) ? b.vx : 0
2474
- b.vy = Number.isFinite(b.vy) ? b.vy : 0
2475
- const dx = b.x - a.x
2476
- const dy = b.y - a.y
2477
- const distance = Math.max(Math.hypot(dx, dy), 1)
2478
- const force = Math.min(2600 / (distance * distance), 0.12) * strength
2479
- const fx = (dx / distance) * force
2480
- const fy = (dy / distance) * force
2481
- a.vx -= fx
2482
- a.vy -= fy
2483
- b.vx += fx
2484
- b.vy += fy
2485
- }
2486
- }
2487
-
2488
- nodes.forEach(node => {
2489
- node.vx = Number.isFinite(node.vx) ? node.vx : 0
2490
- node.vy = Number.isFinite(node.vy) ? node.vy : 0
2491
- node.x = Number.isFinite(node.x) ? node.x : 0
2492
- node.y = Number.isFinite(node.y) ? node.y : 0
2493
- if (state.pointer.dragNode === node) {
2494
- node.vx = 0
2495
- node.vy = 0
2496
- return
2497
- }
2498
- node.vx += -node.x * 0.0008 * strength
2499
- node.vy += -node.y * 0.0008 * strength
2500
- node.vx *= 0.88
2501
- node.vy *= 0.88
2502
- node.x += node.vx * strength
2503
- node.y += node.vy * strength
1537
+ const setupContextControl = () => {
1538
+ elements.context.addEventListener('change', () => {
1539
+ state.contextId = elements.context.value || ''
1540
+ state.selectedNodeId = null
1541
+ writeStoredContext(state.contextId)
1542
+ syncContextInUrl(state.contextId)
1543
+ scheduleChunkFetch({ fit: true })
2504
1544
  })
2505
1545
  }
2506
1546
 
2507
- const worldPoint = event => {
2508
- const rect = canvas.getBoundingClientRect()
2509
- return screenToWorldPoint(event.clientX - rect.left, event.clientY - rect.top)
2510
- }
2511
-
2512
- const connectedNodeIdsFor = (nodeId) => {
2513
- const edges = state.visibleEdgeByNode.get(nodeId) ?? []
2514
- const ids = new Set()
1547
+ const setupRenderWorker = () => {
1548
+ const hasWorker = typeof Worker !== 'undefined'
1549
+ const canTransfer = typeof canvas.transferControlToOffscreen === 'function'
2515
1550
 
2516
- for (let index = 0; index < edges.length; index += 1) {
2517
- const edge = edges[index]
2518
- if (!edge.target) continue
2519
- if (edge.source === nodeId) {
2520
- ids.add(edge.target)
2521
- } else if (edge.target === nodeId) {
2522
- ids.add(edge.source)
2523
- }
1551
+ if (!hasWorker || !canTransfer) {
1552
+ state.rendererMode = 'fallback'
1553
+ drawFallback()
1554
+ return
2524
1555
  }
2525
1556
 
2526
- return ids
2527
- }
1557
+ try {
1558
+ const offscreen = canvas.transferControlToOffscreen()
1559
+ const worker = new Worker('/render-worker.js')
1560
+ state.renderWorker = worker
2528
1561
 
2529
- const applyDragNeighborhoodAdjustment = (dragNode, deltaX, deltaY) => {
2530
- if (!dragNode) return
2531
- if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) return
2532
- if (Math.abs(deltaX) + Math.abs(deltaY) <= 0.001) return
2533
-
2534
- const scale = Math.max(state.transform.scale, 0.0001)
2535
- const influenceRadius = Math.max(220, Math.min(920, 440 / scale))
2536
- const influenceRadiusSquared = influenceRadius * influenceRadius
2537
- const connectedIds = connectedNodeIdsFor(dragNode.id)
2538
- const candidates = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
2539
- let adjusted = 0
2540
-
2541
- for (let index = 0; index < candidates.length && adjusted < dragNeighborhoodMaxAffected; index += 1) {
2542
- const node = candidates[index]
2543
- if (node.id === dragNode.id) continue
2544
-
2545
- const isConnected = connectedIds.has(node.id)
2546
- const dx = node.x - dragNode.x
2547
- const dy = node.y - dragNode.y
2548
- const distanceSquared = dx * dx + dy * dy
2549
- const withinRadius = distanceSquared <= influenceRadiusSquared
2550
- if (!isConnected && !withinRadius) continue
2551
-
2552
- const distance = Math.max(Math.sqrt(distanceSquared), 0.0001)
2553
- const proximity = withinRadius ? 1 - (distance / influenceRadius) : 0
2554
- const coupledStrength = isConnected ? 0.28 : 0.12
2555
- const influence = Math.min(0.46, coupledStrength + proximity * 0.34)
2556
- node.x += deltaX * influence
2557
- node.y += deltaY * influence
2558
- node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + deltaX * influence * 0.06
2559
- node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + deltaY * influence * 0.06
2560
- adjusted += 1
2561
- }
2562
- }
2563
-
2564
- const settleNeighborhoodAroundNode = (dragNode) => {
2565
- if (!dragNode) return
2566
-
2567
- const scale = Math.max(state.transform.scale, 0.0001)
2568
- const settleRadius = Math.max(240, Math.min(980, 520 / scale))
2569
- const settleRadiusSquared = settleRadius * settleRadius
2570
- const connectedIds = connectedNodeIdsFor(dragNode.id)
2571
- const candidates = (state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes)
2572
- .filter((node) => {
2573
- if (node.id === dragNode.id) return true
2574
- const dx = node.x - dragNode.x
2575
- const dy = node.y - dragNode.y
2576
- const distanceSquared = dx * dx + dy * dy
2577
- return connectedIds.has(node.id) || distanceSquared <= settleRadiusSquared
2578
- })
2579
- .slice(0, dragNeighborhoodMaxAffected)
2580
-
2581
- if (candidates.length <= 1) return
2582
-
2583
- for (let round = 0; round < dragSettleRounds; round += 1) {
2584
- for (let leftIndex = 0; leftIndex < candidates.length; leftIndex += 1) {
2585
- const left = candidates[leftIndex]
2586
- for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex += 1) {
2587
- const right = candidates[rightIndex]
2588
- const dx = right.x - left.x
2589
- const dy = right.y - left.y
2590
- const distance = Math.max(Math.hypot(dx, dy), 0.001)
2591
- const minDistance = baseNodeRadius(left) + baseNodeRadius(right) + 10
2592
- if (distance >= minDistance) continue
2593
-
2594
- const push = (minDistance - distance) * 0.36
2595
- const ux = dx / distance
2596
- const uy = dy / distance
2597
- if (left.id !== dragNode.id) {
2598
- left.x -= ux * push
2599
- left.y -= uy * push
2600
- }
2601
- if (right.id !== dragNode.id) {
2602
- right.x += ux * push
2603
- right.y += uy * push
2604
- }
1562
+ worker.onmessage = (event) => {
1563
+ const payload = event.data
1564
+ if (!payload || typeof payload !== 'object') {
1565
+ return
2605
1566
  }
2606
- }
2607
- }
2608
- }
2609
-
2610
- const hitNode = point => {
2611
- computeRenderVisibility()
2612
- if (state.renderClusters.length > 0) {
2613
- return null
2614
- }
2615
- const hitScaleFloor = state.nodes.length > massiveGraphNodeThreshold
2616
- ? 0.2
2617
- : state.nodes.length > largeGraphNodeThreshold
2618
- ? 0.34
2619
- : 0
2620
- if (state.transform.scale < hitScaleFloor) {
2621
- return null
2622
- }
2623
-
2624
- const nodes = state.renderNodes
2625
- for (let index = nodes.length - 1; index >= 0; index -= 1) {
2626
- const node = nodes[index]
2627
- const radius = nodeRadius(node)
2628
- if (Math.hypot(point.x - node.x, point.y - node.y) <= radius + 5) return node
2629
- }
2630
- return null
2631
- }
2632
-
2633
- const baseNodeRadius = node => {
2634
- const degree = state.nodeDegrees.get(node.id) ?? 0
2635
- return 9 + Math.min(degree, 8) * 1.6
2636
- }
2637
-
2638
- const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
2639
-
2640
- const clusterRadiusPx = cluster => {
2641
- if (cluster.id === 'macro-galaxy') {
2642
- return 10
2643
- }
2644
- if (cluster.isHub) {
2645
- return 3.8
2646
- }
2647
- if (String(cluster.id).startsWith('ecosystem-')) {
2648
- const size = Math.max(1, Math.min(ecosystemLevelNodeCap, cluster.size || cluster.count || 1))
2649
- const sizeBias = 0.56 + Math.log10(size + 1) * 0.28
2650
- const densityBias = Math.log10((cluster.count || 1) + 1) * 0.12
2651
- return Math.max(0.62, Math.min(2.4, sizeBias + densityBias))
2652
- }
2653
- return Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
2654
- }
2655
-
2656
- const clusterOpacity = cluster =>
2657
- Math.max(0, Math.min(1, Number.isFinite(cluster.lodOpacity) ? cluster.lodOpacity : 1))
2658
-
2659
- const worldViewportBounds = () => {
2660
- const rect = canvas.getBoundingClientRect()
2661
- const width = Math.max(rect.width, 320)
2662
- const height = Math.max(rect.height, 320)
2663
- const paddingMultiplier =
2664
- state.nodes.length > massiveGraphNodeThreshold
2665
- ? (state.transform.scale >= 0.6 ? 2.8 : state.transform.scale >= 0.25 ? 2.35 : 1.9)
2666
- : state.nodes.length > largeGraphNodeThreshold
2667
- ? 1.45
2668
- : 1
2669
- const padding = viewportPaddingPx * paddingMultiplier
2670
-
2671
- return {
2672
- minX: (-state.transform.x - padding) / state.transform.scale,
2673
- maxX: (width - state.transform.x + padding) / state.transform.scale,
2674
- minY: (-state.transform.y - padding) / state.transform.scale,
2675
- maxY: (height - state.transform.y + padding) / state.transform.scale
2676
- }
2677
- }
2678
-
2679
- const isNodeInViewport = (node, viewport) =>
2680
- node.x >= viewport.minX &&
2681
- node.x <= viewport.maxX &&
2682
- node.y >= viewport.minY &&
2683
- node.y <= viewport.maxY
2684
-
2685
- const expandViewportBounds = (viewport, worldMargin) => ({
2686
- minX: viewport.minX - worldMargin,
2687
- maxX: viewport.maxX + worldMargin,
2688
- minY: viewport.minY - worldMargin,
2689
- maxY: viewport.maxY + worldMargin
2690
- })
2691
-
2692
- const viewportNodeStride = () => {
2693
- if (state.nodes.length <= largeGraphNodeThreshold) {
2694
- return 1
2695
- }
2696
-
2697
- if (state.transform.scale >= 0.95) {
2698
- return 1
2699
- }
2700
- if (state.transform.scale >= 0.7) {
2701
- return 2
2702
- }
2703
- if (state.transform.scale >= 0.48) {
2704
- return 3
2705
- }
2706
- if (state.transform.scale >= 0.28) {
2707
- return 5
2708
- }
2709
-
2710
- return 8
2711
- }
2712
1567
 
2713
- const shouldRenderClusters = viewportNodes =>
2714
- state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
2715
-
2716
- const clusterViewportNodes = viewportNodes => {
2717
- if (!shouldRenderClusters(viewportNodes)) {
2718
- return []
2719
- }
2720
-
2721
- const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
2722
- const buckets = new Map()
2723
-
2724
- for (let index = 0; index < viewportNodes.length; index += 1) {
2725
- const node = viewportNodes[index]
2726
- const keyX = Math.floor(node.x / worldCellSize)
2727
- const keyY = Math.floor(node.y / worldCellSize)
2728
- const key = keyX + ':' + keyY
2729
- const current = buckets.get(key)
2730
- if (current) {
2731
- current.count += 1
2732
- current.sumX += node.x
2733
- current.sumY += node.y
2734
- if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
2735
- current.representative = node
2736
- current.degree = state.nodeDegrees.get(node.id) ?? 0
1568
+ if (payload.type === 'ready') {
1569
+ state.workerReady = true
1570
+ scheduleChunkFetch({ fit: true })
1571
+ return
2737
1572
  }
2738
- continue
2739
- }
2740
-
2741
- buckets.set(key, {
2742
- id: key,
2743
- count: 1,
2744
- sumX: node.x,
2745
- sumY: node.y,
2746
- representative: node,
2747
- degree: state.nodeDegrees.get(node.id) ?? 0
2748
- })
2749
- }
2750
1573
 
2751
- return Array.from(buckets.values())
2752
- .sort((left, right) => right.count - left.count)
2753
- .slice(0, Math.min(renderNodeBudget, 900))
2754
- .map((cluster) => ({
2755
- id: cluster.id,
2756
- x: cluster.sumX / Math.max(cluster.count, 1),
2757
- y: cluster.sumY / Math.max(cluster.count, 1),
2758
- count: cluster.count,
2759
- representative: cluster.representative
2760
- }))
2761
- }
2762
-
2763
- const representativeNodesFromClusters = (clusters, limit) => {
2764
- const representatives = clusters
2765
- .map((cluster) => cluster.representative)
2766
- .filter((node) => Boolean(node))
2767
- const merged = mergeUniqueNodes(
2768
- representatives,
2769
- state.renderNodes ?? [],
2770
- Math.max(1, limit)
2771
- )
2772
- return ensureHubNodesInRenderedSet(merged)
2773
- }
2774
-
2775
- const computeRenderVisibility = () => {
2776
- if (!hasValidTransform()) {
2777
- fitView({ useFiltered: true })
2778
- }
2779
- const viewport = worldViewportBounds()
2780
- const viewportKey =
2781
- Math.round(viewport.minX * 10) + ':' +
2782
- Math.round(viewport.maxX * 10) + ':' +
2783
- Math.round(viewport.minY * 10) + ':' +
2784
- Math.round(viewport.maxY * 10) + ':' +
2785
- visibilityScaleBucket(state.transform.scale)
2786
-
2787
- if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
2788
- return
2789
- }
2790
- state.lastViewportKey = viewportKey
2791
- state.renderVisibilityDirty = false
2792
- state.renderClusterEdges = []
2793
-
2794
- const shouldRenderMacroGalaxy = shouldRenderMacroGalaxyView()
2795
-
2796
- if (shouldRenderMacroGalaxy) {
2797
- const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2798
- const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
2799
- const representative = state.primaryHub ?? state.macroRepresentative ?? sourceNodes[0] ?? null
2800
- if (representative) {
2801
- state.renderClusters = [
2802
- {
2803
- id: 'macro-galaxy',
2804
- x: state.macroCenter.x,
2805
- y: state.macroCenter.y,
2806
- count: sourceNodes.length,
2807
- representative
1574
+ if (payload.type === 'pick-result') {
1575
+ if (payload.node && typeof payload.node.id === 'string' && payload.node.id.length > 0) {
1576
+ handlePickedNode(payload.node)
2808
1577
  }
2809
- ]
2810
- state.renderNodes = [representative]
2811
- } else {
2812
- state.renderClusters = []
2813
- state.renderNodes = []
2814
- }
2815
- state.renderEdges = []
2816
- state.renderClusterEdges = []
2817
- return
2818
- }
2819
-
2820
- const ecosystemScaleThreshold = state.visibleNodes.length > massiveGraphNodeThreshold
2821
- ? massiveEcosystemClusterScaleThreshold
2822
- : ecosystemClusterScaleThreshold
2823
- if (
2824
- state.visibleNodes.length > ecosystemActivationNodeThreshold &&
2825
- state.transform.scale <= ecosystemScaleThreshold &&
2826
- state.ecosystemClusters.length > 0
2827
- ) {
2828
- const clusters = selectHierarchicalEcosystemClusters(viewport)
2829
- .sort((left, right) => right.count - left.count)
2830
- state.renderClusters = clusters
2831
- state.renderClusterEdges = ecosystemEdgesForClusters(clusters)
2832
- state.renderNodes = []
2833
- state.renderEdges = []
2834
- return
2835
- }
2836
-
2837
- if (state.visibleNodes.length <= 2000) {
2838
- state.renderNodes = state.visibleNodes
2839
- state.renderClusters = []
2840
- state.renderClusterEdges = []
2841
- const ids = new Set(state.renderNodes.map((node) => node.id))
2842
- state.renderEdges = withMeshEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
2843
- return
2844
- }
2845
-
2846
- if (state.visibleNodes.length > massiveGraphNodeThreshold) {
2847
- const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2848
- const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
2849
- const sampleLimit = nodeBudgetForScale(state.transform.scale)
2850
- const carryMargin = Math.max(240, Math.min(1200, 340 / Math.max(state.transform.scale, 0.0001)))
2851
- const carryViewport = expandViewportBounds(viewport, carryMargin)
2852
- const carryOverLimit = Math.max(180, Math.min(sampleLimit, Math.floor(sampleLimit * 0.5)))
2853
- const carryOverNodes = (state.renderNodes ?? [])
2854
- .filter((node) => isNodeInViewport(node, carryViewport))
2855
- .slice(0, carryOverLimit)
2856
- const sourceWithCarry = mergeUniqueNodes(
2857
- sourceNodes,
2858
- carryOverNodes,
2859
- Math.max(sampleLimit * 7, carryOverLimit)
2860
- )
2861
- const sourceWithCarryIds = new Set(sourceWithCarry.map((node) => node.id))
2862
- const sampledRaw = selectStableSampleNodes(
2863
- sourceWithCarry,
2864
- sampleLimit
2865
- )
2866
- const continuityBudget = Math.max(24, Math.min(sampleLimit - 8, Math.floor(sampleLimit * 0.42)))
2867
- const previousVisibleNodes = (state.renderNodes ?? [])
2868
- .filter((node) => sourceWithCarryIds.has(node.id))
2869
- const continuityNodes = selectStableSampleNodes(previousVisibleNodes, continuityBudget)
2870
- const sampled = mergeUniqueNodes(
2871
- continuityNodes,
2872
- sampledRaw,
2873
- sampleLimit
2874
- )
2875
- let sampledNodes = ensureHubNodesInRenderedSet(sampled)
2876
- if (state.transform.scale < 0.035) {
2877
- sampledNodes = includeHubPreviewNeighborhood(
2878
- sampledNodes,
2879
- Math.min(renderNodeBudget, sampleLimit + 160)
2880
- )
2881
- }
2882
- const sampledIds = new Set(sampledNodes.map((node) => node.id))
2883
- let sampledEdges = collectVisibleEdgesForNodes(sampledIds)
2884
-
2885
- if (state.transform.scale >= 0.035 && sampledEdges.length === 0) {
2886
- const enriched = enrichSampleWithNeighbors(sampledNodes)
2887
- sampledNodes = ensureHubNodesInRenderedSet(enriched.nodes)
2888
- const sampledWithHubsIds = new Set(sampledNodes.map((node) => node.id))
2889
- sampledEdges = collectVisibleEdgesForNodes(sampledWithHubsIds)
2890
- }
2891
-
2892
- state.renderClusters = []
2893
- state.renderClusterEdges = []
2894
- state.renderNodes = sampledNodes
2895
- state.renderEdges = withMeshEdges(sampledNodes, sampledEdges)
2896
- return
2897
- }
2898
-
2899
- if (state.transform.scale <= 0.0015) {
2900
- const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
2901
- const sampledIds = new Set(sampled.map((node) => node.id))
2902
- state.renderClusters = []
2903
- state.renderClusterEdges = []
2904
- state.renderNodes = sampled
2905
- state.renderEdges = withMeshEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
2906
- return
2907
- }
2908
-
2909
- const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2910
- const clusters = clusterViewportNodes(viewportNodes)
2911
- if (clusters.length > 0) {
2912
- state.renderClusters = []
2913
- state.renderClusterEdges = []
2914
- state.renderNodes = representativeNodesFromClusters(clusters, Math.min(renderNodeBudget, 900))
2915
- state.renderEdges = []
2916
- return
2917
- }
2918
- state.renderClusters = []
2919
- state.renderClusterEdges = []
2920
- const stride = viewportNodeStride()
2921
- const picked = []
2922
-
2923
- for (let index = 0; index < viewportNodes.length; index += 1) {
2924
- const node = viewportNodes[index]
2925
-
2926
- const isPriority =
2927
- node.id === state.selected?.id ||
2928
- node.id === state.hovered?.id ||
2929
- node.id === state.pointer.dragNode?.id
2930
- if (isPriority || index % stride === 0) {
2931
- picked.push(node)
2932
- }
2933
- }
2934
-
2935
- const nodes = picked.length > renderNodeBudget
2936
- ? picked.slice(0, renderNodeBudget)
2937
- : picked
2938
- if (nodes.length === 0 && state.visibleNodes.length > 0) {
2939
- const fallbackNodes = fallbackViewportNodes()
2940
- const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
2941
- state.renderNodes = fallbackNodes
2942
- state.renderClusters = []
2943
- state.renderClusterEdges = []
2944
- state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
2945
- return
2946
- }
2947
-
2948
- const normalizedNodes = ensureHubNodesInRenderedSet(nodes)
2949
- const nodeIds = new Set(normalizedNodes.map((node) => node.id))
2950
- const edges = collectVisibleEdgesForNodes(nodeIds)
2951
-
2952
- state.renderNodes = normalizedNodes
2953
- state.renderEdges = withMeshEdges(normalizedNodes, edges)
2954
-
2955
- if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
2956
- const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
2957
- const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
2958
- state.renderClusters = []
2959
- state.renderClusterEdges = []
2960
- state.renderNodes = fallbackNodes
2961
- state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
2962
- }
2963
- }
2964
-
2965
- const isNodeVisibleOnScreen = (node, width, height) => {
2966
- const radius = nodeRadius(node) * state.transform.scale
2967
- const screenX = node.x * state.transform.scale + state.transform.x
2968
- const screenY = node.y * state.transform.scale + state.transform.y
2969
-
2970
- return (
2971
- screenX + radius >= 0 &&
2972
- screenX - radius <= width &&
2973
- screenY + radius >= 0 &&
2974
- screenY - radius <= height
2975
- )
2976
- }
2977
-
2978
- const hasValidTransform = () =>
2979
- isFiniteNumber(state.transform.x) &&
2980
- isFiniteNumber(state.transform.y) &&
2981
- isFiniteNumber(state.transform.scale) &&
2982
- Math.abs(state.transform.x) <= transformCoordinateLimit &&
2983
- Math.abs(state.transform.y) <= transformCoordinateLimit &&
2984
- state.transform.scale > 0
2985
-
2986
- const sanitizeNodePosition = node => {
2987
- if (!isReasonableCoordinate(node.x)) node.x = 0
2988
- if (!isReasonableCoordinate(node.y)) node.y = 0
2989
- if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
2990
- if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
2991
- }
2992
-
2993
- const sanitizeAllNodePositions = () => {
2994
- state.nodes.forEach(sanitizeNodePosition)
2995
- state.visibleNodes.forEach(sanitizeNodePosition)
2996
- }
2997
-
2998
- const sanitizeGraphState = () => {
2999
- state.renderNodes.forEach(sanitizeNodePosition)
3000
- }
3001
-
3002
- const render = now => {
3003
- const delta = now - state.last
3004
- state.last = now
3005
- const backgroundFrameIntervalMs =
3006
- state.nodes.length > massiveGraphNodeThreshold
3007
- ? (state.transform.scale < 0.035 ? 130 : state.transform.scale < 0.08 ? 110 : 86)
3008
- : state.nodes.length > largeGraphNodeThreshold
3009
- ? 64
3010
- : 16
3011
- const isInteracting =
3012
- state.pointer.down ||
3013
- state.renderVisibilityDirty ||
3014
- state.recoveringViewport
3015
- const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
3016
- if (delta < minFrameIntervalMs) {
3017
- requestAnimationFrame(render)
3018
- return
3019
- }
3020
- const rect = canvas.getBoundingClientRect()
3021
- const width = Math.max(rect.width, 320)
3022
- const height = Math.max(rect.height, 320)
3023
- sanitizeGraphState()
3024
- if (!hasValidTransform()) {
3025
- resetView()
3026
- }
3027
- ctx.clearRect(0, 0, width, height)
3028
- webGlRenderer?.clear(width, height)
3029
- if (state.nodes.length === 0) {
3030
- ctx.fillStyle = '#99a5b5'
3031
- ctx.font = '14px Inter, system-ui, sans-serif'
3032
- ctx.textAlign = 'center'
3033
- ctx.fillText('No indexed notes found', width / 2, height / 2)
3034
- requestAnimationFrame(render)
3035
- return
3036
- }
3037
-
3038
- computeRenderVisibility()
3039
- tick(delta)
3040
- const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
3041
- const manualZoomGuardActive = now - state.lastManualZoomAt < zoomRecoveryGuardMs
3042
- const allowViewportAutoRecovery = state.nodes.length <= massiveGraphNodeThreshold
3043
- if (allowViewportAutoRecovery && !hasVisibleNodeOnScreen && state.renderNodes.length > 0 && !manualZoomGuardActive) {
3044
- state.offscreenFrameCount += 1
3045
- if (state.offscreenFrameCount >= 22 && !state.recoveringViewport) {
3046
- state.recoveringViewport = true
3047
- fitView({ useFiltered: true })
3048
- state.offscreenFrameCount = 0
3049
- requestAnimationFrame(() => {
3050
- state.recoveringViewport = false
3051
- })
3052
- }
3053
- } else {
3054
- state.offscreenFrameCount = 0
3055
- }
3056
- const minimumEdgeScale =
3057
- state.nodes.length > massiveGraphNodeThreshold
3058
- ? 0
3059
- : state.renderNodes.length > 1300
3060
- ? 0.12
3061
- : state.renderNodes.length > 900
3062
- ? 0.085
3063
- : state.renderNodes.length > 500
3064
- ? 0.05
3065
- : 0
3066
- const drawEdges =
3067
- state.renderClusters.length === 0 &&
3068
- state.transform.scale >= minimumEdgeScale
3069
- if (drawAcceleratedGraph(width, height, drawEdges)) {
3070
- // WebGL handles the dense node/edge layer; the 2D canvas remains the interaction overlay.
3071
- } else if (state.renderClusters.length > 0) {
3072
- ctx.save()
3073
- ctx.translate(state.transform.x, state.transform.y)
3074
- ctx.scale(state.transform.scale, state.transform.scale)
3075
- const safeScale = Math.max(state.transform.scale, 0.0001)
3076
- if (state.renderClusterEdges.length > 0) {
3077
- for (let index = 0; index < state.renderClusterEdges.length; index += 1) {
3078
- const edge = state.renderClusterEdges[index]
3079
- const edgeOpacity = Math.min(clusterOpacity(edge.sourceCluster), clusterOpacity(edge.targetCluster))
3080
- if (edgeOpacity <= 0.01) {
3081
- continue
3082
- }
3083
- ctx.beginPath()
3084
- ctx.moveTo(edge.sourceCluster.x, edge.sourceCluster.y)
3085
- ctx.lineTo(edge.targetCluster.x, edge.targetCluster.y)
3086
- ctx.lineWidth = 1.2 / safeScale
3087
- ctx.strokeStyle = 'rgba(153, 165, 181, ' + (edge.inferred ? 0.14 : 0.22) * edgeOpacity + ')'
3088
- ctx.stroke()
3089
- }
3090
- }
3091
- state.renderClusters.forEach(cluster => {
3092
- const isMacro = cluster.id === 'macro-galaxy'
3093
- const isEcosystem = String(cluster.id).startsWith('ecosystem-')
3094
- const isHub = Boolean(cluster.isHub)
3095
- const opacity = clusterOpacity(cluster)
3096
- if (opacity <= 0.01) {
3097
1578
  return
3098
1579
  }
3099
- const radiusPx = clusterRadiusPx(cluster)
3100
- const radius = radiusPx / safeScale
3101
- const haloRadius = (radiusPx + (isMacro ? 8 : isHub ? 4 : isEcosystem ? 1.1 : 4)) / safeScale
3102
- ctx.globalAlpha = opacity
3103
- if (isHub || !isEcosystem || state.transform.scale >= ecosystemSubgraphScaleThreshold) {
3104
- ctx.beginPath()
3105
- ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
3106
- ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
3107
- ctx.fill()
3108
- }
3109
- ctx.beginPath()
3110
- ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
3111
- ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
3112
- ctx.fill()
3113
- ctx.lineWidth = (isEcosystem && !isHub ? 0.7 : 1.4) / safeScale
3114
- ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
3115
- ctx.stroke()
3116
- if (isMacro && cluster.representative?.title) {
3117
- ctx.fillStyle = '#edf2f7'
3118
- ctx.font = 12 / safeScale + 'px Inter, system-ui, sans-serif'
3119
- ctx.textAlign = 'center'
3120
- ctx.textBaseline = 'top'
3121
- ctx.fillText(cluster.representative.title.slice(0, 28), cluster.x, cluster.y + (radiusPx + 9) / safeScale)
3122
- }
3123
- ctx.globalAlpha = 1
3124
- // Keep cluster markers minimal and faster to draw on large graphs.
3125
- })
3126
- ctx.restore()
3127
- } else {
3128
- ctx.save()
3129
- ctx.translate(state.transform.x, state.transform.y)
3130
- ctx.scale(state.transform.scale, state.transform.scale)
3131
- if (drawEdges) {
3132
- drawGraphEdges()
3133
- }
3134
- drawGraphNodes()
3135
- ctx.restore()
3136
- }
3137
- if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
3138
- ctx.fillStyle = '#99a5b5'
3139
- ctx.font = '12px Inter, system-ui, sans-serif'
3140
- ctx.textAlign = 'center'
3141
- ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
3142
- }
3143
- requestAnimationFrame(render)
3144
- }
3145
-
3146
- const list = items => items.length
3147
- ? items.map(item => '<li>' + (item.id ? '<button type="button" data-node-id="' + escapeHtml(item.id) + '">' + escapeHtml(item.title) + '</button>' : escapeHtml(item.title)) + '<small>' + escapeHtml(item.path) + (item.weight ? ' · weight ' + escapeHtml(item.weight) + ' · ' + escapeHtml(item.priority || 'normal') : '') + '</small></li>').join('')
3148
- : '<li><small>No links found.</small></li>'
3149
-
3150
- const linkedNodes = node => {
3151
- const nodeById = new Map(state.nodes.map(item => [item.id, item]))
3152
- const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
3153
- ...linkedNode,
3154
- weight: edge.weight,
3155
- priority: edge.priority
3156
- } : null
3157
- const outgoing = state.edges
3158
- .filter(edge => edge.source === node.id)
3159
- .map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
3160
- .filter(Boolean)
3161
- const incoming = state.edges
3162
- .filter(edge => edge.target === node.id)
3163
- .map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
3164
- .filter(Boolean)
3165
-
3166
- return { outgoing, incoming }
3167
- }
3168
-
3169
- const fetchNodeDetails = async node => {
3170
- const cached = state.nodeDetails.get(node.id)
3171
- if (cached) {
3172
- return cached
3173
- }
3174
-
3175
- const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
3176
- if (!response.ok) {
3177
- throw new Error('Failed to load graph node details')
3178
- }
3179
1580
 
3180
- const payload = await response.json()
3181
- const detail = payload?.node
3182
- if (!detail || !detail.id) {
3183
- throw new Error('Invalid graph node payload')
3184
- }
3185
- state.nodeDetails.set(detail.id, detail)
3186
- return detail
3187
- }
3188
-
3189
- const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
3190
-
3191
- const openContentDialog = async node => {
3192
- if (!node) return
3193
- elements.contentTitle.textContent = node.title || 'Loading...'
3194
- elements.contentPath.textContent = node.path || 'Loading...'
3195
- elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
3196
- ? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
3197
- : '<span>No tags</span>'
3198
- const initialLinks = linkedNodes(node)
3199
- elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
3200
- elements.contentIncoming.innerHTML = list(initialLinks.incoming)
3201
- elements.contentBody.textContent = 'Loading note content...'
3202
- if (!elements.contentDialog.open) {
3203
- elements.contentDialog.showModal()
3204
- }
3205
-
3206
- const applyDetailToDialog = detail => {
3207
- elements.contentTitle.textContent = detail.title
3208
- elements.contentPath.textContent = detail.path
3209
- elements.contentTags.innerHTML = detail.tags.length
3210
- ? detail.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
3211
- : '<span>No tags</span>'
3212
- elements.contentBody.textContent = detail.content
3213
- }
3214
-
3215
- try {
3216
- const detailedNode = await fetchNodeDetails(node)
3217
- if (state.selected?.id !== node.id) {
3218
- return
3219
- }
3220
- applyDetailToDialog(detailedNode)
3221
- } catch {
3222
- try {
3223
- await wait(120)
3224
- const retriedNode = await fetchNodeDetails(node)
3225
- if (state.selected?.id !== node.id) {
1581
+ if (payload.type === 'frame-stats') {
1582
+ state.lastVisibleNodes = Number.isFinite(payload.visibleNodes) ? payload.visibleNodes : state.lastVisibleNodes
1583
+ state.lastVisibleEdges = Number.isFinite(payload.visibleEdges) ? payload.visibleEdges : state.lastVisibleEdges
3226
1584
  return
3227
1585
  }
3228
- applyDetailToDialog(retriedNode)
3229
- } catch {
3230
- elements.contentBody.textContent = 'Unable to load note content.'
3231
- }
3232
- }
3233
- }
3234
-
3235
- const selectNode = (node, options = { openContent: false }) => {
3236
- state.selected = node
3237
- if (node && options.openContent) {
3238
- openContentDialog(node).catch(() => {
3239
- elements.contentBody.textContent = 'Unable to load note content.'
3240
- })
3241
- }
3242
- }
3243
-
3244
- const selectNodeById = id => {
3245
- const node = state.nodes.find(item => item.id === id)
3246
- if (node) selectNode(node, { openContent: true })
3247
- }
3248
1586
 
3249
- const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
3250
- state.lastManualZoomAt = performance.now()
3251
- const effectiveFactor = factor
3252
- const nextScale = clampScale(state.transform.scale * effectiveFactor)
3253
- if (nextScale === state.transform.scale) {
3254
- return
3255
- }
3256
- const worldPointAtCursor = screenToWorldPoint(screenX, screenY)
3257
- const worldX = worldPointAtCursor.x
3258
- const worldY = worldPointAtCursor.y
3259
- state.lastZoomFocus = {
3260
- x: worldX,
3261
- y: worldY,
3262
- at: performance.now()
3263
- }
3264
- state.transform.scale = clampScale(nextScale)
3265
- state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
3266
- state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
3267
- state.offscreenFrameCount = 0
3268
- markRenderDirty()
3269
- }
3270
-
3271
- const wheelZoomFactor = event => {
3272
- const isModifierZoom = event.metaKey || event.ctrlKey
3273
- const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
3274
- const normalizedDelta = event.deltaY * deltaModeFactor
3275
-
3276
- if (!Number.isFinite(normalizedDelta) || Math.abs(normalizedDelta) <= 0.0001) {
3277
- return 1
3278
- }
3279
-
3280
- const isMassiveEcosystemZoom =
3281
- state.visibleNodes.length > massiveGraphNodeThreshold &&
3282
- state.transform.scale <= massiveEcosystemClusterScaleThreshold
3283
- const sensitivityMultiplier = isMassiveEcosystemZoom ? 0.48 : 1
3284
- const capMultiplier = isMassiveEcosystemZoom ? 0.34 : 1
3285
- const sensitivity = wheelZoomExponent * (isModifierZoom ? wheelZoomModifierBoost : 1) * sensitivityMultiplier
3286
- const exponentCap = wheelZoomExponentCap * capMultiplier
3287
- const exponent = Math.max(
3288
- -exponentCap,
3289
- Math.min(exponentCap, -normalizedDelta * sensitivity)
3290
- )
3291
- return Math.exp(exponent)
3292
- }
3293
-
3294
- const handleWheelZoom = event => {
3295
- if (elements.contentDialog?.open) {
3296
- return
3297
- }
1587
+ if (payload.type === 'fatal') {
1588
+ console.error(payload.message)
1589
+ state.rendererMode = 'fallback'
1590
+ state.workerReady = false
1591
+ state.renderWorker.terminate()
1592
+ state.renderWorker = null
1593
+ drawFallback()
1594
+ }
1595
+ }
3298
1596
 
3299
- event.preventDefault()
3300
- const rect = canvas.getBoundingClientRect()
3301
- const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
3302
- const rawCursorY = Number.isFinite(event.offsetY) ? event.offsetY : event.clientY - rect.top
3303
- const cursorX = Math.max(0, Math.min(Math.max(rect.width, 320), rawCursorX))
3304
- const cursorY = Math.max(0, Math.min(Math.max(rect.height, 320), rawCursorY))
3305
- state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
3306
- const factor = wheelZoomFactor(event)
3307
-
3308
- if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
3309
- return
1597
+ worker.postMessage({
1598
+ type: 'init',
1599
+ canvas: offscreen,
1600
+ width: state.viewport.width,
1601
+ height: state.viewport.height,
1602
+ devicePixelRatio: state.viewport.ratio,
1603
+ camera: state.camera,
1604
+ theme: graphTheme
1605
+ }, [offscreen])
1606
+ } catch (error) {
1607
+ console.error(error)
1608
+ state.rendererMode = 'fallback'
1609
+ drawFallback()
3310
1610
  }
3311
-
3312
- zoomAtPoint(cursorX, cursorY, factor, 'wheel')
3313
1611
  }
3314
1612
 
3315
- const bindEvents = () => {
3316
- window.addEventListener('resize', resize)
3317
- elements.search.addEventListener('input', event => {
3318
- state.query = event.target.value
3319
- recomputeVisibility()
3320
- scheduleContentFilterSync()
3321
- })
3322
- elements.agent.addEventListener('change', event => {
3323
- state.agentId = event.target.value
3324
- writeStoredAgent(state.agentId)
3325
- syncAgentInUrl(state.agentId)
3326
- state.selected = null
3327
- state.nodeDetails = new Map()
3328
- resetContentFilter()
3329
- recomputeVisibility()
3330
- scheduleContentFilterSync()
3331
- loadGraph({ reset: true }).catch(error => {
3332
- console.error(error)
3333
- })
3334
- })
3335
- elements.zoomIn.addEventListener('click', () => {
3336
- const rect = canvas.getBoundingClientRect()
3337
- zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.055, 'button')
3338
- })
3339
- elements.zoomOut.addEventListener('click', () => {
3340
- const rect = canvas.getBoundingClientRect()
3341
- zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.948, 'button')
3342
- })
3343
- if (elements.fit) {
3344
- elements.fit.addEventListener('click', () => {
3345
- focusPrimaryHub()
3346
- })
3347
- }
3348
- elements.reset.addEventListener('click', () => {
3349
- resetView()
3350
- })
3351
- elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
3352
- elements.contentDialog.addEventListener('click', event => {
1613
+ const wireNodeLinkClicks = () => {
1614
+ const dialog = elements.contentDialog
1615
+ dialog.addEventListener('click', (event) => {
3353
1616
  const target = event.target
3354
- if (target instanceof HTMLElement && target.dataset.nodeId) {
3355
- selectNodeById(target.dataset.nodeId)
3356
- return
3357
- }
3358
- if (event.target === elements.contentDialog) elements.contentDialog.close()
3359
- })
3360
- canvas.addEventListener('wheel', handleWheelZoom, { passive: false })
3361
- canvas.addEventListener('dblclick', event => {
3362
- const point = worldPoint(event)
3363
- const node = hitNode(point)
3364
- if (node) {
3365
- selectNode(node, { openContent: true })
1617
+ if (!(target instanceof HTMLElement)) {
3366
1618
  return
3367
1619
  }
3368
1620
 
3369
- const rect = canvas.getBoundingClientRect()
3370
- const cursorX = event.clientX - rect.left
3371
- const cursorY = event.clientY - rect.top
3372
- zoomAtPoint(cursorX, cursorY, 1.055)
3373
- })
3374
- canvas.addEventListener('pointerdown', event => {
3375
- const point = worldPoint(event)
3376
- const node = hitNode(point)
3377
- state.pointer = { x: event.clientX, y: event.clientY, down: true, dragNode: node, moved: false }
3378
- if (node) {
3379
- node.x = point.x
3380
- node.y = point.y
3381
- markRenderDirty()
3382
- }
3383
- canvas.setPointerCapture(event.pointerId)
3384
- })
3385
- canvas.addEventListener('pointermove', event => {
3386
- const point = worldPoint(event)
3387
- const now = performance.now()
3388
- const canHoverHitTest =
3389
- !(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.06)
3390
- const shouldHitTest = canHoverHitTest &&
3391
- (state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
3392
- if (shouldHitTest) {
3393
- state.hovered = hitNode(point)
3394
- state.lastHoverHitAt = now
3395
- } else if (!canHoverHitTest) {
3396
- state.hovered = null
3397
- }
3398
- state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
3399
- if (!state.pointer.down) return
3400
- const dx = event.clientX - state.pointer.x
3401
- const dy = event.clientY - state.pointer.y
3402
- state.pointer.x = event.clientX
3403
- state.pointer.y = event.clientY
3404
- state.pointer.moved = state.pointer.moved || Math.abs(dx) + Math.abs(dy) > 3
3405
- if (state.pointer.dragNode) {
3406
- const dragNode = state.pointer.dragNode
3407
- const previousX = dragNode.x
3408
- const previousY = dragNode.y
3409
- dragNode.x = point.x
3410
- dragNode.y = point.y
3411
- applyDragNeighborhoodAdjustment(dragNode, dragNode.x - previousX, dragNode.y - previousY)
3412
- markRenderDirty()
3413
- return
3414
- }
3415
- state.transform.x += dx
3416
- state.transform.y += dy
3417
- state.transform.x = clampTransformCoordinate(state.transform.x)
3418
- state.transform.y = clampTransformCoordinate(state.transform.y)
3419
- state.offscreenFrameCount = 0
3420
- markRenderDirty()
3421
- })
3422
- canvas.addEventListener('pointerup', event => {
3423
- const draggedNode = state.pointer.dragNode
3424
- if (draggedNode && state.pointer.moved) {
3425
- settleNeighborhoodAroundNode(draggedNode)
3426
- markRenderDirty()
3427
- }
3428
- if (draggedNode && !state.pointer.moved) selectNode(draggedNode, { openContent: false })
3429
- if (!draggedNode && !state.pointer.moved) selectNode(state.hovered, { openContent: false })
3430
- state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
3431
- canvas.releasePointerCapture(event.pointerId)
3432
- })
3433
- canvas.addEventListener('pointercancel', () => {
3434
- state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
3435
- })
3436
- canvas.addEventListener('pointerenter', event => {
3437
- state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
3438
- })
3439
- canvas.addEventListener('pointerleave', event => {
3440
- state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
3441
- })
3442
- window.addEventListener('keydown', event => {
3443
- if (event.key === '+' || event.key === '=') {
3444
- event.preventDefault()
3445
- const rect = canvas.getBoundingClientRect()
3446
- zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.05)
3447
- return
3448
- }
3449
-
3450
- if (event.key === '-' || event.key === '_') {
3451
- event.preventDefault()
3452
- const rect = canvas.getBoundingClientRect()
3453
- zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.952)
1621
+ const button = target.closest('button[data-node-id]')
1622
+ if (!button) {
3454
1623
  return
3455
1624
  }
3456
1625
 
3457
- if (event.key === '0') {
3458
- event.preventDefault()
3459
- resetView()
1626
+ const id = button.getAttribute('data-node-id') || ''
1627
+ if (id) {
1628
+ loadNodeDetails(id).catch((error) => console.error(error))
3460
1629
  }
3461
1630
  })
3462
1631
  }
3463
1632
 
3464
- const loadAgents = async () => {
3465
- const response = await fetch('/api/agents')
3466
- const payload = await response.json()
3467
- const agents = Array.isArray(payload.agents) ? payload.agents : []
3468
- const preferredAgent = state.agentId || initialAgentFromUrl || readStoredAgent()
3469
- const currentExists = agents.some(agent => agent.id === preferredAgent)
3470
- const selected = currentExists
3471
- ? preferredAgent
3472
- : (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
3473
- const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
3474
-
3475
- state.agentId = selected
3476
- writeStoredAgent(selected)
3477
- syncAgentInUrl(selected)
3478
- if (signature !== state.agentsSignature) {
3479
- const formatAgentLabel = (agent) => agent.id
3480
- elements.agent.innerHTML = agents.length
3481
- ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
3482
- : '<option value="shared">shared</option>'
3483
- state.agentsSignature = signature
3484
- }
3485
- elements.agent.value = selected
3486
- }
3487
-
3488
- const loadGraph = async (options = { reset: false }) => {
3489
- const response = await fetch('/api/graph-layout' + agentQuery(), {
3490
- headers: state.graphSignature
3491
- ? {
3492
- 'if-none-match': encodeEntityTag(state.graphSignature)
3493
- }
3494
- : undefined
3495
- })
3496
-
3497
- if (response.status === 304) {
3498
- return
3499
- }
1633
+ const bootstrap = async () => {
1634
+ setViewportFromCanvas()
1635
+ setupRenderWorker()
1636
+ setupInput()
1637
+ setupControls()
1638
+ setupContextControl()
1639
+ wireNodeLinkClicks()
3500
1640
 
3501
- const payload = await response.json()
3502
- const graph = payload?.layout ?? payload
3503
- state.graphTotals = {
3504
- nodes: Number.isFinite(payload?.totals?.nodes) ? payload.totals.nodes : (Array.isArray(graph.nodes) ? graph.nodes.length : 0),
3505
- edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
3506
- }
3507
- const signature = payload?.signature ?? graphSignature(graph)
3508
- if (!options.reset && signature === state.graphSignature) return
3509
- const selectedId = state.selected?.id
3510
- const layout = createLayout(graph)
3511
- state.graphSignature = signature
3512
- state.graph = graph
3513
- state.nodes = layout.nodes
3514
- state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
3515
- state.edges = layout.edges
3516
- state.nodeDegrees = state.edges.reduce((degrees, edge) => {
3517
- degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
3518
- if (edge.target) {
3519
- degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
3520
- }
3521
- return degrees
3522
- }, new Map())
3523
- state.nodeDetails = new Map()
3524
- pushNodesToFilterWorker()
3525
- resetContentFilter()
3526
- sanitizeAllNodePositions()
3527
- recomputeVisibility()
3528
- scheduleContentFilterSync()
3529
- const tags = new Set(state.nodes.flatMap(node => node.tags))
3530
- setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
3531
- elements.nodeCount.textContent = state.graphTotals.nodes
3532
- elements.edgeCount.textContent = state.graphTotals.edges
3533
- elements.tagCount.textContent = tags.size
3534
- resize()
3535
- if (options.reset) resetView()
3536
- const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
3537
- selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
3538
- if (!selectedNode && elements.contentDialog.open) {
3539
- elements.contentDialog.close()
3540
- }
3541
- }
3542
-
3543
- bindEvents()
3544
- initFilterWorker()
3545
- requestAnimationFrame(() => {
3546
- resize()
3547
- resetView()
3548
- })
3549
-
3550
- const pollIntervalMs = 5000
3551
- let tickCounter = 0
3552
-
3553
- const refreshGraphLoop = () => {
3554
- if (document.hidden) {
3555
- return
3556
- }
1641
+ window.addEventListener('resize', () => {
1642
+ setViewportFromCanvas()
1643
+ scheduleChunkFetch()
1644
+ })
3557
1645
 
3558
- loadGraph().catch(handleGraphRefreshError)
1646
+ await loadAgents()
1647
+ await loadContexts()
1648
+ updateTotals()
1649
+ updateTagCount()
3559
1650
 
3560
- tickCounter += 1
3561
- if (tickCounter % 3 === 0) {
3562
- loadAgents().catch((error) => {
3563
- console.error(error)
3564
- })
3565
- }
1651
+ scheduleChunkFetch({ fit: true })
3566
1652
  }
3567
1653
 
3568
- loadAgents()
3569
- .then(() => loadGraph({ reset: true }))
3570
- .then(() => {
3571
- requestAnimationFrame(render)
3572
- setInterval(refreshGraphLoop, pollIntervalMs)
3573
- })
3574
- .catch(error => {
3575
- console.error(error)
3576
- })
3577
-
3578
- document.addEventListener('visibilitychange', () => {
3579
- if (document.hidden) {
3580
- return
3581
- }
3582
-
3583
- loadGraph({ reset: true }).catch(handleGraphRefreshError)
1654
+ bootstrap().catch((error) => {
1655
+ console.error(error)
3584
1656
  })
3585
1657
  `;