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