@andespindola/brainlink 0.1.0-beta.11 → 0.1.0-beta.110
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 -18
- package/SECURITY.md +1 -1
- package/dist/application/analyze-vault.js +1 -9
- package/dist/application/build-context.js +56 -1
- package/dist/application/dedupe-notes.js +226 -0
- package/dist/application/frontend/client-css.js +93 -45
- package/dist/application/frontend/client-html.js +34 -25
- package/dist/application/frontend/client-js.js +3504 -132
- package/dist/application/frontend/client-worker-js.js +66 -0
- package/dist/application/get-graph-layout.js +2 -2
- package/dist/application/get-graph-node.js +3 -3
- package/dist/application/get-graph-summary.js +3 -3
- package/dist/application/get-graph.js +3 -3
- package/dist/application/import-legacy-sqlite.js +296 -0
- package/dist/application/index-vault.js +250 -24
- package/dist/application/list-agents.js +3 -3
- package/dist/application/list-links.js +5 -5
- package/dist/application/offline-pack-backup.js +44 -0
- package/dist/application/search-graph-node-ids.js +3 -3
- package/dist/application/search-knowledge.js +6 -6
- package/dist/application/server/routes.js +76 -1
- package/dist/application/start-server.js +75 -4
- package/dist/application/watch-vault.js +23 -2
- package/dist/benchmarks/large-vault.js +1 -1
- package/dist/cli/commands/agent-commands.js +7 -0
- package/dist/cli/commands/write-commands.js +818 -8
- package/dist/domain/context.js +53 -11
- package/dist/domain/graph-layout.js +47 -2
- package/dist/domain/middle-out.js +18 -0
- package/dist/infrastructure/config.js +38 -0
- package/dist/infrastructure/file-index.js +358 -0
- package/dist/infrastructure/file-system-vault.js +15 -0
- package/dist/infrastructure/index-state.js +56 -0
- package/dist/infrastructure/private-pack-codec.js +134 -0
- package/dist/infrastructure/search-packs.js +327 -26
- package/dist/mcp/server.js +11 -1
- package/dist/mcp/tools.js +62 -0
- package/docs/AGENT_USAGE.md +97 -17
- package/docs/ARCHITECTURE.md +23 -27
- package/docs/QUICKSTART.md +7 -0
- package/package.json +6 -4
- package/dist/infrastructure/sqlite/document-writer.js +0 -51
- package/dist/infrastructure/sqlite/graph-reader.js +0 -267
- package/dist/infrastructure/sqlite/recovery.js +0 -83
- package/dist/infrastructure/sqlite/schema.js +0 -114
- package/dist/infrastructure/sqlite/search-reader.js +0 -188
- package/dist/infrastructure/sqlite/types.js +0 -1
- package/dist/infrastructure/sqlite-index.js +0 -38
|
@@ -1,13 +1,83 @@
|
|
|
1
1
|
export const createClientJs = () => `const canvas = document.getElementById('graph')
|
|
2
|
+
const glCanvas = document.getElementById('graphGl')
|
|
2
3
|
const ctx = canvas.getContext('2d')
|
|
3
4
|
const largeGraphNodeThreshold = 4000
|
|
4
|
-
const
|
|
5
|
+
const massiveGraphNodeThreshold = 20000
|
|
6
|
+
const largeGraphEdgeRenderLimit = 120000
|
|
7
|
+
const renderNodeBudget = 900
|
|
8
|
+
const zoomedMassiveRenderNodeBudget = 2200
|
|
9
|
+
const renderEdgeBudget = 2400
|
|
10
|
+
const clusterActivationNodeThreshold = 600
|
|
11
|
+
const clusterZoomThreshold = 0.18
|
|
12
|
+
const macroGalaxyZoomThreshold = 0.012
|
|
13
|
+
const macroGalaxyEnterHysteresis = 0.86
|
|
14
|
+
const macroGalaxyExitHysteresis = 1.24
|
|
15
|
+
const galaxyDiscoveryEnabled = false
|
|
16
|
+
const massiveAutoFitMacroScale = 0.006
|
|
17
|
+
const defaultMacroScale = 0.006
|
|
18
|
+
const clusterCellPixelSize = 64
|
|
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 ecosystemLevelNodeCap = 999
|
|
25
|
+
const ecosystemActivationNodeThreshold = 1000
|
|
26
|
+
const ecosystemClusterEdgeLimit = 520
|
|
27
|
+
const ecosystemHubEdgeLimit = 120
|
|
28
|
+
const ecosystemSiblingEdgeLimit = 180
|
|
29
|
+
const ecosystemClusterScaleThreshold = 0.78
|
|
30
|
+
const massiveEcosystemClusterScaleThreshold = 4.2
|
|
31
|
+
const ecosystemSubgraphScaleThreshold = 0.18
|
|
32
|
+
const ecosystemMicroScaleThreshold = 0.08
|
|
33
|
+
const ecosystemFocusedParentLimit = 2
|
|
34
|
+
const ecosystemDepthNear = 80
|
|
35
|
+
const ecosystemDepthFar = 2600
|
|
36
|
+
const ecosystemDepthPerspective = 620
|
|
37
|
+
const ecosystemDepthTiltY = 0.24
|
|
38
|
+
const ecosystemDepthYaw = 0.22
|
|
39
|
+
const ecosystemDepthPitch = 0.16
|
|
40
|
+
const ecosystemDepthRadialGain = 0.09
|
|
41
|
+
const ecosystemDepthOrbitalMaxOffset = 160
|
|
42
|
+
const ecosystemDepthMinScale = 0.24
|
|
43
|
+
const ecosystemDepthOpacityFloor = 0.2
|
|
44
|
+
const graphDepthNear = 40
|
|
45
|
+
const graphDepthFar = 560
|
|
46
|
+
const graphDepthPerspective = 760
|
|
47
|
+
const graphDepthYaw = 0.2
|
|
48
|
+
const graphDepthPitch = 0.12
|
|
49
|
+
const graphDepthRadialGain = 0.08
|
|
50
|
+
const graphDepthMinScale = 0.58
|
|
51
|
+
const graphDepthOpacityFloor = 0.42
|
|
52
|
+
const graphDepthEdgeOpacityFloor = 0.24
|
|
53
|
+
const graphDepthProjectionNodeThreshold = 120
|
|
54
|
+
const zoomRecoveryGuardMs = 4200
|
|
55
|
+
const zoomCapTargetViewportShare = 0.72
|
|
56
|
+
const meshEdgeScaleThreshold = 0.09
|
|
57
|
+
const meshEdgeMinBudget = 140
|
|
58
|
+
const meshEdgeMaxBudget = 1400
|
|
59
|
+
const layeredCoreScaleThreshold = 0.55
|
|
60
|
+
const dragNeighborhoodMaxAffected = 180
|
|
61
|
+
const dragSettleRounds = 3
|
|
62
|
+
const wheelZoomExponent = 0.0009
|
|
63
|
+
const wheelZoomExponentCap = 0.035
|
|
64
|
+
const wheelZoomModifierBoost = 1.08
|
|
65
|
+
const physicsDragFrameIntervalMs = 16
|
|
66
|
+
const physicsIdleFrameIntervalMs = 78
|
|
67
|
+
const physicsLargeGraphIdleFrameIntervalMs = 108
|
|
68
|
+
const physicsStepDeltaCapMs = 96
|
|
5
69
|
const state = {
|
|
6
70
|
graph: { nodes: [], edges: [] },
|
|
7
71
|
nodes: [],
|
|
72
|
+
nodeById: new Map(),
|
|
8
73
|
edges: [],
|
|
9
74
|
visibleNodes: [],
|
|
10
75
|
visibleEdges: [],
|
|
76
|
+
renderNodes: [],
|
|
77
|
+
renderEdges: [],
|
|
78
|
+
renderClusters: [],
|
|
79
|
+
renderClusterEdges: [],
|
|
80
|
+
renderNodeDepthProjectionById: new Map(),
|
|
11
81
|
nodeDegrees: new Map(),
|
|
12
82
|
selected: null,
|
|
13
83
|
hovered: null,
|
|
@@ -18,9 +88,39 @@ const state = {
|
|
|
18
88
|
nodeDetails: new Map(),
|
|
19
89
|
transform: { x: 0, y: 0, scale: 1 },
|
|
20
90
|
pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
|
|
91
|
+
cursor: { x: 0, y: 0, inCanvas: false },
|
|
21
92
|
graphSignature: '',
|
|
22
93
|
graphStatus: '',
|
|
23
|
-
|
|
94
|
+
graphTotals: { nodes: 0, edges: 0 },
|
|
95
|
+
viewport: { width: 320, height: 320 },
|
|
96
|
+
last: performance.now(),
|
|
97
|
+
lastPhysicsAt: performance.now(),
|
|
98
|
+
physicsRestFrames: 0,
|
|
99
|
+
offscreenFrameCount: 0,
|
|
100
|
+
recoveringViewport: false,
|
|
101
|
+
renderVisibilityDirty: true,
|
|
102
|
+
lastViewportKey: '',
|
|
103
|
+
visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
|
|
104
|
+
visibleEdgeByNode: new Map(),
|
|
105
|
+
ecosystemClusters: [],
|
|
106
|
+
ecosystemClustersBySize: new Map(),
|
|
107
|
+
ecosystemNodeClusterBySize: new Map(),
|
|
108
|
+
ecosystemLevelSizes: [],
|
|
109
|
+
ecosystemLevelIndexBySize: new Map(),
|
|
110
|
+
ecosystemHubNodeIds: new Set(),
|
|
111
|
+
ecosystemExpansionLevels: [],
|
|
112
|
+
ecosystemBaseSize: ecosystemLevelNodeCap,
|
|
113
|
+
ecosystemHubCluster: null,
|
|
114
|
+
macroCenter: { x: 0, y: 0 },
|
|
115
|
+
macroRepresentative: null,
|
|
116
|
+
primaryHub: null,
|
|
117
|
+
hubNeighborDistance: Number.POSITIVE_INFINITY,
|
|
118
|
+
filterWorker: null,
|
|
119
|
+
filterReady: false,
|
|
120
|
+
lastHoverHitAt: 0,
|
|
121
|
+
lastManualZoomAt: 0,
|
|
122
|
+
lastZoomFocus: { x: 0, y: 0, at: 0 },
|
|
123
|
+
macroViewActive: false
|
|
24
124
|
}
|
|
25
125
|
|
|
26
126
|
const byId = id => document.getElementById(id)
|
|
@@ -51,11 +151,54 @@ const elements = {
|
|
|
51
151
|
}
|
|
52
152
|
|
|
53
153
|
const zoomRange = {
|
|
54
|
-
min: 0.
|
|
154
|
+
min: 0.0002,
|
|
55
155
|
max: 4.5
|
|
56
156
|
}
|
|
57
157
|
|
|
58
|
-
const
|
|
158
|
+
const initialAgentFromUrl = (() => {
|
|
159
|
+
try {
|
|
160
|
+
const raw = new URL(window.location.href).searchParams.get('agent')
|
|
161
|
+
const value = raw?.trim() ?? ''
|
|
162
|
+
return value.length > 0 ? value : ''
|
|
163
|
+
} catch {
|
|
164
|
+
return ''
|
|
165
|
+
}
|
|
166
|
+
})()
|
|
167
|
+
|
|
168
|
+
const selectedAgentStorageKey = 'brainlink:selected-agent'
|
|
169
|
+
|
|
170
|
+
const readStoredAgent = () => {
|
|
171
|
+
try {
|
|
172
|
+
const value = window.localStorage.getItem(selectedAgentStorageKey)?.trim() ?? ''
|
|
173
|
+
return value.length > 0 ? value : ''
|
|
174
|
+
} catch {
|
|
175
|
+
return ''
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const writeStoredAgent = (agentId) => {
|
|
180
|
+
try {
|
|
181
|
+
if (!agentId) {
|
|
182
|
+
window.localStorage.removeItem(selectedAgentStorageKey)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
window.localStorage.setItem(selectedAgentStorageKey, agentId)
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const syncAgentInUrl = (agentId) => {
|
|
190
|
+
try {
|
|
191
|
+
const url = new URL(window.location.href)
|
|
192
|
+
if (agentId && agentId.trim().length > 0) {
|
|
193
|
+
url.searchParams.set('agent', agentId)
|
|
194
|
+
} else {
|
|
195
|
+
url.searchParams.delete('agent')
|
|
196
|
+
}
|
|
197
|
+
window.history.replaceState({}, '', url.toString())
|
|
198
|
+
} catch {}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
|
|
59
202
|
|
|
60
203
|
const setGraphStatus = text => {
|
|
61
204
|
state.graphStatus = text
|
|
@@ -78,6 +221,238 @@ const graphTheme = {
|
|
|
78
221
|
label: '#edf2f7'
|
|
79
222
|
}
|
|
80
223
|
|
|
224
|
+
const parseRgb = color => {
|
|
225
|
+
const normalized = color.trim()
|
|
226
|
+
if (normalized.startsWith('#')) {
|
|
227
|
+
const value = normalized.slice(1)
|
|
228
|
+
const expanded = value.length === 3
|
|
229
|
+
? value.split('').map(char => char + char).join('')
|
|
230
|
+
: value
|
|
231
|
+
const parsed = Number.parseInt(expanded, 16)
|
|
232
|
+
return [
|
|
233
|
+
((parsed >> 16) & 255) / 255,
|
|
234
|
+
((parsed >> 8) & 255) / 255,
|
|
235
|
+
(parsed & 255) / 255
|
|
236
|
+
]
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const match = normalized.match(/rgba?\\(([^)]+)\\)/)
|
|
240
|
+
if (!match) return [1, 1, 1]
|
|
241
|
+
const parts = match[1].split(',').map(part => Number.parseFloat(part.trim()))
|
|
242
|
+
return [
|
|
243
|
+
Math.max(0, Math.min(1, (parts[0] ?? 255) / 255)),
|
|
244
|
+
Math.max(0, Math.min(1, (parts[1] ?? 255) / 255)),
|
|
245
|
+
Math.max(0, Math.min(1, (parts[2] ?? 255) / 255))
|
|
246
|
+
]
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const rgba = (color, alpha = 1) => {
|
|
250
|
+
const [red, green, blue] = parseRgb(color)
|
|
251
|
+
return [red, green, blue, Math.max(0, Math.min(1, alpha))]
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const createShader = (gl, type, source) => {
|
|
255
|
+
const shader = gl.createShader(type)
|
|
256
|
+
if (!shader) return null
|
|
257
|
+
gl.shaderSource(shader, source)
|
|
258
|
+
gl.compileShader(shader)
|
|
259
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
260
|
+
gl.deleteShader(shader)
|
|
261
|
+
return null
|
|
262
|
+
}
|
|
263
|
+
return shader
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const createProgram = (gl, vertexSource, fragmentSource) => {
|
|
267
|
+
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource)
|
|
268
|
+
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)
|
|
269
|
+
if (!vertexShader || !fragmentShader) return null
|
|
270
|
+
const program = gl.createProgram()
|
|
271
|
+
if (!program) return null
|
|
272
|
+
gl.attachShader(program, vertexShader)
|
|
273
|
+
gl.attachShader(program, fragmentShader)
|
|
274
|
+
gl.linkProgram(program)
|
|
275
|
+
gl.deleteShader(vertexShader)
|
|
276
|
+
gl.deleteShader(fragmentShader)
|
|
277
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
278
|
+
gl.deleteProgram(program)
|
|
279
|
+
return null
|
|
280
|
+
}
|
|
281
|
+
return program
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const createWebGlRenderer = targetCanvas => {
|
|
285
|
+
if (!targetCanvas) return null
|
|
286
|
+
|
|
287
|
+
const gl = targetCanvas.getContext('webgl2', { alpha: true, antialias: true }) ||
|
|
288
|
+
targetCanvas.getContext('webgl', { alpha: true, antialias: true })
|
|
289
|
+
if (!gl) return null
|
|
290
|
+
|
|
291
|
+
const lineProgram = createProgram(
|
|
292
|
+
gl,
|
|
293
|
+
'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); }',
|
|
294
|
+
'precision mediump float; uniform vec4 u_color; void main() { gl_FragColor = u_color; }'
|
|
295
|
+
)
|
|
296
|
+
const pointProgram = createProgram(
|
|
297
|
+
gl,
|
|
298
|
+
'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; }',
|
|
299
|
+
'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); }'
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
if (!lineProgram || !pointProgram) return null
|
|
303
|
+
|
|
304
|
+
const lineBuffer = gl.createBuffer()
|
|
305
|
+
const pointPositionBuffer = gl.createBuffer()
|
|
306
|
+
const pointSizeBuffer = gl.createBuffer()
|
|
307
|
+
if (!lineBuffer || !pointPositionBuffer || !pointSizeBuffer) return null
|
|
308
|
+
|
|
309
|
+
const linePositionLocation = gl.getAttribLocation(lineProgram, 'a_position')
|
|
310
|
+
const lineResolutionLocation = gl.getUniformLocation(lineProgram, 'u_resolution')
|
|
311
|
+
const lineColorLocation = gl.getUniformLocation(lineProgram, 'u_color')
|
|
312
|
+
const pointPositionLocation = gl.getAttribLocation(pointProgram, 'a_position')
|
|
313
|
+
const pointSizeLocation = gl.getAttribLocation(pointProgram, 'a_size')
|
|
314
|
+
const pointResolutionLocation = gl.getUniformLocation(pointProgram, 'u_resolution')
|
|
315
|
+
const pointColorLocation = gl.getUniformLocation(pointProgram, 'u_color')
|
|
316
|
+
|
|
317
|
+
gl.enable(gl.BLEND)
|
|
318
|
+
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
|
319
|
+
|
|
320
|
+
const setViewport = (width, height) => {
|
|
321
|
+
gl.viewport(0, 0, targetCanvas.width, targetCanvas.height)
|
|
322
|
+
return [targetCanvas.width / Math.max(width, 1), targetCanvas.height / Math.max(height, 1)]
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const screenPoint = (node, ratioX, ratioY) => [
|
|
326
|
+
(node.x * state.transform.scale + state.transform.x) * ratioX,
|
|
327
|
+
(node.y * state.transform.scale + state.transform.y) * ratioY
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
const clear = (width, height) => {
|
|
331
|
+
setViewport(width, height)
|
|
332
|
+
gl.clearColor(0, 0, 0, 0)
|
|
333
|
+
gl.clear(gl.COLOR_BUFFER_BIT)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const drawLines = (edges, color, width, height) => {
|
|
337
|
+
if (edges.length === 0) return
|
|
338
|
+
const [ratioX, ratioY] = setViewport(width, height)
|
|
339
|
+
const positions = new Float32Array(edges.length * 4)
|
|
340
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
341
|
+
const edge = edges[index]
|
|
342
|
+
const source = screenPoint(edge.sourceNode, ratioX, ratioY)
|
|
343
|
+
const target = screenPoint(edge.targetNode, ratioX, ratioY)
|
|
344
|
+
const offset = index * 4
|
|
345
|
+
positions[offset] = source[0]
|
|
346
|
+
positions[offset + 1] = source[1]
|
|
347
|
+
positions[offset + 2] = target[0]
|
|
348
|
+
positions[offset + 3] = target[1]
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
gl.useProgram(lineProgram)
|
|
352
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer)
|
|
353
|
+
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STREAM_DRAW)
|
|
354
|
+
gl.enableVertexAttribArray(linePositionLocation)
|
|
355
|
+
gl.vertexAttribPointer(linePositionLocation, 2, gl.FLOAT, false, 0, 0)
|
|
356
|
+
gl.uniform2f(lineResolutionLocation, targetCanvas.width, targetCanvas.height)
|
|
357
|
+
gl.uniform4fv(lineColorLocation, color)
|
|
358
|
+
gl.lineWidth(1)
|
|
359
|
+
gl.drawArrays(gl.LINES, 0, edges.length * 2)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const drawPoints = (nodes, color, sizeForNode, width, height) => {
|
|
363
|
+
if (nodes.length === 0) return
|
|
364
|
+
const [ratioX, ratioY] = setViewport(width, height)
|
|
365
|
+
const positions = new Float32Array(nodes.length * 2)
|
|
366
|
+
const sizes = new Float32Array(nodes.length)
|
|
367
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
368
|
+
const node = nodes[index]
|
|
369
|
+
const point = screenPoint(node, ratioX, ratioY)
|
|
370
|
+
const offset = index * 2
|
|
371
|
+
positions[offset] = point[0]
|
|
372
|
+
positions[offset + 1] = point[1]
|
|
373
|
+
sizes[index] = sizeForNode(node) * ((ratioX + ratioY) / 2)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
gl.useProgram(pointProgram)
|
|
377
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, pointPositionBuffer)
|
|
378
|
+
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STREAM_DRAW)
|
|
379
|
+
gl.enableVertexAttribArray(pointPositionLocation)
|
|
380
|
+
gl.vertexAttribPointer(pointPositionLocation, 2, gl.FLOAT, false, 0, 0)
|
|
381
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, pointSizeBuffer)
|
|
382
|
+
gl.bufferData(gl.ARRAY_BUFFER, sizes, gl.STREAM_DRAW)
|
|
383
|
+
gl.enableVertexAttribArray(pointSizeLocation)
|
|
384
|
+
gl.vertexAttribPointer(pointSizeLocation, 1, gl.FLOAT, false, 0, 0)
|
|
385
|
+
gl.uniform2f(pointResolutionLocation, targetCanvas.width, targetCanvas.height)
|
|
386
|
+
gl.uniform4fv(pointColorLocation, color)
|
|
387
|
+
gl.drawArrays(gl.POINTS, 0, nodes.length)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return { clear, drawLines, drawPoints }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const webGlRenderer = createWebGlRenderer(glCanvas)
|
|
394
|
+
|
|
395
|
+
const initFilterWorker = () => {
|
|
396
|
+
if (typeof Worker === 'undefined') {
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
const worker = new Worker('/app-worker.js')
|
|
401
|
+
worker.onmessage = event => {
|
|
402
|
+
const payload = event.data
|
|
403
|
+
if (!payload || typeof payload !== 'object') return
|
|
404
|
+
|
|
405
|
+
if (payload.type === 'ready') {
|
|
406
|
+
state.filterReady = true
|
|
407
|
+
if (state.nodes.length > 0) {
|
|
408
|
+
worker.postMessage({
|
|
409
|
+
type: 'load-nodes',
|
|
410
|
+
nodes: state.nodes.map(node => ({
|
|
411
|
+
id: node.id,
|
|
412
|
+
title: node.title,
|
|
413
|
+
path: node.path || '',
|
|
414
|
+
tags: Array.isArray(node.tags) ? node.tags : []
|
|
415
|
+
}))
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (payload.type === 'filter-result') {
|
|
422
|
+
const token = payload.token
|
|
423
|
+
if (token !== state.contentFilter.token) {
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const ids = Array.isArray(payload.ids) ? payload.ids.filter(id => typeof id === 'string') : []
|
|
428
|
+
state.contentFilter.query = normalizeQuery(state.query)
|
|
429
|
+
state.contentFilter.ids = new Set(ids)
|
|
430
|
+
recomputeVisibility()
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
state.filterWorker = worker
|
|
434
|
+
} catch {
|
|
435
|
+
state.filterWorker = null
|
|
436
|
+
state.filterReady = false
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const pushNodesToFilterWorker = () => {
|
|
441
|
+
if (!state.filterWorker || !state.filterReady) {
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
state.filterWorker.postMessage({
|
|
446
|
+
type: 'load-nodes',
|
|
447
|
+
nodes: state.nodes.map(node => ({
|
|
448
|
+
id: node.id,
|
|
449
|
+
title: node.title,
|
|
450
|
+
path: node.path || '',
|
|
451
|
+
tags: Array.isArray(node.tags) ? node.tags : []
|
|
452
|
+
}))
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
|
|
81
456
|
const resize = () => {
|
|
82
457
|
const rect = canvas.getBoundingClientRect()
|
|
83
458
|
const width = Math.max(rect.width, 320)
|
|
@@ -85,26 +460,138 @@ const resize = () => {
|
|
|
85
460
|
const ratio = window.devicePixelRatio || 1
|
|
86
461
|
canvas.width = Math.floor(width * ratio)
|
|
87
462
|
canvas.height = Math.floor(height * ratio)
|
|
463
|
+
if (glCanvas) {
|
|
464
|
+
glCanvas.width = Math.floor(width * ratio)
|
|
465
|
+
glCanvas.height = Math.floor(height * ratio)
|
|
466
|
+
}
|
|
467
|
+
state.viewport = { width, height }
|
|
88
468
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
469
|
+
markRenderDirty()
|
|
89
470
|
}
|
|
90
471
|
|
|
91
472
|
const normalizeQuery = value => value.trim().toLowerCase()
|
|
473
|
+
const hubNodeRetentionLimit = 2
|
|
474
|
+
const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map|mapa)\b/i
|
|
475
|
+
const memoryHubPathPattern = /\bmemory[-_\s]*hub\b/i
|
|
476
|
+
|
|
477
|
+
const hubNodeScore = node => {
|
|
478
|
+
const title = node.title.trim().toLowerCase()
|
|
479
|
+
if (title === 'memory hub') return 6
|
|
480
|
+
if (title === 'knowledge hub') return 5
|
|
481
|
+
if (memoryHubPathPattern.test(node.path || '')) return 4
|
|
482
|
+
if (node.tags.some(tag => tag.trim().toLowerCase() === 'memory-hub')) return 3
|
|
483
|
+
if (/\bmoc\b/i.test(node.title)) return 2
|
|
484
|
+
return hubNodePattern.test(node.title) || hubNodePattern.test(node.path || '') || node.tags.some(tag => hubNodePattern.test(tag))
|
|
485
|
+
? 1
|
|
486
|
+
: 0
|
|
487
|
+
}
|
|
92
488
|
|
|
93
489
|
const localFilteredNodes = query =>
|
|
94
490
|
state.nodes.filter(node =>
|
|
95
491
|
node.title.toLowerCase().includes(query) ||
|
|
96
|
-
node.path.toLowerCase().includes(query) ||
|
|
492
|
+
(node.path || '').toLowerCase().includes(query) ||
|
|
97
493
|
node.tags.some(tag => tag.toLowerCase().includes(query))
|
|
98
494
|
)
|
|
99
495
|
|
|
496
|
+
const rankedHubNodes = () => {
|
|
497
|
+
if (state.nodes.length === 0) {
|
|
498
|
+
return []
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const byTitleAndDegree = [...state.nodes]
|
|
502
|
+
.filter(node => hubNodeScore(node) > 0)
|
|
503
|
+
.sort((left, right) => {
|
|
504
|
+
const byHubScore = hubNodeScore(right) - hubNodeScore(left)
|
|
505
|
+
if (byHubScore !== 0) return byHubScore
|
|
506
|
+
const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
|
|
507
|
+
if (byDegree !== 0) return byDegree
|
|
508
|
+
return left.title.localeCompare(right.title)
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
if (byTitleAndDegree.length > 0) {
|
|
512
|
+
return byTitleAndDegree.slice(0, hubNodeRetentionLimit)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return [...state.nodes]
|
|
516
|
+
.sort((left, right) => {
|
|
517
|
+
const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
|
|
518
|
+
if (byDegree !== 0) return byDegree
|
|
519
|
+
return left.title.localeCompare(right.title)
|
|
520
|
+
})
|
|
521
|
+
.slice(0, 1)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const withPersistentHubNodes = nodes => {
|
|
525
|
+
if (nodes.length === 0) {
|
|
526
|
+
return rankedHubNodes()
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
530
|
+
const hubsToKeep = rankedHubNodes().filter(node => !ids.has(node.id))
|
|
531
|
+
return nodes.concat(hubsToKeep)
|
|
532
|
+
}
|
|
533
|
+
|
|
100
534
|
const filteredNodes = () => {
|
|
101
535
|
const query = normalizeQuery(state.query)
|
|
102
536
|
if (!query) return state.nodes
|
|
103
537
|
if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
|
|
104
|
-
|
|
538
|
+
const matched = state.nodes.filter(node => state.contentFilter.ids.has(node.id))
|
|
539
|
+
return withPersistentHubNodes(matched)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return withPersistentHubNodes(localFilteredNodes(query))
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const resolveMacroRepresentative = (nodes) => {
|
|
546
|
+
if (nodes.length === 0) {
|
|
547
|
+
return null
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const hubCandidate = state.primaryHub && nodes.some(node => node.id === state.primaryHub.id)
|
|
551
|
+
? state.primaryHub
|
|
552
|
+
: null
|
|
553
|
+
let best = hubCandidate ?? nodes[0]
|
|
554
|
+
let bestDegree = state.nodeDegrees.get(best.id) ?? 0
|
|
555
|
+
|
|
556
|
+
for (let index = 1; index < nodes.length; index += 1) {
|
|
557
|
+
const node = nodes[index]
|
|
558
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
559
|
+
if (degree > bestDegree) {
|
|
560
|
+
best = node
|
|
561
|
+
bestDegree = degree
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return best
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const nearestHubNeighborDistance = (hub, nodes) => {
|
|
569
|
+
if (!hub || nodes.length <= 1) {
|
|
570
|
+
return Number.POSITIVE_INFINITY
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
let minimum = Number.POSITIVE_INFINITY
|
|
574
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
575
|
+
const node = nodes[index]
|
|
576
|
+
if (node.id === hub.id) continue
|
|
577
|
+
const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
|
|
578
|
+
if (distance < minimum) {
|
|
579
|
+
minimum = distance
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return minimum
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const isDominantHub = (hub, nodeCount = state.visibleNodes.length) => {
|
|
587
|
+
if (!hub || nodeCount <= 0) {
|
|
588
|
+
return false
|
|
105
589
|
}
|
|
106
590
|
|
|
107
|
-
|
|
591
|
+
const degree = state.nodeDegrees.get(hub.id) ?? 0
|
|
592
|
+
const minimumDegree = Math.max(18, Math.sqrt(nodeCount) * 1.8)
|
|
593
|
+
const degreeRatio = degree / Math.max(nodeCount, 1)
|
|
594
|
+
return degree >= minimumDegree || degreeRatio >= 0.035
|
|
108
595
|
}
|
|
109
596
|
|
|
110
597
|
const recomputeVisibility = () => {
|
|
@@ -117,13 +604,1881 @@ const recomputeVisibility = () => {
|
|
|
117
604
|
.slice(0, largeGraphEdgeRenderLimit)
|
|
118
605
|
: edges
|
|
119
606
|
|
|
120
|
-
state.visibleNodes = nodes
|
|
121
|
-
state.visibleEdges = limitedEdges
|
|
607
|
+
state.visibleNodes = nodes
|
|
608
|
+
state.visibleEdges = limitedEdges
|
|
609
|
+
state.visibleNodeSpatial = createSpatialIndex(nodes)
|
|
610
|
+
state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
|
|
611
|
+
const primaryHub = rankedHubNodes()[0] ?? null
|
|
612
|
+
state.primaryHub = primaryHub
|
|
613
|
+
state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
|
|
614
|
+
const bounds = graphBounds(nodes)
|
|
615
|
+
const macroHub = isDominantHub(primaryHub, nodes.length) ? primaryHub : null
|
|
616
|
+
state.macroCenter = bounds
|
|
617
|
+
? {
|
|
618
|
+
x: macroHub ? macroHub.x : (bounds.minX + bounds.maxX) / 2,
|
|
619
|
+
y: macroHub ? macroHub.y : (bounds.minY + bounds.maxY) / 2
|
|
620
|
+
}
|
|
621
|
+
: { x: 0, y: 0 }
|
|
622
|
+
const ecosystemGraph = nodes.length > 1
|
|
623
|
+
? buildEcosystemGraph(nodes, state.macroCenter, primaryHub)
|
|
624
|
+
: {
|
|
625
|
+
clusters: [],
|
|
626
|
+
clustersBySize: new Map(),
|
|
627
|
+
nodeClusterBySize: new Map(),
|
|
628
|
+
levelSizes: [],
|
|
629
|
+
expansionLevels: [],
|
|
630
|
+
baseSize: ecosystemLevelNodeCap,
|
|
631
|
+
hubCluster: null
|
|
632
|
+
}
|
|
633
|
+
state.ecosystemClusters = ecosystemGraph.clusters
|
|
634
|
+
state.ecosystemClustersBySize = ecosystemGraph.clustersBySize
|
|
635
|
+
state.ecosystemNodeClusterBySize = ecosystemGraph.nodeClusterBySize
|
|
636
|
+
state.ecosystemLevelSizes = ecosystemGraph.levelSizes
|
|
637
|
+
state.ecosystemLevelIndexBySize = ecosystemGraph.levelSizes.reduce((map, size, index) => {
|
|
638
|
+
map.set(size, index)
|
|
639
|
+
return map
|
|
640
|
+
}, new Map())
|
|
641
|
+
state.ecosystemHubNodeIds = new Set(ecosystemGraph.hubCluster?.nodeIds ?? [])
|
|
642
|
+
state.ecosystemExpansionLevels = ecosystemGraph.expansionLevels
|
|
643
|
+
state.ecosystemBaseSize = ecosystemGraph.baseSize
|
|
644
|
+
state.ecosystemHubCluster = ecosystemGraph.hubCluster
|
|
645
|
+
state.macroRepresentative = resolveMacroRepresentative(nodes)
|
|
646
|
+
markRenderDirty()
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
|
|
650
|
+
const markRenderDirty = () => {
|
|
651
|
+
state.renderVisibilityDirty = true
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const createSpatialIndex = nodes => {
|
|
655
|
+
if (nodes.length === 0) {
|
|
656
|
+
return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const bounds = graphBounds(nodes)
|
|
660
|
+
if (!bounds) {
|
|
661
|
+
return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const targetNodesPerCell = 18
|
|
665
|
+
const approximateCellArea = Math.max((bounds.width * bounds.height) / Math.max(nodes.length / targetNodesPerCell, 1), 1)
|
|
666
|
+
const cellSize = Math.max(90, Math.min(2200, Math.sqrt(approximateCellArea)))
|
|
667
|
+
const buckets = new Map()
|
|
668
|
+
|
|
669
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
670
|
+
const node = nodes[index]
|
|
671
|
+
const cellX = Math.floor((node.x - bounds.minX) / cellSize)
|
|
672
|
+
const cellY = Math.floor((node.y - bounds.minY) / cellSize)
|
|
673
|
+
const key = cellX + ':' + cellY
|
|
674
|
+
const bucket = buckets.get(key)
|
|
675
|
+
if (bucket) {
|
|
676
|
+
bucket.push(node)
|
|
677
|
+
continue
|
|
678
|
+
}
|
|
679
|
+
buckets.set(key, [node])
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return {
|
|
683
|
+
cellSize,
|
|
684
|
+
minX: bounds.minX,
|
|
685
|
+
minY: bounds.minY,
|
|
686
|
+
maxX: bounds.maxX,
|
|
687
|
+
maxY: bounds.maxY,
|
|
688
|
+
buckets
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const viewportNodesFromSpatialIndex = viewport => {
|
|
693
|
+
if (state.visibleNodes.length <= 2500) {
|
|
694
|
+
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const spatial = state.visibleNodeSpatial
|
|
698
|
+
if (!spatial || spatial.buckets.size === 0) {
|
|
699
|
+
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const minCellX = Math.floor((viewport.minX - spatial.minX) / spatial.cellSize)
|
|
703
|
+
const maxCellX = Math.floor((viewport.maxX - spatial.minX) / spatial.cellSize)
|
|
704
|
+
const minCellY = Math.floor((viewport.minY - spatial.minY) / spatial.cellSize)
|
|
705
|
+
const maxCellY = Math.floor((viewport.maxY - spatial.minY) / spatial.cellSize)
|
|
706
|
+
const nodes = []
|
|
707
|
+
|
|
708
|
+
for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
|
|
709
|
+
for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
|
|
710
|
+
const bucket = spatial.buckets.get(cellX + ':' + cellY)
|
|
711
|
+
if (!bucket) continue
|
|
712
|
+
|
|
713
|
+
for (let index = 0; index < bucket.length; index += 1) {
|
|
714
|
+
const node = bucket[index]
|
|
715
|
+
if (isNodeInViewport(node, viewport)) {
|
|
716
|
+
nodes.push(node)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return nodes
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const createVisibleEdgeLookup = edges => {
|
|
726
|
+
const lookup = new Map()
|
|
727
|
+
|
|
728
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
729
|
+
const edge = edges[index]
|
|
730
|
+
if (!edge.target) continue
|
|
731
|
+
|
|
732
|
+
const sourceList = lookup.get(edge.source)
|
|
733
|
+
if (sourceList) {
|
|
734
|
+
sourceList.push(edge)
|
|
735
|
+
} else {
|
|
736
|
+
lookup.set(edge.source, [edge])
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const targetList = lookup.get(edge.target)
|
|
740
|
+
if (targetList) {
|
|
741
|
+
targetList.push(edge)
|
|
742
|
+
} else {
|
|
743
|
+
lookup.set(edge.target, [edge])
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return lookup
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const ecosystemKeyForNode = node => {
|
|
751
|
+
if (typeof node.segment === 'string' && node.segment.trim()) {
|
|
752
|
+
return node.segment.trim()
|
|
753
|
+
}
|
|
754
|
+
if (typeof node.group === 'string' && node.group.trim()) {
|
|
755
|
+
return node.group.trim()
|
|
756
|
+
}
|
|
757
|
+
const pathParts = String(node.path || '')
|
|
758
|
+
.split('/')
|
|
759
|
+
.filter(part => part.trim())
|
|
760
|
+
.slice(0, 2)
|
|
761
|
+
return pathParts.length > 0 ? pathParts.join('/') : 'root'
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const compareNodesForEcosystem = (left, right) => {
|
|
765
|
+
const keyComparison = ecosystemKeyForNode(left).localeCompare(ecosystemKeyForNode(right))
|
|
766
|
+
if (keyComparison !== 0) return keyComparison
|
|
767
|
+
const leftDegree = state.nodeDegrees.get(left.id) ?? 0
|
|
768
|
+
const rightDegree = state.nodeDegrees.get(right.id) ?? 0
|
|
769
|
+
if (leftDegree !== rightDegree) return rightDegree - leftDegree
|
|
770
|
+
return String(left.title || left.id).localeCompare(String(right.title || right.id))
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const selectEcosystemRepresentative = nodes => {
|
|
774
|
+
let representative = nodes[0] ?? null
|
|
775
|
+
let representativeScore = Number.NEGATIVE_INFINITY
|
|
776
|
+
|
|
777
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
778
|
+
const node = nodes[index]
|
|
779
|
+
const score = (state.nodeDegrees.get(node.id) ?? 0) + hubNodeScore(node) * 1000
|
|
780
|
+
if (score > representativeScore) {
|
|
781
|
+
representative = node
|
|
782
|
+
representativeScore = score
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return representative
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const ecosystemLayoutSpacingForSize = size => {
|
|
790
|
+
if (size >= ecosystemLevelNodeCap) return 260
|
|
791
|
+
if (size >= 320) return 110
|
|
792
|
+
if (size >= 120) return 64
|
|
793
|
+
if (size >= 48) return 34
|
|
794
|
+
if (size >= 18) return 18
|
|
795
|
+
if (size >= 8) return 11
|
|
796
|
+
return 7
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const buildIntermediateEcosystemSizes = (fromSize, toSize) => {
|
|
800
|
+
if (fromSize <= toSize + 1) {
|
|
801
|
+
return []
|
|
802
|
+
}
|
|
803
|
+
const intermediate = []
|
|
804
|
+
let current = fromSize
|
|
805
|
+
while (current > toSize + 1) {
|
|
806
|
+
const stepped = Math.max(toSize + 1, Math.ceil(current / 3))
|
|
807
|
+
if (stepped >= current) {
|
|
808
|
+
break
|
|
809
|
+
}
|
|
810
|
+
intermediate.push(stepped)
|
|
811
|
+
current = stepped
|
|
812
|
+
}
|
|
813
|
+
return intermediate
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const buildEcosystemLevelSizes = nodeCount => {
|
|
817
|
+
if (nodeCount <= 0) return []
|
|
818
|
+
const primarySizes = []
|
|
819
|
+
let currentSize = Math.max(1, Math.ceil(nodeCount / ecosystemLevelNodeCap))
|
|
820
|
+
while (currentSize >= 1) {
|
|
821
|
+
primarySizes.push(currentSize)
|
|
822
|
+
if (currentSize === 1) {
|
|
823
|
+
break
|
|
824
|
+
}
|
|
825
|
+
const nextSize = Math.max(1, Math.ceil(currentSize / ecosystemLevelNodeCap))
|
|
826
|
+
if (nextSize === currentSize) {
|
|
827
|
+
break
|
|
828
|
+
}
|
|
829
|
+
currentSize = nextSize
|
|
830
|
+
}
|
|
831
|
+
const expandedSizes = []
|
|
832
|
+
for (let index = 0; index < primarySizes.length; index += 1) {
|
|
833
|
+
const size = primarySizes[index]
|
|
834
|
+
if (expandedSizes.length === 0 || expandedSizes[expandedSizes.length - 1] !== size) {
|
|
835
|
+
expandedSizes.push(size)
|
|
836
|
+
}
|
|
837
|
+
const nextSize = primarySizes[index + 1]
|
|
838
|
+
if (!Number.isFinite(nextSize)) {
|
|
839
|
+
continue
|
|
840
|
+
}
|
|
841
|
+
const intermediate = buildIntermediateEcosystemSizes(size, nextSize)
|
|
842
|
+
for (let intermediateIndex = 0; intermediateIndex < intermediate.length; intermediateIndex += 1) {
|
|
843
|
+
const candidate = intermediate[intermediateIndex]
|
|
844
|
+
if (expandedSizes[expandedSizes.length - 1] !== candidate) {
|
|
845
|
+
expandedSizes.push(candidate)
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (expandedSizes[expandedSizes.length - 1] !== 1) {
|
|
850
|
+
expandedSizes.push(1)
|
|
851
|
+
}
|
|
852
|
+
return expandedSizes
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const buildEcosystemExpansionLevels = (levelSizes, nodeCount) => {
|
|
856
|
+
if (levelSizes.length <= 1) {
|
|
857
|
+
return []
|
|
858
|
+
}
|
|
859
|
+
const isMassive = nodeCount > massiveGraphNodeThreshold
|
|
860
|
+
const maxScale = isMassive
|
|
861
|
+
? massiveEcosystemClusterScaleThreshold
|
|
862
|
+
: ecosystemClusterScaleThreshold
|
|
863
|
+
const startScale = isMassive ? 1.12 : 0.24
|
|
864
|
+
const transitionCount = levelSizes.length - 1
|
|
865
|
+
const usableScale = Math.max(0.08, maxScale - startScale)
|
|
866
|
+
const step = usableScale / transitionCount
|
|
867
|
+
const stride = isMassive ? 0.93 : 0.82
|
|
868
|
+
const overlap = isMassive ? 1.22 : 1.62
|
|
869
|
+
const levels = []
|
|
870
|
+
for (let index = 0; index < transitionCount; index += 1) {
|
|
871
|
+
const start = startScale + step * index * stride
|
|
872
|
+
const end = Math.min(maxScale, start + step * overlap)
|
|
873
|
+
levels.push({
|
|
874
|
+
parentSize: levelSizes[index],
|
|
875
|
+
childSize: levelSizes[index + 1],
|
|
876
|
+
start,
|
|
877
|
+
end
|
|
878
|
+
})
|
|
879
|
+
}
|
|
880
|
+
return levels
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const ecosystemCompactPoint = (index, total, center, spacing) => {
|
|
884
|
+
if (total <= 1) {
|
|
885
|
+
return { x: center.x, y: center.y }
|
|
886
|
+
}
|
|
887
|
+
const angle = index * 2.399963229728653
|
|
888
|
+
const radius = spacing * Math.sqrt(index + 1)
|
|
889
|
+
return {
|
|
890
|
+
x: center.x + Math.cos(angle) * radius,
|
|
891
|
+
y: center.y + Math.sin(angle) * radius
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const buildEcosystemCluster = (nodes, index, point) => {
|
|
896
|
+
const count = Math.max(nodes.length, 1)
|
|
897
|
+
const representative = selectEcosystemRepresentative(nodes)
|
|
898
|
+
|
|
899
|
+
return {
|
|
900
|
+
id: 'ecosystem-' + index,
|
|
901
|
+
x: point.x,
|
|
902
|
+
y: point.y,
|
|
903
|
+
count,
|
|
904
|
+
nodeIds: nodes.map(node => node.id),
|
|
905
|
+
representative,
|
|
906
|
+
label: ecosystemKeyForNode(nodes[0] ?? representative ?? { path: '' })
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const buildEcosystemHubCluster = (hub, center) => hub
|
|
911
|
+
? {
|
|
912
|
+
id: 'ecosystem-hub',
|
|
913
|
+
x: center.x,
|
|
914
|
+
y: center.y,
|
|
915
|
+
count: 1,
|
|
916
|
+
size: 1,
|
|
917
|
+
nodeIds: [hub.id],
|
|
918
|
+
representative: hub,
|
|
919
|
+
label: hub.title || 'Memory Hub',
|
|
920
|
+
parentId: null,
|
|
921
|
+
parentX: null,
|
|
922
|
+
parentY: null,
|
|
923
|
+
isHub: true
|
|
924
|
+
}
|
|
925
|
+
: null
|
|
926
|
+
|
|
927
|
+
const buildEcosystemLevel = (sortedNodes, size, parentLookup, center) => {
|
|
928
|
+
const clusters = []
|
|
929
|
+
const clusterByNodeId = new Map()
|
|
930
|
+
const parentChildIndex = new Map()
|
|
931
|
+
|
|
932
|
+
for (let offset = 0; offset < sortedNodes.length; offset += size) {
|
|
933
|
+
const clusterNodes = sortedNodes.slice(offset, offset + size)
|
|
934
|
+
const parentCluster = parentLookup?.get(clusterNodes[0]?.id)
|
|
935
|
+
const siblingIndex = parentCluster
|
|
936
|
+
? (parentChildIndex.get(parentCluster.id) ?? 0)
|
|
937
|
+
: clusters.length
|
|
938
|
+
if (parentCluster) {
|
|
939
|
+
parentChildIndex.set(parentCluster.id, siblingIndex + 1)
|
|
940
|
+
}
|
|
941
|
+
const point = parentCluster
|
|
942
|
+
? ecosystemCompactPoint(siblingIndex, Math.ceil((parentCluster.count || size) / size), parentCluster, ecosystemLayoutSpacingForSize(size))
|
|
943
|
+
: ecosystemCompactPoint(clusters.length, Math.ceil(sortedNodes.length / size), center, ecosystemLayoutSpacingForSize(size))
|
|
944
|
+
const cluster = {
|
|
945
|
+
...buildEcosystemCluster(clusterNodes, clusters.length, point),
|
|
946
|
+
id: 'ecosystem-' + size + '-' + clusters.length,
|
|
947
|
+
size,
|
|
948
|
+
parentId: parentCluster?.id ?? null,
|
|
949
|
+
parentX: parentCluster?.x ?? null,
|
|
950
|
+
parentY: parentCluster?.y ?? null
|
|
951
|
+
}
|
|
952
|
+
clusters.push(cluster)
|
|
953
|
+
for (let index = 0; index < clusterNodes.length; index += 1) {
|
|
954
|
+
clusterByNodeId.set(clusterNodes[index].id, cluster)
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
return { clusters, clusterByNodeId }
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const buildEcosystemGraph = (nodes, center, hub) => {
|
|
962
|
+
if (nodes.length === 0) {
|
|
963
|
+
return {
|
|
964
|
+
clusters: [],
|
|
965
|
+
clustersBySize: new Map(),
|
|
966
|
+
nodeClusterBySize: new Map(),
|
|
967
|
+
levelSizes: [],
|
|
968
|
+
expansionLevels: [],
|
|
969
|
+
baseSize: ecosystemLevelNodeCap,
|
|
970
|
+
hubCluster: null
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const hubCluster = buildEcosystemHubCluster(hub, center)
|
|
975
|
+
const sortedNodes = nodes
|
|
976
|
+
.filter(node => node.id !== hub?.id)
|
|
977
|
+
.sort(compareNodesForEcosystem)
|
|
978
|
+
const levelSizes = buildEcosystemLevelSizes(sortedNodes.length)
|
|
979
|
+
const expansionLevels = buildEcosystemExpansionLevels(levelSizes, nodes.length)
|
|
980
|
+
const baseSize = levelSizes[0] ?? ecosystemLevelNodeCap
|
|
981
|
+
const clustersBySize = new Map()
|
|
982
|
+
const nodeClusterBySize = new Map()
|
|
983
|
+
let parentLookup = null
|
|
984
|
+
|
|
985
|
+
for (let index = 0; index < levelSizes.length; index += 1) {
|
|
986
|
+
const size = levelSizes[index]
|
|
987
|
+
const level = buildEcosystemLevel(sortedNodes, size, parentLookup, center)
|
|
988
|
+
clustersBySize.set(size, level.clusters)
|
|
989
|
+
nodeClusterBySize.set(size, level.clusterByNodeId)
|
|
990
|
+
parentLookup = level.clusterByNodeId
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
return {
|
|
994
|
+
clusters: clustersBySize.get(baseSize) ?? [],
|
|
995
|
+
clustersBySize,
|
|
996
|
+
nodeClusterBySize,
|
|
997
|
+
levelSizes,
|
|
998
|
+
expansionLevels,
|
|
999
|
+
baseSize,
|
|
1000
|
+
hubCluster
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const isClusterInViewport = (cluster, viewport) =>
|
|
1005
|
+
cluster.x >= viewport.minX &&
|
|
1006
|
+
cluster.x <= viewport.maxX &&
|
|
1007
|
+
cluster.y >= viewport.minY &&
|
|
1008
|
+
cluster.y <= viewport.maxY
|
|
1009
|
+
|
|
1010
|
+
const filterEcosystemClustersByViewport = (clusters, viewport) => {
|
|
1011
|
+
const visible = clusters.filter(cluster => isClusterInViewport(cluster, viewport))
|
|
1012
|
+
return visible.length > 0 ? visible : [...clusters]
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const ecosystemFocusPoint = () => {
|
|
1016
|
+
const cursorPoint = cursorWorldPoint()
|
|
1017
|
+
if (cursorPoint) {
|
|
1018
|
+
return cursorPoint
|
|
1019
|
+
}
|
|
1020
|
+
const now = performance.now()
|
|
1021
|
+
if (now - state.lastZoomFocus.at <= 1800) {
|
|
1022
|
+
return { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
|
|
1023
|
+
}
|
|
1024
|
+
return viewportCenterWorldPoint()
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const nearestEcosystemParentIds = (clusters, focusPoint, limit) =>
|
|
1028
|
+
clusters
|
|
1029
|
+
.map(cluster => ({
|
|
1030
|
+
cluster,
|
|
1031
|
+
distance: Math.max(
|
|
1032
|
+
0,
|
|
1033
|
+
Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y) -
|
|
1034
|
+
clusterRadiusPx(cluster) / Math.max(state.transform.scale, 0.0001)
|
|
1035
|
+
)
|
|
1036
|
+
}))
|
|
1037
|
+
.sort((left, right) => left.distance - right.distance)
|
|
1038
|
+
.slice(0, limit)
|
|
1039
|
+
.map(item => item.cluster.id)
|
|
1040
|
+
|
|
1041
|
+
const focusedParentCluster = (clusters, focusPoint) => {
|
|
1042
|
+
if (clusters.length === 0) {
|
|
1043
|
+
return null
|
|
1044
|
+
}
|
|
1045
|
+
let focused = clusters[0]
|
|
1046
|
+
let nearestDistance = Number.POSITIVE_INFINITY
|
|
1047
|
+
for (let index = 0; index < clusters.length; index += 1) {
|
|
1048
|
+
const cluster = clusters[index]
|
|
1049
|
+
const distance = Math.max(
|
|
1050
|
+
0,
|
|
1051
|
+
Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y) -
|
|
1052
|
+
clusterRadiusPx(cluster) / Math.max(state.transform.scale, 0.0001)
|
|
1053
|
+
)
|
|
1054
|
+
if (distance < nearestDistance) {
|
|
1055
|
+
nearestDistance = distance
|
|
1056
|
+
focused = cluster
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
return focused
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const nearestSiblingScreenDistancePx = (focusedCluster, clusters) => {
|
|
1063
|
+
let nearestDistancePx = Number.POSITIVE_INFINITY
|
|
1064
|
+
for (let index = 0; index < clusters.length; index += 1) {
|
|
1065
|
+
const cluster = clusters[index]
|
|
1066
|
+
if (cluster.id === focusedCluster.id) continue
|
|
1067
|
+
const distancePx = Math.hypot(
|
|
1068
|
+
(cluster.x - focusedCluster.x) * state.transform.scale,
|
|
1069
|
+
(cluster.y - focusedCluster.y) * state.transform.scale
|
|
1070
|
+
)
|
|
1071
|
+
if (distancePx < nearestDistancePx) {
|
|
1072
|
+
nearestDistancePx = distancePx
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return nearestDistancePx
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const ecosystemFocusReadiness = (parentClusters, focusPoint, childSize) => {
|
|
1079
|
+
if (parentClusters.length <= 1) {
|
|
1080
|
+
return 1
|
|
1081
|
+
}
|
|
1082
|
+
const focusedCluster = focusedParentCluster(parentClusters, focusPoint)
|
|
1083
|
+
if (!focusedCluster) {
|
|
1084
|
+
return 0
|
|
1085
|
+
}
|
|
1086
|
+
const nearestDistancePx = nearestSiblingScreenDistancePx(focusedCluster, parentClusters)
|
|
1087
|
+
const sizeHalfCap = Math.ceil(ecosystemLevelNodeCap / 2)
|
|
1088
|
+
const sizeEighthCap = Math.ceil(ecosystemLevelNodeCap / 8)
|
|
1089
|
+
const focusDistanceTargetPx = childSize >= sizeHalfCap
|
|
1090
|
+
? 680
|
|
1091
|
+
: childSize >= sizeEighthCap
|
|
1092
|
+
? 520
|
|
1093
|
+
: 380
|
|
1094
|
+
const focusDistanceRangePx = childSize >= sizeHalfCap ? 260 : 220
|
|
1095
|
+
return smoothStep((nearestDistancePx - focusDistanceTargetPx) / focusDistanceRangePx)
|
|
1096
|
+
}
|
|
1097
|
+
const smoothStep = value => {
|
|
1098
|
+
const clamped = Math.max(0, Math.min(1, value))
|
|
1099
|
+
return clamped * clamped * (3 - clamped * 2)
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const zoomProgress = (scale, start, end) =>
|
|
1103
|
+
smoothStep((scale - start) / Math.max(end - start, 0.0001))
|
|
1104
|
+
|
|
1105
|
+
const semanticZoomSpread = (progress, childSize) => {
|
|
1106
|
+
const spreadExponent = childSize <= Math.ceil(ecosystemLevelNodeCap / 12) ? 5.6 : 4.2
|
|
1107
|
+
const curve = Math.pow(progress, spreadExponent)
|
|
1108
|
+
if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
|
|
1109
|
+
return 0.12 + curve * 0.88
|
|
1110
|
+
}
|
|
1111
|
+
return curve
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const opacityForProgress = (progress, childSize) => {
|
|
1115
|
+
const opacityExponent = childSize <= Math.ceil(ecosystemLevelNodeCap / 12) ? 2.8 : 2.1
|
|
1116
|
+
const eased = Math.pow(progress, opacityExponent)
|
|
1117
|
+
if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
|
|
1118
|
+
return 0.22 + eased * 0.78
|
|
1119
|
+
}
|
|
1120
|
+
return eased
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const expandFocusedClusters = (parentClusters, focusPoint, childSize, progress, spread, viewport) => {
|
|
1124
|
+
const expandedParentIds = new Set(nearestEcosystemParentIds(
|
|
1125
|
+
parentClusters,
|
|
1126
|
+
focusPoint,
|
|
1127
|
+
ecosystemFocusedParentLimit
|
|
1128
|
+
))
|
|
1129
|
+
const childClusters = state.ecosystemClustersBySize.get(childSize) ?? []
|
|
1130
|
+
const visibleChildClusters = []
|
|
1131
|
+
for (let index = 0; index < childClusters.length; index += 1) {
|
|
1132
|
+
const cluster = childClusters[index]
|
|
1133
|
+
if (!expandedParentIds.has(cluster.parentId)) {
|
|
1134
|
+
continue
|
|
1135
|
+
}
|
|
1136
|
+
const spreadCluster = spreadChildClusterFromParent(cluster, childSize, progress, spread)
|
|
1137
|
+
if (!isClusterInViewport(spreadCluster, viewport)) {
|
|
1138
|
+
continue
|
|
1139
|
+
}
|
|
1140
|
+
visibleChildClusters.push(spreadCluster)
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
return {
|
|
1144
|
+
expandedParentIds,
|
|
1145
|
+
childClusters: visibleChildClusters
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const spreadChildClusterFromParent = (cluster, childSize, progress, spread) => {
|
|
1150
|
+
if (!Number.isFinite(cluster.parentX) || !Number.isFinite(cluster.parentY)) {
|
|
1151
|
+
return {
|
|
1152
|
+
...cluster,
|
|
1153
|
+
lodOpacity: opacityForProgress(progress, childSize)
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
return {
|
|
1158
|
+
...cluster,
|
|
1159
|
+
x: cluster.parentX + (cluster.x - cluster.parentX) * spread,
|
|
1160
|
+
y: cluster.parentY + (cluster.y - cluster.parentY) * spread,
|
|
1161
|
+
lodOpacity: opacityForProgress(progress, childSize)
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const selectHierarchicalEcosystemClusters = viewport => {
|
|
1166
|
+
const baseClusters = state.ecosystemClustersBySize.get(state.ecosystemBaseSize) ?? state.ecosystemClusters
|
|
1167
|
+
const visibleBaseClusters = filterEcosystemClustersByViewport(baseClusters, viewport)
|
|
1168
|
+
const hubClusters = state.ecosystemHubCluster ? [state.ecosystemHubCluster] : []
|
|
1169
|
+
const visibleClusters = [...visibleBaseClusters]
|
|
1170
|
+
const clustersBySize = new Map()
|
|
1171
|
+
for (let index = 0; index < visibleBaseClusters.length; index += 1) {
|
|
1172
|
+
const cluster = visibleBaseClusters[index]
|
|
1173
|
+
const levelClusters = clustersBySize.get(cluster.size)
|
|
1174
|
+
if (levelClusters) {
|
|
1175
|
+
levelClusters.push(cluster)
|
|
1176
|
+
} else {
|
|
1177
|
+
clustersBySize.set(cluster.size, [cluster])
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
const focusPoint = ecosystemFocusPoint()
|
|
1181
|
+
|
|
1182
|
+
for (let index = 0; index < state.ecosystemExpansionLevels.length; index += 1) {
|
|
1183
|
+
const level = state.ecosystemExpansionLevels[index]
|
|
1184
|
+
const parentClusters = clustersBySize.get(level.parentSize) ?? []
|
|
1185
|
+
if (parentClusters.length === 0) {
|
|
1186
|
+
continue
|
|
1187
|
+
}
|
|
1188
|
+
const zoomLevelProgress = zoomProgress(state.transform.scale, level.start, level.end)
|
|
1189
|
+
const focusReadiness = ecosystemFocusReadiness(parentClusters, focusPoint, level.childSize)
|
|
1190
|
+
const progress = zoomLevelProgress * focusReadiness
|
|
1191
|
+
if (progress <= 0.002) {
|
|
1192
|
+
continue
|
|
1193
|
+
}
|
|
1194
|
+
const spread = semanticZoomSpread(progress, level.childSize)
|
|
1195
|
+
const expansion = expandFocusedClusters(parentClusters, focusPoint, level.childSize, progress, spread, viewport)
|
|
1196
|
+
visibleClusters.push(...expansion.childClusters)
|
|
1197
|
+
if (expansion.childClusters.length > 0) {
|
|
1198
|
+
const levelClusters = clustersBySize.get(level.childSize)
|
|
1199
|
+
if (levelClusters) {
|
|
1200
|
+
levelClusters.push(...expansion.childClusters)
|
|
1201
|
+
} else {
|
|
1202
|
+
clustersBySize.set(level.childSize, [...expansion.childClusters])
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
return [...hubClusters, ...visibleClusters]
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const ecosystemLevelIndexBySize = () => state.ecosystemLevelIndexBySize
|
|
1211
|
+
|
|
1212
|
+
const ecosystemDepthForCluster = (cluster, levelIndexMap) => {
|
|
1213
|
+
if (cluster.isHub) {
|
|
1214
|
+
return ecosystemDepthNear
|
|
1215
|
+
}
|
|
1216
|
+
const maxLevelIndex = Math.max(state.ecosystemLevelSizes.length - 1, 0)
|
|
1217
|
+
const levelIndex = levelIndexMap.get(cluster.size) ?? 0
|
|
1218
|
+
const reverseIndex = Math.max(0, maxLevelIndex - levelIndex)
|
|
1219
|
+
const normalized = maxLevelIndex === 0 ? 0 : reverseIndex / maxLevelIndex
|
|
1220
|
+
return ecosystemDepthNear + normalized * (ecosystemDepthFar - ecosystemDepthNear)
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const projectEcosystemPoint = (x, y, depth, anchor) => {
|
|
1224
|
+
const safeDepth = Math.max(0, depth)
|
|
1225
|
+
const dx = x - anchor.x
|
|
1226
|
+
const dy = y - anchor.y
|
|
1227
|
+
const yawSin = Math.sin(ecosystemDepthYaw)
|
|
1228
|
+
const yawCos = Math.cos(ecosystemDepthYaw)
|
|
1229
|
+
const pitchSin = Math.sin(ecosystemDepthPitch)
|
|
1230
|
+
const pitchCos = Math.cos(ecosystemDepthPitch)
|
|
1231
|
+
const rotatedX = dx * yawCos + safeDepth * yawSin
|
|
1232
|
+
const rotatedZ = Math.max(0, safeDepth * yawCos - dx * yawSin)
|
|
1233
|
+
const rotatedY = dy * pitchCos - rotatedZ * pitchSin
|
|
1234
|
+
const projectedDepth = Math.max(0, rotatedZ + Math.max(0, dy * pitchSin))
|
|
1235
|
+
const factor = ecosystemDepthPerspective / (ecosystemDepthPerspective + projectedDepth)
|
|
1236
|
+
const verticalTilt = projectedDepth * ecosystemDepthTiltY
|
|
1237
|
+
return {
|
|
1238
|
+
x: anchor.x + rotatedX * factor,
|
|
1239
|
+
y: anchor.y + rotatedY * factor - verticalTilt,
|
|
1240
|
+
factor,
|
|
1241
|
+
projectedDepth
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const applyEcosystemDepthProjection = (clusters, edges, anchor) => {
|
|
1246
|
+
const levelIndexMap = ecosystemLevelIndexBySize()
|
|
1247
|
+
const projectedClusters = []
|
|
1248
|
+
const clusterById = new Map()
|
|
1249
|
+
|
|
1250
|
+
for (let index = 0; index < clusters.length; index += 1) {
|
|
1251
|
+
const cluster = clusters[index]
|
|
1252
|
+
const baseDepth = ecosystemDepthForCluster(cluster, levelIndexMap)
|
|
1253
|
+
const radialDistance = Math.hypot(cluster.x - anchor.x, cluster.y - anchor.y)
|
|
1254
|
+
const radialOffset = cluster.isHub ? 0 : Math.min(320, radialDistance * ecosystemDepthRadialGain)
|
|
1255
|
+
const orbitalOffset = cluster.isHub
|
|
1256
|
+
? 0
|
|
1257
|
+
: Math.sin(Math.atan2(cluster.y - anchor.y, cluster.x - anchor.x) * 2.2) * ecosystemDepthOrbitalMaxOffset
|
|
1258
|
+
const depth = Math.max(0, baseDepth + radialOffset + orbitalOffset)
|
|
1259
|
+
const projected = projectEcosystemPoint(cluster.x, cluster.y, depth, anchor)
|
|
1260
|
+
const baseOpacity = Number.isFinite(cluster.lodOpacity) ? cluster.lodOpacity : 1
|
|
1261
|
+
const depthScale = ecosystemDepthMinScale + (1 - ecosystemDepthMinScale) * projected.factor
|
|
1262
|
+
const depthOpacity = Math.max(
|
|
1263
|
+
ecosystemDepthOpacityFloor,
|
|
1264
|
+
Math.min(1, depthScale * 1.08)
|
|
1265
|
+
)
|
|
1266
|
+
const projectedCluster = {
|
|
1267
|
+
...cluster,
|
|
1268
|
+
x: projected.x,
|
|
1269
|
+
y: projected.y,
|
|
1270
|
+
lodOpacity: baseOpacity * depthOpacity,
|
|
1271
|
+
depth: projected.projectedDepth,
|
|
1272
|
+
depthScale
|
|
1273
|
+
}
|
|
1274
|
+
projectedClusters.push(projectedCluster)
|
|
1275
|
+
clusterById.set(projectedCluster.id, projectedCluster)
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const projectedEdges = []
|
|
1279
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
1280
|
+
const edge = edges[index]
|
|
1281
|
+
const sourceCluster = clusterById.get(edge.sourceCluster.id)
|
|
1282
|
+
const targetCluster = clusterById.get(edge.targetCluster.id)
|
|
1283
|
+
if (!sourceCluster || !targetCluster) {
|
|
1284
|
+
continue
|
|
1285
|
+
}
|
|
1286
|
+
projectedEdges.push({
|
|
1287
|
+
...edge,
|
|
1288
|
+
sourceCluster,
|
|
1289
|
+
targetCluster
|
|
1290
|
+
})
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
return {
|
|
1294
|
+
clusters: projectedClusters,
|
|
1295
|
+
edges: projectedEdges
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
const ecosystemSiblingEdgesForClusters = (clusters, existingEdges) => {
|
|
1300
|
+
const byParent = new Map()
|
|
1301
|
+
for (let index = 0; index < clusters.length; index += 1) {
|
|
1302
|
+
const cluster = clusters[index]
|
|
1303
|
+
if (cluster.isHub || !cluster.parentId) {
|
|
1304
|
+
continue
|
|
1305
|
+
}
|
|
1306
|
+
const siblings = byParent.get(cluster.parentId)
|
|
1307
|
+
if (siblings) {
|
|
1308
|
+
siblings.push(cluster)
|
|
1309
|
+
} else {
|
|
1310
|
+
byParent.set(cluster.parentId, [cluster])
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const edges = []
|
|
1315
|
+
for (const siblings of byParent.values()) {
|
|
1316
|
+
const ordered = [...siblings]
|
|
1317
|
+
.sort((left, right) => Math.atan2(left.y - (left.parentY ?? 0), left.x - (left.parentX ?? 0)) - Math.atan2(right.y - (right.parentY ?? 0), right.x - (right.parentX ?? 0)))
|
|
1318
|
+
for (let index = 0; index < ordered.length && edges.length < ecosystemSiblingEdgeLimit; index += 1) {
|
|
1319
|
+
const sourceCluster = ordered[index]
|
|
1320
|
+
const targetCluster = ordered[(index + 1) % ordered.length]
|
|
1321
|
+
if (!targetCluster || sourceCluster.id === targetCluster.id) {
|
|
1322
|
+
continue
|
|
1323
|
+
}
|
|
1324
|
+
const orderedIds = sourceCluster.id < targetCluster.id
|
|
1325
|
+
? [sourceCluster.id, targetCluster.id]
|
|
1326
|
+
: [targetCluster.id, sourceCluster.id]
|
|
1327
|
+
const key = orderedIds.join(':')
|
|
1328
|
+
if (existingEdges.has(key)) {
|
|
1329
|
+
continue
|
|
1330
|
+
}
|
|
1331
|
+
const edge = {
|
|
1332
|
+
id: key,
|
|
1333
|
+
sourceCluster,
|
|
1334
|
+
targetCluster,
|
|
1335
|
+
weight: 0.7,
|
|
1336
|
+
inferred: true
|
|
1337
|
+
}
|
|
1338
|
+
existingEdges.set(key, edge)
|
|
1339
|
+
edges.push(edge)
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
return edges
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const ecosystemEdgesForClusters = clusters => {
|
|
1347
|
+
const edgeClusters = clusters.filter(cluster => cluster.isHub || clusterOpacity(cluster) > 0.018)
|
|
1348
|
+
const clusterById = new Map(edgeClusters.map(cluster => [cluster.id, cluster]))
|
|
1349
|
+
const clusterIds = new Set(clusterById.keys())
|
|
1350
|
+
const levelsBySize = []
|
|
1351
|
+
const seenSizes = new Set()
|
|
1352
|
+
for (let index = 0; index < edgeClusters.length; index += 1) {
|
|
1353
|
+
const cluster = edgeClusters[index]
|
|
1354
|
+
if (!cluster.size || cluster.isHub) continue
|
|
1355
|
+
if (seenSizes.has(cluster.size)) continue
|
|
1356
|
+
seenSizes.add(cluster.size)
|
|
1357
|
+
levelsBySize.push({
|
|
1358
|
+
size: cluster.size,
|
|
1359
|
+
lookup: state.ecosystemNodeClusterBySize.get(cluster.size) ?? new Map()
|
|
1360
|
+
})
|
|
1361
|
+
}
|
|
1362
|
+
levelsBySize.sort((left, right) => left.size - right.size)
|
|
1363
|
+
const resolvedNodeClusterById = new Map()
|
|
1364
|
+
const resolveClusterForNode = nodeId => {
|
|
1365
|
+
if (resolvedNodeClusterById.has(nodeId)) {
|
|
1366
|
+
return resolvedNodeClusterById.get(nodeId)
|
|
1367
|
+
}
|
|
1368
|
+
if (state.ecosystemHubNodeIds.has(nodeId) && state.ecosystemHubCluster && clusterIds.has(state.ecosystemHubCluster.id)) {
|
|
1369
|
+
resolvedNodeClusterById.set(nodeId, state.ecosystemHubCluster)
|
|
1370
|
+
return state.ecosystemHubCluster
|
|
1371
|
+
}
|
|
1372
|
+
for (let index = 0; index < levelsBySize.length; index += 1) {
|
|
1373
|
+
const lookup = levelsBySize[index].lookup
|
|
1374
|
+
const cluster = lookup.get(nodeId)
|
|
1375
|
+
if (cluster && clusterIds.has(cluster.id)) {
|
|
1376
|
+
const resolvedCluster = clusterById.get(cluster.id) ?? cluster
|
|
1377
|
+
resolvedNodeClusterById.set(nodeId, resolvedCluster)
|
|
1378
|
+
return resolvedCluster
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
resolvedNodeClusterById.set(nodeId, null)
|
|
1382
|
+
return null
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const edgeByClusterPair = new Map()
|
|
1386
|
+
for (let index = 0; index < state.visibleEdges.length; index += 1) {
|
|
1387
|
+
const edge = state.visibleEdges[index]
|
|
1388
|
+
const sourceCluster = resolveClusterForNode(edge.source)
|
|
1389
|
+
const targetCluster = resolveClusterForNode(edge.target)
|
|
1390
|
+
if (!sourceCluster || !targetCluster || sourceCluster.id === targetCluster.id) {
|
|
1391
|
+
continue
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const orderedIds = sourceCluster.id < targetCluster.id
|
|
1395
|
+
? [sourceCluster.id, targetCluster.id]
|
|
1396
|
+
: [targetCluster.id, sourceCluster.id]
|
|
1397
|
+
const key = orderedIds.join(':')
|
|
1398
|
+
const current = edgeByClusterPair.get(key)
|
|
1399
|
+
if (current) {
|
|
1400
|
+
current.weight += edgeWeight(edge)
|
|
1401
|
+
continue
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
edgeByClusterPair.set(key, {
|
|
1405
|
+
id: key,
|
|
1406
|
+
sourceCluster,
|
|
1407
|
+
targetCluster,
|
|
1408
|
+
weight: edgeWeight(edge)
|
|
1409
|
+
})
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
ecosystemSiblingEdgesForClusters(edgeClusters, edgeByClusterPair)
|
|
1413
|
+
const edges = Array.from(edgeByClusterPair.values())
|
|
1414
|
+
.sort((left, right) => right.weight - left.weight)
|
|
1415
|
+
.slice(0, ecosystemClusterEdgeLimit)
|
|
1416
|
+
const hubCluster = state.ecosystemHubCluster && clusterIds.has(state.ecosystemHubCluster.id)
|
|
1417
|
+
? state.ecosystemHubCluster
|
|
1418
|
+
: null
|
|
1419
|
+
if (!hubCluster) {
|
|
1420
|
+
return edges
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const existingHubTargets = new Set(edges.flatMap(edge =>
|
|
1424
|
+
edge.sourceCluster.id === hubCluster.id
|
|
1425
|
+
? [edge.targetCluster.id]
|
|
1426
|
+
: edge.targetCluster.id === hubCluster.id
|
|
1427
|
+
? [edge.sourceCluster.id]
|
|
1428
|
+
: []
|
|
1429
|
+
))
|
|
1430
|
+
const syntheticHubEdges = edgeClusters
|
|
1431
|
+
.filter(cluster => cluster.id !== hubCluster.id && !existingHubTargets.has(cluster.id))
|
|
1432
|
+
.slice(0, ecosystemHubEdgeLimit)
|
|
1433
|
+
.map(cluster => ({
|
|
1434
|
+
id: hubCluster.id + ':' + cluster.id,
|
|
1435
|
+
sourceCluster: hubCluster,
|
|
1436
|
+
targetCluster: cluster,
|
|
1437
|
+
weight: 1,
|
|
1438
|
+
inferred: true
|
|
1439
|
+
}))
|
|
1440
|
+
return edges.concat(syntheticHubEdges)
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
const edgeBudgetForCurrentFrame = () => {
|
|
1444
|
+
const zoom = state.transform.scale
|
|
1445
|
+
if (zoom < 0.12) return 380
|
|
1446
|
+
if (zoom < 0.18) return 900
|
|
1447
|
+
if (zoom < 0.28) return 1700
|
|
1448
|
+
if (zoom < 0.45) return 2800
|
|
1449
|
+
if (zoom < 0.7) return 4200
|
|
1450
|
+
if (zoom < 1.05) return 5600
|
|
1451
|
+
return 7600
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const clusterBudgetForScale = (scale) => {
|
|
1455
|
+
if (scale < 0.008) return 90
|
|
1456
|
+
if (scale < 0.014) return 150
|
|
1457
|
+
if (scale < 0.022) return 240
|
|
1458
|
+
if (scale < 0.035) return 360
|
|
1459
|
+
return 520
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
const nodeBudgetForScale = (scale) => {
|
|
1463
|
+
if (scale < 0.035) return 220
|
|
1464
|
+
if (scale < 0.06) return 360
|
|
1465
|
+
if (scale < 0.09) return 520
|
|
1466
|
+
if (scale < 0.14) return 720
|
|
1467
|
+
if (state.visibleNodes.length > massiveGraphNodeThreshold) {
|
|
1468
|
+
if (scale < 0.28) return renderNodeBudget
|
|
1469
|
+
if (scale < 0.45) return 1100
|
|
1470
|
+
if (scale < 0.7) return 1400
|
|
1471
|
+
if (scale < 1.05) return 1800
|
|
1472
|
+
return zoomedMassiveRenderNodeBudget
|
|
1473
|
+
}
|
|
1474
|
+
return renderNodeBudget
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const layerFocusForScale = (scale) => {
|
|
1478
|
+
const normalized = Math.max(0, Math.min(1, (scale - 0.06) / 0.94))
|
|
1479
|
+
const shellCenter = Math.max(0.08, 0.96 - normalized * 0.86)
|
|
1480
|
+
const shellWidth = Math.max(0.24, 0.46 - normalized * 0.16)
|
|
1481
|
+
const coreRadius = Math.max(0.06, 0.1 + normalized * 0.22)
|
|
1482
|
+
const coreRatio = Math.max(0.2, Math.min(0.72, 0.24 + normalized * 0.48))
|
|
1483
|
+
|
|
1484
|
+
return { shellCenter, shellWidth, coreRadius, coreRatio }
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
const selectLayeredNodesForScale = (sourceNodes, targetCount) => {
|
|
1488
|
+
const hub = state.primaryHub
|
|
1489
|
+
if (!hub || sourceNodes.length <= 1200 || state.visibleNodes.length <= massiveGraphNodeThreshold) {
|
|
1490
|
+
return sourceNodes
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
let maxDistance = 0
|
|
1494
|
+
const distances = sourceNodes.map((node) => {
|
|
1495
|
+
const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
|
|
1496
|
+
if (distance > maxDistance) {
|
|
1497
|
+
maxDistance = distance
|
|
1498
|
+
}
|
|
1499
|
+
return { node, distance }
|
|
1500
|
+
})
|
|
1501
|
+
|
|
1502
|
+
if (maxDistance <= 0.001) {
|
|
1503
|
+
return sourceNodes
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
const focus = layerFocusForScale(state.transform.scale)
|
|
1507
|
+
const normalizedRows = distances.map((item) => ({
|
|
1508
|
+
...item,
|
|
1509
|
+
normalized: item.distance / maxDistance
|
|
1510
|
+
}))
|
|
1511
|
+
const desired = Math.max(260, Math.min(sourceNodes.length, targetCount * 2))
|
|
1512
|
+
const coreTarget = Math.max(36, Math.min(desired - 8, Math.floor(desired * focus.coreRatio)))
|
|
1513
|
+
const shellTarget = Math.max(12, desired - coreTarget)
|
|
1514
|
+
const shellHalf = focus.shellWidth / 2
|
|
1515
|
+
|
|
1516
|
+
const coreNodes = normalizedRows
|
|
1517
|
+
.filter((item) => item.normalized <= focus.coreRadius)
|
|
1518
|
+
.sort((left, right) => {
|
|
1519
|
+
const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
|
|
1520
|
+
const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
|
|
1521
|
+
if (leftScore !== rightScore) return rightScore - leftScore
|
|
1522
|
+
return left.node.id.localeCompare(right.node.id)
|
|
1523
|
+
})
|
|
1524
|
+
.slice(0, coreTarget)
|
|
1525
|
+
.map((item) => item.node)
|
|
1526
|
+
|
|
1527
|
+
const shellNodes = normalizedRows
|
|
1528
|
+
.sort((left, right) => {
|
|
1529
|
+
const leftDelta = Math.abs(left.normalized - focus.shellCenter)
|
|
1530
|
+
const rightDelta = Math.abs(right.normalized - focus.shellCenter)
|
|
1531
|
+
const leftInside = leftDelta <= shellHalf ? 0 : 1
|
|
1532
|
+
const rightInside = rightDelta <= shellHalf ? 0 : 1
|
|
1533
|
+
if (leftInside !== rightInside) return leftInside - rightInside
|
|
1534
|
+
if (leftDelta !== rightDelta) return leftDelta - rightDelta
|
|
1535
|
+
const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
|
|
1536
|
+
const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
|
|
1537
|
+
if (leftScore !== rightScore) return rightScore - leftScore
|
|
1538
|
+
return left.node.id.localeCompare(right.node.id)
|
|
1539
|
+
})
|
|
1540
|
+
.slice(0, shellTarget)
|
|
1541
|
+
.map((item) => item.node)
|
|
1542
|
+
|
|
1543
|
+
const merged = []
|
|
1544
|
+
const ids = new Set()
|
|
1545
|
+
const pushUnique = (node) => {
|
|
1546
|
+
if (!node || ids.has(node.id)) return
|
|
1547
|
+
ids.add(node.id)
|
|
1548
|
+
merged.push(node)
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
if (state.transform.scale >= layeredCoreScaleThreshold) {
|
|
1552
|
+
pushUnique(hub)
|
|
1553
|
+
}
|
|
1554
|
+
for (let index = 0; index < coreNodes.length; index += 1) pushUnique(coreNodes[index])
|
|
1555
|
+
for (let index = 0; index < shellNodes.length; index += 1) pushUnique(shellNodes[index])
|
|
1556
|
+
|
|
1557
|
+
return merged.length > 0 ? merged : sourceNodes
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const viewportCenterWorldPoint = () => {
|
|
1561
|
+
const viewport = worldViewportBounds()
|
|
1562
|
+
return {
|
|
1563
|
+
x: (viewport.minX + viewport.maxX) / 2,
|
|
1564
|
+
y: (viewport.minY + viewport.maxY) / 2
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
const screenToWorldPoint = (screenX, screenY) => ({
|
|
1569
|
+
x: (screenX - state.transform.x) / state.transform.scale,
|
|
1570
|
+
y: (screenY - state.transform.y) / state.transform.scale
|
|
1571
|
+
})
|
|
1572
|
+
|
|
1573
|
+
const cursorWorldPoint = () => {
|
|
1574
|
+
if (!state.cursor.inCanvas) {
|
|
1575
|
+
return null
|
|
1576
|
+
}
|
|
1577
|
+
const rect = canvas.getBoundingClientRect()
|
|
1578
|
+
const screenX = state.cursor.x - rect.left
|
|
1579
|
+
const screenY = state.cursor.y - rect.top
|
|
1580
|
+
const width = Math.max(rect.width, 320)
|
|
1581
|
+
const height = Math.max(rect.height, 320)
|
|
1582
|
+
if (!Number.isFinite(screenX) || !Number.isFinite(screenY)) {
|
|
1583
|
+
return null
|
|
1584
|
+
}
|
|
1585
|
+
if (screenX < 0 || screenX > width || screenY < 0 || screenY > height) {
|
|
1586
|
+
return null
|
|
1587
|
+
}
|
|
1588
|
+
return screenToWorldPoint(screenX, screenY)
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
const visibilityScaleBucket = (scale) => {
|
|
1592
|
+
const safeScale = Math.max(zoomRange.min, scale)
|
|
1593
|
+
if (safeScale < 0.01) return Math.round(safeScale * 300_000)
|
|
1594
|
+
if (safeScale < 0.05) return Math.round(safeScale * 120_000)
|
|
1595
|
+
if (safeScale < 0.2) return Math.round(safeScale * 40_000)
|
|
1596
|
+
return Math.round(safeScale * 8_000)
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
const shouldRenderMacroGalaxyView = () => {
|
|
1600
|
+
if (!galaxyDiscoveryEnabled) {
|
|
1601
|
+
state.macroViewActive = false
|
|
1602
|
+
return false
|
|
1603
|
+
}
|
|
1604
|
+
if (state.visibleNodes.length <= 1) {
|
|
1605
|
+
state.macroViewActive = false
|
|
1606
|
+
return false
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
const enterThreshold = macroGalaxyZoomThreshold * macroGalaxyEnterHysteresis
|
|
1610
|
+
const exitThreshold = macroGalaxyZoomThreshold * macroGalaxyExitHysteresis
|
|
1611
|
+
const shouldRender = state.macroViewActive
|
|
1612
|
+
? state.transform.scale <= exitThreshold
|
|
1613
|
+
: state.transform.scale <= enterThreshold
|
|
1614
|
+
state.macroViewActive = shouldRender
|
|
1615
|
+
return shouldRender
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
const mergeUniqueNodes = (leftNodes, rightNodes, limit) => {
|
|
1619
|
+
const merged = []
|
|
1620
|
+
const ids = new Set()
|
|
1621
|
+
|
|
1622
|
+
const push = (node) => {
|
|
1623
|
+
if (!node || ids.has(node.id) || merged.length >= limit) {
|
|
1624
|
+
return
|
|
1625
|
+
}
|
|
1626
|
+
ids.add(node.id)
|
|
1627
|
+
merged.push(node)
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
for (let index = 0; index < leftNodes.length && merged.length < limit; index += 1) {
|
|
1631
|
+
push(leftNodes[index])
|
|
1632
|
+
}
|
|
1633
|
+
for (let index = 0; index < rightNodes.length && merged.length < limit; index += 1) {
|
|
1634
|
+
push(rightNodes[index])
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
return merged
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
const selectStableSampleNodes = (sourceNodes, limit) => {
|
|
1641
|
+
if (sourceNodes.length <= limit) {
|
|
1642
|
+
return sourceNodes
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
const now = performance.now()
|
|
1646
|
+
const cursorPoint = cursorWorldPoint()
|
|
1647
|
+
const recentZoomFocus =
|
|
1648
|
+
now - state.lastZoomFocus.at <= 1500
|
|
1649
|
+
? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
|
|
1650
|
+
: null
|
|
1651
|
+
const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
|
|
1652
|
+
const previousIds = new Set(state.renderNodes.map((node) => node.id))
|
|
1653
|
+
const preferAnchorDistance = state.visibleNodes.length > massiveGraphNodeThreshold && state.transform.scale >= 0.28
|
|
1654
|
+
|
|
1655
|
+
return [...sourceNodes]
|
|
1656
|
+
.sort((left, right) => {
|
|
1657
|
+
const leftWasVisible = previousIds.has(left.id) ? 1 : 0
|
|
1658
|
+
const rightWasVisible = previousIds.has(right.id) ? 1 : 0
|
|
1659
|
+
const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
|
|
1660
|
+
const rightDistance = Math.hypot(right.x - anchor.x, right.y - anchor.y)
|
|
1661
|
+
|
|
1662
|
+
if (preferAnchorDistance) {
|
|
1663
|
+
if (leftDistance !== rightDistance) return leftDistance - rightDistance
|
|
1664
|
+
if (leftWasVisible !== rightWasVisible) return rightWasVisible - leftWasVisible
|
|
1665
|
+
} else {
|
|
1666
|
+
if (leftWasVisible !== rightWasVisible) return rightWasVisible - leftWasVisible
|
|
1667
|
+
if (leftDistance !== rightDistance) return leftDistance - rightDistance
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
const leftDegree = state.nodeDegrees.get(left.id) ?? 0
|
|
1671
|
+
const rightDegree = state.nodeDegrees.get(right.id) ?? 0
|
|
1672
|
+
if (leftDegree !== rightDegree) return rightDegree - leftDegree
|
|
1673
|
+
|
|
1674
|
+
return left.id.localeCompare(right.id)
|
|
1675
|
+
})
|
|
1676
|
+
.slice(0, limit)
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
const selectAccessBridgeNodes = (sourceNodes, limit) => {
|
|
1680
|
+
if (limit <= 0 || sourceNodes.length === 0) {
|
|
1681
|
+
return []
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
const now = performance.now()
|
|
1685
|
+
const cursorPoint = cursorWorldPoint()
|
|
1686
|
+
const recentZoomFocus =
|
|
1687
|
+
now - state.lastZoomFocus.at <= 1200
|
|
1688
|
+
? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
|
|
1689
|
+
: null
|
|
1690
|
+
const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
|
|
1691
|
+
return [...sourceNodes]
|
|
1692
|
+
.sort((left, right) => {
|
|
1693
|
+
const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
|
|
1694
|
+
const rightDistance = Math.hypot(right.x - anchor.x, right.y - anchor.y)
|
|
1695
|
+
if (leftDistance !== rightDistance) return leftDistance - rightDistance
|
|
1696
|
+
const leftDegree = state.nodeDegrees.get(left.id) ?? 0
|
|
1697
|
+
const rightDegree = state.nodeDegrees.get(right.id) ?? 0
|
|
1698
|
+
if (leftDegree !== rightDegree) return rightDegree - leftDegree
|
|
1699
|
+
return left.id.localeCompare(right.id)
|
|
1700
|
+
})
|
|
1701
|
+
.slice(0, limit)
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
const edgeIdentityKey = edge => {
|
|
1705
|
+
if (!edge.target) return ''
|
|
1706
|
+
const pair = edge.source < edge.target
|
|
1707
|
+
? edge.source + '|' + edge.target
|
|
1708
|
+
: edge.target + '|' + edge.source
|
|
1709
|
+
return pair + '|' + (edge.inferred ? 'mesh' : 'real')
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
const edgeRelevanceScore = edge => {
|
|
1713
|
+
let score = edgeWeight(edge) * 10
|
|
1714
|
+
if (!edge.inferred) {
|
|
1715
|
+
score += 8
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
const selectedId = state.selected?.id
|
|
1719
|
+
if (selectedId && (edge.source === selectedId || edge.target === selectedId)) {
|
|
1720
|
+
score += 120
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
const hoveredId = state.hovered?.id
|
|
1724
|
+
if (hoveredId && (edge.source === hoveredId || edge.target === hoveredId)) {
|
|
1725
|
+
score += 70
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
const hubId = state.primaryHub?.id
|
|
1729
|
+
if (hubId && (edge.source === hubId || edge.target === hubId)) {
|
|
1730
|
+
score += 42
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
return score
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
const collectVisibleEdgesForNodes = nodeIds => {
|
|
1737
|
+
if (nodeIds.size === 0) {
|
|
1738
|
+
return []
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
const seen = new Set()
|
|
1742
|
+
const candidates = []
|
|
1743
|
+
const limit = edgeBudgetForCurrentFrame()
|
|
1744
|
+
|
|
1745
|
+
nodeIds.forEach(nodeId => {
|
|
1746
|
+
const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
1747
|
+
for (let index = 0; index < candidateEdges.length; index += 1) {
|
|
1748
|
+
const edge = candidateEdges[index]
|
|
1749
|
+
if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
|
1750
|
+
continue
|
|
1751
|
+
}
|
|
1752
|
+
const key = edgeIdentityKey(edge)
|
|
1753
|
+
if (seen.has(key)) continue
|
|
1754
|
+
|
|
1755
|
+
seen.add(key)
|
|
1756
|
+
candidates.push(edge)
|
|
1757
|
+
}
|
|
1758
|
+
})
|
|
1759
|
+
|
|
1760
|
+
if (candidates.length <= limit) {
|
|
1761
|
+
return candidates
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
return candidates
|
|
1765
|
+
.sort((left, right) => {
|
|
1766
|
+
const scoreDelta = edgeRelevanceScore(right) - edgeRelevanceScore(left)
|
|
1767
|
+
if (scoreDelta !== 0) {
|
|
1768
|
+
return scoreDelta
|
|
1769
|
+
}
|
|
1770
|
+
const leftKey = edgeIdentityKey(left)
|
|
1771
|
+
const rightKey = edgeIdentityKey(right)
|
|
1772
|
+
return leftKey.localeCompare(rightKey)
|
|
1773
|
+
})
|
|
1774
|
+
.slice(0, limit)
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
const edgeOpacityForScale = (edge, scale) => {
|
|
1778
|
+
if (edge.inferred) {
|
|
1779
|
+
if (scale < 0.2) return 0.06
|
|
1780
|
+
if (scale < 0.4) return 0.08
|
|
1781
|
+
if (scale < 0.7) return 0.1
|
|
1782
|
+
return 0.14
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
if (scale < 0.2) return 0.14
|
|
1786
|
+
if (scale < 0.4) return 0.2
|
|
1787
|
+
if (scale < 0.7) return 0.28
|
|
1788
|
+
if (scale < 1.05) return 0.36
|
|
1789
|
+
return 0.46
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
const edgeDepthOpacity = edge => {
|
|
1793
|
+
if (!shouldProjectRenderNodesInDepth()) {
|
|
1794
|
+
return 1
|
|
1795
|
+
}
|
|
1796
|
+
return Math.max(
|
|
1797
|
+
graphDepthEdgeOpacityFloor,
|
|
1798
|
+
Math.min(nodeRenderOpacity(edge.sourceNode), nodeRenderOpacity(edge.targetNode))
|
|
1799
|
+
)
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
const edgeDepthScale = edge => {
|
|
1803
|
+
if (!shouldProjectRenderNodesInDepth()) {
|
|
1804
|
+
return 1
|
|
1805
|
+
}
|
|
1806
|
+
return Math.max(0.62, Math.min(1.18, Math.min(nodeRenderScale(edge.sourceNode), nodeRenderScale(edge.targetNode))))
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
const edgeStrokeFor = (edge, selectedEdge) => {
|
|
1810
|
+
if (selectedEdge) {
|
|
1811
|
+
return graphTheme.edgeActive
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
const opacity = edgeOpacityForScale(edge, state.transform.scale) * edgeDepthOpacity(edge)
|
|
1815
|
+
return edge.inferred
|
|
1816
|
+
? 'rgba(203, 213, 225, ' + opacity + ')'
|
|
1817
|
+
: 'rgba(153, 165, 181, ' + opacity + ')'
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
const edgeWidthFor = (edge, selectedEdge) => {
|
|
1821
|
+
if (edge.inferred) {
|
|
1822
|
+
const width = selectedEdge ? 1.22 : 0.84
|
|
1823
|
+
return width * edgeDepthScale(edge)
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
const width = (selectedEdge ? 1.9 : 1.05) + Math.min(edgeWeight(edge) - 1, 8) * 0.24
|
|
1827
|
+
return width * edgeDepthScale(edge)
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
const drawGraphEdge = (edge) => {
|
|
1831
|
+
const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
1832
|
+
ctx.beginPath()
|
|
1833
|
+
ctx.moveTo(nodeRenderX(edge.sourceNode), nodeRenderY(edge.sourceNode))
|
|
1834
|
+
ctx.lineTo(nodeRenderX(edge.targetNode), nodeRenderY(edge.targetNode))
|
|
1835
|
+
ctx.strokeStyle = edgeStrokeFor(edge, selectedEdge)
|
|
1836
|
+
ctx.lineWidth = edgeWidthFor(edge, selectedEdge)
|
|
1837
|
+
ctx.stroke()
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
const drawEdgeBatch = (edges, options) => {
|
|
1841
|
+
if (edges.length === 0) {
|
|
1842
|
+
return
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
ctx.beginPath()
|
|
1846
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
1847
|
+
const edge = edges[index]
|
|
1848
|
+
ctx.moveTo(nodeRenderX(edge.sourceNode), nodeRenderY(edge.sourceNode))
|
|
1849
|
+
ctx.lineTo(nodeRenderX(edge.targetNode), nodeRenderY(edge.targetNode))
|
|
1850
|
+
}
|
|
1851
|
+
ctx.strokeStyle = options.strokeStyle
|
|
1852
|
+
ctx.lineWidth = options.lineWidth
|
|
1853
|
+
ctx.stroke()
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
const regularEdgeBatchOptions = (edge) => ({
|
|
1857
|
+
strokeStyle: edgeStrokeFor(edge, false),
|
|
1858
|
+
lineWidth: edgeWidthFor(edge, false)
|
|
1859
|
+
})
|
|
1860
|
+
|
|
1861
|
+
const regularEdgeBatchKey = (edge) => {
|
|
1862
|
+
const options = regularEdgeBatchOptions(edge)
|
|
1863
|
+
return options.strokeStyle + '|' + options.lineWidth.toFixed(2)
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
const drawGraphEdges = () => {
|
|
1867
|
+
const edgeBatches = new Map()
|
|
1868
|
+
const selectedEdges = []
|
|
1869
|
+
|
|
1870
|
+
for (let index = 0; index < state.renderEdges.length; index += 1) {
|
|
1871
|
+
const edge = state.renderEdges[index]
|
|
1872
|
+
const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
1873
|
+
if (isSelected) {
|
|
1874
|
+
selectedEdges.push(edge)
|
|
1875
|
+
continue
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
const key = regularEdgeBatchKey(edge)
|
|
1879
|
+
const batch = edgeBatches.get(key)
|
|
1880
|
+
if (batch) {
|
|
1881
|
+
batch.edges.push(edge)
|
|
1882
|
+
} else {
|
|
1883
|
+
edgeBatches.set(key, {
|
|
1884
|
+
edges: [edge],
|
|
1885
|
+
options: regularEdgeBatchOptions(edge)
|
|
1886
|
+
})
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
edgeBatches.forEach((batch) => drawEdgeBatch(batch.edges, batch.options))
|
|
1891
|
+
|
|
1892
|
+
for (let index = 0; index < selectedEdges.length; index += 1) {
|
|
1893
|
+
drawGraphEdge(selectedEdges[index])
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
const shouldDrawNodeLabels = (node, isSelected, isHovered) =>
|
|
1898
|
+
isSelected ||
|
|
1899
|
+
isHovered ||
|
|
1900
|
+
(state.nodes.length > largeGraphNodeThreshold && state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) ||
|
|
1901
|
+
(state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
|
|
1902
|
+
|
|
1903
|
+
const drawSingleNode = (node, options = { drawLabel: true }) => {
|
|
1904
|
+
const radius = nodeRadius(node) * nodeRenderScale(node)
|
|
1905
|
+
const x = nodeRenderX(node)
|
|
1906
|
+
const y = nodeRenderY(node)
|
|
1907
|
+
const opacity = nodeRenderOpacity(node)
|
|
1908
|
+
const isSelected = state.selected?.id === node.id
|
|
1909
|
+
const isHovered = state.hovered?.id === node.id
|
|
1910
|
+
ctx.globalAlpha = opacity
|
|
1911
|
+
ctx.beginPath()
|
|
1912
|
+
ctx.arc(x, y, radius + (isSelected ? 7 : isHovered ? 4 : 0), 0, Math.PI * 2)
|
|
1913
|
+
ctx.fillStyle = isSelected || isHovered ? graphTheme.nodeHaloActive : graphTheme.nodeHalo
|
|
1914
|
+
ctx.fill()
|
|
1915
|
+
ctx.beginPath()
|
|
1916
|
+
ctx.arc(x, y, radius, 0, Math.PI * 2)
|
|
1917
|
+
ctx.fillStyle = isSelected ? graphTheme.nodeSelected : isHovered ? graphTheme.nodeHover : graphTheme.node
|
|
1918
|
+
ctx.fill()
|
|
1919
|
+
ctx.lineWidth = isSelected ? 2.6 : 1.5
|
|
1920
|
+
ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
|
|
1921
|
+
ctx.stroke()
|
|
1922
|
+
|
|
1923
|
+
if (options.drawLabel && shouldDrawNodeLabels(node, isSelected, isHovered)) {
|
|
1924
|
+
ctx.globalAlpha = Math.max(0.58, opacity)
|
|
1925
|
+
ctx.fillStyle = graphTheme.label
|
|
1926
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
1927
|
+
ctx.textAlign = 'center'
|
|
1928
|
+
ctx.textBaseline = 'top'
|
|
1929
|
+
ctx.fillText(node.title.slice(0, 34), x, y + radius + 8)
|
|
1930
|
+
}
|
|
1931
|
+
ctx.globalAlpha = 1
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
const drawNodeBatch = (nodes) => {
|
|
1935
|
+
if (nodes.length === 0) {
|
|
1936
|
+
return
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
const drawHalos = state.renderNodes.length <= 1200 || state.transform.scale >= 0.45
|
|
1940
|
+
if (drawHalos) {
|
|
1941
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
1942
|
+
const node = nodes[index]
|
|
1943
|
+
const radius = nodeRadius(node) * nodeRenderScale(node)
|
|
1944
|
+
const x = nodeRenderX(node)
|
|
1945
|
+
const y = nodeRenderY(node)
|
|
1946
|
+
ctx.globalAlpha = Math.max(0.2, nodeRenderOpacity(node) * 0.5)
|
|
1947
|
+
ctx.beginPath()
|
|
1948
|
+
ctx.arc(x, y, radius + 3, 0, Math.PI * 2)
|
|
1949
|
+
ctx.fillStyle = graphTheme.nodeHalo
|
|
1950
|
+
ctx.fill()
|
|
1951
|
+
}
|
|
1952
|
+
ctx.globalAlpha = 1
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
1956
|
+
const node = nodes[index]
|
|
1957
|
+
const radius = nodeRadius(node) * nodeRenderScale(node)
|
|
1958
|
+
const x = nodeRenderX(node)
|
|
1959
|
+
const y = nodeRenderY(node)
|
|
1960
|
+
const opacity = nodeRenderOpacity(node)
|
|
1961
|
+
ctx.globalAlpha = opacity
|
|
1962
|
+
ctx.beginPath()
|
|
1963
|
+
ctx.arc(x, y, radius, 0, Math.PI * 2)
|
|
1964
|
+
ctx.fillStyle = graphTheme.node
|
|
1965
|
+
ctx.fill()
|
|
1966
|
+
ctx.lineWidth = 1.25
|
|
1967
|
+
ctx.strokeStyle = graphTheme.nodeStroke
|
|
1968
|
+
ctx.stroke()
|
|
1969
|
+
}
|
|
1970
|
+
ctx.globalAlpha = 1
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
const drawGraphNodes = () => {
|
|
1974
|
+
const regularNodes = []
|
|
1975
|
+
const priorityNodes = []
|
|
1976
|
+
|
|
1977
|
+
for (let index = 0; index < state.renderNodes.length; index += 1) {
|
|
1978
|
+
const node = state.renderNodes[index]
|
|
1979
|
+
const isPriority =
|
|
1980
|
+
state.selected?.id === node.id ||
|
|
1981
|
+
state.hovered?.id === node.id
|
|
1982
|
+
if (isPriority) {
|
|
1983
|
+
priorityNodes.push(node)
|
|
1984
|
+
} else {
|
|
1985
|
+
regularNodes.push(node)
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
if (shouldProjectRenderNodesInDepth()) {
|
|
1990
|
+
regularNodes.sort((left, right) => nodeRenderDepth(right) - nodeRenderDepth(left))
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
drawNodeBatch(regularNodes)
|
|
1994
|
+
|
|
1995
|
+
if (state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) {
|
|
1996
|
+
ctx.fillStyle = graphTheme.label
|
|
1997
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
1998
|
+
ctx.textAlign = 'center'
|
|
1999
|
+
ctx.textBaseline = 'top'
|
|
2000
|
+
for (let index = 0; index < regularNodes.length; index += 1) {
|
|
2001
|
+
const node = regularNodes[index]
|
|
2002
|
+
const x = nodeRenderX(node)
|
|
2003
|
+
const y = nodeRenderY(node)
|
|
2004
|
+
const radius = nodeRadius(node) * nodeRenderScale(node)
|
|
2005
|
+
ctx.globalAlpha = Math.max(0.6, nodeRenderOpacity(node))
|
|
2006
|
+
ctx.fillText(node.title.slice(0, 34), x, y + radius + 8)
|
|
2007
|
+
}
|
|
2008
|
+
ctx.globalAlpha = 1
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
priorityNodes.forEach(node => drawSingleNode(node))
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
const partitionGraphForAcceleratedRenderer = () => {
|
|
2015
|
+
const regularNodes = []
|
|
2016
|
+
const priorityNodes = []
|
|
2017
|
+
const regularEdges = []
|
|
2018
|
+
const inferredEdges = []
|
|
2019
|
+
const selectedEdges = []
|
|
2020
|
+
|
|
2021
|
+
for (let index = 0; index < state.renderNodes.length; index += 1) {
|
|
2022
|
+
const node = state.renderNodes[index]
|
|
2023
|
+
const isPriority =
|
|
2024
|
+
state.selected?.id === node.id ||
|
|
2025
|
+
state.hovered?.id === node.id
|
|
2026
|
+
if (isPriority) {
|
|
2027
|
+
priorityNodes.push(node)
|
|
2028
|
+
} else {
|
|
2029
|
+
regularNodes.push(node)
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
for (let index = 0; index < state.renderEdges.length; index += 1) {
|
|
2034
|
+
const edge = state.renderEdges[index]
|
|
2035
|
+
const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
2036
|
+
if (isSelected) {
|
|
2037
|
+
selectedEdges.push(edge)
|
|
2038
|
+
} else if (edge.inferred) {
|
|
2039
|
+
inferredEdges.push(edge)
|
|
2040
|
+
} else {
|
|
2041
|
+
regularEdges.push(edge)
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
return { regularNodes, priorityNodes, regularEdges, inferredEdges, selectedEdges }
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
const drawGraphLabels = nodes => {
|
|
2049
|
+
if (!(state.transform.scale >= 0.62 && state.renderNodes.length <= 1200)) {
|
|
2050
|
+
return
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
ctx.fillStyle = graphTheme.label
|
|
2054
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
2055
|
+
ctx.textAlign = 'center'
|
|
2056
|
+
ctx.textBaseline = 'top'
|
|
2057
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
2058
|
+
const node = nodes[index]
|
|
2059
|
+
const x = nodeRenderX(node)
|
|
2060
|
+
const y = nodeRenderY(node)
|
|
2061
|
+
const radius = nodeRadius(node) * nodeRenderScale(node)
|
|
2062
|
+
ctx.globalAlpha = Math.max(0.6, nodeRenderOpacity(node))
|
|
2063
|
+
ctx.fillText(node.title.slice(0, 34), x, y + radius + 8)
|
|
2064
|
+
}
|
|
2065
|
+
ctx.globalAlpha = 1
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
const drawAcceleratedGraph = (width, height, drawEdges) => {
|
|
2069
|
+
if (!webGlRenderer || state.renderClusters.length > 0 || shouldProjectRenderNodesInDepth()) {
|
|
2070
|
+
return false
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
const graphParts = partitionGraphForAcceleratedRenderer()
|
|
2074
|
+
const scale = state.transform.scale
|
|
2075
|
+
webGlRenderer.clear(width, height)
|
|
2076
|
+
if (drawEdges) {
|
|
2077
|
+
webGlRenderer.drawLines(
|
|
2078
|
+
graphParts.regularEdges,
|
|
2079
|
+
rgba('rgb(153, 165, 181)', edgeOpacityForScale({ inferred: false }, scale)),
|
|
2080
|
+
width,
|
|
2081
|
+
height
|
|
2082
|
+
)
|
|
2083
|
+
webGlRenderer.drawLines(
|
|
2084
|
+
graphParts.inferredEdges,
|
|
2085
|
+
rgba('rgb(203, 213, 225)', edgeOpacityForScale({ inferred: true }, scale)),
|
|
2086
|
+
width,
|
|
2087
|
+
height
|
|
2088
|
+
)
|
|
2089
|
+
}
|
|
2090
|
+
webGlRenderer.drawPoints(
|
|
2091
|
+
graphParts.regularNodes,
|
|
2092
|
+
rgba(graphTheme.nodeHalo, 0.28),
|
|
2093
|
+
node => Math.max((nodeRadius(node) + 3) * state.transform.scale * 2, 1.5),
|
|
2094
|
+
width,
|
|
2095
|
+
height
|
|
2096
|
+
)
|
|
2097
|
+
webGlRenderer.drawPoints(
|
|
2098
|
+
graphParts.regularNodes,
|
|
2099
|
+
rgba(graphTheme.node, 1),
|
|
2100
|
+
node => Math.max(nodeRadius(node) * state.transform.scale * 2, 1.2),
|
|
2101
|
+
width,
|
|
2102
|
+
height
|
|
2103
|
+
)
|
|
2104
|
+
|
|
2105
|
+
ctx.save()
|
|
2106
|
+
ctx.translate(state.transform.x, state.transform.y)
|
|
2107
|
+
ctx.scale(state.transform.scale, state.transform.scale)
|
|
2108
|
+
if (drawEdges) {
|
|
2109
|
+
graphParts.selectedEdges.forEach(edge => drawGraphEdge(edge))
|
|
2110
|
+
}
|
|
2111
|
+
drawGraphLabels(graphParts.regularNodes)
|
|
2112
|
+
graphParts.priorityNodes.forEach(node => drawSingleNode(node))
|
|
2113
|
+
ctx.restore()
|
|
2114
|
+
|
|
2115
|
+
return true
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
const edgePairKey = (source, target) =>
|
|
2119
|
+
source < target ? source + '|' + target : target + '|' + source
|
|
2120
|
+
|
|
2121
|
+
const meshNeighborBuckets = (nodes, cellSize) => {
|
|
2122
|
+
const buckets = new Map()
|
|
2123
|
+
|
|
2124
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
2125
|
+
const node = nodes[index]
|
|
2126
|
+
const cellX = Math.floor(node.x / cellSize)
|
|
2127
|
+
const cellY = Math.floor(node.y / cellSize)
|
|
2128
|
+
const key = cellX + ':' + cellY
|
|
2129
|
+
const bucket = buckets.get(key)
|
|
2130
|
+
if (bucket) {
|
|
2131
|
+
bucket.push(node)
|
|
2132
|
+
} else {
|
|
2133
|
+
buckets.set(key, [node])
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
return buckets
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
const meshCandidatesForNode = (node, buckets, cellSize) => {
|
|
2141
|
+
const cellX = Math.floor(node.x / cellSize)
|
|
2142
|
+
const cellY = Math.floor(node.y / cellSize)
|
|
2143
|
+
const candidates = []
|
|
2144
|
+
|
|
2145
|
+
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
|
2146
|
+
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
|
2147
|
+
const bucket = buckets.get((cellX + offsetX) + ':' + (cellY + offsetY))
|
|
2148
|
+
if (!bucket) continue
|
|
2149
|
+
for (let index = 0; index < bucket.length; index += 1) {
|
|
2150
|
+
const candidate = bucket[index]
|
|
2151
|
+
if (candidate.id !== node.id) {
|
|
2152
|
+
candidates.push(candidate)
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
return candidates
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
const buildMeshEdgesForNodes = (nodes, existingEdges) => {
|
|
2162
|
+
if (nodes.length < 2 || state.transform.scale < meshEdgeScaleThreshold) {
|
|
2163
|
+
return []
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
const existingKeys = new Set()
|
|
2167
|
+
for (let index = 0; index < existingEdges.length; index += 1) {
|
|
2168
|
+
const edge = existingEdges[index]
|
|
2169
|
+
if (edge.target) {
|
|
2170
|
+
existingKeys.add(edgePairKey(edge.source, edge.target))
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
const desiredBudget = Math.min(
|
|
2175
|
+
meshEdgeMaxBudget,
|
|
2176
|
+
Math.max(meshEdgeMinBudget, Math.floor(edgeBudgetForCurrentFrame() * 0.62))
|
|
2177
|
+
)
|
|
2178
|
+
const perNodeNeighborCount =
|
|
2179
|
+
state.transform.scale >= 1.05 ? 4
|
|
2180
|
+
: state.transform.scale >= 0.62 ? 3
|
|
2181
|
+
: 2
|
|
2182
|
+
const cellSize = Math.max(120, 280 / Math.max(state.transform.scale, 0.0001))
|
|
2183
|
+
const maxDistance = 980
|
|
2184
|
+
const maxDistanceSquared = maxDistance * maxDistance
|
|
2185
|
+
const buckets = meshNeighborBuckets(nodes, cellSize)
|
|
2186
|
+
const meshEdges = []
|
|
2187
|
+
const meshKeys = new Set()
|
|
2188
|
+
|
|
2189
|
+
for (let index = 0; index < nodes.length && meshEdges.length < desiredBudget; index += 1) {
|
|
2190
|
+
const node = nodes[index]
|
|
2191
|
+
const candidates = meshCandidatesForNode(node, buckets, cellSize)
|
|
2192
|
+
.map((candidate) => ({
|
|
2193
|
+
node: candidate,
|
|
2194
|
+
distanceSquared: (candidate.x - node.x) ** 2 + (candidate.y - node.y) ** 2
|
|
2195
|
+
}))
|
|
2196
|
+
.filter((candidate) => candidate.distanceSquared <= maxDistanceSquared)
|
|
2197
|
+
.sort((left, right) => left.distanceSquared - right.distanceSquared)
|
|
2198
|
+
|
|
2199
|
+
let linked = 0
|
|
2200
|
+
for (let candidateIndex = 0; candidateIndex < candidates.length && linked < perNodeNeighborCount && meshEdges.length < desiredBudget; candidateIndex += 1) {
|
|
2201
|
+
const candidate = candidates[candidateIndex].node
|
|
2202
|
+
const key = edgePairKey(node.id, candidate.id)
|
|
2203
|
+
if (existingKeys.has(key) || meshKeys.has(key)) {
|
|
2204
|
+
continue
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
meshKeys.add(key)
|
|
2208
|
+
meshEdges.push({
|
|
2209
|
+
source: node.id,
|
|
2210
|
+
target: candidate.id,
|
|
2211
|
+
targetTitle: candidate.title,
|
|
2212
|
+
weight: 1,
|
|
2213
|
+
priority: 'normal',
|
|
2214
|
+
sourceNode: node,
|
|
2215
|
+
targetNode: candidate,
|
|
2216
|
+
inferred: true
|
|
2217
|
+
})
|
|
2218
|
+
linked += 1
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
return meshEdges
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
const withMeshEdges = (nodes, edges) => {
|
|
2226
|
+
if (nodes.length === 0 || state.visibleNodes.length <= largeGraphNodeThreshold || state.transform.scale < meshEdgeScaleThreshold) {
|
|
2227
|
+
return edges
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
const meshEdges = buildMeshEdgesForNodes(nodes, edges)
|
|
2231
|
+
return meshEdges.length > 0 ? edges.concat(meshEdges) : edges
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
const fallbackViewportNodes = () => {
|
|
2235
|
+
const nodes = []
|
|
2236
|
+
const maxNodes = Math.min(renderNodeBudget, 220)
|
|
2237
|
+
const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
|
|
2238
|
+
|
|
2239
|
+
for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
|
|
2240
|
+
nodes.push(state.visibleNodes[index])
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
2244
|
+
nodes.push(state.selected)
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
return nodes
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
const sampleVisibleNodes = (limit = renderNodeBudget, sourceNodes = state.visibleNodes) => {
|
|
2251
|
+
if (sourceNodes.length === 0 || limit <= 0) {
|
|
2252
|
+
return []
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
const nodes = []
|
|
2256
|
+
const maxNodes = Math.min(Math.max(limit, 1), sourceNodes.length)
|
|
2257
|
+
const step = Math.max(1, Math.ceil(sourceNodes.length / maxNodes))
|
|
2258
|
+
|
|
2259
|
+
for (let index = 0; index < sourceNodes.length && nodes.length < maxNodes; index += step) {
|
|
2260
|
+
nodes.push(sourceNodes[index])
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
2264
|
+
nodes.push(state.selected)
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
return nodes
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
const enrichSampleWithNeighbors = (nodes) => {
|
|
2271
|
+
if (nodes.length === 0) {
|
|
2272
|
+
return {
|
|
2273
|
+
nodes,
|
|
2274
|
+
edges: []
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
const maxNodes = Math.min(renderNodeBudget, nodes.length + 200)
|
|
2279
|
+
const expanded = [...nodes]
|
|
2280
|
+
const ids = new Set(expanded.map((node) => node.id))
|
|
2281
|
+
|
|
2282
|
+
for (let index = 0; index < nodes.length && expanded.length < maxNodes; index += 1) {
|
|
2283
|
+
const node = nodes[index]
|
|
2284
|
+
const candidates = [...(state.visibleEdgeByNode.get(node.id) ?? [])]
|
|
2285
|
+
.filter((edge) => edge.target)
|
|
2286
|
+
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
2287
|
+
.slice(0, 3)
|
|
2288
|
+
|
|
2289
|
+
for (let candidateIndex = 0; candidateIndex < candidates.length && expanded.length < maxNodes; candidateIndex += 1) {
|
|
2290
|
+
const edge = candidates[candidateIndex]
|
|
2291
|
+
const otherId = edge.source === node.id ? edge.target : edge.source
|
|
2292
|
+
|
|
2293
|
+
if (!otherId || ids.has(otherId)) {
|
|
2294
|
+
continue
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
const otherNode = state.nodeById.get(otherId)
|
|
2298
|
+
if (!otherNode) {
|
|
2299
|
+
continue
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
ids.add(otherId)
|
|
2303
|
+
expanded.push(otherNode)
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
const edges = collectVisibleEdgesForNodes(ids)
|
|
2308
|
+
|
|
2309
|
+
return {
|
|
2310
|
+
nodes: expanded,
|
|
2311
|
+
edges
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
const includeHubPreviewNeighborhood = (nodes, limit) => {
|
|
2316
|
+
const hub = state.primaryHub
|
|
2317
|
+
if (!hub) {
|
|
2318
|
+
return nodes
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
const maxNodes = Math.max(1, Math.min(renderNodeBudget, limit))
|
|
2322
|
+
const merged = [...nodes]
|
|
2323
|
+
const ids = new Set(merged.map((node) => node.id))
|
|
2324
|
+
const protectedIds = new Set()
|
|
2325
|
+
|
|
2326
|
+
if (!ids.has(hub.id)) {
|
|
2327
|
+
if (merged.length < maxNodes) {
|
|
2328
|
+
merged.push(hub)
|
|
2329
|
+
ids.add(hub.id)
|
|
2330
|
+
} else {
|
|
2331
|
+
const replaceIndex = merged.findIndex((node) => node.id !== hub.id)
|
|
2332
|
+
if (replaceIndex >= 0) {
|
|
2333
|
+
ids.delete(merged[replaceIndex].id)
|
|
2334
|
+
merged[replaceIndex] = hub
|
|
2335
|
+
ids.add(hub.id)
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
protectedIds.add(hub.id)
|
|
2340
|
+
|
|
2341
|
+
const hubEdges = [...(state.visibleEdgeByNode.get(hub.id) ?? [])]
|
|
2342
|
+
.filter((edge) => edge.target && (edge.source === hub.id || edge.target === hub.id))
|
|
2343
|
+
.sort((left, right) => {
|
|
2344
|
+
const byWeight = edgeWeight(right) - edgeWeight(left)
|
|
2345
|
+
if (byWeight !== 0) return byWeight
|
|
2346
|
+
|
|
2347
|
+
const leftOtherId = left.source === hub.id ? left.target : left.source
|
|
2348
|
+
const rightOtherId = right.source === hub.id ? right.target : right.source
|
|
2349
|
+
const leftDegree = state.nodeDegrees.get(leftOtherId ?? '') ?? 0
|
|
2350
|
+
const rightDegree = state.nodeDegrees.get(rightOtherId ?? '') ?? 0
|
|
2351
|
+
if (leftDegree !== rightDegree) return rightDegree - leftDegree
|
|
2352
|
+
|
|
2353
|
+
return edgeIdentityKey(left).localeCompare(edgeIdentityKey(right))
|
|
2354
|
+
})
|
|
2355
|
+
|
|
2356
|
+
for (let index = 0; index < hubEdges.length && merged.length < maxNodes; index += 1) {
|
|
2357
|
+
const edge = hubEdges[index]
|
|
2358
|
+
const otherId = edge.source === hub.id ? edge.target : edge.source
|
|
2359
|
+
if (!otherId || ids.has(otherId)) {
|
|
2360
|
+
continue
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
const otherNode = state.nodeById.get(otherId)
|
|
2364
|
+
if (!otherNode) {
|
|
2365
|
+
continue
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
if (merged.length < maxNodes) {
|
|
2369
|
+
ids.add(otherId)
|
|
2370
|
+
merged.push(otherNode)
|
|
2371
|
+
protectedIds.add(otherId)
|
|
2372
|
+
continue
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
const replaceIndex = (() => {
|
|
2376
|
+
for (let cursor = merged.length - 1; cursor >= 0; cursor -= 1) {
|
|
2377
|
+
const candidateId = merged[cursor]?.id
|
|
2378
|
+
if (candidateId && !protectedIds.has(candidateId)) {
|
|
2379
|
+
return cursor
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
return -1
|
|
2383
|
+
})()
|
|
2384
|
+
if (replaceIndex >= 0) {
|
|
2385
|
+
const replacedId = merged[replaceIndex]?.id
|
|
2386
|
+
if (replacedId) {
|
|
2387
|
+
ids.delete(replacedId)
|
|
2388
|
+
}
|
|
2389
|
+
merged[replaceIndex] = otherNode
|
|
2390
|
+
ids.add(otherId)
|
|
2391
|
+
protectedIds.add(otherId)
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
return merged
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
const ensureHubNodesInRenderedSet = (nodes) => {
|
|
2399
|
+
if (nodes.length === 0) {
|
|
2400
|
+
return nodes
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
const maxNodes = Math.max(Math.min(renderNodeBudget, nodes.length), 1)
|
|
2404
|
+
const ids = new Set(nodes.map((node) => node.id))
|
|
2405
|
+
const hubs = rankedHubNodes()
|
|
2406
|
+
const merged = [...nodes]
|
|
2407
|
+
|
|
2408
|
+
for (let index = 0; index < hubs.length; index += 1) {
|
|
2409
|
+
const hub = hubs[index]
|
|
2410
|
+
if (ids.has(hub.id)) {
|
|
2411
|
+
continue
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
if (merged.length < maxNodes) {
|
|
2415
|
+
merged.push(hub)
|
|
2416
|
+
ids.add(hub.id)
|
|
2417
|
+
continue
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
const replacementIndex = merged.findIndex((node) => !hubs.some((candidate) => candidate.id === node.id))
|
|
2421
|
+
if (replacementIndex >= 0) {
|
|
2422
|
+
ids.delete(merged[replacementIndex].id)
|
|
2423
|
+
merged[replacementIndex] = hub
|
|
2424
|
+
ids.add(hub.id)
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
return merged
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
const zoomCapByNodeCount = (nodeCount) => {
|
|
2432
|
+
if (nodeCount > 50000) return 5.4
|
|
2433
|
+
if (nodeCount > 20000) return 4.8
|
|
2434
|
+
if (nodeCount > 6000) return 4.2
|
|
2435
|
+
if (nodeCount > 2000) return 4
|
|
2436
|
+
return zoomRange.max
|
|
122
2437
|
}
|
|
123
2438
|
|
|
124
|
-
const
|
|
2439
|
+
const zoomCapByHubDistance = (distance) => {
|
|
2440
|
+
if (!Number.isFinite(distance) || distance <= 0) {
|
|
2441
|
+
return zoomRange.max
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
const rect = canvas.getBoundingClientRect()
|
|
2445
|
+
const viewportWidth = Math.max(rect.width, 320)
|
|
2446
|
+
const viewportHeight = Math.max(rect.height, 320)
|
|
2447
|
+
const reference = Math.max(220, Math.min(viewportWidth, viewportHeight) * zoomCapTargetViewportShare)
|
|
2448
|
+
return Math.max(0.3, Math.min(zoomRange.max, reference / distance))
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
const currentZoomMax = () => {
|
|
2452
|
+
const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
|
|
2453
|
+
const hubDistanceCap = isDominantHub(state.primaryHub, nodeCount)
|
|
2454
|
+
? zoomCapByHubDistance(state.hubNeighborDistance)
|
|
2455
|
+
: zoomRange.max
|
|
2456
|
+
const minimumUsefulCap = nodeCount > massiveGraphNodeThreshold ? 1.9 : nodeCount > largeGraphNodeThreshold ? 1.35 : 0.8
|
|
2457
|
+
const capped = Math.min(zoomCapByNodeCount(nodeCount), Math.max(minimumUsefulCap, hubDistanceCap))
|
|
2458
|
+
return Math.max(zoomRange.min * 2, capped)
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
const zoomFloorByNodeCount = (nodeCount) => {
|
|
2462
|
+
if (nodeCount > massiveGraphNodeThreshold) return 0.0042
|
|
2463
|
+
if (nodeCount > largeGraphNodeThreshold) return 0.0021
|
|
2464
|
+
if (nodeCount > ecosystemActivationNodeThreshold) return 0.001
|
|
2465
|
+
return zoomRange.min
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
const currentZoomMin = () => {
|
|
2469
|
+
const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
|
|
2470
|
+
return Math.max(zoomRange.min, zoomFloorByNodeCount(nodeCount))
|
|
2471
|
+
}
|
|
125
2472
|
|
|
126
|
-
const clampScale = value => Math.max(
|
|
2473
|
+
const clampScale = value => Math.max(currentZoomMin(), Math.min(currentZoomMax(), value))
|
|
2474
|
+
const isFiniteNumber = value => Number.isFinite(value)
|
|
2475
|
+
const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
|
|
2476
|
+
const clampTransformCoordinate = value => {
|
|
2477
|
+
if (!isFiniteNumber(value)) return 0
|
|
2478
|
+
if (value > transformCoordinateLimit) return transformCoordinateLimit
|
|
2479
|
+
if (value < -transformCoordinateLimit) return -transformCoordinateLimit
|
|
2480
|
+
return value
|
|
2481
|
+
}
|
|
127
2482
|
|
|
128
2483
|
const graphBounds = nodes => {
|
|
129
2484
|
if (nodes.length === 0) return null
|
|
@@ -133,7 +2488,7 @@ const graphBounds = nodes => {
|
|
|
133
2488
|
let maxY = Number.NEGATIVE_INFINITY
|
|
134
2489
|
|
|
135
2490
|
nodes.forEach(node => {
|
|
136
|
-
const radius =
|
|
2491
|
+
const radius = baseNodeRadius(node)
|
|
137
2492
|
minX = Math.min(minX, node.x - radius)
|
|
138
2493
|
maxX = Math.max(maxX, node.x + radius)
|
|
139
2494
|
minY = Math.min(minY, node.y - radius)
|
|
@@ -150,7 +2505,79 @@ const graphBounds = nodes => {
|
|
|
150
2505
|
}
|
|
151
2506
|
}
|
|
152
2507
|
|
|
153
|
-
const
|
|
2508
|
+
const fitScaleBiasByNodeCount = nodeCount => {
|
|
2509
|
+
if (nodeCount <= 6) return 1.22
|
|
2510
|
+
if (nodeCount <= 20) return 1.12
|
|
2511
|
+
if (nodeCount <= 60) return 1.04
|
|
2512
|
+
if (nodeCount <= 180) return 1
|
|
2513
|
+
if (nodeCount <= 600) return 0.94
|
|
2514
|
+
if (nodeCount <= 2000) return 0.82
|
|
2515
|
+
if (nodeCount <= 6000) return 0.68
|
|
2516
|
+
return 0.56
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
const autoFitScaleRangeByNodeCount = nodeCount => {
|
|
2520
|
+
if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
|
|
2521
|
+
if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
|
|
2522
|
+
if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
|
|
2523
|
+
if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
|
|
2524
|
+
if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
|
|
2525
|
+
if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
|
|
2526
|
+
if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
|
|
2527
|
+
return { min: 0.0012, max: 0.24 }
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
const macroFaceToFaceScale = (nodeCount, hubDistance) => {
|
|
2531
|
+
if (!Number.isFinite(hubDistance) || hubDistance <= 0 || nodeCount <= ecosystemActivationNodeThreshold) {
|
|
2532
|
+
return 0
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
const rect = canvas.getBoundingClientRect()
|
|
2536
|
+
const viewportReference = Math.max(320, Math.min(rect.width, rect.height))
|
|
2537
|
+
const share = nodeCount > massiveGraphNodeThreshold ? 0.082 : 0.068
|
|
2538
|
+
const targetPx = Math.max(24, viewportReference * share)
|
|
2539
|
+
return targetPx / hubDistance
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
const nearestClusterNeighborDistance = (clusters) => {
|
|
2543
|
+
if (!Array.isArray(clusters) || clusters.length < 2) {
|
|
2544
|
+
return Number.POSITIVE_INFINITY
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
let nearestDistance = Number.POSITIVE_INFINITY
|
|
2548
|
+
for (let index = 0; index < clusters.length; index += 1) {
|
|
2549
|
+
const source = clusters[index]
|
|
2550
|
+
for (let neighborIndex = index + 1; neighborIndex < clusters.length; neighborIndex += 1) {
|
|
2551
|
+
const target = clusters[neighborIndex]
|
|
2552
|
+
const distance = Math.hypot(source.x - target.x, source.y - target.y)
|
|
2553
|
+
if (distance > 0 && distance < nearestDistance) {
|
|
2554
|
+
nearestDistance = distance
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
return nearestDistance
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
const macroEcosystemFaceScale = (nodeCount) => {
|
|
2563
|
+
if (nodeCount <= ecosystemActivationNodeThreshold) {
|
|
2564
|
+
return 0
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
const baseClusters = state.ecosystemClustersBySize.get(state.ecosystemBaseSize) ?? state.ecosystemClusters
|
|
2568
|
+
const siblingClusters = baseClusters.filter(cluster => !cluster.isHub)
|
|
2569
|
+
const nearestDistance = nearestClusterNeighborDistance(siblingClusters)
|
|
2570
|
+
if (!Number.isFinite(nearestDistance) || nearestDistance <= 0) {
|
|
2571
|
+
return 0
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
const rect = canvas.getBoundingClientRect()
|
|
2575
|
+
const viewportReference = Math.max(320, Math.min(rect.width, rect.height))
|
|
2576
|
+
const targetShare = nodeCount > massiveGraphNodeThreshold ? 0.114 : 0.096
|
|
2577
|
+
const targetPx = Math.max(30, viewportReference * targetShare)
|
|
2578
|
+
return targetPx / nearestDistance
|
|
2579
|
+
}
|
|
2580
|
+
const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
|
|
154
2581
|
const rect = canvas.getBoundingClientRect()
|
|
155
2582
|
const width = Math.max(rect.width, 320)
|
|
156
2583
|
const height = Math.max(rect.height, 320)
|
|
@@ -159,33 +2586,141 @@ const fitView = (options = { useFiltered: true }) => {
|
|
|
159
2586
|
|
|
160
2587
|
if (!bounds) {
|
|
161
2588
|
state.transform = { x: width / 2, y: height / 2, scale: 1 }
|
|
2589
|
+
state.offscreenFrameCount = 0
|
|
2590
|
+
state.recoveringViewport = false
|
|
2591
|
+
markRenderDirty()
|
|
162
2592
|
return
|
|
163
2593
|
}
|
|
164
2594
|
|
|
165
|
-
const
|
|
2595
|
+
const paddingByNodeCount = nodeCount => {
|
|
2596
|
+
if (nodeCount <= 6) return 28
|
|
2597
|
+
if (nodeCount <= 20) return 44
|
|
2598
|
+
if (nodeCount <= 60) return 68
|
|
2599
|
+
if (nodeCount <= 180) return 86
|
|
2600
|
+
if (nodeCount <= 600) return 110
|
|
2601
|
+
if (nodeCount <= 2000) return 140
|
|
2602
|
+
return 180
|
|
2603
|
+
}
|
|
2604
|
+
const padding = paddingByNodeCount(nodes.length)
|
|
166
2605
|
const scaleX = width / (bounds.width + padding * 2)
|
|
167
2606
|
const scaleY = height / (bounds.height + padding * 2)
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
const
|
|
2607
|
+
const fitScale = Math.min(scaleX, scaleY)
|
|
2608
|
+
const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
|
|
2609
|
+
const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
|
|
2610
|
+
const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
|
|
2611
|
+
const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
|
|
2612
|
+
const scale = options.macro && nodes.length > 1
|
|
2613
|
+
? clampScale(Math.min(baselineScale, macroScale))
|
|
2614
|
+
: nodes.length > massiveGraphNodeThreshold
|
|
2615
|
+
? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
|
|
2616
|
+
: baselineScale
|
|
2617
|
+
const macroFloorScale = options.macro
|
|
2618
|
+
? clampScale(Math.max(
|
|
2619
|
+
macroFaceToFaceScale(nodes.length, state.hubNeighborDistance),
|
|
2620
|
+
macroEcosystemFaceScale(nodes.length)
|
|
2621
|
+
))
|
|
2622
|
+
: 0
|
|
2623
|
+
const resolvedScale = options.macro
|
|
2624
|
+
? clampScale(Math.max(scale, macroFloorScale))
|
|
2625
|
+
: scale
|
|
2626
|
+
const hubCenter =
|
|
2627
|
+
options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
|
|
2628
|
+
? state.primaryHub
|
|
2629
|
+
: null
|
|
2630
|
+
const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
|
|
2631
|
+
const centerY = hubCenter ? hubCenter.y : (bounds.minY + bounds.maxY) / 2
|
|
2632
|
+
|
|
2633
|
+
state.transform = {
|
|
2634
|
+
x: clampTransformCoordinate(width / 2 - centerX * resolvedScale),
|
|
2635
|
+
y: clampTransformCoordinate(height / 2 - centerY * resolvedScale),
|
|
2636
|
+
scale: clampScale(resolvedScale)
|
|
2637
|
+
}
|
|
2638
|
+
state.offscreenFrameCount = 0
|
|
2639
|
+
state.recoveringViewport = false
|
|
2640
|
+
markRenderDirty()
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
const resetView = () => fitView({ useFiltered: false, macro: true, preferHubCenter: true })
|
|
2644
|
+
|
|
2645
|
+
const focusPrimaryHub = () => {
|
|
2646
|
+
const hub = state.primaryHub
|
|
2647
|
+
if (!hub) {
|
|
2648
|
+
fitView({ useFiltered: true, macro: false, preferHubCenter: true })
|
|
2649
|
+
return
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
const rect = canvas.getBoundingClientRect()
|
|
2653
|
+
const width = Math.max(rect.width, 320)
|
|
2654
|
+
const height = Math.max(rect.height, 320)
|
|
2655
|
+
const targetScale = clampScale(Math.max(0.78, state.transform.scale))
|
|
171
2656
|
|
|
172
2657
|
state.transform = {
|
|
173
|
-
x: width / 2 -
|
|
174
|
-
y: height / 2 -
|
|
175
|
-
scale
|
|
2658
|
+
x: clampTransformCoordinate(width / 2 - hub.x * targetScale),
|
|
2659
|
+
y: clampTransformCoordinate(height / 2 - hub.y * targetScale),
|
|
2660
|
+
scale: targetScale
|
|
176
2661
|
}
|
|
2662
|
+
state.offscreenFrameCount = 0
|
|
2663
|
+
markRenderDirty()
|
|
177
2664
|
}
|
|
178
2665
|
|
|
179
|
-
const
|
|
2666
|
+
const layoutDensityScaleForNodeCount = (nodeCount) => {
|
|
2667
|
+
if (nodeCount > 50000) return 0.26
|
|
2668
|
+
if (nodeCount > 20000) return 0.3
|
|
2669
|
+
if (nodeCount > 6000) return 0.36
|
|
2670
|
+
if (nodeCount > 2000) return 0.42
|
|
2671
|
+
if (nodeCount > 600) return 0.5
|
|
2672
|
+
if (nodeCount > 180) return 0.58
|
|
2673
|
+
if (nodeCount > 60) return 0.68
|
|
2674
|
+
if (nodeCount > 20) return 0.78
|
|
2675
|
+
return 0.88
|
|
2676
|
+
}
|
|
180
2677
|
|
|
181
2678
|
const createLayout = graph => {
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
2679
|
+
const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
|
|
2680
|
+
const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
|
|
2681
|
+
const densityScale = layoutDensityScaleForNodeCount(nodeRows.length)
|
|
2682
|
+
const nodes = nodeRows.map(node => {
|
|
2683
|
+
if (Array.isArray(node)) {
|
|
2684
|
+
const [id, title, x, y, group, segment] = node
|
|
2685
|
+
return {
|
|
2686
|
+
id: typeof id === 'string' ? id : '',
|
|
2687
|
+
title: typeof title === 'string' ? title : 'Untitled',
|
|
2688
|
+
path: '',
|
|
2689
|
+
tags: [],
|
|
2690
|
+
group: typeof group === 'string' ? group : 'root',
|
|
2691
|
+
segment: typeof segment === 'string' ? segment : 'root',
|
|
2692
|
+
x: Number.isFinite(x) ? x * densityScale : 0,
|
|
2693
|
+
y: Number.isFinite(y) ? y * densityScale : 0,
|
|
2694
|
+
vx: 0,
|
|
2695
|
+
vy: 0
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
return {
|
|
2700
|
+
...node,
|
|
2701
|
+
path: typeof node.path === 'string' ? node.path : '',
|
|
2702
|
+
tags: Array.isArray(node.tags) ? node.tags : [],
|
|
2703
|
+
x: Number.isFinite(node.x) ? node.x * densityScale : 0,
|
|
2704
|
+
y: Number.isFinite(node.y) ? node.y * densityScale : 0,
|
|
2705
|
+
vx: Number.isFinite(node.vx) ? node.vx : 0,
|
|
2706
|
+
vy: Number.isFinite(node.vy) ? node.vy : 0
|
|
2707
|
+
}
|
|
2708
|
+
})
|
|
187
2709
|
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
188
|
-
const edges =
|
|
2710
|
+
const edges = edgeRows
|
|
2711
|
+
.map(edge => {
|
|
2712
|
+
if (Array.isArray(edge)) {
|
|
2713
|
+
const [source, target, weight, priority] = edge
|
|
2714
|
+
return {
|
|
2715
|
+
source: typeof source === 'string' ? source : '',
|
|
2716
|
+
target: typeof target === 'string' ? target : null,
|
|
2717
|
+
targetTitle: '',
|
|
2718
|
+
weight: Number.isFinite(weight) ? weight : 1,
|
|
2719
|
+
priority: typeof priority === 'string' ? priority : 'normal'
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
return edge
|
|
2723
|
+
})
|
|
189
2724
|
.filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
|
|
190
2725
|
.map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
|
|
191
2726
|
return { nodes, edges }
|
|
@@ -222,11 +2757,11 @@ const resetContentFilter = () => {
|
|
|
222
2757
|
|
|
223
2758
|
const syncContentFilter = async (query, token) => {
|
|
224
2759
|
const response = await fetch(
|
|
225
|
-
|
|
2760
|
+
'/api/graph-filter?q=' +
|
|
226
2761
|
encodeURIComponent(query) +
|
|
227
2762
|
'&limit=' +
|
|
228
2763
|
encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
|
|
229
|
-
agentQuery()
|
|
2764
|
+
agentQuery('&')
|
|
230
2765
|
)
|
|
231
2766
|
|
|
232
2767
|
if (!response.ok || token !== state.contentFilter.token) {
|
|
@@ -240,7 +2775,8 @@ const syncContentFilter = async (query, token) => {
|
|
|
240
2775
|
}
|
|
241
2776
|
|
|
242
2777
|
state.contentFilter.query = query
|
|
243
|
-
state.contentFilter.ids
|
|
2778
|
+
const merged = new Set([...(state.contentFilter.ids instanceof Set ? state.contentFilter.ids : []), ...nodeIds])
|
|
2779
|
+
state.contentFilter.ids = merged
|
|
244
2780
|
recomputeVisibility()
|
|
245
2781
|
}
|
|
246
2782
|
|
|
@@ -261,28 +2797,59 @@ const scheduleContentFilterSync = () => {
|
|
|
261
2797
|
ids: state.contentFilter.ids,
|
|
262
2798
|
token,
|
|
263
2799
|
timer: setTimeout(() => {
|
|
2800
|
+
if (state.filterWorker && state.filterReady) {
|
|
2801
|
+
state.filterWorker.postMessage({
|
|
2802
|
+
type: 'filter',
|
|
2803
|
+
query,
|
|
2804
|
+
token,
|
|
2805
|
+
limit: Math.max(state.nodes.length, 1)
|
|
2806
|
+
})
|
|
2807
|
+
}
|
|
264
2808
|
syncContentFilter(query, token).catch(() => {})
|
|
265
2809
|
}, 180)
|
|
266
2810
|
}
|
|
267
2811
|
}
|
|
268
2812
|
|
|
269
|
-
const tick = delta => {
|
|
270
|
-
const nodes = state.visibleNodes
|
|
271
|
-
const edges = state.visibleEdges
|
|
272
|
-
|
|
2813
|
+
const tick = (delta, now) => {
|
|
2814
|
+
const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
|
|
2815
|
+
const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
|
|
2816
|
+
const shouldRunPhysics =
|
|
2817
|
+
state.nodes.length <= 8000 &&
|
|
2818
|
+
nodes.length <= 320 &&
|
|
2819
|
+
state.transform.scale >= 0.08
|
|
2820
|
+
if (!shouldRunPhysics) {
|
|
2821
|
+
state.physicsRestFrames = 0
|
|
2822
|
+
return
|
|
2823
|
+
}
|
|
2824
|
+
const isDragging = Boolean(state.pointer.dragNode)
|
|
2825
|
+
const intervalMs = isDragging
|
|
2826
|
+
? physicsDragFrameIntervalMs
|
|
2827
|
+
: state.nodes.length > largeGraphNodeThreshold
|
|
2828
|
+
? physicsLargeGraphIdleFrameIntervalMs
|
|
2829
|
+
: physicsIdleFrameIntervalMs
|
|
2830
|
+
if (!isDragging && state.physicsRestFrames >= 18) {
|
|
2831
|
+
return
|
|
2832
|
+
}
|
|
2833
|
+
const elapsedSincePhysics = now - state.lastPhysicsAt
|
|
2834
|
+
if (elapsedSincePhysics < intervalMs) {
|
|
273
2835
|
return
|
|
274
2836
|
}
|
|
275
|
-
|
|
2837
|
+
state.lastPhysicsAt = now
|
|
2838
|
+
const strength = Math.min(Math.max(elapsedSincePhysics, delta, 16) / 16, physicsStepDeltaCapMs / 16)
|
|
276
2839
|
|
|
277
2840
|
edges.forEach(edge => {
|
|
278
2841
|
const source = edge.sourceNode
|
|
279
2842
|
const target = edge.targetNode
|
|
2843
|
+
source.vx = Number.isFinite(source.vx) ? source.vx : 0
|
|
2844
|
+
source.vy = Number.isFinite(source.vy) ? source.vy : 0
|
|
2845
|
+
target.vx = Number.isFinite(target.vx) ? target.vx : 0
|
|
2846
|
+
target.vy = Number.isFinite(target.vy) ? target.vy : 0
|
|
280
2847
|
const dx = target.x - source.x
|
|
281
2848
|
const dy = target.y - source.y
|
|
282
2849
|
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
283
2850
|
const force = (distance - 150) * 0.002 * strength
|
|
284
|
-
const fx = dx * force
|
|
285
|
-
const fy = dy * force
|
|
2851
|
+
const fx = (dx / distance) * force
|
|
2852
|
+
const fy = (dy / distance) * force
|
|
286
2853
|
source.vx += fx
|
|
287
2854
|
source.vy += fy
|
|
288
2855
|
target.vx -= fx
|
|
@@ -293,6 +2860,10 @@ const tick = delta => {
|
|
|
293
2860
|
for (let j = i + 1; j < nodes.length; j += 1) {
|
|
294
2861
|
const a = nodes[i]
|
|
295
2862
|
const b = nodes[j]
|
|
2863
|
+
a.vx = Number.isFinite(a.vx) ? a.vx : 0
|
|
2864
|
+
a.vy = Number.isFinite(a.vy) ? a.vy : 0
|
|
2865
|
+
b.vx = Number.isFinite(b.vx) ? b.vx : 0
|
|
2866
|
+
b.vy = Number.isFinite(b.vy) ? b.vy : 0
|
|
296
2867
|
const dx = b.x - a.x
|
|
297
2868
|
const dy = b.y - a.y
|
|
298
2869
|
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
@@ -306,7 +2877,12 @@ const tick = delta => {
|
|
|
306
2877
|
}
|
|
307
2878
|
}
|
|
308
2879
|
|
|
2880
|
+
let kinetic = 0
|
|
309
2881
|
nodes.forEach(node => {
|
|
2882
|
+
node.vx = Number.isFinite(node.vx) ? node.vx : 0
|
|
2883
|
+
node.vy = Number.isFinite(node.vy) ? node.vy : 0
|
|
2884
|
+
node.x = Number.isFinite(node.x) ? node.x : 0
|
|
2885
|
+
node.y = Number.isFinite(node.y) ? node.y : 0
|
|
310
2886
|
if (state.pointer.dragNode === node) {
|
|
311
2887
|
node.vx = 0
|
|
312
2888
|
node.vy = 0
|
|
@@ -318,40 +2894,616 @@ const tick = delta => {
|
|
|
318
2894
|
node.vy *= 0.88
|
|
319
2895
|
node.x += node.vx * strength
|
|
320
2896
|
node.y += node.vy * strength
|
|
2897
|
+
kinetic += Math.abs(node.vx) + Math.abs(node.vy)
|
|
321
2898
|
})
|
|
2899
|
+
if (isDragging) {
|
|
2900
|
+
state.physicsRestFrames = 0
|
|
2901
|
+
return
|
|
2902
|
+
}
|
|
2903
|
+
const kineticFloor = Math.max(0.16, nodes.length * 0.01)
|
|
2904
|
+
state.physicsRestFrames = kinetic <= kineticFloor
|
|
2905
|
+
? state.physicsRestFrames + 1
|
|
2906
|
+
: 0
|
|
322
2907
|
}
|
|
323
2908
|
|
|
324
2909
|
const worldPoint = event => {
|
|
325
2910
|
const rect = canvas.getBoundingClientRect()
|
|
326
|
-
return
|
|
327
|
-
|
|
328
|
-
|
|
2911
|
+
return screenToWorldPoint(event.clientX - rect.left, event.clientY - rect.top)
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
const connectedNodeIdsFor = (nodeId) => {
|
|
2915
|
+
const edges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
2916
|
+
const ids = new Set()
|
|
2917
|
+
|
|
2918
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
2919
|
+
const edge = edges[index]
|
|
2920
|
+
if (!edge.target) continue
|
|
2921
|
+
if (edge.source === nodeId) {
|
|
2922
|
+
ids.add(edge.target)
|
|
2923
|
+
} else if (edge.target === nodeId) {
|
|
2924
|
+
ids.add(edge.source)
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
return ids
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
const applyDragNeighborhoodAdjustment = (dragNode, deltaX, deltaY) => {
|
|
2932
|
+
if (!dragNode) return
|
|
2933
|
+
if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) return
|
|
2934
|
+
if (Math.abs(deltaX) + Math.abs(deltaY) <= 0.001) return
|
|
2935
|
+
|
|
2936
|
+
const scale = Math.max(state.transform.scale, 0.0001)
|
|
2937
|
+
const influenceRadius = Math.max(220, Math.min(920, 440 / scale))
|
|
2938
|
+
const influenceRadiusSquared = influenceRadius * influenceRadius
|
|
2939
|
+
const connectedIds = connectedNodeIdsFor(dragNode.id)
|
|
2940
|
+
const candidates = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
|
|
2941
|
+
let adjusted = 0
|
|
2942
|
+
|
|
2943
|
+
for (let index = 0; index < candidates.length && adjusted < dragNeighborhoodMaxAffected; index += 1) {
|
|
2944
|
+
const node = candidates[index]
|
|
2945
|
+
if (node.id === dragNode.id) continue
|
|
2946
|
+
|
|
2947
|
+
const isConnected = connectedIds.has(node.id)
|
|
2948
|
+
const dx = node.x - dragNode.x
|
|
2949
|
+
const dy = node.y - dragNode.y
|
|
2950
|
+
const distanceSquared = dx * dx + dy * dy
|
|
2951
|
+
const withinRadius = distanceSquared <= influenceRadiusSquared
|
|
2952
|
+
if (!isConnected && !withinRadius) continue
|
|
2953
|
+
|
|
2954
|
+
const distance = Math.max(Math.sqrt(distanceSquared), 0.0001)
|
|
2955
|
+
const proximity = withinRadius ? 1 - (distance / influenceRadius) : 0
|
|
2956
|
+
const coupledStrength = isConnected ? 0.28 : 0.12
|
|
2957
|
+
const influence = Math.min(0.46, coupledStrength + proximity * 0.34)
|
|
2958
|
+
node.x += deltaX * influence
|
|
2959
|
+
node.y += deltaY * influence
|
|
2960
|
+
node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + deltaX * influence * 0.06
|
|
2961
|
+
node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + deltaY * influence * 0.06
|
|
2962
|
+
adjusted += 1
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
const settleNeighborhoodAroundNode = (dragNode) => {
|
|
2967
|
+
if (!dragNode) return
|
|
2968
|
+
|
|
2969
|
+
const scale = Math.max(state.transform.scale, 0.0001)
|
|
2970
|
+
const settleRadius = Math.max(240, Math.min(980, 520 / scale))
|
|
2971
|
+
const settleRadiusSquared = settleRadius * settleRadius
|
|
2972
|
+
const connectedIds = connectedNodeIdsFor(dragNode.id)
|
|
2973
|
+
const candidates = (state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes)
|
|
2974
|
+
.filter((node) => {
|
|
2975
|
+
if (node.id === dragNode.id) return true
|
|
2976
|
+
const dx = node.x - dragNode.x
|
|
2977
|
+
const dy = node.y - dragNode.y
|
|
2978
|
+
const distanceSquared = dx * dx + dy * dy
|
|
2979
|
+
return connectedIds.has(node.id) || distanceSquared <= settleRadiusSquared
|
|
2980
|
+
})
|
|
2981
|
+
.slice(0, dragNeighborhoodMaxAffected)
|
|
2982
|
+
|
|
2983
|
+
if (candidates.length <= 1) return
|
|
2984
|
+
|
|
2985
|
+
for (let round = 0; round < dragSettleRounds; round += 1) {
|
|
2986
|
+
for (let leftIndex = 0; leftIndex < candidates.length; leftIndex += 1) {
|
|
2987
|
+
const left = candidates[leftIndex]
|
|
2988
|
+
for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex += 1) {
|
|
2989
|
+
const right = candidates[rightIndex]
|
|
2990
|
+
const dx = right.x - left.x
|
|
2991
|
+
const dy = right.y - left.y
|
|
2992
|
+
const distance = Math.max(Math.hypot(dx, dy), 0.001)
|
|
2993
|
+
const minDistance = baseNodeRadius(left) + baseNodeRadius(right) + 10
|
|
2994
|
+
if (distance >= minDistance) continue
|
|
2995
|
+
|
|
2996
|
+
const push = (minDistance - distance) * 0.36
|
|
2997
|
+
const ux = dx / distance
|
|
2998
|
+
const uy = dy / distance
|
|
2999
|
+
if (left.id !== dragNode.id) {
|
|
3000
|
+
left.x -= ux * push
|
|
3001
|
+
left.y -= uy * push
|
|
3002
|
+
}
|
|
3003
|
+
if (right.id !== dragNode.id) {
|
|
3004
|
+
right.x += ux * push
|
|
3005
|
+
right.y += uy * push
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
329
3009
|
}
|
|
330
3010
|
}
|
|
331
3011
|
|
|
332
3012
|
const hitNode = point => {
|
|
333
|
-
|
|
3013
|
+
computeRenderVisibility()
|
|
3014
|
+
if (state.renderClusters.length > 0) {
|
|
3015
|
+
return null
|
|
3016
|
+
}
|
|
3017
|
+
const hitScaleFloor = state.nodes.length > massiveGraphNodeThreshold
|
|
3018
|
+
? 0.2
|
|
3019
|
+
: state.nodes.length > largeGraphNodeThreshold
|
|
3020
|
+
? 0.34
|
|
3021
|
+
: 0
|
|
3022
|
+
if (state.transform.scale < hitScaleFloor) {
|
|
334
3023
|
return null
|
|
335
3024
|
}
|
|
336
3025
|
|
|
337
|
-
const nodes = state.
|
|
3026
|
+
const nodes = state.renderNodes
|
|
338
3027
|
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
339
3028
|
const node = nodes[index]
|
|
340
|
-
const radius = nodeRadius(node)
|
|
341
|
-
|
|
3029
|
+
const radius = nodeRadius(node) * nodeRenderScale(node)
|
|
3030
|
+
const x = nodeRenderX(node)
|
|
3031
|
+
const y = nodeRenderY(node)
|
|
3032
|
+
if (Math.hypot(point.x - x, point.y - y) <= radius + 5) return node
|
|
342
3033
|
}
|
|
343
3034
|
return null
|
|
344
3035
|
}
|
|
345
3036
|
|
|
346
|
-
const
|
|
3037
|
+
const baseNodeRadius = node => {
|
|
347
3038
|
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
348
3039
|
return 9 + Math.min(degree, 8) * 1.6
|
|
349
3040
|
}
|
|
350
3041
|
|
|
3042
|
+
const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
|
|
3043
|
+
|
|
3044
|
+
const clusterRadiusPx = cluster => {
|
|
3045
|
+
if (cluster.id === 'macro-galaxy') {
|
|
3046
|
+
return 10
|
|
3047
|
+
}
|
|
3048
|
+
if (cluster.isHub) {
|
|
3049
|
+
return 3.8
|
|
3050
|
+
}
|
|
3051
|
+
if (String(cluster.id).startsWith('ecosystem-')) {
|
|
3052
|
+
const size = Math.max(1, Math.min(ecosystemLevelNodeCap, cluster.size || cluster.count || 1))
|
|
3053
|
+
const sizeBias = 0.56 + Math.log10(size + 1) * 0.28
|
|
3054
|
+
const densityBias = Math.log10((cluster.count || 1) + 1) * 0.12
|
|
3055
|
+
const radius = Math.max(0.62, Math.min(2.4, sizeBias + densityBias))
|
|
3056
|
+
const depthScale = Number.isFinite(cluster.depthScale) ? cluster.depthScale : 1
|
|
3057
|
+
return Math.max(0.56, Math.min(3.2, radius * depthScale))
|
|
3058
|
+
}
|
|
3059
|
+
return Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
const clusterOpacity = cluster =>
|
|
3063
|
+
Math.max(0, Math.min(1, Number.isFinite(cluster.lodOpacity) ? cluster.lodOpacity : 1))
|
|
3064
|
+
|
|
3065
|
+
const clusterDepth = cluster => Number.isFinite(cluster.depth) ? cluster.depth : ecosystemDepthNear
|
|
3066
|
+
const clusterDepthScale = cluster => Number.isFinite(cluster.depthScale) ? cluster.depthScale : 1
|
|
3067
|
+
|
|
3068
|
+
const shouldProjectRenderNodesInDepth = () =>
|
|
3069
|
+
state.renderClusters.length === 0 &&
|
|
3070
|
+
state.renderNodes.length >= graphDepthProjectionNodeThreshold
|
|
3071
|
+
|
|
3072
|
+
const nodeProjectionAnchor = () => {
|
|
3073
|
+
const hub = state.primaryHub
|
|
3074
|
+
if (hub) {
|
|
3075
|
+
return { x: hub.x, y: hub.y }
|
|
3076
|
+
}
|
|
3077
|
+
if (state.macroRepresentative) {
|
|
3078
|
+
return { x: state.macroRepresentative.x, y: state.macroRepresentative.y }
|
|
3079
|
+
}
|
|
3080
|
+
return ecosystemFocusPoint()
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
const projectGraphNodePoint = (x, y, depth, anchor) => {
|
|
3084
|
+
const safeDepth = Math.max(0, depth)
|
|
3085
|
+
const dx = x - anchor.x
|
|
3086
|
+
const dy = y - anchor.y
|
|
3087
|
+
const yawSin = Math.sin(graphDepthYaw)
|
|
3088
|
+
const yawCos = Math.cos(graphDepthYaw)
|
|
3089
|
+
const pitchSin = Math.sin(graphDepthPitch)
|
|
3090
|
+
const pitchCos = Math.cos(graphDepthPitch)
|
|
3091
|
+
const rotatedX = dx * yawCos + safeDepth * yawSin
|
|
3092
|
+
const rotatedZ = Math.max(0, safeDepth * yawCos - dx * yawSin)
|
|
3093
|
+
const rotatedY = dy * pitchCos - rotatedZ * pitchSin
|
|
3094
|
+
const projectedDepth = Math.max(0, rotatedZ + Math.max(0, dy * pitchSin))
|
|
3095
|
+
const factor = graphDepthPerspective / (graphDepthPerspective + projectedDepth)
|
|
3096
|
+
return {
|
|
3097
|
+
x: anchor.x + rotatedX * factor,
|
|
3098
|
+
y: anchor.y + rotatedY * factor,
|
|
3099
|
+
depth: projectedDepth,
|
|
3100
|
+
scale: graphDepthMinScale + (1 - graphDepthMinScale) * factor
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
const refreshRenderNodeDepthProjection = () => {
|
|
3105
|
+
if (!shouldProjectRenderNodesInDepth()) {
|
|
3106
|
+
state.renderNodeDepthProjectionById = new Map()
|
|
3107
|
+
return
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
const anchor = nodeProjectionAnchor()
|
|
3111
|
+
let maxDistance = 1
|
|
3112
|
+
for (let index = 0; index < state.renderNodes.length; index += 1) {
|
|
3113
|
+
const node = state.renderNodes[index]
|
|
3114
|
+
const distance = Math.hypot(node.x - anchor.x, node.y - anchor.y)
|
|
3115
|
+
if (distance > maxDistance) {
|
|
3116
|
+
maxDistance = distance
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
const projectionById = new Map()
|
|
3121
|
+
for (let index = 0; index < state.renderNodes.length; index += 1) {
|
|
3122
|
+
const node = state.renderNodes[index]
|
|
3123
|
+
const radialDistance = Math.hypot(node.x - anchor.x, node.y - anchor.y)
|
|
3124
|
+
const radialRatio = radialDistance / maxDistance
|
|
3125
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
3126
|
+
const degreeLift = Math.min(60, degree * 2.2)
|
|
3127
|
+
const radialDepth = radialRatio * graphDepthFar * graphDepthRadialGain
|
|
3128
|
+
const depth = Math.max(0, graphDepthNear + radialDepth - degreeLift)
|
|
3129
|
+
const projected = projectGraphNodePoint(node.x, node.y, depth, anchor)
|
|
3130
|
+
const opacity = Math.max(graphDepthOpacityFloor, Math.min(1, projected.scale * 1.08))
|
|
3131
|
+
projectionById.set(node.id, {
|
|
3132
|
+
x: projected.x,
|
|
3133
|
+
y: projected.y,
|
|
3134
|
+
depth: projected.depth,
|
|
3135
|
+
scale: projected.scale,
|
|
3136
|
+
opacity
|
|
3137
|
+
})
|
|
3138
|
+
}
|
|
3139
|
+
state.renderNodeDepthProjectionById = projectionById
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
const projectedNode = node => state.renderNodeDepthProjectionById.get(node.id) ?? null
|
|
3143
|
+
const nodeRenderX = node => projectedNode(node)?.x ?? node.x
|
|
3144
|
+
const nodeRenderY = node => projectedNode(node)?.y ?? node.y
|
|
3145
|
+
const nodeRenderDepth = node => projectedNode(node)?.depth ?? graphDepthNear
|
|
3146
|
+
const nodeRenderScale = node => projectedNode(node)?.scale ?? 1
|
|
3147
|
+
const nodeRenderOpacity = node => projectedNode(node)?.opacity ?? 1
|
|
3148
|
+
const worldViewportBounds = () => {
|
|
3149
|
+
const width = Math.max(state.viewport.width, 320)
|
|
3150
|
+
const height = Math.max(state.viewport.height, 320)
|
|
3151
|
+
const paddingMultiplier =
|
|
3152
|
+
state.nodes.length > massiveGraphNodeThreshold
|
|
3153
|
+
? (state.transform.scale >= 0.6 ? 2.8 : state.transform.scale >= 0.25 ? 2.35 : 1.9)
|
|
3154
|
+
: state.nodes.length > largeGraphNodeThreshold
|
|
3155
|
+
? 1.45
|
|
3156
|
+
: 1
|
|
3157
|
+
const padding = viewportPaddingPx * paddingMultiplier
|
|
3158
|
+
|
|
3159
|
+
return {
|
|
3160
|
+
minX: (-state.transform.x - padding) / state.transform.scale,
|
|
3161
|
+
maxX: (width - state.transform.x + padding) / state.transform.scale,
|
|
3162
|
+
minY: (-state.transform.y - padding) / state.transform.scale,
|
|
3163
|
+
maxY: (height - state.transform.y + padding) / state.transform.scale
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
const isNodeInViewport = (node, viewport) =>
|
|
3168
|
+
node.x >= viewport.minX &&
|
|
3169
|
+
node.x <= viewport.maxX &&
|
|
3170
|
+
node.y >= viewport.minY &&
|
|
3171
|
+
node.y <= viewport.maxY
|
|
3172
|
+
|
|
3173
|
+
const expandViewportBounds = (viewport, worldMargin) => ({
|
|
3174
|
+
minX: viewport.minX - worldMargin,
|
|
3175
|
+
maxX: viewport.maxX + worldMargin,
|
|
3176
|
+
minY: viewport.minY - worldMargin,
|
|
3177
|
+
maxY: viewport.maxY + worldMargin
|
|
3178
|
+
})
|
|
3179
|
+
|
|
3180
|
+
const viewportNodeStride = () => {
|
|
3181
|
+
if (state.nodes.length <= largeGraphNodeThreshold) {
|
|
3182
|
+
return 1
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
if (state.transform.scale >= 0.95) {
|
|
3186
|
+
return 1
|
|
3187
|
+
}
|
|
3188
|
+
if (state.transform.scale >= 0.7) {
|
|
3189
|
+
return 2
|
|
3190
|
+
}
|
|
3191
|
+
if (state.transform.scale >= 0.48) {
|
|
3192
|
+
return 3
|
|
3193
|
+
}
|
|
3194
|
+
if (state.transform.scale >= 0.28) {
|
|
3195
|
+
return 5
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
return 8
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
const shouldRenderClusters = viewportNodes =>
|
|
3202
|
+
state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
|
|
3203
|
+
|
|
3204
|
+
const clusterViewportNodes = viewportNodes => {
|
|
3205
|
+
if (!shouldRenderClusters(viewportNodes)) {
|
|
3206
|
+
return []
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
|
|
3210
|
+
const buckets = new Map()
|
|
3211
|
+
|
|
3212
|
+
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
3213
|
+
const node = viewportNodes[index]
|
|
3214
|
+
const keyX = Math.floor(node.x / worldCellSize)
|
|
3215
|
+
const keyY = Math.floor(node.y / worldCellSize)
|
|
3216
|
+
const key = keyX + ':' + keyY
|
|
3217
|
+
const current = buckets.get(key)
|
|
3218
|
+
if (current) {
|
|
3219
|
+
current.count += 1
|
|
3220
|
+
current.sumX += node.x
|
|
3221
|
+
current.sumY += node.y
|
|
3222
|
+
if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
|
|
3223
|
+
current.representative = node
|
|
3224
|
+
current.degree = state.nodeDegrees.get(node.id) ?? 0
|
|
3225
|
+
}
|
|
3226
|
+
continue
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
buckets.set(key, {
|
|
3230
|
+
id: key,
|
|
3231
|
+
count: 1,
|
|
3232
|
+
sumX: node.x,
|
|
3233
|
+
sumY: node.y,
|
|
3234
|
+
representative: node,
|
|
3235
|
+
degree: state.nodeDegrees.get(node.id) ?? 0
|
|
3236
|
+
})
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
return Array.from(buckets.values())
|
|
3240
|
+
.sort((left, right) => right.count - left.count)
|
|
3241
|
+
.slice(0, Math.min(renderNodeBudget, 900))
|
|
3242
|
+
.map((cluster) => ({
|
|
3243
|
+
id: cluster.id,
|
|
3244
|
+
x: cluster.sumX / Math.max(cluster.count, 1),
|
|
3245
|
+
y: cluster.sumY / Math.max(cluster.count, 1),
|
|
3246
|
+
count: cluster.count,
|
|
3247
|
+
representative: cluster.representative
|
|
3248
|
+
}))
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
const representativeNodesFromClusters = (clusters, limit) => {
|
|
3252
|
+
const representatives = clusters
|
|
3253
|
+
.map((cluster) => cluster.representative)
|
|
3254
|
+
.filter((node) => Boolean(node))
|
|
3255
|
+
const merged = mergeUniqueNodes(
|
|
3256
|
+
representatives,
|
|
3257
|
+
state.renderNodes ?? [],
|
|
3258
|
+
Math.max(1, limit)
|
|
3259
|
+
)
|
|
3260
|
+
return ensureHubNodesInRenderedSet(merged)
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
const computeRenderVisibility = () => {
|
|
3264
|
+
if (!hasValidTransform()) {
|
|
3265
|
+
fitView({ useFiltered: true })
|
|
3266
|
+
}
|
|
3267
|
+
const viewport = worldViewportBounds()
|
|
3268
|
+
const viewportKey =
|
|
3269
|
+
Math.round(viewport.minX * 10) + ':' +
|
|
3270
|
+
Math.round(viewport.maxX * 10) + ':' +
|
|
3271
|
+
Math.round(viewport.minY * 10) + ':' +
|
|
3272
|
+
Math.round(viewport.maxY * 10) + ':' +
|
|
3273
|
+
visibilityScaleBucket(state.transform.scale)
|
|
3274
|
+
|
|
3275
|
+
if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
|
|
3276
|
+
return
|
|
3277
|
+
}
|
|
3278
|
+
state.lastViewportKey = viewportKey
|
|
3279
|
+
state.renderVisibilityDirty = false
|
|
3280
|
+
state.renderClusterEdges = []
|
|
3281
|
+
|
|
3282
|
+
const shouldRenderMacroGalaxy = shouldRenderMacroGalaxyView()
|
|
3283
|
+
|
|
3284
|
+
if (shouldRenderMacroGalaxy) {
|
|
3285
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
3286
|
+
const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
|
|
3287
|
+
const representative = state.primaryHub ?? state.macroRepresentative ?? sourceNodes[0] ?? null
|
|
3288
|
+
if (representative) {
|
|
3289
|
+
state.renderClusters = [
|
|
3290
|
+
{
|
|
3291
|
+
id: 'macro-galaxy',
|
|
3292
|
+
x: state.macroCenter.x,
|
|
3293
|
+
y: state.macroCenter.y,
|
|
3294
|
+
count: sourceNodes.length,
|
|
3295
|
+
representative
|
|
3296
|
+
}
|
|
3297
|
+
]
|
|
3298
|
+
state.renderNodes = [representative]
|
|
3299
|
+
} else {
|
|
3300
|
+
state.renderClusters = []
|
|
3301
|
+
state.renderNodes = []
|
|
3302
|
+
}
|
|
3303
|
+
state.renderEdges = []
|
|
3304
|
+
state.renderClusterEdges = []
|
|
3305
|
+
return
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
const ecosystemScaleThreshold = state.visibleNodes.length > massiveGraphNodeThreshold
|
|
3309
|
+
? massiveEcosystemClusterScaleThreshold
|
|
3310
|
+
: ecosystemClusterScaleThreshold
|
|
3311
|
+
if (
|
|
3312
|
+
state.ecosystemExpansionLevels.length > 0 &&
|
|
3313
|
+
state.transform.scale <= ecosystemScaleThreshold &&
|
|
3314
|
+
state.ecosystemClusters.length > 0
|
|
3315
|
+
) {
|
|
3316
|
+
const clusters = selectHierarchicalEcosystemClusters(viewport)
|
|
3317
|
+
.sort((left, right) => right.count - left.count)
|
|
3318
|
+
const edges = ecosystemEdgesForClusters(clusters)
|
|
3319
|
+
const projectionAnchor = ecosystemFocusPoint()
|
|
3320
|
+
const projected = applyEcosystemDepthProjection(clusters, edges, projectionAnchor)
|
|
3321
|
+
state.renderClusters = projected.clusters
|
|
3322
|
+
state.renderClusterEdges = projected.edges
|
|
3323
|
+
state.renderNodes = []
|
|
3324
|
+
state.renderEdges = []
|
|
3325
|
+
return
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
if (state.visibleNodes.length <= 2000) {
|
|
3329
|
+
state.renderNodes = state.visibleNodes
|
|
3330
|
+
state.renderClusters = []
|
|
3331
|
+
state.renderClusterEdges = []
|
|
3332
|
+
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
3333
|
+
state.renderEdges = withMeshEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
|
|
3334
|
+
return
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
if (state.visibleNodes.length > massiveGraphNodeThreshold) {
|
|
3338
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
3339
|
+
const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
|
|
3340
|
+
const sampleLimit = nodeBudgetForScale(state.transform.scale)
|
|
3341
|
+
const carryMargin = Math.max(240, Math.min(1200, 340 / Math.max(state.transform.scale, 0.0001)))
|
|
3342
|
+
const carryViewport = expandViewportBounds(viewport, carryMargin)
|
|
3343
|
+
const carryOverLimit = Math.max(180, Math.min(sampleLimit, Math.floor(sampleLimit * 0.5)))
|
|
3344
|
+
const carryOverNodes = (state.renderNodes ?? [])
|
|
3345
|
+
.filter((node) => isNodeInViewport(node, carryViewport))
|
|
3346
|
+
.slice(0, carryOverLimit)
|
|
3347
|
+
const sourceWithCarry = mergeUniqueNodes(
|
|
3348
|
+
sourceNodes,
|
|
3349
|
+
carryOverNodes,
|
|
3350
|
+
Math.max(sampleLimit * 7, carryOverLimit)
|
|
3351
|
+
)
|
|
3352
|
+
const sourceWithCarryIds = new Set(sourceWithCarry.map((node) => node.id))
|
|
3353
|
+
const sampledRaw = selectStableSampleNodes(
|
|
3354
|
+
sourceWithCarry,
|
|
3355
|
+
sampleLimit
|
|
3356
|
+
)
|
|
3357
|
+
const continuityBudget = Math.max(24, Math.min(sampleLimit - 8, Math.floor(sampleLimit * 0.42)))
|
|
3358
|
+
const previousVisibleNodes = (state.renderNodes ?? [])
|
|
3359
|
+
.filter((node) => sourceWithCarryIds.has(node.id))
|
|
3360
|
+
const continuityNodes = selectStableSampleNodes(previousVisibleNodes, continuityBudget)
|
|
3361
|
+
const sampled = mergeUniqueNodes(
|
|
3362
|
+
continuityNodes,
|
|
3363
|
+
sampledRaw,
|
|
3364
|
+
sampleLimit
|
|
3365
|
+
)
|
|
3366
|
+
let sampledNodes = ensureHubNodesInRenderedSet(sampled)
|
|
3367
|
+
if (state.transform.scale < 0.035) {
|
|
3368
|
+
sampledNodes = includeHubPreviewNeighborhood(
|
|
3369
|
+
sampledNodes,
|
|
3370
|
+
Math.min(renderNodeBudget, sampleLimit + 160)
|
|
3371
|
+
)
|
|
3372
|
+
}
|
|
3373
|
+
const sampledIds = new Set(sampledNodes.map((node) => node.id))
|
|
3374
|
+
let sampledEdges = collectVisibleEdgesForNodes(sampledIds)
|
|
3375
|
+
|
|
3376
|
+
if (state.transform.scale >= 0.035 && sampledEdges.length === 0) {
|
|
3377
|
+
const enriched = enrichSampleWithNeighbors(sampledNodes)
|
|
3378
|
+
sampledNodes = ensureHubNodesInRenderedSet(enriched.nodes)
|
|
3379
|
+
const sampledWithHubsIds = new Set(sampledNodes.map((node) => node.id))
|
|
3380
|
+
sampledEdges = collectVisibleEdgesForNodes(sampledWithHubsIds)
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
state.renderClusters = []
|
|
3384
|
+
state.renderClusterEdges = []
|
|
3385
|
+
state.renderNodes = sampledNodes
|
|
3386
|
+
state.renderEdges = withMeshEdges(sampledNodes, sampledEdges)
|
|
3387
|
+
return
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3390
|
+
if (state.transform.scale <= 0.0015) {
|
|
3391
|
+
const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
|
|
3392
|
+
const sampledIds = new Set(sampled.map((node) => node.id))
|
|
3393
|
+
state.renderClusters = []
|
|
3394
|
+
state.renderClusterEdges = []
|
|
3395
|
+
state.renderNodes = sampled
|
|
3396
|
+
state.renderEdges = withMeshEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
|
|
3397
|
+
return
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
3401
|
+
const clusters = clusterViewportNodes(viewportNodes)
|
|
3402
|
+
if (clusters.length > 0) {
|
|
3403
|
+
state.renderClusters = []
|
|
3404
|
+
state.renderClusterEdges = []
|
|
3405
|
+
state.renderNodes = representativeNodesFromClusters(clusters, Math.min(renderNodeBudget, 900))
|
|
3406
|
+
state.renderEdges = []
|
|
3407
|
+
return
|
|
3408
|
+
}
|
|
3409
|
+
state.renderClusters = []
|
|
3410
|
+
state.renderClusterEdges = []
|
|
3411
|
+
const stride = viewportNodeStride()
|
|
3412
|
+
const picked = []
|
|
3413
|
+
|
|
3414
|
+
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
3415
|
+
const node = viewportNodes[index]
|
|
3416
|
+
|
|
3417
|
+
const isPriority =
|
|
3418
|
+
node.id === state.selected?.id ||
|
|
3419
|
+
node.id === state.hovered?.id ||
|
|
3420
|
+
node.id === state.pointer.dragNode?.id
|
|
3421
|
+
if (isPriority || index % stride === 0) {
|
|
3422
|
+
picked.push(node)
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
const nodes = picked.length > renderNodeBudget
|
|
3427
|
+
? picked.slice(0, renderNodeBudget)
|
|
3428
|
+
: picked
|
|
3429
|
+
if (nodes.length === 0 && state.visibleNodes.length > 0) {
|
|
3430
|
+
const fallbackNodes = fallbackViewportNodes()
|
|
3431
|
+
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
3432
|
+
state.renderNodes = fallbackNodes
|
|
3433
|
+
state.renderClusters = []
|
|
3434
|
+
state.renderClusterEdges = []
|
|
3435
|
+
state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
|
|
3436
|
+
return
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
const normalizedNodes = ensureHubNodesInRenderedSet(nodes)
|
|
3440
|
+
const nodeIds = new Set(normalizedNodes.map((node) => node.id))
|
|
3441
|
+
const edges = collectVisibleEdgesForNodes(nodeIds)
|
|
3442
|
+
|
|
3443
|
+
state.renderNodes = normalizedNodes
|
|
3444
|
+
state.renderEdges = withMeshEdges(normalizedNodes, edges)
|
|
3445
|
+
|
|
3446
|
+
if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
|
|
3447
|
+
const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
|
|
3448
|
+
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
3449
|
+
state.renderClusters = []
|
|
3450
|
+
state.renderClusterEdges = []
|
|
3451
|
+
state.renderNodes = fallbackNodes
|
|
3452
|
+
state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
const isNodeVisibleOnScreen = (node, width, height) => {
|
|
3457
|
+
const radius = nodeRadius(node) * state.transform.scale
|
|
3458
|
+
const screenX = node.x * state.transform.scale + state.transform.x
|
|
3459
|
+
const screenY = node.y * state.transform.scale + state.transform.y
|
|
3460
|
+
|
|
3461
|
+
return (
|
|
3462
|
+
screenX + radius >= 0 &&
|
|
3463
|
+
screenX - radius <= width &&
|
|
3464
|
+
screenY + radius >= 0 &&
|
|
3465
|
+
screenY - radius <= height
|
|
3466
|
+
)
|
|
3467
|
+
}
|
|
3468
|
+
|
|
3469
|
+
const hasValidTransform = () =>
|
|
3470
|
+
isFiniteNumber(state.transform.x) &&
|
|
3471
|
+
isFiniteNumber(state.transform.y) &&
|
|
3472
|
+
isFiniteNumber(state.transform.scale) &&
|
|
3473
|
+
Math.abs(state.transform.x) <= transformCoordinateLimit &&
|
|
3474
|
+
Math.abs(state.transform.y) <= transformCoordinateLimit &&
|
|
3475
|
+
state.transform.scale > 0
|
|
3476
|
+
|
|
3477
|
+
const sanitizeNodePosition = node => {
|
|
3478
|
+
if (!isReasonableCoordinate(node.x)) node.x = 0
|
|
3479
|
+
if (!isReasonableCoordinate(node.y)) node.y = 0
|
|
3480
|
+
if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
|
|
3481
|
+
if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
const sanitizeAllNodePositions = () => {
|
|
3485
|
+
state.nodes.forEach(sanitizeNodePosition)
|
|
3486
|
+
state.visibleNodes.forEach(sanitizeNodePosition)
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
const sanitizeGraphState = () => {
|
|
3490
|
+
state.renderNodes.forEach(sanitizeNodePosition)
|
|
3491
|
+
}
|
|
3492
|
+
|
|
351
3493
|
const render = now => {
|
|
352
3494
|
const delta = now - state.last
|
|
353
3495
|
state.last = now
|
|
354
|
-
const
|
|
3496
|
+
const backgroundFrameIntervalMs =
|
|
3497
|
+
state.nodes.length > massiveGraphNodeThreshold
|
|
3498
|
+
? (state.transform.scale < 0.035 ? 156 : state.transform.scale < 0.08 ? 128 : 98)
|
|
3499
|
+
: state.nodes.length > largeGraphNodeThreshold
|
|
3500
|
+
? 72
|
|
3501
|
+
: 18
|
|
3502
|
+
const isInteracting =
|
|
3503
|
+
state.pointer.down ||
|
|
3504
|
+
state.renderVisibilityDirty ||
|
|
3505
|
+
state.recoveringViewport
|
|
3506
|
+
const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
|
|
355
3507
|
if (delta < minFrameIntervalMs) {
|
|
356
3508
|
requestAnimationFrame(render)
|
|
357
3509
|
return
|
|
@@ -359,7 +3511,13 @@ const render = now => {
|
|
|
359
3511
|
const rect = canvas.getBoundingClientRect()
|
|
360
3512
|
const width = Math.max(rect.width, 320)
|
|
361
3513
|
const height = Math.max(rect.height, 320)
|
|
3514
|
+
state.viewport = { width, height }
|
|
3515
|
+
sanitizeGraphState()
|
|
3516
|
+
if (!hasValidTransform()) {
|
|
3517
|
+
resetView()
|
|
3518
|
+
}
|
|
362
3519
|
ctx.clearRect(0, 0, width, height)
|
|
3520
|
+
webGlRenderer?.clear(width, height)
|
|
363
3521
|
if (state.nodes.length === 0) {
|
|
364
3522
|
ctx.fillStyle = '#99a5b5'
|
|
365
3523
|
ctx.font = '14px Inter, system-ui, sans-serif'
|
|
@@ -368,54 +3526,117 @@ const render = now => {
|
|
|
368
3526
|
requestAnimationFrame(render)
|
|
369
3527
|
return
|
|
370
3528
|
}
|
|
371
|
-
ctx.save()
|
|
372
|
-
ctx.translate(state.transform.x, state.transform.y)
|
|
373
|
-
ctx.scale(state.transform.scale, state.transform.scale)
|
|
374
3529
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
3530
|
+
computeRenderVisibility()
|
|
3531
|
+
tick(delta, now)
|
|
3532
|
+
refreshRenderNodeDepthProjection()
|
|
3533
|
+
const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
|
|
3534
|
+
const manualZoomGuardActive = now - state.lastManualZoomAt < zoomRecoveryGuardMs
|
|
3535
|
+
const allowViewportAutoRecovery = state.nodes.length <= massiveGraphNodeThreshold
|
|
3536
|
+
if (allowViewportAutoRecovery && !hasVisibleNodeOnScreen && state.renderNodes.length > 0 && !manualZoomGuardActive) {
|
|
3537
|
+
state.offscreenFrameCount += 1
|
|
3538
|
+
if (state.offscreenFrameCount >= 22 && !state.recoveringViewport) {
|
|
3539
|
+
state.recoveringViewport = true
|
|
3540
|
+
fitView({ useFiltered: true })
|
|
3541
|
+
state.offscreenFrameCount = 0
|
|
3542
|
+
requestAnimationFrame(() => {
|
|
3543
|
+
state.recoveringViewport = false
|
|
3544
|
+
})
|
|
3545
|
+
}
|
|
3546
|
+
} else {
|
|
3547
|
+
state.offscreenFrameCount = 0
|
|
387
3548
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
(
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
3549
|
+
const minimumEdgeScale =
|
|
3550
|
+
state.nodes.length > massiveGraphNodeThreshold
|
|
3551
|
+
? 0
|
|
3552
|
+
: state.renderNodes.length > 1300
|
|
3553
|
+
? 0.12
|
|
3554
|
+
: state.renderNodes.length > 900
|
|
3555
|
+
? 0.085
|
|
3556
|
+
: state.renderNodes.length > 500
|
|
3557
|
+
? 0.05
|
|
3558
|
+
: 0
|
|
3559
|
+
const drawEdges =
|
|
3560
|
+
state.renderClusters.length === 0 &&
|
|
3561
|
+
state.transform.scale >= minimumEdgeScale
|
|
3562
|
+
if (drawAcceleratedGraph(width, height, drawEdges)) {
|
|
3563
|
+
// WebGL handles the dense node/edge layer; the 2D canvas remains the interaction overlay.
|
|
3564
|
+
} else if (state.renderClusters.length > 0) {
|
|
3565
|
+
ctx.save()
|
|
3566
|
+
ctx.translate(state.transform.x, state.transform.y)
|
|
3567
|
+
ctx.scale(state.transform.scale, state.transform.scale)
|
|
3568
|
+
const orderedClusters = [...state.renderClusters]
|
|
3569
|
+
.sort((left, right) => clusterDepth(right) - clusterDepth(left))
|
|
3570
|
+
const safeScale = Math.max(state.transform.scale, 0.0001)
|
|
3571
|
+
if (state.renderClusterEdges.length > 0) {
|
|
3572
|
+
for (let index = 0; index < state.renderClusterEdges.length; index += 1) {
|
|
3573
|
+
const edge = state.renderClusterEdges[index]
|
|
3574
|
+
const edgeOpacity = Math.min(clusterOpacity(edge.sourceCluster), clusterOpacity(edge.targetCluster))
|
|
3575
|
+
if (edgeOpacity <= 0.01) {
|
|
3576
|
+
continue
|
|
3577
|
+
}
|
|
3578
|
+
const depthScale = Math.min(clusterDepthScale(edge.sourceCluster), clusterDepthScale(edge.targetCluster))
|
|
3579
|
+
const widthScale = 0.6 + depthScale * 0.9
|
|
3580
|
+
ctx.beginPath()
|
|
3581
|
+
ctx.moveTo(edge.sourceCluster.x, edge.sourceCluster.y)
|
|
3582
|
+
ctx.lineTo(edge.targetCluster.x, edge.targetCluster.y)
|
|
3583
|
+
ctx.lineWidth = (1.2 * widthScale) / safeScale
|
|
3584
|
+
ctx.strokeStyle = 'rgba(153, 165, 181, ' + (edge.inferred ? 0.14 : 0.22) * edgeOpacity + ')'
|
|
3585
|
+
ctx.stroke()
|
|
3586
|
+
}
|
|
415
3587
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
3588
|
+
orderedClusters.forEach(cluster => {
|
|
3589
|
+
const isMacro = cluster.id === 'macro-galaxy'
|
|
3590
|
+
const isEcosystem = String(cluster.id).startsWith('ecosystem-')
|
|
3591
|
+
const isHub = Boolean(cluster.isHub)
|
|
3592
|
+
const opacity = clusterOpacity(cluster)
|
|
3593
|
+
if (opacity <= 0.01) {
|
|
3594
|
+
return
|
|
3595
|
+
}
|
|
3596
|
+
const radiusPx = clusterRadiusPx(cluster)
|
|
3597
|
+
const radius = radiusPx / safeScale
|
|
3598
|
+
const haloRadius = (radiusPx + (isMacro ? 8 : isHub ? 4 : isEcosystem ? 1.1 : 4)) / safeScale
|
|
3599
|
+
ctx.globalAlpha = opacity
|
|
3600
|
+
if (isHub || !isEcosystem || state.transform.scale >= ecosystemSubgraphScaleThreshold) {
|
|
3601
|
+
ctx.beginPath()
|
|
3602
|
+
ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
|
|
3603
|
+
ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
|
|
3604
|
+
ctx.fill()
|
|
3605
|
+
}
|
|
3606
|
+
ctx.beginPath()
|
|
3607
|
+
ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
|
|
3608
|
+
ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
|
|
3609
|
+
ctx.fill()
|
|
3610
|
+
ctx.lineWidth = (isEcosystem && !isHub ? 0.7 : 1.4) / safeScale
|
|
3611
|
+
ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
|
|
3612
|
+
ctx.stroke()
|
|
3613
|
+
if (isMacro && cluster.representative?.title) {
|
|
3614
|
+
ctx.fillStyle = '#edf2f7'
|
|
3615
|
+
ctx.font = 12 / safeScale + 'px Inter, system-ui, sans-serif'
|
|
3616
|
+
ctx.textAlign = 'center'
|
|
3617
|
+
ctx.textBaseline = 'top'
|
|
3618
|
+
ctx.fillText(cluster.representative.title.slice(0, 28), cluster.x, cluster.y + (radiusPx + 9) / safeScale)
|
|
3619
|
+
}
|
|
3620
|
+
ctx.globalAlpha = 1
|
|
3621
|
+
// Keep cluster markers minimal and faster to draw on large graphs.
|
|
3622
|
+
})
|
|
3623
|
+
ctx.restore()
|
|
3624
|
+
} else {
|
|
3625
|
+
ctx.save()
|
|
3626
|
+
ctx.translate(state.transform.x, state.transform.y)
|
|
3627
|
+
ctx.scale(state.transform.scale, state.transform.scale)
|
|
3628
|
+
if (drawEdges) {
|
|
3629
|
+
drawGraphEdges()
|
|
3630
|
+
}
|
|
3631
|
+
drawGraphNodes()
|
|
3632
|
+
ctx.restore()
|
|
3633
|
+
}
|
|
3634
|
+
if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
|
|
3635
|
+
ctx.fillStyle = '#99a5b5'
|
|
3636
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
3637
|
+
ctx.textAlign = 'center'
|
|
3638
|
+
ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
|
|
3639
|
+
}
|
|
419
3640
|
requestAnimationFrame(render)
|
|
420
3641
|
}
|
|
421
3642
|
|
|
@@ -430,11 +3651,11 @@ const linkedNodes = node => {
|
|
|
430
3651
|
weight: edge.weight,
|
|
431
3652
|
priority: edge.priority
|
|
432
3653
|
} : null
|
|
433
|
-
const outgoing = state.
|
|
3654
|
+
const outgoing = state.edges
|
|
434
3655
|
.filter(edge => edge.source === node.id)
|
|
435
|
-
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' }, edge))
|
|
3656
|
+
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
|
|
436
3657
|
.filter(Boolean)
|
|
437
|
-
const incoming = state.
|
|
3658
|
+
const incoming = state.edges
|
|
438
3659
|
.filter(edge => edge.target === node.id)
|
|
439
3660
|
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
440
3661
|
.filter(Boolean)
|
|
@@ -448,7 +3669,7 @@ const fetchNodeDetails = async node => {
|
|
|
448
3669
|
return cached
|
|
449
3670
|
}
|
|
450
3671
|
|
|
451
|
-
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery())
|
|
3672
|
+
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
|
|
452
3673
|
if (!response.ok) {
|
|
453
3674
|
throw new Error('Failed to load graph node details')
|
|
454
3675
|
}
|
|
@@ -462,29 +3683,49 @@ const fetchNodeDetails = async node => {
|
|
|
462
3683
|
return detail
|
|
463
3684
|
}
|
|
464
3685
|
|
|
3686
|
+
const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
3687
|
+
|
|
465
3688
|
const openContentDialog = async node => {
|
|
466
3689
|
if (!node) return
|
|
467
|
-
|
|
468
|
-
elements.
|
|
469
|
-
elements.
|
|
470
|
-
elements.contentTags.innerHTML = node.tags.length
|
|
3690
|
+
elements.contentTitle.textContent = node.title || 'Loading...'
|
|
3691
|
+
elements.contentPath.textContent = node.path || 'Loading...'
|
|
3692
|
+
elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
|
|
471
3693
|
? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
472
3694
|
: '<span>No tags</span>'
|
|
473
|
-
|
|
474
|
-
elements.
|
|
3695
|
+
const initialLinks = linkedNodes(node)
|
|
3696
|
+
elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
|
|
3697
|
+
elements.contentIncoming.innerHTML = list(initialLinks.incoming)
|
|
475
3698
|
elements.contentBody.textContent = 'Loading note content...'
|
|
476
3699
|
if (!elements.contentDialog.open) {
|
|
477
3700
|
elements.contentDialog.showModal()
|
|
478
3701
|
}
|
|
479
3702
|
|
|
3703
|
+
const applyDetailToDialog = detail => {
|
|
3704
|
+
elements.contentTitle.textContent = detail.title
|
|
3705
|
+
elements.contentPath.textContent = detail.path
|
|
3706
|
+
elements.contentTags.innerHTML = detail.tags.length
|
|
3707
|
+
? detail.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
3708
|
+
: '<span>No tags</span>'
|
|
3709
|
+
elements.contentBody.textContent = detail.content
|
|
3710
|
+
}
|
|
3711
|
+
|
|
480
3712
|
try {
|
|
481
3713
|
const detailedNode = await fetchNodeDetails(node)
|
|
482
3714
|
if (state.selected?.id !== node.id) {
|
|
483
3715
|
return
|
|
484
3716
|
}
|
|
485
|
-
|
|
3717
|
+
applyDetailToDialog(detailedNode)
|
|
486
3718
|
} catch {
|
|
487
|
-
|
|
3719
|
+
try {
|
|
3720
|
+
await wait(120)
|
|
3721
|
+
const retriedNode = await fetchNodeDetails(node)
|
|
3722
|
+
if (state.selected?.id !== node.id) {
|
|
3723
|
+
return
|
|
3724
|
+
}
|
|
3725
|
+
applyDetailToDialog(retriedNode)
|
|
3726
|
+
} catch {
|
|
3727
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
3728
|
+
}
|
|
488
3729
|
}
|
|
489
3730
|
}
|
|
490
3731
|
|
|
@@ -502,14 +3743,70 @@ const selectNodeById = id => {
|
|
|
502
3743
|
if (node) selectNode(node, { openContent: true })
|
|
503
3744
|
}
|
|
504
3745
|
|
|
505
|
-
const zoomAtPoint = (screenX, screenY, factor) => {
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
3746
|
+
const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
|
|
3747
|
+
state.lastManualZoomAt = performance.now()
|
|
3748
|
+
const effectiveFactor = factor
|
|
3749
|
+
const nextScale = clampScale(state.transform.scale * effectiveFactor)
|
|
3750
|
+
if (nextScale === state.transform.scale) {
|
|
3751
|
+
return
|
|
3752
|
+
}
|
|
3753
|
+
const worldPointAtCursor = screenToWorldPoint(screenX, screenY)
|
|
3754
|
+
const worldX = worldPointAtCursor.x
|
|
3755
|
+
const worldY = worldPointAtCursor.y
|
|
3756
|
+
state.lastZoomFocus = {
|
|
3757
|
+
x: worldX,
|
|
3758
|
+
y: worldY,
|
|
3759
|
+
at: performance.now()
|
|
3760
|
+
}
|
|
3761
|
+
state.transform.scale = clampScale(nextScale)
|
|
3762
|
+
state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
|
|
3763
|
+
state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
|
|
3764
|
+
state.offscreenFrameCount = 0
|
|
3765
|
+
markRenderDirty()
|
|
3766
|
+
}
|
|
3767
|
+
|
|
3768
|
+
const wheelZoomFactor = event => {
|
|
3769
|
+
const isModifierZoom = event.metaKey || event.ctrlKey
|
|
3770
|
+
const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
|
|
3771
|
+
const normalizedDelta = event.deltaY * deltaModeFactor
|
|
3772
|
+
|
|
3773
|
+
if (!Number.isFinite(normalizedDelta) || Math.abs(normalizedDelta) <= 0.0001) {
|
|
3774
|
+
return 1
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3777
|
+
const isMassiveEcosystemZoom =
|
|
3778
|
+
state.visibleNodes.length > massiveGraphNodeThreshold &&
|
|
3779
|
+
state.transform.scale <= massiveEcosystemClusterScaleThreshold
|
|
3780
|
+
const sensitivityMultiplier = isMassiveEcosystemZoom ? 0.48 : 1
|
|
3781
|
+
const capMultiplier = isMassiveEcosystemZoom ? 0.34 : 1
|
|
3782
|
+
const sensitivity = wheelZoomExponent * (isModifierZoom ? wheelZoomModifierBoost : 1) * sensitivityMultiplier
|
|
3783
|
+
const exponentCap = wheelZoomExponentCap * capMultiplier
|
|
3784
|
+
const exponent = Math.max(
|
|
3785
|
+
-exponentCap,
|
|
3786
|
+
Math.min(exponentCap, -normalizedDelta * sensitivity)
|
|
3787
|
+
)
|
|
3788
|
+
return Math.exp(exponent)
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
const handleWheelZoom = event => {
|
|
3792
|
+
if (elements.contentDialog?.open) {
|
|
3793
|
+
return
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
event.preventDefault()
|
|
3797
|
+
const rect = canvas.getBoundingClientRect()
|
|
3798
|
+
const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
|
|
3799
|
+
const rawCursorY = Number.isFinite(event.offsetY) ? event.offsetY : event.clientY - rect.top
|
|
3800
|
+
const cursorX = Math.max(0, Math.min(Math.max(rect.width, 320), rawCursorX))
|
|
3801
|
+
const cursorY = Math.max(0, Math.min(Math.max(rect.height, 320), rawCursorY))
|
|
3802
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
3803
|
+
const factor = wheelZoomFactor(event)
|
|
3804
|
+
|
|
3805
|
+
if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
|
|
3806
|
+
return
|
|
3807
|
+
}
|
|
3808
|
+
|
|
3809
|
+
zoomAtPoint(cursorX, cursorY, factor, 'wheel')
|
|
513
3810
|
}
|
|
514
3811
|
|
|
515
3812
|
const bindEvents = () => {
|
|
@@ -521,6 +3818,8 @@ const bindEvents = () => {
|
|
|
521
3818
|
})
|
|
522
3819
|
elements.agent.addEventListener('change', event => {
|
|
523
3820
|
state.agentId = event.target.value
|
|
3821
|
+
writeStoredAgent(state.agentId)
|
|
3822
|
+
syncAgentInUrl(state.agentId)
|
|
524
3823
|
state.selected = null
|
|
525
3824
|
state.nodeDetails = new Map()
|
|
526
3825
|
resetContentFilter()
|
|
@@ -532,15 +3831,15 @@ const bindEvents = () => {
|
|
|
532
3831
|
})
|
|
533
3832
|
elements.zoomIn.addEventListener('click', () => {
|
|
534
3833
|
const rect = canvas.getBoundingClientRect()
|
|
535
|
-
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.
|
|
3834
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.055, 'button')
|
|
536
3835
|
})
|
|
537
3836
|
elements.zoomOut.addEventListener('click', () => {
|
|
538
3837
|
const rect = canvas.getBoundingClientRect()
|
|
539
|
-
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.
|
|
3838
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.948, 'button')
|
|
540
3839
|
})
|
|
541
3840
|
if (elements.fit) {
|
|
542
3841
|
elements.fit.addEventListener('click', () => {
|
|
543
|
-
|
|
3842
|
+
focusPrimaryHub()
|
|
544
3843
|
})
|
|
545
3844
|
}
|
|
546
3845
|
elements.reset.addEventListener('click', () => {
|
|
@@ -555,14 +3854,20 @@ const bindEvents = () => {
|
|
|
555
3854
|
}
|
|
556
3855
|
if (event.target === elements.contentDialog) elements.contentDialog.close()
|
|
557
3856
|
})
|
|
558
|
-
canvas.addEventListener('wheel',
|
|
559
|
-
|
|
3857
|
+
canvas.addEventListener('wheel', handleWheelZoom, { passive: false })
|
|
3858
|
+
canvas.addEventListener('dblclick', event => {
|
|
3859
|
+
const point = worldPoint(event)
|
|
3860
|
+
const node = hitNode(point)
|
|
3861
|
+
if (node) {
|
|
3862
|
+
selectNode(node, { openContent: true })
|
|
3863
|
+
return
|
|
3864
|
+
}
|
|
3865
|
+
|
|
560
3866
|
const rect = canvas.getBoundingClientRect()
|
|
561
3867
|
const cursorX = event.clientX - rect.left
|
|
562
3868
|
const cursorY = event.clientY - rect.top
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
}, { passive: false })
|
|
3869
|
+
zoomAtPoint(cursorX, cursorY, 1.055)
|
|
3870
|
+
})
|
|
566
3871
|
canvas.addEventListener('pointerdown', event => {
|
|
567
3872
|
const point = worldPoint(event)
|
|
568
3873
|
const node = hitNode(point)
|
|
@@ -570,12 +3875,24 @@ const bindEvents = () => {
|
|
|
570
3875
|
if (node) {
|
|
571
3876
|
node.x = point.x
|
|
572
3877
|
node.y = point.y
|
|
3878
|
+
markRenderDirty()
|
|
573
3879
|
}
|
|
574
3880
|
canvas.setPointerCapture(event.pointerId)
|
|
575
3881
|
})
|
|
576
3882
|
canvas.addEventListener('pointermove', event => {
|
|
577
3883
|
const point = worldPoint(event)
|
|
578
|
-
|
|
3884
|
+
const now = performance.now()
|
|
3885
|
+
const canHoverHitTest =
|
|
3886
|
+
!(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.06)
|
|
3887
|
+
const shouldHitTest = canHoverHitTest &&
|
|
3888
|
+
(state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
|
|
3889
|
+
if (shouldHitTest) {
|
|
3890
|
+
state.hovered = hitNode(point)
|
|
3891
|
+
state.lastHoverHitAt = now
|
|
3892
|
+
} else if (!canHoverHitTest) {
|
|
3893
|
+
state.hovered = null
|
|
3894
|
+
}
|
|
3895
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
579
3896
|
if (!state.pointer.down) return
|
|
580
3897
|
const dx = event.clientX - state.pointer.x
|
|
581
3898
|
const dy = event.clientY - state.pointer.y
|
|
@@ -583,36 +3900,83 @@ const bindEvents = () => {
|
|
|
583
3900
|
state.pointer.y = event.clientY
|
|
584
3901
|
state.pointer.moved = state.pointer.moved || Math.abs(dx) + Math.abs(dy) > 3
|
|
585
3902
|
if (state.pointer.dragNode) {
|
|
586
|
-
state.pointer.dragNode
|
|
587
|
-
|
|
3903
|
+
const dragNode = state.pointer.dragNode
|
|
3904
|
+
const previousX = dragNode.x
|
|
3905
|
+
const previousY = dragNode.y
|
|
3906
|
+
dragNode.x = point.x
|
|
3907
|
+
dragNode.y = point.y
|
|
3908
|
+
applyDragNeighborhoodAdjustment(dragNode, dragNode.x - previousX, dragNode.y - previousY)
|
|
3909
|
+
markRenderDirty()
|
|
588
3910
|
return
|
|
589
3911
|
}
|
|
590
3912
|
state.transform.x += dx
|
|
591
3913
|
state.transform.y += dy
|
|
3914
|
+
state.transform.x = clampTransformCoordinate(state.transform.x)
|
|
3915
|
+
state.transform.y = clampTransformCoordinate(state.transform.y)
|
|
3916
|
+
state.offscreenFrameCount = 0
|
|
3917
|
+
markRenderDirty()
|
|
592
3918
|
})
|
|
593
3919
|
canvas.addEventListener('pointerup', event => {
|
|
594
|
-
|
|
595
|
-
if (
|
|
3920
|
+
const draggedNode = state.pointer.dragNode
|
|
3921
|
+
if (draggedNode && state.pointer.moved) {
|
|
3922
|
+
settleNeighborhoodAroundNode(draggedNode)
|
|
3923
|
+
markRenderDirty()
|
|
3924
|
+
}
|
|
3925
|
+
if (draggedNode && !state.pointer.moved) selectNode(draggedNode, { openContent: false })
|
|
3926
|
+
if (!draggedNode && !state.pointer.moved) selectNode(state.hovered, { openContent: false })
|
|
596
3927
|
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
597
3928
|
canvas.releasePointerCapture(event.pointerId)
|
|
598
3929
|
})
|
|
3930
|
+
canvas.addEventListener('pointercancel', () => {
|
|
3931
|
+
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
3932
|
+
})
|
|
3933
|
+
canvas.addEventListener('pointerenter', event => {
|
|
3934
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
3935
|
+
})
|
|
3936
|
+
canvas.addEventListener('pointerleave', event => {
|
|
3937
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
|
|
3938
|
+
})
|
|
3939
|
+
window.addEventListener('keydown', event => {
|
|
3940
|
+
if (event.key === '+' || event.key === '=') {
|
|
3941
|
+
event.preventDefault()
|
|
3942
|
+
const rect = canvas.getBoundingClientRect()
|
|
3943
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.05)
|
|
3944
|
+
return
|
|
3945
|
+
}
|
|
3946
|
+
|
|
3947
|
+
if (event.key === '-' || event.key === '_') {
|
|
3948
|
+
event.preventDefault()
|
|
3949
|
+
const rect = canvas.getBoundingClientRect()
|
|
3950
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.952)
|
|
3951
|
+
return
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3954
|
+
if (event.key === '0') {
|
|
3955
|
+
event.preventDefault()
|
|
3956
|
+
resetView()
|
|
3957
|
+
}
|
|
3958
|
+
})
|
|
599
3959
|
}
|
|
600
3960
|
|
|
601
3961
|
const loadAgents = async () => {
|
|
602
3962
|
const response = await fetch('/api/agents')
|
|
603
3963
|
const payload = await response.json()
|
|
604
3964
|
const agents = Array.isArray(payload.agents) ? payload.agents : []
|
|
605
|
-
const
|
|
3965
|
+
const preferredAgent = state.agentId || initialAgentFromUrl || readStoredAgent()
|
|
3966
|
+
const currentExists = agents.some(agent => agent.id === preferredAgent)
|
|
606
3967
|
const selected = currentExists
|
|
607
|
-
?
|
|
3968
|
+
? preferredAgent
|
|
608
3969
|
: (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
|
|
609
3970
|
const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
|
|
610
3971
|
|
|
611
3972
|
state.agentId = selected
|
|
3973
|
+
writeStoredAgent(selected)
|
|
3974
|
+
syncAgentInUrl(selected)
|
|
612
3975
|
if (signature !== state.agentsSignature) {
|
|
3976
|
+
const formatAgentLabel = (agent) => agent.id
|
|
613
3977
|
elements.agent.innerHTML = agents.length
|
|
614
|
-
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent
|
|
615
|
-
: '<option value="shared">shared
|
|
3978
|
+
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
|
|
3979
|
+
: '<option value="shared">shared</option>'
|
|
616
3980
|
state.agentsSignature = signature
|
|
617
3981
|
}
|
|
618
3982
|
elements.agent.value = selected
|
|
@@ -633,6 +3997,10 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
633
3997
|
|
|
634
3998
|
const payload = await response.json()
|
|
635
3999
|
const graph = payload?.layout ?? payload
|
|
4000
|
+
state.graphTotals = {
|
|
4001
|
+
nodes: Number.isFinite(payload?.totals?.nodes) ? payload.totals.nodes : (Array.isArray(graph.nodes) ? graph.nodes.length : 0),
|
|
4002
|
+
edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
|
|
4003
|
+
}
|
|
636
4004
|
const signature = payload?.signature ?? graphSignature(graph)
|
|
637
4005
|
if (!options.reset && signature === state.graphSignature) return
|
|
638
4006
|
const selectedId = state.selected?.id
|
|
@@ -640,6 +4008,7 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
640
4008
|
state.graphSignature = signature
|
|
641
4009
|
state.graph = graph
|
|
642
4010
|
state.nodes = layout.nodes
|
|
4011
|
+
state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
|
|
643
4012
|
state.edges = layout.edges
|
|
644
4013
|
state.nodeDegrees = state.edges.reduce((degrees, edge) => {
|
|
645
4014
|
degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
|
|
@@ -649,13 +4018,15 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
649
4018
|
return degrees
|
|
650
4019
|
}, new Map())
|
|
651
4020
|
state.nodeDetails = new Map()
|
|
4021
|
+
pushNodesToFilterWorker()
|
|
652
4022
|
resetContentFilter()
|
|
4023
|
+
sanitizeAllNodePositions()
|
|
653
4024
|
recomputeVisibility()
|
|
654
4025
|
scheduleContentFilterSync()
|
|
655
|
-
const tags = new Set(
|
|
656
|
-
setGraphStatus(state.agentId + ' · ' +
|
|
657
|
-
elements.nodeCount.textContent =
|
|
658
|
-
elements.edgeCount.textContent =
|
|
4026
|
+
const tags = new Set(state.nodes.flatMap(node => node.tags))
|
|
4027
|
+
setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
|
|
4028
|
+
elements.nodeCount.textContent = state.graphTotals.nodes
|
|
4029
|
+
elements.edgeCount.textContent = state.graphTotals.edges
|
|
659
4030
|
elements.tagCount.textContent = tags.size
|
|
660
4031
|
resize()
|
|
661
4032
|
if (options.reset) resetView()
|
|
@@ -667,6 +4038,7 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
667
4038
|
}
|
|
668
4039
|
|
|
669
4040
|
bindEvents()
|
|
4041
|
+
initFilterWorker()
|
|
670
4042
|
requestAnimationFrame(() => {
|
|
671
4043
|
resize()
|
|
672
4044
|
resetView()
|