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