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