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