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