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