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