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