@andespindola/brainlink 0.1.0-beta.142 → 0.1.0-beta.144

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