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