@andespindola/brainlink 0.1.0-beta.98 → 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 -3217
  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,3425 +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 maxScale = nodeCount > massiveGraphNodeThreshold
786
- ? massiveEcosystemClusterScaleThreshold
787
- : ecosystemClusterScaleThreshold
788
- const startScale = 0.04
789
- const transitionCount = levelSizes.length - 1
790
- const usableScale = Math.max(0.08, maxScale - startScale)
791
- const step = usableScale / transitionCount
792
- const levels = []
793
- for (let index = 0; index < transitionCount; index += 1) {
794
- const start = startScale + step * index * 0.72
795
- const end = Math.min(maxScale, start + step * 1.85)
796
- levels.push({
797
- parentSize: levelSizes[index],
798
- childSize: levelSizes[index + 1],
799
- start,
800
- end
615
+ state.renderWorker.postMessage({
616
+ type: 'camera',
617
+ camera: state.camera
801
618
  })
802
- }
803
- return levels
619
+ })
804
620
  }
805
621
 
806
- const ecosystemCompactPoint = (index, total, center, spacing) => {
807
- if (total <= 1) {
808
- return { x: center.x, y: center.y }
809
- }
810
- const angle = index * 2.399963229728653
811
- const radius = spacing * Math.sqrt(index + 1)
812
- return {
813
- x: center.x + Math.cos(angle) * radius,
814
- y: center.y + Math.sin(angle) * radius
622
+ const updateWorkerSize = () => {
623
+ updateGraphOverlays()
624
+ if (!state.renderWorker || !state.workerReady) {
625
+ return
815
626
  }
627
+ state.renderWorker.postMessage({
628
+ type: 'resize',
629
+ width: state.viewport.width,
630
+ height: state.viewport.height,
631
+ devicePixelRatio: state.viewport.ratio
632
+ })
816
633
  }
817
634
 
818
- const buildEcosystemCluster = (nodes, index, point) => {
819
- const count = Math.max(nodes.length, 1)
820
- const representative = selectEcosystemRepresentative(nodes)
635
+ const normalizeList = (items) => Array.isArray(items) ? items : []
821
636
 
822
- return {
823
- id: 'ecosystem-' + index,
824
- x: point.x,
825
- y: point.y,
826
- count,
827
- nodeIds: nodes.map(node => node.id),
828
- representative,
829
- label: ecosystemKeyForNode(nodes[0] ?? representative ?? { path: '' })
830
- }
831
- }
832
-
833
- const buildEcosystemHubCluster = (hub, center) => hub
834
- ? {
835
- id: 'ecosystem-hub',
836
- x: center.x,
837
- y: center.y,
838
- count: 1,
839
- size: 1,
840
- nodeIds: [hub.id],
841
- representative: hub,
842
- label: hub.title || 'Memory Hub',
843
- parentId: null,
844
- parentX: null,
845
- parentY: null,
846
- isHub: true
847
- }
848
- : null
849
-
850
- const buildEcosystemLevel = (sortedNodes, size, parentLookup, center) => {
851
- const clusters = []
852
- const clusterByNodeId = new Map()
853
- const parentChildIndex = new Map()
854
-
855
- for (let offset = 0; offset < sortedNodes.length; offset += size) {
856
- const clusterNodes = sortedNodes.slice(offset, offset + size)
857
- const parentCluster = parentLookup?.get(clusterNodes[0]?.id)
858
- const siblingIndex = parentCluster
859
- ? (parentChildIndex.get(parentCluster.id) ?? 0)
860
- : clusters.length
861
- if (parentCluster) {
862
- parentChildIndex.set(parentCluster.id, siblingIndex + 1)
863
- }
864
- const point = parentCluster
865
- ? ecosystemCompactPoint(siblingIndex, Math.ceil((parentCluster.count || size) / size), parentCluster, ecosystemLayoutSpacingForSize(size))
866
- : ecosystemCompactPoint(clusters.length, Math.ceil(sortedNodes.length / size), center, ecosystemLayoutSpacingForSize(size))
867
- const cluster = {
868
- ...buildEcosystemCluster(clusterNodes, clusters.length, point),
869
- id: 'ecosystem-' + size + '-' + clusters.length,
870
- size,
871
- parentId: parentCluster?.id ?? null,
872
- parentX: parentCluster?.x ?? null,
873
- parentY: parentCluster?.y ?? null
874
- }
875
- clusters.push(cluster)
876
- for (let index = 0; index < clusterNodes.length; index += 1) {
877
- clusterByNodeId.set(clusterNodes[index].id, cluster)
878
- }
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
879
642
  }
880
643
 
881
- return { clusters, clusterByNodeId }
882
- }
644
+ const next = [...node]
645
+ next[2] = position.x
646
+ next[3] = position.y
647
+ return next
648
+ })
883
649
 
884
- const buildEcosystemGraph = (nodes, center, hub) => {
885
- if (nodes.length === 0) {
886
- return {
887
- clusters: [],
888
- clustersBySize: new Map(),
889
- nodeClusterBySize: new Map(),
890
- levelSizes: [],
891
- expansionLevels: [],
892
- baseSize: ecosystemLevelNodeCap,
893
- hubCluster: null
894
- }
650
+ const updateNodePositionInChunk = (nodeId, x, y) => {
651
+ if (!nodeId || !Number.isFinite(x) || !Number.isFinite(y)) {
652
+ return
895
653
  }
896
654
 
897
- const hubCluster = buildEcosystemHubCluster(hub, center)
898
- const sortedNodes = nodes
899
- .filter(node => node.id !== hub?.id)
900
- .sort(compareNodesForEcosystem)
901
- const levelSizes = buildEcosystemLevelSizes(sortedNodes.length)
902
- const expansionLevels = buildEcosystemExpansionLevels(levelSizes, nodes.length)
903
- const baseSize = levelSizes[0] ?? ecosystemLevelNodeCap
904
- const clustersBySize = new Map()
905
- const nodeClusterBySize = new Map()
906
- let parentLookup = null
907
-
908
- for (let index = 0; index < levelSizes.length; index += 1) {
909
- const size = levelSizes[index]
910
- const level = buildEcosystemLevel(sortedNodes, size, parentLookup, center)
911
- clustersBySize.set(size, level.clusters)
912
- nodeClusterBySize.set(size, level.clusterByNodeId)
913
- 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
+ })
914
666
  }
667
+ state.spatialIndex.key = ''
915
668
 
916
- return {
917
- clusters: clustersBySize.get(baseSize) ?? [],
918
- clustersBySize,
919
- nodeClusterBySize,
920
- levelSizes,
921
- expansionLevels,
922
- baseSize,
923
- hubCluster
669
+ if (state.renderWorker && state.workerReady) {
670
+ state.renderWorker.postMessage({ type: 'move-node', id: nodeId, x, y })
924
671
  }
672
+ updateGraphOverlays()
925
673
  }
926
674
 
927
- const isClusterInViewport = (cluster, viewport) =>
928
- cluster.x >= viewport.minX &&
929
- cluster.x <= viewport.maxX &&
930
- cluster.y >= viewport.minY &&
931
- cluster.y <= viewport.maxY
675
+ const focusNodeInViewport = (nodeId, nextScale = null) => {
676
+ const node = nodeByIdFromChunk().get(nodeId)
677
+ if (!node) {
678
+ return false
679
+ }
932
680
 
933
- const filterEcosystemClustersByViewport = (clusters, viewport) => {
934
- const visible = clusters.filter(cluster => isClusterInViewport(cluster, viewport))
935
- return visible.length > 0 ? visible : [...clusters]
936
- }
937
-
938
- const ecosystemFocusPoint = () => {
939
- const cursorPoint = cursorWorldPoint()
940
- if (cursorPoint) {
941
- return cursorPoint
942
- }
943
- const now = performance.now()
944
- if (now - state.lastZoomFocus.at <= 1800) {
945
- 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
946
685
  }
947
- return viewportCenterWorldPoint()
948
- }
949
-
950
- const nearestEcosystemParentIds = (clusters, focusPoint, limit) =>
951
- clusters
952
- .map(cluster => ({
953
- cluster,
954
- distance: Math.max(
955
- 0,
956
- Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y) -
957
- clusterRadiusPx(cluster) / Math.max(state.transform.scale, 0.0001)
958
- )
959
- }))
960
- .sort((left, right) => left.distance - right.distance)
961
- .slice(0, limit)
962
- .map(item => item.cluster.id)
963
-
964
- const smoothStep = value => {
965
- const clamped = Math.max(0, Math.min(1, value))
966
- return clamped * clamped * (3 - clamped * 2)
967
- }
968
-
969
- const zoomProgress = (scale, start, end) =>
970
- smoothStep((scale - start) / Math.max(end - start, 0.0001))
971
686
 
972
- const semanticZoomSpread = (progress, childSize) => {
973
- const curve = Math.pow(progress, 4.2)
974
- if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
975
- return 0.12 + curve * 0.88
687
+ if (Number.isFinite(nextScale)) {
688
+ state.camera.scale = clampScale(Number(nextScale))
976
689
  }
977
- 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
978
695
  }
979
696
 
980
- const opacityForProgress = (progress, childSize) => {
981
- const eased = Math.pow(progress, 2.1)
982
- if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
983
- return 0.22 + eased * 0.78
697
+ const showTooltip = (node, pointer) => {
698
+ if (!elements.tooltip || !node) {
699
+ return
984
700
  }
985
- return eased
986
- }
987
701
 
988
- const expandFocusedClusters = (parentClusters, childSize, progress, spread, viewport) => {
989
- const focusPoint = ecosystemFocusPoint()
990
- const expandedParentIds = new Set(nearestEcosystemParentIds(
991
- parentClusters,
992
- focusPoint,
993
- ecosystemFocusedParentLimit
994
- ))
995
- const childClusters = state.ecosystemClustersBySize.get(childSize) ?? []
996
- const visibleChildClusters = childClusters
997
- .filter(cluster => expandedParentIds.has(cluster.parentId))
998
- .map(cluster => spreadChildClusterFromParent(cluster, childSize, progress, spread))
999
- .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
+ }
1000
709
 
1001
- return {
1002
- expandedParentIds,
1003
- childClusters: visibleChildClusters
710
+ const hideTooltip = () => {
711
+ if (elements.tooltip) {
712
+ elements.tooltip.hidden = true
1004
713
  }
1005
714
  }
1006
715
 
1007
- const spreadChildClusterFromParent = (cluster, childSize, progress, spread) => {
1008
- if (!Number.isFinite(cluster.parentX) || !Number.isFinite(cluster.parentY)) {
1009
- return {
1010
- ...cluster,
1011
- lodOpacity: opacityForProgress(progress, childSize)
1012
- }
1013
- }
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
1014
727
 
1015
- return {
1016
- ...cluster,
1017
- x: cluster.parentX + (cluster.x - cluster.parentX) * spread,
1018
- y: cluster.parentY + (cluster.y - cluster.parentY) * spread,
1019
- lodOpacity: opacityForProgress(progress, childSize)
1020
- }
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)
1021
737
  }
1022
738
 
1023
- const selectHierarchicalEcosystemClusters = viewport => {
1024
- const baseClusters = state.ecosystemClustersBySize.get(state.ecosystemBaseSize) ?? state.ecosystemClusters
1025
- const visibleBaseClusters = filterEcosystemClustersByViewport(baseClusters, viewport)
1026
- const hubClusters = state.ecosystemHubCluster ? [state.ecosystemHubCluster] : []
1027
- const visibleClusters = [...visibleBaseClusters]
1028
-
1029
- for (let index = 0; index < state.ecosystemExpansionLevels.length; index += 1) {
1030
- const level = state.ecosystemExpansionLevels[index]
1031
- const parentClusters = visibleClusters.filter(cluster => cluster.size === level.parentSize)
1032
- if (parentClusters.length === 0) {
1033
- continue
1034
- }
1035
- const progress = zoomProgress(state.transform.scale, level.start, level.end)
1036
- const spread = semanticZoomSpread(progress, level.childSize)
1037
- const expansion = expandFocusedClusters(parentClusters, level.childSize, progress, spread, viewport)
1038
- visibleClusters.push(...expansion.childClusters)
739
+ const drawLabels = () => {
740
+ if (!elements.labels) {
741
+ return
1039
742
  }
1040
743
 
1041
- 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('')
1042
750
  }
1043
751
 
1044
- const ecosystemSiblingEdgesForClusters = (clusters, existingEdges) => {
1045
- const byParent = new Map()
1046
- for (let index = 0; index < clusters.length; index += 1) {
1047
- const cluster = clusters[index]
1048
- if (cluster.isHub || !cluster.parentId) {
1049
- continue
1050
- }
1051
- const siblings = byParent.get(cluster.parentId)
1052
- if (siblings) {
1053
- siblings.push(cluster)
1054
- } else {
1055
- byParent.set(cluster.parentId, [cluster])
1056
- }
752
+ const drawMiniMap = () => {
753
+ const miniMap = elements.miniMap
754
+ if (!(miniMap instanceof HTMLCanvasElement)) {
755
+ return
1057
756
  }
1058
-
1059
- const edges = []
1060
- for (const siblings of byParent.values()) {
1061
- const ordered = [...siblings]
1062
- .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)))
1063
- for (let index = 0; index < ordered.length && edges.length < ecosystemSiblingEdgeLimit; index += 1) {
1064
- const sourceCluster = ordered[index]
1065
- const targetCluster = ordered[(index + 1) % ordered.length]
1066
- if (!targetCluster || sourceCluster.id === targetCluster.id) {
1067
- continue
1068
- }
1069
- const orderedIds = sourceCluster.id < targetCluster.id
1070
- ? [sourceCluster.id, targetCluster.id]
1071
- : [targetCluster.id, sourceCluster.id]
1072
- const key = orderedIds.join(':')
1073
- if (existingEdges.has(key)) {
1074
- continue
1075
- }
1076
- const edge = {
1077
- id: key,
1078
- sourceCluster,
1079
- targetCluster,
1080
- weight: 0.7,
1081
- inferred: true
1082
- }
1083
- existingEdges.set(key, edge)
1084
- edges.push(edge)
1085
- }
757
+ const nodes = normalizeList(state.chunk.nodes)
758
+ const ctx = miniMap.getContext('2d')
759
+ if (!ctx || nodes.length === 0) {
760
+ return
1086
761
  }
1087
762
 
1088
- 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))
1089
803
  }
1090
804
 
1091
- const ecosystemEdgesForClusters = clusters => {
1092
- const edgeClusters = clusters.filter(cluster => cluster.isHub || clusterOpacity(cluster) > 0.018)
1093
- const clusterById = new Map(edgeClusters.map(cluster => [cluster.id, cluster]))
1094
- const clusterIds = new Set(clusterById.keys())
1095
- const levelsBySize = []
1096
- for (let index = 0; index < edgeClusters.length; index += 1) {
1097
- const cluster = edgeClusters[index]
1098
- if (!cluster.size || cluster.isHub) continue
1099
- if (!levelsBySize.some(level => level.size === cluster.size)) {
1100
- levelsBySize.push({
1101
- size: cluster.size,
1102
- lookup: state.ecosystemNodeClusterBySize.get(cluster.size) ?? new Map()
1103
- })
1104
- }
805
+ const shouldDeferGraphOverlays = () => state.pointer.down || performance.now() - state.lastWheelAt < 150
806
+
807
+ const updateGraphOverlays = () => {
808
+ if (state.overlayScheduled) {
809
+ return
1105
810
  }
1106
- levelsBySize.sort((left, right) => left.size - right.size)
1107
- const resolveClusterForNode = nodeId => {
1108
- if (state.ecosystemHubCluster?.nodeIds.includes(nodeId) && clusterIds.has(state.ecosystemHubCluster.id)) {
1109
- return state.ecosystemHubCluster
1110
- }
1111
- for (let index = 0; index < levelsBySize.length; index += 1) {
1112
- const lookup = levelsBySize[index].lookup
1113
- const cluster = lookup.get(nodeId)
1114
- if (cluster && clusterIds.has(cluster.id)) {
1115
- 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)
1116
821
  }
822
+ return
1117
823
  }
1118
- return null
1119
- }
1120
-
1121
- const edgeByClusterPair = new Map()
1122
- for (let index = 0; index < state.visibleEdges.length; index += 1) {
1123
- const edge = state.visibleEdges[index]
1124
- const sourceCluster = resolveClusterForNode(edge.source)
1125
- const targetCluster = resolveClusterForNode(edge.target)
1126
- if (!sourceCluster || !targetCluster || sourceCluster.id === targetCluster.id) {
1127
- continue
1128
- }
1129
-
1130
- const orderedIds = sourceCluster.id < targetCluster.id
1131
- ? [sourceCluster.id, targetCluster.id]
1132
- : [targetCluster.id, sourceCluster.id]
1133
- const key = orderedIds.join(':')
1134
- const current = edgeByClusterPair.get(key)
1135
- if (current) {
1136
- current.weight += edgeWeight(edge)
1137
- continue
824
+ elements.labels?.classList.remove('is-stale')
825
+ drawLabels()
826
+ if (state.miniMapDirty) {
827
+ drawMiniMap()
828
+ state.miniMapDirty = false
1138
829
  }
830
+ })
831
+ }
1139
832
 
1140
- edgeByClusterPair.set(key, {
1141
- id: key,
1142
- sourceCluster,
1143
- targetCluster,
1144
- 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>'
1145
848
  })
1146
- }
1147
-
1148
- ecosystemSiblingEdgesForClusters(edgeClusters, edgeByClusterPair)
1149
- const edges = Array.from(edgeByClusterPair.values())
1150
- .sort((left, right) => right.weight - left.weight)
1151
- .slice(0, ecosystemClusterEdgeLimit)
1152
- const hubCluster = state.ecosystemHubCluster && clusterIds.has(state.ecosystemHubCluster.id)
1153
- ? state.ecosystemHubCluster
1154
- : null
1155
- if (!hubCluster) {
1156
- return edges
1157
- }
1158
-
1159
- const existingHubTargets = new Set(edges.flatMap(edge =>
1160
- edge.sourceCluster.id === hubCluster.id
1161
- ? [edge.targetCluster.id]
1162
- : edge.targetCluster.id === hubCluster.id
1163
- ? [edge.sourceCluster.id]
1164
- : []
1165
- ))
1166
- const syntheticHubEdges = edgeClusters
1167
- .filter(cluster => cluster.id !== hubCluster.id && !existingHubTargets.has(cluster.id))
1168
- .slice(0, ecosystemHubEdgeLimit)
1169
- .map(cluster => ({
1170
- id: hubCluster.id + ':' + cluster.id,
1171
- sourceCluster: hubCluster,
1172
- targetCluster: cluster,
1173
- weight: 1,
1174
- inferred: true
1175
- }))
1176
- return edges.concat(syntheticHubEdges)
849
+ .join('')
1177
850
  }
1178
851
 
1179
- const edgeBudgetForCurrentFrame = () => {
1180
- const zoom = state.transform.scale
1181
- if (zoom < 0.12) return 380
1182
- if (zoom < 0.18) return 900
1183
- if (zoom < 0.28) return 1700
1184
- if (zoom < 0.45) return 2800
1185
- if (zoom < 0.7) return 4200
1186
- if (zoom < 1.05) return 5600
1187
- 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
+ ]
1188
862
  }
1189
863
 
1190
- const clusterBudgetForScale = (scale) => {
1191
- if (scale < 0.008) return 90
1192
- if (scale < 0.014) return 150
1193
- if (scale < 0.022) return 240
1194
- if (scale < 0.035) return 360
1195
- return 520
1196
- }
864
+ const listFacts = (facts) => facts
865
+ .map((fact) => '<li><strong>' + escapeHtml(fact.label) + ':</strong> <small>' + escapeHtml(fact.value) + '</small></li>')
866
+ .join('')
1197
867
 
1198
- const nodeBudgetForScale = (scale) => {
1199
- if (scale < 0.035) return 220
1200
- if (scale < 0.06) return 360
1201
- if (scale < 0.09) return 520
1202
- if (scale < 0.14) return 720
1203
- if (state.visibleNodes.length > massiveGraphNodeThreshold) {
1204
- if (scale < 0.28) return renderNodeBudget
1205
- if (scale < 0.45) return 1100
1206
- if (scale < 0.7) return 1400
1207
- if (scale < 1.05) return 1800
1208
- return zoomedMassiveRenderNodeBudget
868
+ const listContextLinks = (links) => {
869
+ if (!Array.isArray(links) || links.length === 0) {
870
+ return '<li><small>No context links found.</small></li>'
1209
871
  }
1210
- return renderNodeBudget
872
+ return links
873
+ .map((link) => '<li><span>' + escapeHtml(link.title) + '</span><small>' + escapeHtml(link.priority || 'normal') + '</small></li>')
874
+ .join('')
1211
875
  }
1212
876
 
1213
- const layerFocusForScale = (scale) => {
1214
- const normalized = Math.max(0, Math.min(1, (scale - 0.06) / 0.94))
1215
- const shellCenter = Math.max(0.08, 0.96 - normalized * 0.86)
1216
- const shellWidth = Math.max(0.24, 0.46 - normalized * 0.16)
1217
- const coreRadius = Math.max(0.06, 0.1 + normalized * 0.22)
1218
- 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]))
1219
880
 
1220
- 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)
1221
890
  }
1222
891
 
1223
- const selectLayeredNodesForScale = (sourceNodes, targetCount) => {
1224
- const hub = state.primaryHub
1225
- if (!hub || sourceNodes.length <= 1200 || state.visibleNodes.length <= massiveGraphNodeThreshold) {
1226
- return sourceNodes
1227
- }
892
+ const linkedNodes = (node) => {
893
+ const nodeById = new Map((state.chunk.nodes || []).map((item) => [item[0], item]))
894
+ const edges = normalizeList(state.chunk.edges)
1228
895
 
1229
- let maxDistance = 0
1230
- const distances = sourceNodes.map((node) => {
1231
- const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
1232
- if (distance > maxDistance) {
1233
- 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
+ }
1234
911
  }
1235
- return { node, distance }
1236
- })
1237
-
1238
- if (maxDistance <= 0.001) {
1239
- return sourceNodes
1240
912
  }
1241
913
 
1242
- const focus = layerFocusForScale(state.transform.scale)
1243
- const normalizedRows = distances.map((item) => ({
1244
- ...item,
1245
- normalized: item.distance / maxDistance
1246
- }))
1247
- const desired = Math.max(260, Math.min(sourceNodes.length, targetCount * 2))
1248
- const coreTarget = Math.max(36, Math.min(desired - 8, Math.floor(desired * focus.coreRatio)))
1249
- const shellTarget = Math.max(12, desired - coreTarget)
1250
- const shellHalf = focus.shellWidth / 2
914
+ return { outgoing, incoming }
915
+ }
1251
916
 
1252
- const coreNodes = normalizedRows
1253
- .filter((item) => item.normalized <= focus.coreRadius)
1254
- .sort((left, right) => {
1255
- const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
1256
- const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
1257
- if (leftScore !== rightScore) return rightScore - leftScore
1258
- return left.node.id.localeCompare(right.node.id)
1259
- })
1260
- .slice(0, coreTarget)
1261
- .map((item) => item.node)
917
+ const openContentDialog = () => {
918
+ elements.contentDialog.hidden = false
919
+ }
1262
920
 
1263
- const shellNodes = normalizedRows
1264
- .sort((left, right) => {
1265
- const leftDelta = Math.abs(left.normalized - focus.shellCenter)
1266
- const rightDelta = Math.abs(right.normalized - focus.shellCenter)
1267
- const leftInside = leftDelta <= shellHalf ? 0 : 1
1268
- const rightInside = rightDelta <= shellHalf ? 0 : 1
1269
- if (leftInside !== rightInside) return leftInside - rightInside
1270
- if (leftDelta !== rightDelta) return leftDelta - rightDelta
1271
- const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
1272
- const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
1273
- if (leftScore !== rightScore) return rightScore - leftScore
1274
- return left.node.id.localeCompare(right.node.id)
1275
- })
1276
- .slice(0, shellTarget)
1277
- .map((item) => item.node)
921
+ const closeContentDialog = () => {
922
+ elements.contentDialog.hidden = true
923
+ }
1278
924
 
1279
- const merged = []
1280
- const ids = new Set()
1281
- const pushUnique = (node) => {
1282
- if (!node || ids.has(node.id)) return
1283
- ids.add(node.id)
1284
- merged.push(node)
925
+ const loadNodeDetails = async (nodeId) => {
926
+ if (!nodeId) {
927
+ return
1285
928
  }
1286
929
 
1287
- if (state.transform.scale >= layeredCoreScaleThreshold) {
1288
- 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')
1289
933
  }
1290
- for (let index = 0; index < coreNodes.length; index += 1) pushUnique(coreNodes[index])
1291
- for (let index = 0; index < shellNodes.length; index += 1) pushUnique(shellNodes[index])
1292
-
1293
- return merged.length > 0 ? merged : sourceNodes
1294
- }
1295
934
 
1296
- const viewportCenterWorldPoint = () => {
1297
- const viewport = worldViewportBounds()
1298
- return {
1299
- x: (viewport.minX + viewport.maxX) / 2,
1300
- 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')
1301
938
  }
1302
- }
1303
939
 
1304
- const screenToWorldPoint = (screenX, screenY) => ({
1305
- x: (screenX - state.transform.x) / state.transform.scale,
1306
- y: (screenY - state.transform.y) / state.transform.scale
1307
- })
940
+ const node = payload.node
941
+ state.selectedNodeId = node.id
942
+ setFocusedNodeIds(linkedNodeIds(node.id))
1308
943
 
1309
- const cursorWorldPoint = () => {
1310
- if (!state.cursor.inCanvas) {
1311
- return null
1312
- }
1313
- const rect = canvas.getBoundingClientRect()
1314
- const screenX = state.cursor.x - rect.left
1315
- const screenY = state.cursor.y - rect.top
1316
- const width = Math.max(rect.width, 320)
1317
- const height = Math.max(rect.height, 320)
1318
- if (!Number.isFinite(screenX) || !Number.isFinite(screenY)) {
1319
- return null
1320
- }
1321
- if (screenX < 0 || screenX > width || screenY < 0 || screenY > height) {
1322
- return null
944
+ if (state.renderWorker && state.workerReady) {
945
+ state.renderWorker.postMessage({ type: 'select', id: node.id })
1323
946
  }
1324
- return screenToWorldPoint(screenX, screenY)
1325
- }
1326
947
 
1327
- const visibilityScaleBucket = (scale) => {
1328
- const safeScale = Math.max(zoomRange.min, scale)
1329
- if (safeScale < 0.01) return Math.round(safeScale * 300_000)
1330
- if (safeScale < 0.05) return Math.round(safeScale * 120_000)
1331
- if (safeScale < 0.2) return Math.round(safeScale * 40_000)
1332
- return Math.round(safeScale * 8_000)
1333
- }
948
+ elements.contentTitle.textContent = node.title || 'Untitled'
949
+ elements.contentPath.textContent = node.path || ''
1334
950
 
1335
- const shouldRenderMacroGalaxyView = () => {
1336
- if (!galaxyDiscoveryEnabled) {
1337
- state.macroViewActive = false
1338
- return false
1339
- }
1340
- if (state.visibleNodes.length <= 1) {
1341
- state.macroViewActive = false
1342
- return false
1343
- }
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 : ''
1344
964
 
1345
- const enterThreshold = macroGalaxyZoomThreshold * macroGalaxyEnterHysteresis
1346
- const exitThreshold = macroGalaxyZoomThreshold * macroGalaxyExitHysteresis
1347
- const shouldRender = state.macroViewActive
1348
- ? state.transform.scale <= exitThreshold
1349
- : state.transform.scale <= enterThreshold
1350
- state.macroViewActive = shouldRender
1351
- return shouldRender
965
+ openContentDialog()
1352
966
  }
1353
967
 
1354
- const mergeUniqueNodes = (leftNodes, rightNodes, limit) => {
1355
- const merged = []
1356
- const ids = new Set()
968
+ const fitFromChunk = () => {
969
+ const nodes = normalizeList(state.chunk.nodes)
970
+ if (nodes.length === 0) {
971
+ return
972
+ }
1357
973
 
1358
- const push = (node) => {
1359
- if (!node || ids.has(node.id) || merged.length >= limit) {
1360
- 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
1361
985
  }
1362
- ids.add(node.id)
1363
- 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
1364
990
  }
1365
991
 
1366
- for (let index = 0; index < leftNodes.length && merged.length < limit; index += 1) {
1367
- push(leftNodes[index])
1368
- }
1369
- for (let index = 0; index < rightNodes.length && merged.length < limit; index += 1) {
1370
- push(rightNodes[index])
992
+ if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
993
+ return
1371
994
  }
1372
995
 
1373
- return merged
1374
- }
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
+ })
1375
1031
 
1376
- const selectStableSampleNodes = (sourceNodes, limit) => {
1377
- if (sourceNodes.length <= limit) {
1378
- return sourceNodes
1032
+ if (state.agentId) {
1033
+ params.set('agent', state.agentId)
1379
1034
  }
1380
-
1381
- const now = performance.now()
1382
- const cursorPoint = cursorWorldPoint()
1383
- const recentZoomFocus =
1384
- now - state.lastZoomFocus.at <= 1500
1385
- ? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
1386
- : null
1387
- const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
1388
- const previousIds = new Set(state.renderNodes.map((node) => node.id))
1389
- const preferAnchorDistance = state.visibleNodes.length > massiveGraphNodeThreshold && state.transform.scale >= 0.28
1390
-
1391
- return [...sourceNodes]
1392
- .sort((left, right) => {
1393
- const leftWasVisible = previousIds.has(left.id) ? 1 : 0
1394
- const rightWasVisible = previousIds.has(right.id) ? 1 : 0
1395
- const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
1396
- const rightDistance = Math.hypot(right.x - anchor.x, right.y - anchor.y)
1397
-
1398
- if (preferAnchorDistance) {
1399
- if (leftDistance !== rightDistance) return leftDistance - rightDistance
1400
- if (leftWasVisible !== rightWasVisible) return rightWasVisible - leftWasVisible
1401
- } else {
1402
- if (leftWasVisible !== rightWasVisible) return rightWasVisible - leftWasVisible
1403
- if (leftDistance !== rightDistance) return leftDistance - rightDistance
1404
- }
1405
-
1406
- const leftDegree = state.nodeDegrees.get(left.id) ?? 0
1407
- const rightDegree = state.nodeDegrees.get(right.id) ?? 0
1408
- if (leftDegree !== rightDegree) return rightDegree - leftDegree
1409
-
1410
- return left.id.localeCompare(right.id)
1411
- })
1412
- .slice(0, limit)
1413
- }
1414
-
1415
- const selectAccessBridgeNodes = (sourceNodes, limit) => {
1416
- if (limit <= 0 || sourceNodes.length === 0) {
1417
- return []
1035
+ if (state.contextId) {
1036
+ params.set('context', state.contextId)
1418
1037
  }
1419
1038
 
1420
- const now = performance.now()
1421
- const cursorPoint = cursorWorldPoint()
1422
- const recentZoomFocus =
1423
- now - state.lastZoomFocus.at <= 1200
1424
- ? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
1425
- : null
1426
- const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
1427
- return [...sourceNodes]
1428
- .sort((left, right) => {
1429
- const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
1430
- const rightDistance = Math.hypot(right.x - anchor.x, right.y - anchor.y)
1431
- if (leftDistance !== rightDistance) return leftDistance - rightDistance
1432
- const leftDegree = state.nodeDegrees.get(left.id) ?? 0
1433
- const rightDegree = state.nodeDegrees.get(right.id) ?? 0
1434
- if (leftDegree !== rightDegree) return rightDegree - leftDegree
1435
- return left.id.localeCompare(right.id)
1436
- })
1437
- .slice(0, limit)
1438
- }
1439
-
1440
- const edgeIdentityKey = edge => {
1441
- if (!edge.target) return ''
1442
- const pair = edge.source < edge.target
1443
- ? edge.source + '|' + edge.target
1444
- : edge.target + '|' + edge.source
1445
- return pair + '|' + (edge.inferred ? 'mesh' : 'real')
1446
- }
1447
-
1448
- const edgeRelevanceScore = edge => {
1449
- let score = edgeWeight(edge) * 10
1450
- if (!edge.inferred) {
1451
- score += 8
1039
+ const requestKey = graphStreamRequestKey({ x, y, w, h })
1040
+ if (!fit && state.lastChunkRequestKey === requestKey && state.chunk.nodes.length > 0) {
1041
+ return
1452
1042
  }
1453
1043
 
1454
- const selectedId = state.selected?.id
1455
- if (selectedId && (edge.source === selectedId || edge.target === selectedId)) {
1456
- 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')
1457
1047
  }
1458
1048
 
1459
- const hoveredId = state.hovered?.id
1460
- if (hoveredId && (edge.source === hoveredId || edge.target === hoveredId)) {
1461
- score += 70
1049
+ const chunk = await response.json()
1050
+ if (controller.signal.aborted) {
1051
+ return
1052
+ }
1053
+ if (token !== state.fetchToken) {
1054
+ return
1462
1055
  }
1463
1056
 
1464
- const hubId = state.primaryHub?.id
1465
- if (hubId && (edge.source === hubId || edge.target === hubId)) {
1466
- 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
1467
1073
  }
1468
1074
 
1469
- return score
1470
- }
1075
+ updateTotals()
1076
+ updateTagCount()
1471
1077
 
1472
- const collectVisibleEdgesForNodes = nodeIds => {
1473
- if (nodeIds.size === 0) {
1474
- return []
1078
+ if (fit) {
1079
+ fitFromChunk()
1475
1080
  }
1476
1081
 
1477
- const seen = new Set()
1478
- const candidates = []
1479
- const limit = edgeBudgetForCurrentFrame()
1480
-
1481
- nodeIds.forEach(nodeId => {
1482
- const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
1483
- for (let index = 0; index < candidateEdges.length; index += 1) {
1484
- const edge = candidateEdges[index]
1485
- if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
1486
- continue
1487
- }
1488
- const key = edgeIdentityKey(edge)
1489
- 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
+ }
1490
1087
 
1491
- seen.add(key)
1492
- candidates.push(edge)
1493
- }
1494
- })
1088
+ updateGraphOverlays()
1089
+ drawFallback()
1090
+ }
1495
1091
 
1496
- if (candidates.length <= limit) {
1497
- return candidates
1092
+ const scheduleChunkFetch = ({ fit } = { fit: false }) => {
1093
+ if (state.fetchTimer) {
1094
+ clearTimeout(state.fetchTimer)
1498
1095
  }
1499
1096
 
1500
- return candidates
1501
- .sort((left, right) => {
1502
- const scoreDelta = edgeRelevanceScore(right) - edgeRelevanceScore(left)
1503
- if (scoreDelta !== 0) {
1504
- 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
1505
1106
  }
1506
- const leftKey = edgeIdentityKey(left)
1507
- const rightKey = edgeIdentityKey(right)
1508
- return leftKey.localeCompare(rightKey)
1107
+ console.error(error)
1509
1108
  })
1510
- .slice(0, limit)
1109
+ }, delay)
1511
1110
  }
1512
1111
 
1513
- const edgeOpacityForScale = (edge, scale) => {
1514
- if (edge.inferred) {
1515
- if (scale < 0.2) return 0.06
1516
- if (scale < 0.4) return 0.08
1517
- if (scale < 0.7) return 0.1
1518
- return 0.14
1519
- }
1520
-
1521
- if (scale < 0.2) return 0.14
1522
- if (scale < 0.4) return 0.2
1523
- if (scale < 0.7) return 0.28
1524
- if (scale < 1.05) return 0.36
1525
- 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()
1526
1120
  }
1527
1121
 
1528
- const edgeStrokeFor = (edge, selectedEdge) => {
1529
- if (selectedEdge) {
1530
- return graphTheme.edgeActive
1122
+ const pickFallbackNode = (screenX, screenY) => {
1123
+ const nodes = spatialCandidates(screenX, screenY)
1124
+ if (nodes.length === 0) {
1125
+ return null
1531
1126
  }
1532
1127
 
1533
- const opacity = edgeOpacityForScale(edge, state.transform.scale)
1534
- return edge.inferred
1535
- ? 'rgba(203, 213, 225, ' + opacity + ')'
1536
- : 'rgba(153, 165, 181, ' + opacity + ')'
1537
- }
1538
-
1539
- const edgeWidthFor = (edge, selectedEdge) => {
1540
- if (edge.inferred) {
1541
- 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
+ }
1542
1145
  }
1543
1146
 
1544
- return (selectedEdge ? 1.9 : 1.05) + Math.min(edgeWeight(edge) - 1, 8) * 0.24
1147
+ return bestNode
1545
1148
  }
1546
1149
 
1547
- const drawGraphEdge = (edge) => {
1548
- const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
1549
- ctx.beginPath()
1550
- ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
1551
- ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
1552
- ctx.strokeStyle = edgeStrokeFor(edge, selectedEdge)
1553
- ctx.lineWidth = edgeWidthFor(edge, selectedEdge)
1554
- ctx.stroke()
1150
+ const pickFallbackNodeId = (screenX, screenY) => {
1151
+ const node = pickFallbackNode(screenX, screenY)
1152
+ return typeof node?.[0] === 'string' ? node[0] : ''
1555
1153
  }
1556
1154
 
1557
- const drawEdgeBatch = (edges, options) => {
1558
- 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) {
1559
1158
  return
1560
1159
  }
1561
1160
 
1562
- ctx.beginPath()
1563
- for (let index = 0; index < edges.length; index += 1) {
1564
- const edge = edges[index]
1565
- ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
1566
- 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
1567
1167
  }
1568
- ctx.strokeStyle = options.strokeStyle
1569
- ctx.lineWidth = options.lineWidth
1570
- ctx.stroke()
1571
- }
1572
-
1573
- const regularEdgeBatchOptions = (edge) => ({
1574
- strokeStyle: edgeStrokeFor(edge, false),
1575
- lineWidth: edgeWidthFor(edge, false)
1576
- })
1577
1168
 
1578
- const regularEdgeBatchKey = (edge) => {
1579
- const options = regularEdgeBatchOptions(edge)
1580
- return options.strokeStyle + '|' + options.lineWidth.toFixed(2)
1169
+ loadNodeDetails(nodeId).catch((error) => console.error(error))
1581
1170
  }
1582
1171
 
1583
- const drawGraphEdges = () => {
1584
- const edgeBatches = new Map()
1585
- const selectedEdges = []
1586
-
1587
- for (let index = 0; index < state.renderEdges.length; index += 1) {
1588
- const edge = state.renderEdges[index]
1589
- const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
1590
- if (isSelected) {
1591
- selectedEdges.push(edge)
1592
- continue
1593
- }
1594
-
1595
- const key = regularEdgeBatchKey(edge)
1596
- const batch = edgeBatches.get(key)
1597
- if (batch) {
1598
- batch.edges.push(edge)
1599
- } else {
1600
- edgeBatches.set(key, {
1601
- edges: [edge],
1602
- options: regularEdgeBatchOptions(edge)
1603
- })
1172
+ const pickAt = (screenX, screenY) => {
1173
+ if (state.rendererMode === 'fallback') {
1174
+ const node = pickFallbackNode(screenX, screenY)
1175
+ if (node) {
1176
+ handlePickedNode(node)
1604
1177
  }
1178
+ return
1605
1179
  }
1606
1180
 
1607
- edgeBatches.forEach((batch) => drawEdgeBatch(batch.edges, batch.options))
1608
-
1609
- for (let index = 0; index < selectedEdges.length; index += 1) {
1610
- drawGraphEdge(selectedEdges[index])
1181
+ if (!state.renderWorker || !state.workerReady) {
1182
+ return
1611
1183
  }
1612
- }
1613
-
1614
- const shouldDrawNodeLabels = (node, isSelected, isHovered) =>
1615
- isSelected ||
1616
- isHovered ||
1617
- (state.nodes.length > largeGraphNodeThreshold && state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) ||
1618
- (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
1619
1184
 
1620
- const drawSingleNode = (node, options = { drawLabel: true }) => {
1621
- const radius = nodeRadius(node)
1622
- const isSelected = state.selected?.id === node.id
1623
- const isHovered = state.hovered?.id === node.id
1624
- ctx.beginPath()
1625
- ctx.arc(node.x, node.y, radius + (isSelected ? 7 : isHovered ? 4 : 0), 0, Math.PI * 2)
1626
- ctx.fillStyle = isSelected || isHovered ? graphTheme.nodeHaloActive : graphTheme.nodeHalo
1627
- ctx.fill()
1628
- ctx.beginPath()
1629
- ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
1630
- ctx.fillStyle = isSelected ? graphTheme.nodeSelected : isHovered ? graphTheme.nodeHover : graphTheme.node
1631
- ctx.fill()
1632
- ctx.lineWidth = isSelected ? 2.6 : 1.5
1633
- ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
1634
- 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
+ }
1635
1193
 
1636
- if (options.drawLabel && shouldDrawNodeLabels(node, isSelected, isHovered)) {
1637
- ctx.fillStyle = graphTheme.label
1638
- ctx.font = '12px Inter, system-ui, sans-serif'
1639
- ctx.textAlign = 'center'
1640
- ctx.textBaseline = 'top'
1641
- ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
1642
- }
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()
1643
1202
  }
1644
1203
 
1645
- const drawNodeBatch = (nodes) => {
1646
- if (nodes.length === 0) {
1647
- return
1648
- }
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)
1649
1268
 
1650
- const drawHalos = state.renderNodes.length <= 1200 || state.transform.scale >= 0.45
1651
- if (drawHalos) {
1652
- ctx.beginPath()
1653
- for (let index = 0; index < nodes.length; index += 1) {
1654
- const node = nodes[index]
1655
- ctx.moveTo(node.x + nodeRadius(node) + 3, node.y)
1656
- 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
1657
1301
  }
1658
- ctx.fillStyle = graphTheme.nodeHalo
1659
- ctx.fill()
1660
- }
1661
1302
 
1662
- ctx.beginPath()
1663
- for (let index = 0; index < nodes.length; index += 1) {
1664
- const node = nodes[index]
1665
- const radius = nodeRadius(node)
1666
- ctx.moveTo(node.x + radius, node.y)
1667
- ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
1668
- }
1669
- ctx.fillStyle = graphTheme.node
1670
- ctx.fill()
1671
- ctx.lineWidth = 1.25
1672
- ctx.strokeStyle = graphTheme.nodeStroke
1673
- ctx.stroke()
1674
- }
1675
-
1676
- const drawGraphNodes = () => {
1677
- const regularNodes = []
1678
- const priorityNodes = []
1679
-
1680
- for (let index = 0; index < state.renderNodes.length; index += 1) {
1681
- const node = state.renderNodes[index]
1682
- const isPriority =
1683
- state.selected?.id === node.id ||
1684
- state.hovered?.id === node.id
1685
- if (isPriority) {
1686
- 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)
1687
1312
  } else {
1688
- regularNodes.push(node)
1313
+ hideTooltip()
1689
1314
  }
1690
- }
1315
+ })
1691
1316
 
1692
- 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)
1693
1324
 
1694
- if (state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) {
1695
- ctx.fillStyle = graphTheme.label
1696
- ctx.font = '12px Inter, system-ui, sans-serif'
1697
- ctx.textAlign = 'center'
1698
- ctx.textBaseline = 'top'
1699
- for (let index = 0; index < regularNodes.length; index += 1) {
1700
- const node = regularNodes[index]
1701
- 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
1702
1328
  }
1703
- }
1329
+ if (shouldPersistNodePosition) {
1330
+ writeStoredNodePositions()
1331
+ persistNodePositionsToServer()
1332
+ return
1333
+ }
1334
+ if (shouldRefreshAfterDrag) {
1335
+ scheduleChunkFetch()
1336
+ }
1337
+ })
1704
1338
 
1705
- priorityNodes.forEach(node => drawSingleNode(node))
1706
- }
1339
+ canvas.addEventListener('pointerleave', () => {
1340
+ state.hoveredNodeId = ''
1341
+ canvas.classList.remove('is-node-hover')
1342
+ hideTooltip()
1343
+ updateGraphOverlays()
1344
+ })
1707
1345
 
1708
- const partitionGraphForAcceleratedRenderer = () => {
1709
- const regularNodes = []
1710
- const priorityNodes = []
1711
- const regularEdges = []
1712
- const inferredEdges = []
1713
- const selectedEdges = []
1346
+ canvas.addEventListener('pointercancel', (event) => {
1347
+ resetPointerState(event.pointerId)
1348
+ hideTooltip()
1349
+ updateGraphOverlays()
1350
+ })
1714
1351
 
1715
- for (let index = 0; index < state.renderNodes.length; index += 1) {
1716
- const node = state.renderNodes[index]
1717
- const isPriority =
1718
- state.selected?.id === node.id ||
1719
- state.hovered?.id === node.id
1720
- if (isPriority) {
1721
- priorityNodes.push(node)
1722
- } else {
1723
- regularNodes.push(node)
1724
- }
1725
- }
1352
+ canvas.addEventListener('lostpointercapture', () => {
1353
+ resetPointerState()
1354
+ })
1726
1355
 
1727
- for (let index = 0; index < state.renderEdges.length; index += 1) {
1728
- const edge = state.renderEdges[index]
1729
- const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
1730
- if (isSelected) {
1731
- selectedEdges.push(edge)
1732
- } else if (edge.inferred) {
1733
- inferredEdges.push(edge)
1734
- } else {
1735
- regularEdges.push(edge)
1356
+ elements.miniMap.addEventListener('click', (event) => {
1357
+ if (!state.miniMapView) {
1358
+ return
1736
1359
  }
1737
- }
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
+ })
1738
1375
 
1739
- 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
+ })
1740
1393
  }
1741
1394
 
1742
- const drawGraphLabels = nodes => {
1743
- if (!(state.transform.scale >= 0.62 && state.renderNodes.length <= 1200)) {
1744
- return
1745
- }
1395
+ const setupControls = () => {
1396
+ elements.zoomIn.addEventListener('click', () => {
1397
+ zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
1398
+ })
1746
1399
 
1747
- ctx.fillStyle = graphTheme.label
1748
- ctx.font = '12px Inter, system-ui, sans-serif'
1749
- ctx.textAlign = 'center'
1750
- ctx.textBaseline = 'top'
1751
- for (let index = 0; index < nodes.length; index += 1) {
1752
- const node = nodes[index]
1753
- ctx.fillText(node.title.slice(0, 34), node.x, node.y + nodeRadius(node) + 8)
1754
- }
1755
- }
1400
+ elements.zoomOut.addEventListener('click', () => {
1401
+ zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
1402
+ })
1756
1403
 
1757
- const drawAcceleratedGraph = (width, height, drawEdges) => {
1758
- if (!webGlRenderer || state.renderClusters.length > 0) {
1759
- return false
1760
- }
1404
+ elements.fit.addEventListener('click', () => {
1405
+ fitFromChunk()
1406
+ scheduleChunkFetch()
1407
+ })
1761
1408
 
1762
- const graphParts = partitionGraphForAcceleratedRenderer()
1763
- const scale = state.transform.scale
1764
- webGlRenderer.clear(width, height)
1765
- if (drawEdges) {
1766
- webGlRenderer.drawLines(
1767
- graphParts.regularEdges,
1768
- rgba('rgb(153, 165, 181)', edgeOpacityForScale({ inferred: false }, scale)),
1769
- width,
1770
- height
1771
- )
1772
- webGlRenderer.drawLines(
1773
- graphParts.inferredEdges,
1774
- rgba('rgb(203, 213, 225)', edgeOpacityForScale({ inferred: true }, scale)),
1775
- width,
1776
- height
1777
- )
1778
- }
1779
- webGlRenderer.drawPoints(
1780
- graphParts.regularNodes,
1781
- rgba(graphTheme.nodeHalo, 0.28),
1782
- node => Math.max((nodeRadius(node) + 3) * state.transform.scale * 2, 1.5),
1783
- width,
1784
- height
1785
- )
1786
- webGlRenderer.drawPoints(
1787
- graphParts.regularNodes,
1788
- rgba(graphTheme.node, 1),
1789
- node => Math.max(nodeRadius(node) * state.transform.scale * 2, 1.2),
1790
- width,
1791
- height
1792
- )
1793
-
1794
- ctx.save()
1795
- ctx.translate(state.transform.x, state.transform.y)
1796
- ctx.scale(state.transform.scale, state.transform.scale)
1797
- if (drawEdges) {
1798
- graphParts.selectedEdges.forEach(edge => drawGraphEdge(edge))
1799
- }
1800
- drawGraphLabels(graphParts.regularNodes)
1801
- graphParts.priorityNodes.forEach(node => drawSingleNode(node))
1802
- ctx.restore()
1409
+ elements.releaseNode.addEventListener('click', () => {
1410
+ releaseSelectedNodePosition()
1411
+ })
1803
1412
 
1804
- return true
1805
- }
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
+ })
1806
1420
 
1807
- const edgePairKey = (source, target) =>
1808
- source < target ? source + '|' + target : target + '|' + source
1421
+ elements.contentClose.addEventListener('click', () => {
1422
+ closeContentDialog()
1423
+ })
1809
1424
 
1810
- const meshNeighborBuckets = (nodes, cellSize) => {
1811
- const buckets = new Map()
1425
+ elements.contentDialog.addEventListener('click', (event) => {
1426
+ if (event.target === elements.contentDialog) {
1427
+ closeContentDialog()
1428
+ }
1429
+ })
1812
1430
 
1813
- for (let index = 0; index < nodes.length; index += 1) {
1814
- const node = nodes[index]
1815
- const cellX = Math.floor(node.x / cellSize)
1816
- const cellY = Math.floor(node.y / cellSize)
1817
- const key = cellX + ':' + cellY
1818
- const bucket = buckets.get(key)
1819
- if (bucket) {
1820
- bucket.push(node)
1821
- } else {
1822
- buckets.set(key, [node])
1431
+ elements.search.addEventListener('input', () => {
1432
+ if (state.searchTimer) {
1433
+ clearTimeout(state.searchTimer)
1823
1434
  }
1824
- }
1825
-
1826
- return buckets
1827
- }
1828
-
1829
- const meshCandidatesForNode = (node, buckets, cellSize) => {
1830
- const cellX = Math.floor(node.x / cellSize)
1831
- const cellY = Math.floor(node.y / cellSize)
1832
- const candidates = []
1833
-
1834
- for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
1835
- for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
1836
- const bucket = buckets.get((cellX + offsetX) + ':' + (cellY + offsetY))
1837
- if (!bucket) continue
1838
- for (let index = 0; index < bucket.length; index += 1) {
1839
- const candidate = bucket[index]
1840
- if (candidate.id !== node.id) {
1841
- candidates.push(candidate)
1842
- }
1843
- }
1844
- }
1845
- }
1846
-
1847
- return candidates
1435
+ state.searchTimer = setTimeout(() => {
1436
+ state.searchTimer = null
1437
+ runGraphSearch().catch((error) => console.error(error))
1438
+ }, 160)
1439
+ })
1848
1440
  }
1849
1441
 
1850
- const buildMeshEdgesForNodes = (nodes, existingEdges) => {
1851
- if (nodes.length < 2 || state.transform.scale < meshEdgeScaleThreshold) {
1852
- return []
1853
- }
1854
-
1855
- const existingKeys = new Set()
1856
- for (let index = 0; index < existingEdges.length; index += 1) {
1857
- const edge = existingEdges[index]
1858
- if (edge.target) {
1859
- existingKeys.add(edgePairKey(edge.source, edge.target))
1860
- }
1861
- }
1862
-
1863
- const desiredBudget = Math.min(
1864
- meshEdgeMaxBudget,
1865
- Math.max(meshEdgeMinBudget, Math.floor(edgeBudgetForCurrentFrame() * 0.62))
1866
- )
1867
- const perNodeNeighborCount =
1868
- state.transform.scale >= 1.05 ? 4
1869
- : state.transform.scale >= 0.62 ? 3
1870
- : 2
1871
- const cellSize = Math.max(120, 280 / Math.max(state.transform.scale, 0.0001))
1872
- const maxDistance = 980
1873
- const maxDistanceSquared = maxDistance * maxDistance
1874
- const buckets = meshNeighborBuckets(nodes, cellSize)
1875
- const meshEdges = []
1876
- const meshKeys = new Set()
1877
-
1878
- for (let index = 0; index < nodes.length && meshEdges.length < desiredBudget; index += 1) {
1879
- const node = nodes[index]
1880
- const candidates = meshCandidatesForNode(node, buckets, cellSize)
1881
- .map((candidate) => ({
1882
- node: candidate,
1883
- distanceSquared: (candidate.x - node.x) ** 2 + (candidate.y - node.y) ** 2
1884
- }))
1885
- .filter((candidate) => candidate.distanceSquared <= maxDistanceSquared)
1886
- .sort((left, right) => left.distanceSquared - right.distanceSquared)
1887
-
1888
- let linked = 0
1889
- for (let candidateIndex = 0; candidateIndex < candidates.length && linked < perNodeNeighborCount && meshEdges.length < desiredBudget; candidateIndex += 1) {
1890
- const candidate = candidates[candidateIndex].node
1891
- const key = edgePairKey(node.id, candidate.id)
1892
- if (existingKeys.has(key) || meshKeys.has(key)) {
1893
- continue
1894
- }
1895
-
1896
- meshKeys.add(key)
1897
- meshEdges.push({
1898
- source: node.id,
1899
- target: candidate.id,
1900
- targetTitle: candidate.title,
1901
- weight: 1,
1902
- priority: 'normal',
1903
- sourceNode: node,
1904
- targetNode: candidate,
1905
- inferred: true
1906
- })
1907
- 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: [] })
1908
1450
  }
1451
+ return
1909
1452
  }
1910
1453
 
1911
- return meshEdges
1912
- }
1913
-
1914
- const withMeshEdges = (nodes, edges) => {
1915
- if (nodes.length === 0 || state.visibleNodes.length <= largeGraphNodeThreshold || state.transform.scale < meshEdgeScaleThreshold) {
1916
- return edges
1917
- }
1918
-
1919
- const meshEdges = buildMeshEdgesForNodes(nodes, edges)
1920
- return meshEdges.length > 0 ? edges.concat(meshEdges) : edges
1921
- }
1922
-
1923
- const fallbackViewportNodes = () => {
1924
- const nodes = []
1925
- const maxNodes = Math.min(renderNodeBudget, 220)
1926
- const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
1927
-
1928
- for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
1929
- nodes.push(state.visibleNodes[index])
1930
- }
1931
-
1932
- if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
1933
- nodes.push(state.selected)
1934
- }
1935
-
1936
- return nodes
1937
- }
1938
-
1939
- const sampleVisibleNodes = (limit = renderNodeBudget, sourceNodes = state.visibleNodes) => {
1940
- if (sourceNodes.length === 0 || limit <= 0) {
1941
- return []
1942
- }
1943
-
1944
- const nodes = []
1945
- const maxNodes = Math.min(Math.max(limit, 1), sourceNodes.length)
1946
- const step = Math.max(1, Math.ceil(sourceNodes.length / maxNodes))
1947
-
1948
- for (let index = 0; index < sourceNodes.length && nodes.length < maxNodes; index += step) {
1949
- nodes.push(sourceNodes[index])
1950
- }
1951
-
1952
- if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
1953
- 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')
1954
1457
  }
1955
-
1956
- return nodes
1957
- }
1958
-
1959
- const enrichSampleWithNeighbors = (nodes) => {
1960
- if (nodes.length === 0) {
1961
- return {
1962
- nodes,
1963
- edges: []
1964
- }
1458
+ const payload = await response.json()
1459
+ if (token !== state.searchToken) {
1460
+ return
1965
1461
  }
1966
1462
 
1967
- const maxNodes = Math.min(renderNodeBudget, nodes.length + 200)
1968
- const expanded = [...nodes]
1969
- const ids = new Set(expanded.map((node) => node.id))
1970
-
1971
- for (let index = 0; index < nodes.length && expanded.length < maxNodes; index += 1) {
1972
- const node = nodes[index]
1973
- const candidates = [...(state.visibleEdgeByNode.get(node.id) ?? [])]
1974
- .filter((edge) => edge.target)
1975
- .sort((left, right) => edgeWeight(right) - edgeWeight(left))
1976
- .slice(0, 3)
1977
-
1978
- for (let candidateIndex = 0; candidateIndex < candidates.length && expanded.length < maxNodes; candidateIndex += 1) {
1979
- const edge = candidates[candidateIndex]
1980
- const otherId = edge.source === node.id ? edge.target : edge.source
1981
-
1982
- if (!otherId || ids.has(otherId)) {
1983
- continue
1984
- }
1985
-
1986
- const otherNode = state.nodeById.get(otherId)
1987
- if (!otherNode) {
1988
- continue
1989
- }
1990
-
1991
- ids.add(otherId)
1992
- expanded.push(otherNode)
1993
- }
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 })
1994
1468
  }
1995
-
1996
- const edges = collectVisibleEdgesForNodes(ids)
1997
-
1998
- return {
1999
- nodes: expanded,
2000
- edges
1469
+ if (ids.length > 0 && state.graphMode === 'far') {
1470
+ state.camera.scale = Math.max(state.camera.scale, 0.82)
1471
+ updateWorkerCamera()
1472
+ scheduleChunkFetch()
2001
1473
  }
2002
1474
  }
2003
1475
 
2004
- const includeHubPreviewNeighborhood = (nodes, limit) => {
2005
- const hub = state.primaryHub
2006
- if (!hub) {
2007
- return nodes
2008
- }
2009
-
2010
- const maxNodes = Math.max(1, Math.min(renderNodeBudget, limit))
2011
- const merged = [...nodes]
2012
- const ids = new Set(merged.map((node) => node.id))
2013
- const protectedIds = new Set()
2014
-
2015
- if (!ids.has(hub.id)) {
2016
- if (merged.length < maxNodes) {
2017
- merged.push(hub)
2018
- ids.add(hub.id)
2019
- } else {
2020
- const replaceIndex = merged.findIndex((node) => node.id !== hub.id)
2021
- if (replaceIndex >= 0) {
2022
- ids.delete(merged[replaceIndex].id)
2023
- merged[replaceIndex] = hub
2024
- ids.add(hub.id)
2025
- }
2026
- }
1476
+ const loadAgents = async () => {
1477
+ const response = await fetch('/api/agents')
1478
+ if (!response.ok) {
1479
+ throw new Error('Failed to load agents')
2027
1480
  }
2028
- protectedIds.add(hub.id)
2029
1481
 
2030
- const hubEdges = [...(state.visibleEdgeByNode.get(hub.id) ?? [])]
2031
- .filter((edge) => edge.target && (edge.source === hub.id || edge.target === hub.id))
2032
- .sort((left, right) => {
2033
- const byWeight = edgeWeight(right) - edgeWeight(left)
2034
- if (byWeight !== 0) return byWeight
2035
-
2036
- const leftOtherId = left.source === hub.id ? left.target : left.source
2037
- const rightOtherId = right.source === hub.id ? right.target : right.source
2038
- const leftDegree = state.nodeDegrees.get(leftOtherId ?? '') ?? 0
2039
- const rightDegree = state.nodeDegrees.get(rightOtherId ?? '') ?? 0
2040
- if (leftDegree !== rightDegree) return rightDegree - leftDegree
2041
-
2042
- 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>'
2043
1491
  })
1492
+ .join('')
2044
1493
 
2045
- for (let index = 0; index < hubEdges.length && merged.length < maxNodes; index += 1) {
2046
- const edge = hubEdges[index]
2047
- const otherId = edge.source === hub.id ? edge.target : edge.source
2048
- if (!otherId || ids.has(otherId)) {
2049
- continue
2050
- }
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
2051
1498
 
2052
- const otherNode = state.nodeById.get(otherId)
2053
- if (!otherNode) {
2054
- continue
2055
- }
2056
-
2057
- if (merged.length < maxNodes) {
2058
- ids.add(otherId)
2059
- merged.push(otherNode)
2060
- protectedIds.add(otherId)
2061
- continue
2062
- }
2063
-
2064
- const replaceIndex = (() => {
2065
- for (let cursor = merged.length - 1; cursor >= 0; cursor -= 1) {
2066
- const candidateId = merged[cursor]?.id
2067
- if (candidateId && !protectedIds.has(candidateId)) {
2068
- return cursor
2069
- }
2070
- }
2071
- return -1
2072
- })()
2073
- if (replaceIndex >= 0) {
2074
- const replacedId = merged[replaceIndex]?.id
2075
- if (replacedId) {
2076
- ids.delete(replacedId)
2077
- }
2078
- merged[replaceIndex] = otherNode
2079
- ids.add(otherId)
2080
- protectedIds.add(otherId)
2081
- }
2082
- }
2083
-
2084
- return merged
2085
- }
2086
-
2087
- const ensureHubNodesInRenderedSet = (nodes) => {
2088
- if (nodes.length === 0) {
2089
- return nodes
2090
- }
2091
-
2092
- const maxNodes = Math.max(Math.min(renderNodeBudget, nodes.length), 1)
2093
- const ids = new Set(nodes.map((node) => node.id))
2094
- const hubs = rankedHubNodes()
2095
- const merged = [...nodes]
2096
-
2097
- for (let index = 0; index < hubs.length; index += 1) {
2098
- const hub = hubs[index]
2099
- if (ids.has(hub.id)) {
2100
- continue
2101
- }
2102
-
2103
- if (merged.length < maxNodes) {
2104
- merged.push(hub)
2105
- ids.add(hub.id)
2106
- continue
2107
- }
2108
-
2109
- const replacementIndex = merged.findIndex((node) => !hubs.some((candidate) => candidate.id === node.id))
2110
- if (replacementIndex >= 0) {
2111
- ids.delete(merged[replacementIndex].id)
2112
- merged[replacementIndex] = hub
2113
- ids.add(hub.id)
2114
- }
2115
- }
2116
-
2117
- return merged
2118
- }
2119
-
2120
- const zoomCapByNodeCount = (nodeCount) => {
2121
- if (nodeCount > 50000) return 5.4
2122
- if (nodeCount > 20000) return 4.8
2123
- if (nodeCount > 6000) return 4.2
2124
- if (nodeCount > 2000) return 4
2125
- return zoomRange.max
2126
- }
2127
-
2128
- const zoomCapByHubDistance = (distance) => {
2129
- if (!Number.isFinite(distance) || distance <= 0) {
2130
- return zoomRange.max
2131
- }
2132
-
2133
- const rect = canvas.getBoundingClientRect()
2134
- const viewportWidth = Math.max(rect.width, 320)
2135
- const viewportHeight = Math.max(rect.height, 320)
2136
- const reference = Math.max(220, Math.min(viewportWidth, viewportHeight) * zoomCapTargetViewportShare)
2137
- return Math.max(0.3, Math.min(zoomRange.max, reference / distance))
2138
- }
2139
-
2140
- const currentZoomMax = () => {
2141
- const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
2142
- const hubDistanceCap = isDominantHub(state.primaryHub, nodeCount)
2143
- ? zoomCapByHubDistance(state.hubNeighborDistance)
2144
- : zoomRange.max
2145
- const minimumUsefulCap = nodeCount > massiveGraphNodeThreshold ? 1.9 : nodeCount > largeGraphNodeThreshold ? 1.35 : 0.8
2146
- const capped = Math.min(zoomCapByNodeCount(nodeCount), Math.max(minimumUsefulCap, hubDistanceCap))
2147
- return Math.max(zoomRange.min * 2, capped)
2148
- }
2149
-
2150
- const clampScale = value => Math.max(zoomRange.min, Math.min(currentZoomMax(), value))
2151
- const isFiniteNumber = value => Number.isFinite(value)
2152
- const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
2153
- const clampTransformCoordinate = value => {
2154
- if (!isFiniteNumber(value)) return 0
2155
- if (value > transformCoordinateLimit) return transformCoordinateLimit
2156
- if (value < -transformCoordinateLimit) return -transformCoordinateLimit
2157
- return value
2158
- }
2159
-
2160
- const graphBounds = nodes => {
2161
- if (nodes.length === 0) return null
2162
- let minX = Number.POSITIVE_INFINITY
2163
- let maxX = Number.NEGATIVE_INFINITY
2164
- let minY = Number.POSITIVE_INFINITY
2165
- let maxY = Number.NEGATIVE_INFINITY
2166
-
2167
- nodes.forEach(node => {
2168
- const radius = baseNodeRadius(node)
2169
- minX = Math.min(minX, node.x - radius)
2170
- maxX = Math.max(maxX, node.x + radius)
2171
- minY = Math.min(minY, node.y - radius)
2172
- maxY = Math.max(maxY, node.y + radius)
2173
- })
2174
-
2175
- return {
2176
- minX,
2177
- maxX,
2178
- minY,
2179
- maxY,
2180
- width: Math.max(maxX - minX, 1),
2181
- height: Math.max(maxY - minY, 1)
2182
- }
2183
- }
2184
-
2185
- const fitScaleBiasByNodeCount = nodeCount => {
2186
- if (nodeCount <= 6) return 1.22
2187
- if (nodeCount <= 20) return 1.12
2188
- if (nodeCount <= 60) return 1.04
2189
- if (nodeCount <= 180) return 1
2190
- if (nodeCount <= 600) return 0.94
2191
- if (nodeCount <= 2000) return 0.82
2192
- if (nodeCount <= 6000) return 0.68
2193
- return 0.56
2194
- }
2195
-
2196
- const autoFitScaleRangeByNodeCount = nodeCount => {
2197
- if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
2198
- if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
2199
- if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
2200
- if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
2201
- if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
2202
- if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
2203
- if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
2204
- return { min: 0.0012, max: 0.24 }
2205
- }
2206
-
2207
- const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
2208
- const rect = canvas.getBoundingClientRect()
2209
- const width = Math.max(rect.width, 320)
2210
- const height = Math.max(rect.height, 320)
2211
- const nodes = options.useFiltered ? filteredNodes() : state.nodes
2212
- const bounds = graphBounds(nodes)
2213
-
2214
- if (!bounds) {
2215
- state.transform = { x: width / 2, y: height / 2, scale: 1 }
2216
- state.offscreenFrameCount = 0
2217
- state.recoveringViewport = false
2218
- markRenderDirty()
2219
- return
2220
- }
2221
-
2222
- const paddingByNodeCount = nodeCount => {
2223
- if (nodeCount <= 6) return 28
2224
- if (nodeCount <= 20) return 44
2225
- if (nodeCount <= 60) return 68
2226
- if (nodeCount <= 180) return 86
2227
- if (nodeCount <= 600) return 110
2228
- if (nodeCount <= 2000) return 140
2229
- return 180
2230
- }
2231
- const padding = paddingByNodeCount(nodes.length)
2232
- const scaleX = width / (bounds.width + padding * 2)
2233
- const scaleY = height / (bounds.height + padding * 2)
2234
- const fitScale = Math.min(scaleX, scaleY)
2235
- const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
2236
- const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
2237
- const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
2238
- const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
2239
- const scale = options.macro && nodes.length > 1
2240
- ? clampScale(Math.min(baselineScale, macroScale))
2241
- : nodes.length > massiveGraphNodeThreshold
2242
- ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
2243
- : baselineScale
2244
- const hubCenter =
2245
- options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
2246
- ? state.primaryHub
2247
- : null
2248
- const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
2249
- const centerY = hubCenter ? hubCenter.y : (bounds.minY + bounds.maxY) / 2
2250
-
2251
- state.transform = {
2252
- x: clampTransformCoordinate(width / 2 - centerX * scale),
2253
- y: clampTransformCoordinate(height / 2 - centerY * scale),
2254
- scale: clampScale(scale)
2255
- }
2256
- state.offscreenFrameCount = 0
2257
- state.recoveringViewport = false
2258
- markRenderDirty()
2259
- }
2260
-
2261
- const resetView = () => fitView({ useFiltered: false, macro: true, preferHubCenter: true })
2262
-
2263
- const focusPrimaryHub = () => {
2264
- const hub = state.primaryHub
2265
- if (!hub) {
2266
- fitView({ useFiltered: true, macro: false, preferHubCenter: true })
2267
- return
2268
- }
2269
-
2270
- const rect = canvas.getBoundingClientRect()
2271
- const width = Math.max(rect.width, 320)
2272
- const height = Math.max(rect.height, 320)
2273
- const targetScale = clampScale(Math.max(0.78, state.transform.scale))
2274
-
2275
- state.transform = {
2276
- x: clampTransformCoordinate(width / 2 - hub.x * targetScale),
2277
- y: clampTransformCoordinate(height / 2 - hub.y * targetScale),
2278
- scale: targetScale
2279
- }
2280
- state.offscreenFrameCount = 0
2281
- markRenderDirty()
2282
- }
2283
-
2284
- const layoutDensityScaleForNodeCount = (nodeCount) => {
2285
- if (nodeCount > 50000) return 0.26
2286
- if (nodeCount > 20000) return 0.3
2287
- if (nodeCount > 6000) return 0.36
2288
- if (nodeCount > 2000) return 0.42
2289
- if (nodeCount > 600) return 0.5
2290
- if (nodeCount > 180) return 0.58
2291
- if (nodeCount > 60) return 0.68
2292
- if (nodeCount > 20) return 0.78
2293
- return 0.88
2294
- }
2295
-
2296
- const createLayout = graph => {
2297
- const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
2298
- const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
2299
- const densityScale = layoutDensityScaleForNodeCount(nodeRows.length)
2300
- const nodes = nodeRows.map(node => {
2301
- if (Array.isArray(node)) {
2302
- const [id, title, x, y, group, segment] = node
2303
- return {
2304
- id: typeof id === 'string' ? id : '',
2305
- title: typeof title === 'string' ? title : 'Untitled',
2306
- path: '',
2307
- tags: [],
2308
- group: typeof group === 'string' ? group : 'root',
2309
- segment: typeof segment === 'string' ? segment : 'root',
2310
- x: Number.isFinite(x) ? x * densityScale : 0,
2311
- y: Number.isFinite(y) ? y * densityScale : 0,
2312
- vx: 0,
2313
- vy: 0
2314
- }
2315
- }
2316
-
2317
- return {
2318
- ...node,
2319
- path: typeof node.path === 'string' ? node.path : '',
2320
- tags: Array.isArray(node.tags) ? node.tags : [],
2321
- x: Number.isFinite(node.x) ? node.x * densityScale : 0,
2322
- y: Number.isFinite(node.y) ? node.y * densityScale : 0,
2323
- vx: Number.isFinite(node.vx) ? node.vx : 0,
2324
- vy: Number.isFinite(node.vy) ? node.vy : 0
2325
- }
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))
2326
1504
  })
2327
- const nodeMap = new Map(nodes.map(node => [node.id, node]))
2328
- const edges = edgeRows
2329
- .map(edge => {
2330
- if (Array.isArray(edge)) {
2331
- const [source, target, weight, priority] = edge
2332
- return {
2333
- source: typeof source === 'string' ? source : '',
2334
- target: typeof target === 'string' ? target : null,
2335
- targetTitle: '',
2336
- weight: Number.isFinite(weight) ? weight : 1,
2337
- priority: typeof priority === 'string' ? priority : 'normal'
2338
- }
2339
- }
2340
- return edge
2341
- })
2342
- .filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
2343
- .map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
2344
- return { nodes, edges }
2345
- }
2346
-
2347
- const encodeEntityTag = (value) => {
2348
- const utf8 = new TextEncoder().encode(value)
2349
- let binary = ''
2350
1505
 
2351
- for (let index = 0; index < utf8.length; index += 1) {
2352
- binary += String.fromCharCode(utf8[index])
2353
- }
2354
-
2355
- return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
2356
- }
2357
-
2358
- const graphSignature = graph => JSON.stringify({
2359
- nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
2360
- edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
2361
- })
2362
-
2363
- const resetContentFilter = () => {
2364
- if (state.contentFilter.timer) {
2365
- clearTimeout(state.contentFilter.timer)
2366
- }
2367
- state.contentFilter = {
2368
- query: '',
2369
- ids: null,
2370
- token: state.contentFilter.token + 1,
2371
- timer: null
2372
- }
2373
- recomputeVisibility()
1506
+ syncAgentInUrl(state.agentId)
2374
1507
  }
2375
1508
 
2376
- const syncContentFilter = async (query, token) => {
2377
- const response = await fetch(
2378
- '/api/graph-filter?q=' +
2379
- encodeURIComponent(query) +
2380
- '&limit=' +
2381
- encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
2382
- agentQuery('&')
2383
- )
2384
-
2385
- if (!response.ok || token !== state.contentFilter.token) {
2386
- 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')
2387
1513
  }
2388
1514
 
2389
1515
  const payload = await response.json()
2390
- const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
2391
- if (token !== state.contentFilter.token) {
2392
- return
2393
- }
2394
-
2395
- state.contentFilter.query = query
2396
- const merged = new Set([...(state.contentFilter.ids instanceof Set ? state.contentFilter.ids : []), ...nodeIds])
2397
- state.contentFilter.ids = merged
2398
- recomputeVisibility()
2399
- }
2400
-
2401
- const scheduleContentFilterSync = () => {
2402
- const query = normalizeQuery(state.query)
2403
- if (!query) {
2404
- resetContentFilter()
2405
- return
2406
- }
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
+ ]
2407
1526
 
2408
- if (state.contentFilter.timer) {
2409
- clearTimeout(state.contentFilter.timer)
2410
- }
1527
+ elements.context.innerHTML = options.join('')
2411
1528
 
2412
- const token = state.contentFilter.token + 1
2413
- state.contentFilter = {
2414
- query: state.contentFilter.query,
2415
- ids: state.contentFilter.ids,
2416
- token,
2417
- timer: setTimeout(() => {
2418
- if (state.filterWorker && state.filterReady) {
2419
- state.filterWorker.postMessage({
2420
- type: 'filter',
2421
- query,
2422
- token,
2423
- limit: Math.max(state.nodes.length, 1)
2424
- })
2425
- }
2426
- syncContentFilter(query, token).catch(() => {})
2427
- }, 180)
2428
- }
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)
2429
1535
  }
2430
1536
 
2431
- const tick = delta => {
2432
- const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
2433
- const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
2434
- const shouldRunPhysics =
2435
- state.nodes.length <= 8000 &&
2436
- nodes.length <= 320 &&
2437
- state.transform.scale >= 0.08
2438
- if (!shouldRunPhysics) {
2439
- return
2440
- }
2441
- const strength = Math.min(delta / 16, 2)
2442
-
2443
- edges.forEach(edge => {
2444
- const source = edge.sourceNode
2445
- const target = edge.targetNode
2446
- source.vx = Number.isFinite(source.vx) ? source.vx : 0
2447
- source.vy = Number.isFinite(source.vy) ? source.vy : 0
2448
- target.vx = Number.isFinite(target.vx) ? target.vx : 0
2449
- target.vy = Number.isFinite(target.vy) ? target.vy : 0
2450
- const dx = target.x - source.x
2451
- const dy = target.y - source.y
2452
- const distance = Math.max(Math.hypot(dx, dy), 1)
2453
- const force = (distance - 150) * 0.002 * strength
2454
- const fx = (dx / distance) * force
2455
- const fy = (dy / distance) * force
2456
- source.vx += fx
2457
- source.vy += fy
2458
- target.vx -= fx
2459
- target.vy -= fy
2460
- })
2461
-
2462
- for (let i = 0; i < nodes.length; i += 1) {
2463
- for (let j = i + 1; j < nodes.length; j += 1) {
2464
- const a = nodes[i]
2465
- const b = nodes[j]
2466
- a.vx = Number.isFinite(a.vx) ? a.vx : 0
2467
- a.vy = Number.isFinite(a.vy) ? a.vy : 0
2468
- b.vx = Number.isFinite(b.vx) ? b.vx : 0
2469
- b.vy = Number.isFinite(b.vy) ? b.vy : 0
2470
- const dx = b.x - a.x
2471
- const dy = b.y - a.y
2472
- const distance = Math.max(Math.hypot(dx, dy), 1)
2473
- const force = Math.min(2600 / (distance * distance), 0.12) * strength
2474
- const fx = (dx / distance) * force
2475
- const fy = (dy / distance) * force
2476
- a.vx -= fx
2477
- a.vy -= fy
2478
- b.vx += fx
2479
- b.vy += fy
2480
- }
2481
- }
2482
-
2483
- nodes.forEach(node => {
2484
- node.vx = Number.isFinite(node.vx) ? node.vx : 0
2485
- node.vy = Number.isFinite(node.vy) ? node.vy : 0
2486
- node.x = Number.isFinite(node.x) ? node.x : 0
2487
- node.y = Number.isFinite(node.y) ? node.y : 0
2488
- if (state.pointer.dragNode === node) {
2489
- node.vx = 0
2490
- node.vy = 0
2491
- return
2492
- }
2493
- node.vx += -node.x * 0.0008 * strength
2494
- node.vy += -node.y * 0.0008 * strength
2495
- node.vx *= 0.88
2496
- node.vy *= 0.88
2497
- node.x += node.vx * strength
2498
- 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 })
2499
1544
  })
2500
1545
  }
2501
1546
 
2502
- const worldPoint = event => {
2503
- const rect = canvas.getBoundingClientRect()
2504
- return screenToWorldPoint(event.clientX - rect.left, event.clientY - rect.top)
2505
- }
2506
-
2507
- const connectedNodeIdsFor = (nodeId) => {
2508
- const edges = state.visibleEdgeByNode.get(nodeId) ?? []
2509
- const ids = new Set()
1547
+ const setupRenderWorker = () => {
1548
+ const hasWorker = typeof Worker !== 'undefined'
1549
+ const canTransfer = typeof canvas.transferControlToOffscreen === 'function'
2510
1550
 
2511
- for (let index = 0; index < edges.length; index += 1) {
2512
- const edge = edges[index]
2513
- if (!edge.target) continue
2514
- if (edge.source === nodeId) {
2515
- ids.add(edge.target)
2516
- } else if (edge.target === nodeId) {
2517
- ids.add(edge.source)
2518
- }
1551
+ if (!hasWorker || !canTransfer) {
1552
+ state.rendererMode = 'fallback'
1553
+ drawFallback()
1554
+ return
2519
1555
  }
2520
1556
 
2521
- return ids
2522
- }
1557
+ try {
1558
+ const offscreen = canvas.transferControlToOffscreen()
1559
+ const worker = new Worker('/render-worker.js')
1560
+ state.renderWorker = worker
2523
1561
 
2524
- const applyDragNeighborhoodAdjustment = (dragNode, deltaX, deltaY) => {
2525
- if (!dragNode) return
2526
- if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) return
2527
- if (Math.abs(deltaX) + Math.abs(deltaY) <= 0.001) return
2528
-
2529
- const scale = Math.max(state.transform.scale, 0.0001)
2530
- const influenceRadius = Math.max(220, Math.min(920, 440 / scale))
2531
- const influenceRadiusSquared = influenceRadius * influenceRadius
2532
- const connectedIds = connectedNodeIdsFor(dragNode.id)
2533
- const candidates = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
2534
- let adjusted = 0
2535
-
2536
- for (let index = 0; index < candidates.length && adjusted < dragNeighborhoodMaxAffected; index += 1) {
2537
- const node = candidates[index]
2538
- if (node.id === dragNode.id) continue
2539
-
2540
- const isConnected = connectedIds.has(node.id)
2541
- const dx = node.x - dragNode.x
2542
- const dy = node.y - dragNode.y
2543
- const distanceSquared = dx * dx + dy * dy
2544
- const withinRadius = distanceSquared <= influenceRadiusSquared
2545
- if (!isConnected && !withinRadius) continue
2546
-
2547
- const distance = Math.max(Math.sqrt(distanceSquared), 0.0001)
2548
- const proximity = withinRadius ? 1 - (distance / influenceRadius) : 0
2549
- const coupledStrength = isConnected ? 0.28 : 0.12
2550
- const influence = Math.min(0.46, coupledStrength + proximity * 0.34)
2551
- node.x += deltaX * influence
2552
- node.y += deltaY * influence
2553
- node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + deltaX * influence * 0.06
2554
- node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + deltaY * influence * 0.06
2555
- adjusted += 1
2556
- }
2557
- }
2558
-
2559
- const settleNeighborhoodAroundNode = (dragNode) => {
2560
- if (!dragNode) return
2561
-
2562
- const scale = Math.max(state.transform.scale, 0.0001)
2563
- const settleRadius = Math.max(240, Math.min(980, 520 / scale))
2564
- const settleRadiusSquared = settleRadius * settleRadius
2565
- const connectedIds = connectedNodeIdsFor(dragNode.id)
2566
- const candidates = (state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes)
2567
- .filter((node) => {
2568
- if (node.id === dragNode.id) return true
2569
- const dx = node.x - dragNode.x
2570
- const dy = node.y - dragNode.y
2571
- const distanceSquared = dx * dx + dy * dy
2572
- return connectedIds.has(node.id) || distanceSquared <= settleRadiusSquared
2573
- })
2574
- .slice(0, dragNeighborhoodMaxAffected)
2575
-
2576
- if (candidates.length <= 1) return
2577
-
2578
- for (let round = 0; round < dragSettleRounds; round += 1) {
2579
- for (let leftIndex = 0; leftIndex < candidates.length; leftIndex += 1) {
2580
- const left = candidates[leftIndex]
2581
- for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex += 1) {
2582
- const right = candidates[rightIndex]
2583
- const dx = right.x - left.x
2584
- const dy = right.y - left.y
2585
- const distance = Math.max(Math.hypot(dx, dy), 0.001)
2586
- const minDistance = baseNodeRadius(left) + baseNodeRadius(right) + 10
2587
- if (distance >= minDistance) continue
2588
-
2589
- const push = (minDistance - distance) * 0.36
2590
- const ux = dx / distance
2591
- const uy = dy / distance
2592
- if (left.id !== dragNode.id) {
2593
- left.x -= ux * push
2594
- left.y -= uy * push
2595
- }
2596
- if (right.id !== dragNode.id) {
2597
- right.x += ux * push
2598
- right.y += uy * push
2599
- }
1562
+ worker.onmessage = (event) => {
1563
+ const payload = event.data
1564
+ if (!payload || typeof payload !== 'object') {
1565
+ return
2600
1566
  }
2601
- }
2602
- }
2603
- }
2604
-
2605
- const hitNode = point => {
2606
- computeRenderVisibility()
2607
- if (state.renderClusters.length > 0) {
2608
- return null
2609
- }
2610
- const hitScaleFloor = state.nodes.length > massiveGraphNodeThreshold
2611
- ? 0.2
2612
- : state.nodes.length > largeGraphNodeThreshold
2613
- ? 0.34
2614
- : 0
2615
- if (state.transform.scale < hitScaleFloor) {
2616
- return null
2617
- }
2618
-
2619
- const nodes = state.renderNodes
2620
- for (let index = nodes.length - 1; index >= 0; index -= 1) {
2621
- const node = nodes[index]
2622
- const radius = nodeRadius(node)
2623
- if (Math.hypot(point.x - node.x, point.y - node.y) <= radius + 5) return node
2624
- }
2625
- return null
2626
- }
2627
-
2628
- const baseNodeRadius = node => {
2629
- const degree = state.nodeDegrees.get(node.id) ?? 0
2630
- return 9 + Math.min(degree, 8) * 1.6
2631
- }
2632
-
2633
- const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
2634
-
2635
- const clusterRadiusPx = cluster => {
2636
- if (cluster.id === 'macro-galaxy') {
2637
- return 10
2638
- }
2639
- if (cluster.isHub) {
2640
- return 3.8
2641
- }
2642
- if (String(cluster.id).startsWith('ecosystem-')) {
2643
- const size = Math.max(1, Math.min(ecosystemLevelNodeCap, cluster.size || cluster.count || 1))
2644
- const sizeBias = 0.56 + Math.log10(size + 1) * 0.28
2645
- const densityBias = Math.log10((cluster.count || 1) + 1) * 0.12
2646
- return Math.max(0.62, Math.min(2.4, sizeBias + densityBias))
2647
- }
2648
- return Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
2649
- }
2650
-
2651
- const clusterOpacity = cluster =>
2652
- Math.max(0, Math.min(1, Number.isFinite(cluster.lodOpacity) ? cluster.lodOpacity : 1))
2653
-
2654
- const worldViewportBounds = () => {
2655
- const rect = canvas.getBoundingClientRect()
2656
- const width = Math.max(rect.width, 320)
2657
- const height = Math.max(rect.height, 320)
2658
- const paddingMultiplier =
2659
- state.nodes.length > massiveGraphNodeThreshold
2660
- ? (state.transform.scale >= 0.6 ? 2.8 : state.transform.scale >= 0.25 ? 2.35 : 1.9)
2661
- : state.nodes.length > largeGraphNodeThreshold
2662
- ? 1.45
2663
- : 1
2664
- const padding = viewportPaddingPx * paddingMultiplier
2665
-
2666
- return {
2667
- minX: (-state.transform.x - padding) / state.transform.scale,
2668
- maxX: (width - state.transform.x + padding) / state.transform.scale,
2669
- minY: (-state.transform.y - padding) / state.transform.scale,
2670
- maxY: (height - state.transform.y + padding) / state.transform.scale
2671
- }
2672
- }
2673
-
2674
- const isNodeInViewport = (node, viewport) =>
2675
- node.x >= viewport.minX &&
2676
- node.x <= viewport.maxX &&
2677
- node.y >= viewport.minY &&
2678
- node.y <= viewport.maxY
2679
-
2680
- const expandViewportBounds = (viewport, worldMargin) => ({
2681
- minX: viewport.minX - worldMargin,
2682
- maxX: viewport.maxX + worldMargin,
2683
- minY: viewport.minY - worldMargin,
2684
- maxY: viewport.maxY + worldMargin
2685
- })
2686
-
2687
- const viewportNodeStride = () => {
2688
- if (state.nodes.length <= largeGraphNodeThreshold) {
2689
- return 1
2690
- }
2691
-
2692
- if (state.transform.scale >= 0.95) {
2693
- return 1
2694
- }
2695
- if (state.transform.scale >= 0.7) {
2696
- return 2
2697
- }
2698
- if (state.transform.scale >= 0.48) {
2699
- return 3
2700
- }
2701
- if (state.transform.scale >= 0.28) {
2702
- return 5
2703
- }
2704
-
2705
- return 8
2706
- }
2707
1567
 
2708
- const shouldRenderClusters = viewportNodes =>
2709
- state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
2710
-
2711
- const clusterViewportNodes = viewportNodes => {
2712
- if (!shouldRenderClusters(viewportNodes)) {
2713
- return []
2714
- }
2715
-
2716
- const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
2717
- const buckets = new Map()
2718
-
2719
- for (let index = 0; index < viewportNodes.length; index += 1) {
2720
- const node = viewportNodes[index]
2721
- const keyX = Math.floor(node.x / worldCellSize)
2722
- const keyY = Math.floor(node.y / worldCellSize)
2723
- const key = keyX + ':' + keyY
2724
- const current = buckets.get(key)
2725
- if (current) {
2726
- current.count += 1
2727
- current.sumX += node.x
2728
- current.sumY += node.y
2729
- if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
2730
- current.representative = node
2731
- current.degree = state.nodeDegrees.get(node.id) ?? 0
1568
+ if (payload.type === 'ready') {
1569
+ state.workerReady = true
1570
+ scheduleChunkFetch({ fit: true })
1571
+ return
2732
1572
  }
2733
- continue
2734
- }
2735
-
2736
- buckets.set(key, {
2737
- id: key,
2738
- count: 1,
2739
- sumX: node.x,
2740
- sumY: node.y,
2741
- representative: node,
2742
- degree: state.nodeDegrees.get(node.id) ?? 0
2743
- })
2744
- }
2745
1573
 
2746
- return Array.from(buckets.values())
2747
- .sort((left, right) => right.count - left.count)
2748
- .slice(0, Math.min(renderNodeBudget, 900))
2749
- .map((cluster) => ({
2750
- id: cluster.id,
2751
- x: cluster.sumX / Math.max(cluster.count, 1),
2752
- y: cluster.sumY / Math.max(cluster.count, 1),
2753
- count: cluster.count,
2754
- representative: cluster.representative
2755
- }))
2756
- }
2757
-
2758
- const representativeNodesFromClusters = (clusters, limit) => {
2759
- const representatives = clusters
2760
- .map((cluster) => cluster.representative)
2761
- .filter((node) => Boolean(node))
2762
- const merged = mergeUniqueNodes(
2763
- representatives,
2764
- state.renderNodes ?? [],
2765
- Math.max(1, limit)
2766
- )
2767
- return ensureHubNodesInRenderedSet(merged)
2768
- }
2769
-
2770
- const computeRenderVisibility = () => {
2771
- if (!hasValidTransform()) {
2772
- fitView({ useFiltered: true })
2773
- }
2774
- const viewport = worldViewportBounds()
2775
- const viewportKey =
2776
- Math.round(viewport.minX * 10) + ':' +
2777
- Math.round(viewport.maxX * 10) + ':' +
2778
- Math.round(viewport.minY * 10) + ':' +
2779
- Math.round(viewport.maxY * 10) + ':' +
2780
- visibilityScaleBucket(state.transform.scale)
2781
-
2782
- if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
2783
- return
2784
- }
2785
- state.lastViewportKey = viewportKey
2786
- state.renderVisibilityDirty = false
2787
- state.renderClusterEdges = []
2788
-
2789
- const shouldRenderMacroGalaxy = shouldRenderMacroGalaxyView()
2790
-
2791
- if (shouldRenderMacroGalaxy) {
2792
- const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2793
- const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
2794
- const representative = state.primaryHub ?? state.macroRepresentative ?? sourceNodes[0] ?? null
2795
- if (representative) {
2796
- state.renderClusters = [
2797
- {
2798
- id: 'macro-galaxy',
2799
- x: state.macroCenter.x,
2800
- y: state.macroCenter.y,
2801
- count: sourceNodes.length,
2802
- 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)
2803
1577
  }
2804
- ]
2805
- state.renderNodes = [representative]
2806
- } else {
2807
- state.renderClusters = []
2808
- state.renderNodes = []
2809
- }
2810
- state.renderEdges = []
2811
- state.renderClusterEdges = []
2812
- return
2813
- }
2814
-
2815
- const ecosystemScaleThreshold = state.visibleNodes.length > massiveGraphNodeThreshold
2816
- ? massiveEcosystemClusterScaleThreshold
2817
- : ecosystemClusterScaleThreshold
2818
- if (
2819
- state.visibleNodes.length > ecosystemActivationNodeThreshold &&
2820
- state.transform.scale <= ecosystemScaleThreshold &&
2821
- state.ecosystemClusters.length > 0
2822
- ) {
2823
- const clusters = selectHierarchicalEcosystemClusters(viewport)
2824
- .sort((left, right) => right.count - left.count)
2825
- state.renderClusters = clusters
2826
- state.renderClusterEdges = ecosystemEdgesForClusters(clusters)
2827
- state.renderNodes = []
2828
- state.renderEdges = []
2829
- return
2830
- }
2831
-
2832
- if (state.visibleNodes.length <= 2000) {
2833
- state.renderNodes = state.visibleNodes
2834
- state.renderClusters = []
2835
- state.renderClusterEdges = []
2836
- const ids = new Set(state.renderNodes.map((node) => node.id))
2837
- state.renderEdges = withMeshEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
2838
- return
2839
- }
2840
-
2841
- if (state.visibleNodes.length > massiveGraphNodeThreshold) {
2842
- const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2843
- const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
2844
- const sampleLimit = nodeBudgetForScale(state.transform.scale)
2845
- const carryMargin = Math.max(240, Math.min(1200, 340 / Math.max(state.transform.scale, 0.0001)))
2846
- const carryViewport = expandViewportBounds(viewport, carryMargin)
2847
- const carryOverLimit = Math.max(180, Math.min(sampleLimit, Math.floor(sampleLimit * 0.5)))
2848
- const carryOverNodes = (state.renderNodes ?? [])
2849
- .filter((node) => isNodeInViewport(node, carryViewport))
2850
- .slice(0, carryOverLimit)
2851
- const sourceWithCarry = mergeUniqueNodes(
2852
- sourceNodes,
2853
- carryOverNodes,
2854
- Math.max(sampleLimit * 7, carryOverLimit)
2855
- )
2856
- const sourceWithCarryIds = new Set(sourceWithCarry.map((node) => node.id))
2857
- const sampledRaw = selectStableSampleNodes(
2858
- sourceWithCarry,
2859
- sampleLimit
2860
- )
2861
- const continuityBudget = Math.max(24, Math.min(sampleLimit - 8, Math.floor(sampleLimit * 0.42)))
2862
- const previousVisibleNodes = (state.renderNodes ?? [])
2863
- .filter((node) => sourceWithCarryIds.has(node.id))
2864
- const continuityNodes = selectStableSampleNodes(previousVisibleNodes, continuityBudget)
2865
- const sampled = mergeUniqueNodes(
2866
- continuityNodes,
2867
- sampledRaw,
2868
- sampleLimit
2869
- )
2870
- let sampledNodes = ensureHubNodesInRenderedSet(sampled)
2871
- if (state.transform.scale < 0.035) {
2872
- sampledNodes = includeHubPreviewNeighborhood(
2873
- sampledNodes,
2874
- Math.min(renderNodeBudget, sampleLimit + 160)
2875
- )
2876
- }
2877
- const sampledIds = new Set(sampledNodes.map((node) => node.id))
2878
- let sampledEdges = collectVisibleEdgesForNodes(sampledIds)
2879
-
2880
- if (state.transform.scale >= 0.035 && sampledEdges.length === 0) {
2881
- const enriched = enrichSampleWithNeighbors(sampledNodes)
2882
- sampledNodes = ensureHubNodesInRenderedSet(enriched.nodes)
2883
- const sampledWithHubsIds = new Set(sampledNodes.map((node) => node.id))
2884
- sampledEdges = collectVisibleEdgesForNodes(sampledWithHubsIds)
2885
- }
2886
-
2887
- state.renderClusters = []
2888
- state.renderClusterEdges = []
2889
- state.renderNodes = sampledNodes
2890
- state.renderEdges = withMeshEdges(sampledNodes, sampledEdges)
2891
- return
2892
- }
2893
-
2894
- if (state.transform.scale <= 0.0015) {
2895
- const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
2896
- const sampledIds = new Set(sampled.map((node) => node.id))
2897
- state.renderClusters = []
2898
- state.renderClusterEdges = []
2899
- state.renderNodes = sampled
2900
- state.renderEdges = withMeshEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
2901
- return
2902
- }
2903
-
2904
- const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2905
- const clusters = clusterViewportNodes(viewportNodes)
2906
- if (clusters.length > 0) {
2907
- state.renderClusters = []
2908
- state.renderClusterEdges = []
2909
- state.renderNodes = representativeNodesFromClusters(clusters, Math.min(renderNodeBudget, 900))
2910
- state.renderEdges = []
2911
- return
2912
- }
2913
- state.renderClusters = []
2914
- state.renderClusterEdges = []
2915
- const stride = viewportNodeStride()
2916
- const picked = []
2917
-
2918
- for (let index = 0; index < viewportNodes.length; index += 1) {
2919
- const node = viewportNodes[index]
2920
-
2921
- const isPriority =
2922
- node.id === state.selected?.id ||
2923
- node.id === state.hovered?.id ||
2924
- node.id === state.pointer.dragNode?.id
2925
- if (isPriority || index % stride === 0) {
2926
- picked.push(node)
2927
- }
2928
- }
2929
-
2930
- const nodes = picked.length > renderNodeBudget
2931
- ? picked.slice(0, renderNodeBudget)
2932
- : picked
2933
- if (nodes.length === 0 && state.visibleNodes.length > 0) {
2934
- const fallbackNodes = fallbackViewportNodes()
2935
- const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
2936
- state.renderNodes = fallbackNodes
2937
- state.renderClusters = []
2938
- state.renderClusterEdges = []
2939
- state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
2940
- return
2941
- }
2942
-
2943
- const normalizedNodes = ensureHubNodesInRenderedSet(nodes)
2944
- const nodeIds = new Set(normalizedNodes.map((node) => node.id))
2945
- const edges = collectVisibleEdgesForNodes(nodeIds)
2946
-
2947
- state.renderNodes = normalizedNodes
2948
- state.renderEdges = withMeshEdges(normalizedNodes, edges)
2949
-
2950
- if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
2951
- const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
2952
- const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
2953
- state.renderClusters = []
2954
- state.renderClusterEdges = []
2955
- state.renderNodes = fallbackNodes
2956
- state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
2957
- }
2958
- }
2959
-
2960
- const isNodeVisibleOnScreen = (node, width, height) => {
2961
- const radius = nodeRadius(node) * state.transform.scale
2962
- const screenX = node.x * state.transform.scale + state.transform.x
2963
- const screenY = node.y * state.transform.scale + state.transform.y
2964
-
2965
- return (
2966
- screenX + radius >= 0 &&
2967
- screenX - radius <= width &&
2968
- screenY + radius >= 0 &&
2969
- screenY - radius <= height
2970
- )
2971
- }
2972
-
2973
- const hasValidTransform = () =>
2974
- isFiniteNumber(state.transform.x) &&
2975
- isFiniteNumber(state.transform.y) &&
2976
- isFiniteNumber(state.transform.scale) &&
2977
- Math.abs(state.transform.x) <= transformCoordinateLimit &&
2978
- Math.abs(state.transform.y) <= transformCoordinateLimit &&
2979
- state.transform.scale > 0
2980
-
2981
- const sanitizeNodePosition = node => {
2982
- if (!isReasonableCoordinate(node.x)) node.x = 0
2983
- if (!isReasonableCoordinate(node.y)) node.y = 0
2984
- if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
2985
- if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
2986
- }
2987
-
2988
- const sanitizeAllNodePositions = () => {
2989
- state.nodes.forEach(sanitizeNodePosition)
2990
- state.visibleNodes.forEach(sanitizeNodePosition)
2991
- }
2992
-
2993
- const sanitizeGraphState = () => {
2994
- state.renderNodes.forEach(sanitizeNodePosition)
2995
- }
2996
-
2997
- const render = now => {
2998
- const delta = now - state.last
2999
- state.last = now
3000
- const backgroundFrameIntervalMs =
3001
- state.nodes.length > massiveGraphNodeThreshold
3002
- ? (state.transform.scale < 0.035 ? 130 : state.transform.scale < 0.08 ? 110 : 86)
3003
- : state.nodes.length > largeGraphNodeThreshold
3004
- ? 64
3005
- : 16
3006
- const isInteracting =
3007
- state.pointer.down ||
3008
- state.renderVisibilityDirty ||
3009
- state.recoveringViewport
3010
- const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
3011
- if (delta < minFrameIntervalMs) {
3012
- requestAnimationFrame(render)
3013
- return
3014
- }
3015
- const rect = canvas.getBoundingClientRect()
3016
- const width = Math.max(rect.width, 320)
3017
- const height = Math.max(rect.height, 320)
3018
- sanitizeGraphState()
3019
- if (!hasValidTransform()) {
3020
- resetView()
3021
- }
3022
- ctx.clearRect(0, 0, width, height)
3023
- webGlRenderer?.clear(width, height)
3024
- if (state.nodes.length === 0) {
3025
- ctx.fillStyle = '#99a5b5'
3026
- ctx.font = '14px Inter, system-ui, sans-serif'
3027
- ctx.textAlign = 'center'
3028
- ctx.fillText('No indexed notes found', width / 2, height / 2)
3029
- requestAnimationFrame(render)
3030
- return
3031
- }
3032
-
3033
- computeRenderVisibility()
3034
- tick(delta)
3035
- const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
3036
- const manualZoomGuardActive = now - state.lastManualZoomAt < zoomRecoveryGuardMs
3037
- const allowViewportAutoRecovery = state.nodes.length <= massiveGraphNodeThreshold
3038
- if (allowViewportAutoRecovery && !hasVisibleNodeOnScreen && state.renderNodes.length > 0 && !manualZoomGuardActive) {
3039
- state.offscreenFrameCount += 1
3040
- if (state.offscreenFrameCount >= 22 && !state.recoveringViewport) {
3041
- state.recoveringViewport = true
3042
- fitView({ useFiltered: true })
3043
- state.offscreenFrameCount = 0
3044
- requestAnimationFrame(() => {
3045
- state.recoveringViewport = false
3046
- })
3047
- }
3048
- } else {
3049
- state.offscreenFrameCount = 0
3050
- }
3051
- const minimumEdgeScale =
3052
- state.nodes.length > massiveGraphNodeThreshold
3053
- ? 0
3054
- : state.renderNodes.length > 1300
3055
- ? 0.12
3056
- : state.renderNodes.length > 900
3057
- ? 0.085
3058
- : state.renderNodes.length > 500
3059
- ? 0.05
3060
- : 0
3061
- const drawEdges =
3062
- state.renderClusters.length === 0 &&
3063
- state.transform.scale >= minimumEdgeScale
3064
- if (drawAcceleratedGraph(width, height, drawEdges)) {
3065
- // WebGL handles the dense node/edge layer; the 2D canvas remains the interaction overlay.
3066
- } else if (state.renderClusters.length > 0) {
3067
- ctx.save()
3068
- ctx.translate(state.transform.x, state.transform.y)
3069
- ctx.scale(state.transform.scale, state.transform.scale)
3070
- const safeScale = Math.max(state.transform.scale, 0.0001)
3071
- if (state.renderClusterEdges.length > 0) {
3072
- for (let index = 0; index < state.renderClusterEdges.length; index += 1) {
3073
- const edge = state.renderClusterEdges[index]
3074
- const edgeOpacity = Math.min(clusterOpacity(edge.sourceCluster), clusterOpacity(edge.targetCluster))
3075
- if (edgeOpacity <= 0.01) {
3076
- continue
3077
- }
3078
- ctx.beginPath()
3079
- ctx.moveTo(edge.sourceCluster.x, edge.sourceCluster.y)
3080
- ctx.lineTo(edge.targetCluster.x, edge.targetCluster.y)
3081
- ctx.lineWidth = 1.2 / safeScale
3082
- ctx.strokeStyle = 'rgba(153, 165, 181, ' + (edge.inferred ? 0.14 : 0.22) * edgeOpacity + ')'
3083
- ctx.stroke()
3084
- }
3085
- }
3086
- state.renderClusters.forEach(cluster => {
3087
- const isMacro = cluster.id === 'macro-galaxy'
3088
- const isEcosystem = String(cluster.id).startsWith('ecosystem-')
3089
- const isHub = Boolean(cluster.isHub)
3090
- const opacity = clusterOpacity(cluster)
3091
- if (opacity <= 0.01) {
3092
1578
  return
3093
1579
  }
3094
- const radiusPx = clusterRadiusPx(cluster)
3095
- const radius = radiusPx / safeScale
3096
- const haloRadius = (radiusPx + (isMacro ? 8 : isHub ? 4 : isEcosystem ? 1.1 : 4)) / safeScale
3097
- ctx.globalAlpha = opacity
3098
- if (isHub || !isEcosystem || state.transform.scale >= ecosystemSubgraphScaleThreshold) {
3099
- ctx.beginPath()
3100
- ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
3101
- ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
3102
- ctx.fill()
3103
- }
3104
- ctx.beginPath()
3105
- ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
3106
- ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
3107
- ctx.fill()
3108
- ctx.lineWidth = (isEcosystem && !isHub ? 0.7 : 1.4) / safeScale
3109
- ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
3110
- ctx.stroke()
3111
- if (isMacro && cluster.representative?.title) {
3112
- ctx.fillStyle = '#edf2f7'
3113
- ctx.font = 12 / safeScale + 'px Inter, system-ui, sans-serif'
3114
- ctx.textAlign = 'center'
3115
- ctx.textBaseline = 'top'
3116
- ctx.fillText(cluster.representative.title.slice(0, 28), cluster.x, cluster.y + (radiusPx + 9) / safeScale)
3117
- }
3118
- ctx.globalAlpha = 1
3119
- // Keep cluster markers minimal and faster to draw on large graphs.
3120
- })
3121
- ctx.restore()
3122
- } else {
3123
- ctx.save()
3124
- ctx.translate(state.transform.x, state.transform.y)
3125
- ctx.scale(state.transform.scale, state.transform.scale)
3126
- if (drawEdges) {
3127
- drawGraphEdges()
3128
- }
3129
- drawGraphNodes()
3130
- ctx.restore()
3131
- }
3132
- if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
3133
- ctx.fillStyle = '#99a5b5'
3134
- ctx.font = '12px Inter, system-ui, sans-serif'
3135
- ctx.textAlign = 'center'
3136
- ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
3137
- }
3138
- requestAnimationFrame(render)
3139
- }
3140
-
3141
- const list = items => items.length
3142
- ? 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('')
3143
- : '<li><small>No links found.</small></li>'
3144
-
3145
- const linkedNodes = node => {
3146
- const nodeById = new Map(state.nodes.map(item => [item.id, item]))
3147
- const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
3148
- ...linkedNode,
3149
- weight: edge.weight,
3150
- priority: edge.priority
3151
- } : null
3152
- const outgoing = state.edges
3153
- .filter(edge => edge.source === node.id)
3154
- .map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
3155
- .filter(Boolean)
3156
- const incoming = state.edges
3157
- .filter(edge => edge.target === node.id)
3158
- .map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
3159
- .filter(Boolean)
3160
-
3161
- return { outgoing, incoming }
3162
- }
3163
-
3164
- const fetchNodeDetails = async node => {
3165
- const cached = state.nodeDetails.get(node.id)
3166
- if (cached) {
3167
- return cached
3168
- }
3169
-
3170
- const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
3171
- if (!response.ok) {
3172
- throw new Error('Failed to load graph node details')
3173
- }
3174
1580
 
3175
- const payload = await response.json()
3176
- const detail = payload?.node
3177
- if (!detail || !detail.id) {
3178
- throw new Error('Invalid graph node payload')
3179
- }
3180
- state.nodeDetails.set(detail.id, detail)
3181
- return detail
3182
- }
3183
-
3184
- const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
3185
-
3186
- const openContentDialog = async node => {
3187
- if (!node) return
3188
- elements.contentTitle.textContent = node.title || 'Loading...'
3189
- elements.contentPath.textContent = node.path || 'Loading...'
3190
- elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
3191
- ? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
3192
- : '<span>No tags</span>'
3193
- const initialLinks = linkedNodes(node)
3194
- elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
3195
- elements.contentIncoming.innerHTML = list(initialLinks.incoming)
3196
- elements.contentBody.textContent = 'Loading note content...'
3197
- if (!elements.contentDialog.open) {
3198
- elements.contentDialog.showModal()
3199
- }
3200
-
3201
- const applyDetailToDialog = detail => {
3202
- elements.contentTitle.textContent = detail.title
3203
- elements.contentPath.textContent = detail.path
3204
- elements.contentTags.innerHTML = detail.tags.length
3205
- ? detail.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
3206
- : '<span>No tags</span>'
3207
- elements.contentBody.textContent = detail.content
3208
- }
3209
-
3210
- try {
3211
- const detailedNode = await fetchNodeDetails(node)
3212
- if (state.selected?.id !== node.id) {
3213
- return
3214
- }
3215
- applyDetailToDialog(detailedNode)
3216
- } catch {
3217
- try {
3218
- await wait(120)
3219
- const retriedNode = await fetchNodeDetails(node)
3220
- 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
3221
1584
  return
3222
1585
  }
3223
- applyDetailToDialog(retriedNode)
3224
- } catch {
3225
- elements.contentBody.textContent = 'Unable to load note content.'
3226
- }
3227
- }
3228
- }
3229
-
3230
- const selectNode = (node, options = { openContent: false }) => {
3231
- state.selected = node
3232
- if (node && options.openContent) {
3233
- openContentDialog(node).catch(() => {
3234
- elements.contentBody.textContent = 'Unable to load note content.'
3235
- })
3236
- }
3237
- }
3238
-
3239
- const selectNodeById = id => {
3240
- const node = state.nodes.find(item => item.id === id)
3241
- if (node) selectNode(node, { openContent: true })
3242
- }
3243
1586
 
3244
- const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
3245
- state.lastManualZoomAt = performance.now()
3246
- const effectiveFactor = factor
3247
- const nextScale = clampScale(state.transform.scale * effectiveFactor)
3248
- if (nextScale === state.transform.scale) {
3249
- return
3250
- }
3251
- const worldPointAtCursor = screenToWorldPoint(screenX, screenY)
3252
- const worldX = worldPointAtCursor.x
3253
- const worldY = worldPointAtCursor.y
3254
- state.lastZoomFocus = {
3255
- x: worldX,
3256
- y: worldY,
3257
- at: performance.now()
3258
- }
3259
- state.transform.scale = clampScale(nextScale)
3260
- state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
3261
- state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
3262
- state.offscreenFrameCount = 0
3263
- markRenderDirty()
3264
- }
3265
-
3266
- const wheelZoomFactor = event => {
3267
- const isModifierZoom = event.metaKey || event.ctrlKey
3268
- const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
3269
- const normalizedDelta = event.deltaY * deltaModeFactor
3270
-
3271
- if (!Number.isFinite(normalizedDelta) || Math.abs(normalizedDelta) <= 0.0001) {
3272
- return 1
3273
- }
3274
-
3275
- const isMassiveEcosystemZoom =
3276
- state.visibleNodes.length > massiveGraphNodeThreshold &&
3277
- state.transform.scale <= massiveEcosystemClusterScaleThreshold
3278
- const sensitivityMultiplier = isMassiveEcosystemZoom ? 0.48 : 1
3279
- const capMultiplier = isMassiveEcosystemZoom ? 0.34 : 1
3280
- const sensitivity = wheelZoomExponent * (isModifierZoom ? wheelZoomModifierBoost : 1) * sensitivityMultiplier
3281
- const exponentCap = wheelZoomExponentCap * capMultiplier
3282
- const exponent = Math.max(
3283
- -exponentCap,
3284
- Math.min(exponentCap, -normalizedDelta * sensitivity)
3285
- )
3286
- return Math.exp(exponent)
3287
- }
3288
-
3289
- const handleWheelZoom = event => {
3290
- if (elements.contentDialog?.open) {
3291
- return
3292
- }
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
+ }
3293
1596
 
3294
- event.preventDefault()
3295
- const rect = canvas.getBoundingClientRect()
3296
- const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
3297
- const rawCursorY = Number.isFinite(event.offsetY) ? event.offsetY : event.clientY - rect.top
3298
- const cursorX = Math.max(0, Math.min(Math.max(rect.width, 320), rawCursorX))
3299
- const cursorY = Math.max(0, Math.min(Math.max(rect.height, 320), rawCursorY))
3300
- state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
3301
- const factor = wheelZoomFactor(event)
3302
-
3303
- if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
3304
- 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()
3305
1610
  }
3306
-
3307
- zoomAtPoint(cursorX, cursorY, factor, 'wheel')
3308
1611
  }
3309
1612
 
3310
- const bindEvents = () => {
3311
- window.addEventListener('resize', resize)
3312
- elements.search.addEventListener('input', event => {
3313
- state.query = event.target.value
3314
- recomputeVisibility()
3315
- scheduleContentFilterSync()
3316
- })
3317
- elements.agent.addEventListener('change', event => {
3318
- state.agentId = event.target.value
3319
- writeStoredAgent(state.agentId)
3320
- syncAgentInUrl(state.agentId)
3321
- state.selected = null
3322
- state.nodeDetails = new Map()
3323
- resetContentFilter()
3324
- recomputeVisibility()
3325
- scheduleContentFilterSync()
3326
- loadGraph({ reset: true }).catch(error => {
3327
- console.error(error)
3328
- })
3329
- })
3330
- elements.zoomIn.addEventListener('click', () => {
3331
- const rect = canvas.getBoundingClientRect()
3332
- zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.055, 'button')
3333
- })
3334
- elements.zoomOut.addEventListener('click', () => {
3335
- const rect = canvas.getBoundingClientRect()
3336
- zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.948, 'button')
3337
- })
3338
- if (elements.fit) {
3339
- elements.fit.addEventListener('click', () => {
3340
- focusPrimaryHub()
3341
- })
3342
- }
3343
- elements.reset.addEventListener('click', () => {
3344
- resetView()
3345
- })
3346
- elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
3347
- elements.contentDialog.addEventListener('click', event => {
1613
+ const wireNodeLinkClicks = () => {
1614
+ const dialog = elements.contentDialog
1615
+ dialog.addEventListener('click', (event) => {
3348
1616
  const target = event.target
3349
- if (target instanceof HTMLElement && target.dataset.nodeId) {
3350
- selectNodeById(target.dataset.nodeId)
3351
- return
3352
- }
3353
- if (event.target === elements.contentDialog) elements.contentDialog.close()
3354
- })
3355
- canvas.addEventListener('wheel', handleWheelZoom, { passive: false })
3356
- canvas.addEventListener('dblclick', event => {
3357
- const point = worldPoint(event)
3358
- const node = hitNode(point)
3359
- if (node) {
3360
- selectNode(node, { openContent: true })
1617
+ if (!(target instanceof HTMLElement)) {
3361
1618
  return
3362
1619
  }
3363
1620
 
3364
- const rect = canvas.getBoundingClientRect()
3365
- const cursorX = event.clientX - rect.left
3366
- const cursorY = event.clientY - rect.top
3367
- zoomAtPoint(cursorX, cursorY, 1.055)
3368
- })
3369
- canvas.addEventListener('pointerdown', event => {
3370
- const point = worldPoint(event)
3371
- const node = hitNode(point)
3372
- state.pointer = { x: event.clientX, y: event.clientY, down: true, dragNode: node, moved: false }
3373
- if (node) {
3374
- node.x = point.x
3375
- node.y = point.y
3376
- markRenderDirty()
3377
- }
3378
- canvas.setPointerCapture(event.pointerId)
3379
- })
3380
- canvas.addEventListener('pointermove', event => {
3381
- const point = worldPoint(event)
3382
- const now = performance.now()
3383
- const canHoverHitTest =
3384
- !(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.06)
3385
- const shouldHitTest = canHoverHitTest &&
3386
- (state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
3387
- if (shouldHitTest) {
3388
- state.hovered = hitNode(point)
3389
- state.lastHoverHitAt = now
3390
- } else if (!canHoverHitTest) {
3391
- state.hovered = null
3392
- }
3393
- state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
3394
- if (!state.pointer.down) return
3395
- const dx = event.clientX - state.pointer.x
3396
- const dy = event.clientY - state.pointer.y
3397
- state.pointer.x = event.clientX
3398
- state.pointer.y = event.clientY
3399
- state.pointer.moved = state.pointer.moved || Math.abs(dx) + Math.abs(dy) > 3
3400
- if (state.pointer.dragNode) {
3401
- const dragNode = state.pointer.dragNode
3402
- const previousX = dragNode.x
3403
- const previousY = dragNode.y
3404
- dragNode.x = point.x
3405
- dragNode.y = point.y
3406
- applyDragNeighborhoodAdjustment(dragNode, dragNode.x - previousX, dragNode.y - previousY)
3407
- markRenderDirty()
3408
- return
3409
- }
3410
- state.transform.x += dx
3411
- state.transform.y += dy
3412
- state.transform.x = clampTransformCoordinate(state.transform.x)
3413
- state.transform.y = clampTransformCoordinate(state.transform.y)
3414
- state.offscreenFrameCount = 0
3415
- markRenderDirty()
3416
- })
3417
- canvas.addEventListener('pointerup', event => {
3418
- const draggedNode = state.pointer.dragNode
3419
- if (draggedNode && state.pointer.moved) {
3420
- settleNeighborhoodAroundNode(draggedNode)
3421
- markRenderDirty()
3422
- }
3423
- if (draggedNode && !state.pointer.moved) selectNode(draggedNode, { openContent: false })
3424
- if (!draggedNode && !state.pointer.moved) selectNode(state.hovered, { openContent: false })
3425
- state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
3426
- canvas.releasePointerCapture(event.pointerId)
3427
- })
3428
- canvas.addEventListener('pointercancel', () => {
3429
- state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
3430
- })
3431
- canvas.addEventListener('pointerenter', event => {
3432
- state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
3433
- })
3434
- canvas.addEventListener('pointerleave', event => {
3435
- state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
3436
- })
3437
- window.addEventListener('keydown', event => {
3438
- if (event.key === '+' || event.key === '=') {
3439
- event.preventDefault()
3440
- const rect = canvas.getBoundingClientRect()
3441
- zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.05)
3442
- return
3443
- }
3444
-
3445
- if (event.key === '-' || event.key === '_') {
3446
- event.preventDefault()
3447
- const rect = canvas.getBoundingClientRect()
3448
- 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) {
3449
1623
  return
3450
1624
  }
3451
1625
 
3452
- if (event.key === '0') {
3453
- event.preventDefault()
3454
- resetView()
1626
+ const id = button.getAttribute('data-node-id') || ''
1627
+ if (id) {
1628
+ loadNodeDetails(id).catch((error) => console.error(error))
3455
1629
  }
3456
1630
  })
3457
1631
  }
3458
1632
 
3459
- const loadAgents = async () => {
3460
- const response = await fetch('/api/agents')
3461
- const payload = await response.json()
3462
- const agents = Array.isArray(payload.agents) ? payload.agents : []
3463
- const preferredAgent = state.agentId || initialAgentFromUrl || readStoredAgent()
3464
- const currentExists = agents.some(agent => agent.id === preferredAgent)
3465
- const selected = currentExists
3466
- ? preferredAgent
3467
- : (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
3468
- const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
3469
-
3470
- state.agentId = selected
3471
- writeStoredAgent(selected)
3472
- syncAgentInUrl(selected)
3473
- if (signature !== state.agentsSignature) {
3474
- const formatAgentLabel = (agent) => agent.id
3475
- elements.agent.innerHTML = agents.length
3476
- ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
3477
- : '<option value="shared">shared</option>'
3478
- state.agentsSignature = signature
3479
- }
3480
- elements.agent.value = selected
3481
- }
3482
-
3483
- const loadGraph = async (options = { reset: false }) => {
3484
- const response = await fetch('/api/graph-layout' + agentQuery(), {
3485
- headers: state.graphSignature
3486
- ? {
3487
- 'if-none-match': encodeEntityTag(state.graphSignature)
3488
- }
3489
- : undefined
3490
- })
3491
-
3492
- if (response.status === 304) {
3493
- return
3494
- }
1633
+ const bootstrap = async () => {
1634
+ setViewportFromCanvas()
1635
+ setupRenderWorker()
1636
+ setupInput()
1637
+ setupControls()
1638
+ setupContextControl()
1639
+ wireNodeLinkClicks()
3495
1640
 
3496
- const payload = await response.json()
3497
- const graph = payload?.layout ?? payload
3498
- state.graphTotals = {
3499
- nodes: Number.isFinite(payload?.totals?.nodes) ? payload.totals.nodes : (Array.isArray(graph.nodes) ? graph.nodes.length : 0),
3500
- edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
3501
- }
3502
- const signature = payload?.signature ?? graphSignature(graph)
3503
- if (!options.reset && signature === state.graphSignature) return
3504
- const selectedId = state.selected?.id
3505
- const layout = createLayout(graph)
3506
- state.graphSignature = signature
3507
- state.graph = graph
3508
- state.nodes = layout.nodes
3509
- state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
3510
- state.edges = layout.edges
3511
- state.nodeDegrees = state.edges.reduce((degrees, edge) => {
3512
- degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
3513
- if (edge.target) {
3514
- degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
3515
- }
3516
- return degrees
3517
- }, new Map())
3518
- state.nodeDetails = new Map()
3519
- pushNodesToFilterWorker()
3520
- resetContentFilter()
3521
- sanitizeAllNodePositions()
3522
- recomputeVisibility()
3523
- scheduleContentFilterSync()
3524
- const tags = new Set(state.nodes.flatMap(node => node.tags))
3525
- setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
3526
- elements.nodeCount.textContent = state.graphTotals.nodes
3527
- elements.edgeCount.textContent = state.graphTotals.edges
3528
- elements.tagCount.textContent = tags.size
3529
- resize()
3530
- if (options.reset) resetView()
3531
- const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
3532
- selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
3533
- if (!selectedNode && elements.contentDialog.open) {
3534
- elements.contentDialog.close()
3535
- }
3536
- }
3537
-
3538
- bindEvents()
3539
- initFilterWorker()
3540
- requestAnimationFrame(() => {
3541
- resize()
3542
- resetView()
3543
- })
3544
-
3545
- const pollIntervalMs = 5000
3546
- let tickCounter = 0
3547
-
3548
- const refreshGraphLoop = () => {
3549
- if (document.hidden) {
3550
- return
3551
- }
1641
+ window.addEventListener('resize', () => {
1642
+ setViewportFromCanvas()
1643
+ scheduleChunkFetch()
1644
+ })
3552
1645
 
3553
- loadGraph().catch(handleGraphRefreshError)
1646
+ await loadAgents()
1647
+ await loadContexts()
1648
+ updateTotals()
1649
+ updateTagCount()
3554
1650
 
3555
- tickCounter += 1
3556
- if (tickCounter % 3 === 0) {
3557
- loadAgents().catch((error) => {
3558
- console.error(error)
3559
- })
3560
- }
1651
+ scheduleChunkFetch({ fit: true })
3561
1652
  }
3562
1653
 
3563
- loadAgents()
3564
- .then(() => loadGraph({ reset: true }))
3565
- .then(() => {
3566
- requestAnimationFrame(render)
3567
- setInterval(refreshGraphLoop, pollIntervalMs)
3568
- })
3569
- .catch(error => {
3570
- console.error(error)
3571
- })
3572
-
3573
- document.addEventListener('visibilitychange', () => {
3574
- if (document.hidden) {
3575
- return
3576
- }
3577
-
3578
- loadGraph({ reset: true }).catch(handleGraphRefreshError)
1654
+ bootstrap().catch((error) => {
1655
+ console.error(error)
3579
1656
  })
3580
1657
  `;