@andespindola/brainlink 0.1.0-beta.8 → 0.1.0-beta.80
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 +58 -2
- package/CONTRIBUTING.md +2 -2
- package/COPYRIGHT.md +5 -0
- package/README.md +266 -20
- package/SECURITY.md +1 -1
- package/dist/application/add-note.js +62 -13
- package/dist/application/analyze-vault.js +95 -8
- package/dist/application/build-context.js +56 -1
- package/dist/application/dedupe-notes.js +226 -0
- package/dist/application/frontend/client-css.js +138 -103
- package/dist/application/frontend/client-html.js +47 -41
- package/dist/application/frontend/client-js.js +2469 -156
- 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/migrate-vault.js +46 -16
- 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 +75 -5
- 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 +419 -0
- package/dist/cli/commands/config-commands.js +167 -0
- package/dist/cli/commands/read-commands.js +25 -8
- package/dist/cli/commands/write-commands.js +973 -10
- package/dist/cli/main.js +4 -0
- package/dist/cli/runtime.js +5 -2
- 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/markdown.js +36 -4
- package/dist/domain/middle-out.js +18 -0
- package/dist/infrastructure/config.js +132 -8
- 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/paths.js +9 -1
- package/dist/infrastructure/private-pack-codec.js +134 -0
- package/dist/infrastructure/search-packs.js +452 -0
- package/dist/infrastructure/session-state.js +172 -0
- package/dist/mcp/main.js +11 -3
- package/dist/mcp/server.js +27 -2
- package/dist/mcp/startup.js +35 -0
- package/dist/mcp/tools.js +633 -19
- package/docs/AGENT_USAGE.md +177 -15
- package/docs/ARCHITECTURE.md +37 -26
- package/docs/QUICKSTART.md +111 -0
- 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,80 @@
|
|
|
1
1
|
export const createClientJs = () => `const canvas = document.getElementById('graph')
|
|
2
2
|
const ctx = canvas.getContext('2d')
|
|
3
|
+
const largeGraphNodeThreshold = 4000
|
|
4
|
+
const massiveGraphNodeThreshold = 20000
|
|
5
|
+
const largeGraphEdgeRenderLimit = 120000
|
|
6
|
+
const renderNodeBudget = 900
|
|
7
|
+
const zoomedMassiveRenderNodeBudget = 2200
|
|
8
|
+
const renderEdgeBudget = 2400
|
|
9
|
+
const clusterActivationNodeThreshold = 600
|
|
10
|
+
const clusterZoomThreshold = 0.18
|
|
11
|
+
const macroGalaxyZoomThreshold = 0.012
|
|
12
|
+
const macroGalaxyEnterHysteresis = 0.86
|
|
13
|
+
const macroGalaxyExitHysteresis = 1.24
|
|
14
|
+
const galaxyDiscoveryEnabled = false
|
|
15
|
+
const massiveAutoFitMacroScale = 0.006
|
|
16
|
+
const defaultMacroScale = 0.006
|
|
17
|
+
const clusterCellPixelSize = 64
|
|
18
|
+
const minNodePixelRadius = 2.3
|
|
19
|
+
const viewportPaddingPx = 280
|
|
20
|
+
const worldCoordinateLimit = 5_000_000
|
|
21
|
+
const transformCoordinateLimit = 20_000_000
|
|
22
|
+
const hoverHitTestIntervalMs = 64
|
|
23
|
+
const overviewClusterMaxCount = 1400
|
|
24
|
+
const zoomRecoveryGuardMs = 4200
|
|
25
|
+
const zoomCapTargetViewportShare = 0.72
|
|
26
|
+
const meshEdgeScaleThreshold = 0.09
|
|
27
|
+
const meshEdgeMinBudget = 140
|
|
28
|
+
const meshEdgeMaxBudget = 1400
|
|
29
|
+
const layeredCoreScaleThreshold = 0.55
|
|
30
|
+
const massiveOverviewClusterScaleThreshold = 0.035
|
|
31
|
+
const dragNeighborhoodMaxAffected = 180
|
|
32
|
+
const dragSettleRounds = 3
|
|
33
|
+
const wheelZoomExponent = 0.0018
|
|
34
|
+
const wheelZoomExponentCap = 0.09
|
|
35
|
+
const wheelZoomModifierBoost = 1.22
|
|
3
36
|
const state = {
|
|
4
37
|
graph: { nodes: [], edges: [] },
|
|
5
38
|
nodes: [],
|
|
39
|
+
nodeById: new Map(),
|
|
6
40
|
edges: [],
|
|
41
|
+
visibleNodes: [],
|
|
42
|
+
visibleEdges: [],
|
|
43
|
+
renderNodes: [],
|
|
44
|
+
renderEdges: [],
|
|
45
|
+
renderClusters: [],
|
|
46
|
+
nodeDegrees: new Map(),
|
|
7
47
|
selected: null,
|
|
8
48
|
hovered: null,
|
|
9
49
|
query: '',
|
|
50
|
+
contentFilter: { query: '', ids: null, token: 0, timer: null },
|
|
10
51
|
agentId: '',
|
|
11
52
|
agentsSignature: '',
|
|
53
|
+
nodeDetails: new Map(),
|
|
12
54
|
transform: { x: 0, y: 0, scale: 1 },
|
|
13
55
|
pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
|
|
56
|
+
cursor: { x: 0, y: 0, inCanvas: false },
|
|
14
57
|
graphSignature: '',
|
|
15
58
|
graphStatus: '',
|
|
16
|
-
|
|
59
|
+
graphTotals: { nodes: 0, edges: 0 },
|
|
60
|
+
last: performance.now(),
|
|
61
|
+
offscreenFrameCount: 0,
|
|
62
|
+
recoveringViewport: false,
|
|
63
|
+
renderVisibilityDirty: true,
|
|
64
|
+
lastViewportKey: '',
|
|
65
|
+
visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
|
|
66
|
+
visibleEdgeByNode: new Map(),
|
|
67
|
+
overviewClusters: [],
|
|
68
|
+
macroCenter: { x: 0, y: 0 },
|
|
69
|
+
macroRepresentative: null,
|
|
70
|
+
primaryHub: null,
|
|
71
|
+
hubNeighborDistance: Number.POSITIVE_INFINITY,
|
|
72
|
+
filterWorker: null,
|
|
73
|
+
filterReady: false,
|
|
74
|
+
lastHoverHitAt: 0,
|
|
75
|
+
lastManualZoomAt: 0,
|
|
76
|
+
lastZoomFocus: { x: 0, y: 0, at: 0 },
|
|
77
|
+
macroViewActive: false
|
|
17
78
|
}
|
|
18
79
|
|
|
19
80
|
const byId = id => document.getElementById(id)
|
|
@@ -24,43 +85,80 @@ const escapeHtml = value => String(value)
|
|
|
24
85
|
.replaceAll('"', '"')
|
|
25
86
|
.replaceAll("'", ''')
|
|
26
87
|
const elements = {
|
|
27
|
-
stats: byId('stats'),
|
|
28
88
|
search: byId('search'),
|
|
29
89
|
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
90
|
nodeCount: byId('nodeCount'),
|
|
37
91
|
edgeCount: byId('edgeCount'),
|
|
38
92
|
tagCount: byId('tagCount'),
|
|
39
93
|
zoomIn: byId('zoomIn'),
|
|
40
94
|
zoomOut: byId('zoomOut'),
|
|
95
|
+
fit: byId('fit'),
|
|
41
96
|
reset: byId('reset'),
|
|
42
97
|
contentDialog: byId('contentDialog'),
|
|
43
98
|
contentTitle: byId('contentTitle'),
|
|
44
99
|
contentPath: byId('contentPath'),
|
|
100
|
+
contentTags: byId('contentTags'),
|
|
101
|
+
contentOutgoing: byId('contentOutgoing'),
|
|
102
|
+
contentIncoming: byId('contentIncoming'),
|
|
45
103
|
contentBody: byId('contentBody'),
|
|
46
104
|
contentClose: byId('contentClose')
|
|
47
105
|
}
|
|
48
106
|
|
|
49
|
-
const
|
|
107
|
+
const zoomRange = {
|
|
108
|
+
min: 0.0002,
|
|
109
|
+
max: 4.5
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const initialAgentFromUrl = (() => {
|
|
113
|
+
try {
|
|
114
|
+
const raw = new URL(window.location.href).searchParams.get('agent')
|
|
115
|
+
const value = raw?.trim() ?? ''
|
|
116
|
+
return value.length > 0 ? value : ''
|
|
117
|
+
} catch {
|
|
118
|
+
return ''
|
|
119
|
+
}
|
|
120
|
+
})()
|
|
121
|
+
|
|
122
|
+
const selectedAgentStorageKey = 'brainlink:selected-agent'
|
|
123
|
+
|
|
124
|
+
const readStoredAgent = () => {
|
|
125
|
+
try {
|
|
126
|
+
const value = window.localStorage.getItem(selectedAgentStorageKey)?.trim() ?? ''
|
|
127
|
+
return value.length > 0 ? value : ''
|
|
128
|
+
} catch {
|
|
129
|
+
return ''
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const writeStoredAgent = (agentId) => {
|
|
134
|
+
try {
|
|
135
|
+
if (!agentId) {
|
|
136
|
+
window.localStorage.removeItem(selectedAgentStorageKey)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
window.localStorage.setItem(selectedAgentStorageKey, agentId)
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const syncAgentInUrl = (agentId) => {
|
|
144
|
+
try {
|
|
145
|
+
const url = new URL(window.location.href)
|
|
146
|
+
if (agentId && agentId.trim().length > 0) {
|
|
147
|
+
url.searchParams.set('agent', agentId)
|
|
148
|
+
} else {
|
|
149
|
+
url.searchParams.delete('agent')
|
|
150
|
+
}
|
|
151
|
+
window.history.replaceState({}, '', url.toString())
|
|
152
|
+
} catch {}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
|
|
50
156
|
|
|
51
157
|
const setGraphStatus = text => {
|
|
52
158
|
state.graphStatus = text
|
|
53
|
-
elements.stats.textContent = text
|
|
54
159
|
}
|
|
55
160
|
|
|
56
161
|
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
162
|
console.error(error)
|
|
65
163
|
}
|
|
66
164
|
|
|
@@ -77,6 +175,67 @@ const graphTheme = {
|
|
|
77
175
|
label: '#edf2f7'
|
|
78
176
|
}
|
|
79
177
|
|
|
178
|
+
const initFilterWorker = () => {
|
|
179
|
+
if (typeof Worker === 'undefined') {
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const worker = new Worker('/app-worker.js')
|
|
184
|
+
worker.onmessage = event => {
|
|
185
|
+
const payload = event.data
|
|
186
|
+
if (!payload || typeof payload !== 'object') return
|
|
187
|
+
|
|
188
|
+
if (payload.type === 'ready') {
|
|
189
|
+
state.filterReady = true
|
|
190
|
+
if (state.nodes.length > 0) {
|
|
191
|
+
worker.postMessage({
|
|
192
|
+
type: 'load-nodes',
|
|
193
|
+
nodes: state.nodes.map(node => ({
|
|
194
|
+
id: node.id,
|
|
195
|
+
title: node.title,
|
|
196
|
+
path: node.path || '',
|
|
197
|
+
tags: Array.isArray(node.tags) ? node.tags : []
|
|
198
|
+
}))
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (payload.type === 'filter-result') {
|
|
205
|
+
const token = payload.token
|
|
206
|
+
if (token !== state.contentFilter.token) {
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const ids = Array.isArray(payload.ids) ? payload.ids.filter(id => typeof id === 'string') : []
|
|
211
|
+
state.contentFilter.query = normalizeQuery(state.query)
|
|
212
|
+
state.contentFilter.ids = new Set(ids)
|
|
213
|
+
recomputeVisibility()
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
state.filterWorker = worker
|
|
217
|
+
} catch {
|
|
218
|
+
state.filterWorker = null
|
|
219
|
+
state.filterReady = false
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const pushNodesToFilterWorker = () => {
|
|
224
|
+
if (!state.filterWorker || !state.filterReady) {
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
state.filterWorker.postMessage({
|
|
229
|
+
type: 'load-nodes',
|
|
230
|
+
nodes: state.nodes.map(node => ({
|
|
231
|
+
id: node.id,
|
|
232
|
+
title: node.title,
|
|
233
|
+
path: node.path || '',
|
|
234
|
+
tags: Array.isArray(node.tags) ? node.tags : []
|
|
235
|
+
}))
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
80
239
|
const resize = () => {
|
|
81
240
|
const rect = canvas.getBoundingClientRect()
|
|
82
241
|
const width = Math.max(rect.width, 320)
|
|
@@ -85,76 +244,1489 @@ const resize = () => {
|
|
|
85
244
|
canvas.width = Math.floor(width * ratio)
|
|
86
245
|
canvas.height = Math.floor(height * ratio)
|
|
87
246
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
247
|
+
markRenderDirty()
|
|
88
248
|
}
|
|
89
249
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
250
|
+
const normalizeQuery = value => value.trim().toLowerCase()
|
|
251
|
+
const hubNodeRetentionLimit = 2
|
|
252
|
+
const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map|mapa)\b/i
|
|
253
|
+
const memoryHubPathPattern = /\bmemory[-_\s]*hub\b/i
|
|
254
|
+
|
|
255
|
+
const hubNodeScore = node => {
|
|
256
|
+
const title = node.title.trim().toLowerCase()
|
|
257
|
+
if (title === 'memory hub') return 6
|
|
258
|
+
if (title === 'knowledge hub') return 5
|
|
259
|
+
if (memoryHubPathPattern.test(node.path || '')) return 4
|
|
260
|
+
if (node.tags.some(tag => tag.trim().toLowerCase() === 'memory-hub')) return 3
|
|
261
|
+
if (/\bmoc\b/i.test(node.title)) return 2
|
|
262
|
+
return hubNodePattern.test(node.title) || hubNodePattern.test(node.path || '') || node.tags.some(tag => hubNodePattern.test(tag))
|
|
263
|
+
? 1
|
|
264
|
+
: 0
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const localFilteredNodes = query =>
|
|
268
|
+
state.nodes.filter(node =>
|
|
94
269
|
node.title.toLowerCase().includes(query) ||
|
|
95
|
-
node.path.toLowerCase().includes(query) ||
|
|
270
|
+
(node.path || '').toLowerCase().includes(query) ||
|
|
96
271
|
node.tags.some(tag => tag.toLowerCase().includes(query))
|
|
97
272
|
)
|
|
273
|
+
|
|
274
|
+
const rankedHubNodes = () => {
|
|
275
|
+
if (state.nodes.length === 0) {
|
|
276
|
+
return []
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const byTitleAndDegree = [...state.nodes]
|
|
280
|
+
.filter(node => hubNodeScore(node) > 0)
|
|
281
|
+
.sort((left, right) => {
|
|
282
|
+
const byHubScore = hubNodeScore(right) - hubNodeScore(left)
|
|
283
|
+
if (byHubScore !== 0) return byHubScore
|
|
284
|
+
const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
|
|
285
|
+
if (byDegree !== 0) return byDegree
|
|
286
|
+
return left.title.localeCompare(right.title)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
if (byTitleAndDegree.length > 0) {
|
|
290
|
+
return byTitleAndDegree.slice(0, hubNodeRetentionLimit)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return [...state.nodes]
|
|
294
|
+
.sort((left, right) => {
|
|
295
|
+
const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
|
|
296
|
+
if (byDegree !== 0) return byDegree
|
|
297
|
+
return left.title.localeCompare(right.title)
|
|
298
|
+
})
|
|
299
|
+
.slice(0, 1)
|
|
98
300
|
}
|
|
99
301
|
|
|
100
|
-
const
|
|
302
|
+
const withPersistentHubNodes = nodes => {
|
|
303
|
+
if (nodes.length === 0) {
|
|
304
|
+
return rankedHubNodes()
|
|
305
|
+
}
|
|
101
306
|
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
return
|
|
307
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
308
|
+
const hubsToKeep = rankedHubNodes().filter(node => !ids.has(node.id))
|
|
309
|
+
return nodes.concat(hubsToKeep)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const filteredNodes = () => {
|
|
313
|
+
const query = normalizeQuery(state.query)
|
|
314
|
+
if (!query) return state.nodes
|
|
315
|
+
if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
|
|
316
|
+
const matched = state.nodes.filter(node => state.contentFilter.ids.has(node.id))
|
|
317
|
+
return withPersistentHubNodes(matched)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return withPersistentHubNodes(localFilteredNodes(query))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const resolveMacroRepresentative = (nodes) => {
|
|
324
|
+
if (nodes.length === 0) {
|
|
325
|
+
return null
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const hubCandidate = state.primaryHub && nodes.some(node => node.id === state.primaryHub.id)
|
|
329
|
+
? state.primaryHub
|
|
330
|
+
: null
|
|
331
|
+
let best = hubCandidate ?? nodes[0]
|
|
332
|
+
let bestDegree = state.nodeDegrees.get(best.id) ?? 0
|
|
333
|
+
|
|
334
|
+
for (let index = 1; index < nodes.length; index += 1) {
|
|
335
|
+
const node = nodes[index]
|
|
336
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
337
|
+
if (degree > bestDegree) {
|
|
338
|
+
best = node
|
|
339
|
+
bestDegree = degree
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return best
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const nearestHubNeighborDistance = (hub, nodes) => {
|
|
347
|
+
if (!hub || nodes.length <= 1) {
|
|
348
|
+
return Number.POSITIVE_INFINITY
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let minimum = Number.POSITIVE_INFINITY
|
|
352
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
353
|
+
const node = nodes[index]
|
|
354
|
+
if (node.id === hub.id) continue
|
|
355
|
+
const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
|
|
356
|
+
if (distance < minimum) {
|
|
357
|
+
minimum = distance
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return minimum
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const recomputeVisibility = () => {
|
|
365
|
+
const nodes = filteredNodes()
|
|
366
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
367
|
+
const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
368
|
+
const limitedEdges = state.nodes.length > largeGraphNodeThreshold
|
|
369
|
+
? [...edges]
|
|
370
|
+
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
371
|
+
.slice(0, largeGraphEdgeRenderLimit)
|
|
372
|
+
: edges
|
|
373
|
+
|
|
374
|
+
state.visibleNodes = nodes
|
|
375
|
+
state.visibleEdges = limitedEdges
|
|
376
|
+
state.visibleNodeSpatial = createSpatialIndex(nodes)
|
|
377
|
+
state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
|
|
378
|
+
state.overviewClusters = nodes.length > massiveGraphNodeThreshold ? buildOverviewClusters(nodes) : []
|
|
379
|
+
const primaryHub = rankedHubNodes()[0] ?? null
|
|
380
|
+
state.primaryHub = primaryHub
|
|
381
|
+
state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
|
|
382
|
+
const bounds = graphBounds(nodes)
|
|
383
|
+
state.macroCenter = bounds
|
|
384
|
+
? {
|
|
385
|
+
x: primaryHub ? primaryHub.x : (bounds.minX + bounds.maxX) / 2,
|
|
386
|
+
y: primaryHub ? primaryHub.y : (bounds.minY + bounds.maxY) / 2
|
|
387
|
+
}
|
|
388
|
+
: { x: 0, y: 0 }
|
|
389
|
+
state.macroRepresentative = resolveMacroRepresentative(nodes)
|
|
390
|
+
markRenderDirty()
|
|
105
391
|
}
|
|
106
392
|
|
|
107
393
|
const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
|
|
394
|
+
const markRenderDirty = () => {
|
|
395
|
+
state.renderVisibilityDirty = true
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const createSpatialIndex = nodes => {
|
|
399
|
+
if (nodes.length === 0) {
|
|
400
|
+
return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const bounds = graphBounds(nodes)
|
|
404
|
+
if (!bounds) {
|
|
405
|
+
return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const targetNodesPerCell = 18
|
|
409
|
+
const approximateCellArea = Math.max((bounds.width * bounds.height) / Math.max(nodes.length / targetNodesPerCell, 1), 1)
|
|
410
|
+
const cellSize = Math.max(90, Math.min(2200, Math.sqrt(approximateCellArea)))
|
|
411
|
+
const buckets = new Map()
|
|
412
|
+
|
|
413
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
414
|
+
const node = nodes[index]
|
|
415
|
+
const cellX = Math.floor((node.x - bounds.minX) / cellSize)
|
|
416
|
+
const cellY = Math.floor((node.y - bounds.minY) / cellSize)
|
|
417
|
+
const key = cellX + ':' + cellY
|
|
418
|
+
const bucket = buckets.get(key)
|
|
419
|
+
if (bucket) {
|
|
420
|
+
bucket.push(node)
|
|
421
|
+
continue
|
|
422
|
+
}
|
|
423
|
+
buckets.set(key, [node])
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
cellSize,
|
|
428
|
+
minX: bounds.minX,
|
|
429
|
+
minY: bounds.minY,
|
|
430
|
+
maxX: bounds.maxX,
|
|
431
|
+
maxY: bounds.maxY,
|
|
432
|
+
buckets
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const viewportNodesFromSpatialIndex = viewport => {
|
|
437
|
+
if (state.visibleNodes.length <= 2500) {
|
|
438
|
+
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const spatial = state.visibleNodeSpatial
|
|
442
|
+
if (!spatial || spatial.buckets.size === 0) {
|
|
443
|
+
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const minCellX = Math.floor((viewport.minX - spatial.minX) / spatial.cellSize)
|
|
447
|
+
const maxCellX = Math.floor((viewport.maxX - spatial.minX) / spatial.cellSize)
|
|
448
|
+
const minCellY = Math.floor((viewport.minY - spatial.minY) / spatial.cellSize)
|
|
449
|
+
const maxCellY = Math.floor((viewport.maxY - spatial.minY) / spatial.cellSize)
|
|
450
|
+
const nodes = []
|
|
451
|
+
|
|
452
|
+
for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
|
|
453
|
+
for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
|
|
454
|
+
const bucket = spatial.buckets.get(cellX + ':' + cellY)
|
|
455
|
+
if (!bucket) continue
|
|
456
|
+
|
|
457
|
+
for (let index = 0; index < bucket.length; index += 1) {
|
|
458
|
+
const node = bucket[index]
|
|
459
|
+
if (isNodeInViewport(node, viewport)) {
|
|
460
|
+
nodes.push(node)
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return nodes
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const createVisibleEdgeLookup = edges => {
|
|
470
|
+
const lookup = new Map()
|
|
471
|
+
|
|
472
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
473
|
+
const edge = edges[index]
|
|
474
|
+
if (!edge.target) continue
|
|
475
|
+
|
|
476
|
+
const sourceList = lookup.get(edge.source)
|
|
477
|
+
if (sourceList) {
|
|
478
|
+
sourceList.push(edge)
|
|
479
|
+
} else {
|
|
480
|
+
lookup.set(edge.source, [edge])
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const targetList = lookup.get(edge.target)
|
|
484
|
+
if (targetList) {
|
|
485
|
+
targetList.push(edge)
|
|
486
|
+
} else {
|
|
487
|
+
lookup.set(edge.target, [edge])
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return lookup
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const buildOverviewClusters = nodes => {
|
|
495
|
+
if (nodes.length === 0) {
|
|
496
|
+
return []
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const bounds = graphBounds(nodes)
|
|
500
|
+
if (!bounds) {
|
|
501
|
+
return []
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const longest = Math.max(bounds.width, bounds.height, 1)
|
|
505
|
+
const cellSize = Math.max(longest / 56, 900)
|
|
506
|
+
const buckets = new Map()
|
|
507
|
+
|
|
508
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
509
|
+
const node = nodes[index]
|
|
510
|
+
const keyX = Math.floor((node.x - bounds.minX) / cellSize)
|
|
511
|
+
const keyY = Math.floor((node.y - bounds.minY) / cellSize)
|
|
512
|
+
const key = keyX + ':' + keyY
|
|
513
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
514
|
+
const current = buckets.get(key)
|
|
515
|
+
if (current) {
|
|
516
|
+
current.count += 1
|
|
517
|
+
current.sumX += node.x
|
|
518
|
+
current.sumY += node.y
|
|
519
|
+
if (degree > current.degree) {
|
|
520
|
+
current.representative = node
|
|
521
|
+
current.degree = degree
|
|
522
|
+
}
|
|
523
|
+
continue
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
buckets.set(key, {
|
|
527
|
+
id: key,
|
|
528
|
+
count: 1,
|
|
529
|
+
sumX: node.x,
|
|
530
|
+
sumY: node.y,
|
|
531
|
+
representative: node,
|
|
532
|
+
degree
|
|
533
|
+
})
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return Array.from(buckets.values())
|
|
537
|
+
.sort((left, right) => right.count - left.count)
|
|
538
|
+
.slice(0, overviewClusterMaxCount)
|
|
539
|
+
.map((cluster) => ({
|
|
540
|
+
id: cluster.id,
|
|
541
|
+
x: cluster.sumX / Math.max(cluster.count, 1),
|
|
542
|
+
y: cluster.sumY / Math.max(cluster.count, 1),
|
|
543
|
+
count: cluster.count,
|
|
544
|
+
representative: cluster.representative
|
|
545
|
+
}))
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const filterOverviewClustersByViewport = viewport =>
|
|
549
|
+
state.overviewClusters.filter((cluster) =>
|
|
550
|
+
cluster.x >= viewport.minX &&
|
|
551
|
+
cluster.x <= viewport.maxX &&
|
|
552
|
+
cluster.y >= viewport.minY &&
|
|
553
|
+
cluster.y <= viewport.maxY
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
const edgeBudgetForCurrentFrame = () => {
|
|
557
|
+
const zoom = state.transform.scale
|
|
558
|
+
if (zoom < 0.12) return 380
|
|
559
|
+
if (zoom < 0.18) return 900
|
|
560
|
+
if (zoom < 0.28) return 1700
|
|
561
|
+
if (zoom < 0.45) return 2800
|
|
562
|
+
if (zoom < 0.7) return 4200
|
|
563
|
+
if (zoom < 1.05) return 5600
|
|
564
|
+
return 7600
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const clusterBudgetForScale = (scale) => {
|
|
568
|
+
if (scale < 0.008) return 90
|
|
569
|
+
if (scale < 0.014) return 150
|
|
570
|
+
if (scale < 0.022) return 240
|
|
571
|
+
if (scale < 0.035) return 360
|
|
572
|
+
return 520
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const nodeBudgetForScale = (scale) => {
|
|
576
|
+
if (scale < 0.035) return 220
|
|
577
|
+
if (scale < 0.06) return 360
|
|
578
|
+
if (scale < 0.09) return 520
|
|
579
|
+
if (scale < 0.14) return 720
|
|
580
|
+
if (state.visibleNodes.length > massiveGraphNodeThreshold) {
|
|
581
|
+
if (scale < 0.28) return renderNodeBudget
|
|
582
|
+
if (scale < 0.45) return 1100
|
|
583
|
+
if (scale < 0.7) return 1400
|
|
584
|
+
if (scale < 1.05) return 1800
|
|
585
|
+
return zoomedMassiveRenderNodeBudget
|
|
586
|
+
}
|
|
587
|
+
return renderNodeBudget
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const massiveLowZoomNodeBudgetForScale = (scale) => {
|
|
591
|
+
if (scale < 0.004) return 780
|
|
592
|
+
if (scale < 0.01) return 860
|
|
593
|
+
if (scale < 0.02) return 900
|
|
594
|
+
if (scale < 0.035) return 900
|
|
595
|
+
return renderNodeBudget
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const layerFocusForScale = (scale) => {
|
|
599
|
+
const normalized = Math.max(0, Math.min(1, (scale - 0.06) / 0.94))
|
|
600
|
+
const shellCenter = Math.max(0.08, 0.96 - normalized * 0.86)
|
|
601
|
+
const shellWidth = Math.max(0.24, 0.46 - normalized * 0.16)
|
|
602
|
+
const coreRadius = Math.max(0.06, 0.1 + normalized * 0.22)
|
|
603
|
+
const coreRatio = Math.max(0.2, Math.min(0.72, 0.24 + normalized * 0.48))
|
|
604
|
+
|
|
605
|
+
return { shellCenter, shellWidth, coreRadius, coreRatio }
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const selectLayeredNodesForScale = (sourceNodes, targetCount) => {
|
|
609
|
+
const hub = state.primaryHub
|
|
610
|
+
if (!hub || sourceNodes.length <= 1200 || state.visibleNodes.length <= massiveGraphNodeThreshold) {
|
|
611
|
+
return sourceNodes
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
let maxDistance = 0
|
|
615
|
+
const distances = sourceNodes.map((node) => {
|
|
616
|
+
const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
|
|
617
|
+
if (distance > maxDistance) {
|
|
618
|
+
maxDistance = distance
|
|
619
|
+
}
|
|
620
|
+
return { node, distance }
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
if (maxDistance <= 0.001) {
|
|
624
|
+
return sourceNodes
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const focus = layerFocusForScale(state.transform.scale)
|
|
628
|
+
const normalizedRows = distances.map((item) => ({
|
|
629
|
+
...item,
|
|
630
|
+
normalized: item.distance / maxDistance
|
|
631
|
+
}))
|
|
632
|
+
const desired = Math.max(260, Math.min(sourceNodes.length, targetCount * 2))
|
|
633
|
+
const coreTarget = Math.max(36, Math.min(desired - 8, Math.floor(desired * focus.coreRatio)))
|
|
634
|
+
const shellTarget = Math.max(12, desired - coreTarget)
|
|
635
|
+
const shellHalf = focus.shellWidth / 2
|
|
636
|
+
|
|
637
|
+
const coreNodes = normalizedRows
|
|
638
|
+
.filter((item) => item.normalized <= focus.coreRadius)
|
|
639
|
+
.sort((left, right) => {
|
|
640
|
+
const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
|
|
641
|
+
const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
|
|
642
|
+
if (leftScore !== rightScore) return rightScore - leftScore
|
|
643
|
+
return left.node.id.localeCompare(right.node.id)
|
|
644
|
+
})
|
|
645
|
+
.slice(0, coreTarget)
|
|
646
|
+
.map((item) => item.node)
|
|
647
|
+
|
|
648
|
+
const shellNodes = normalizedRows
|
|
649
|
+
.sort((left, right) => {
|
|
650
|
+
const leftDelta = Math.abs(left.normalized - focus.shellCenter)
|
|
651
|
+
const rightDelta = Math.abs(right.normalized - focus.shellCenter)
|
|
652
|
+
const leftInside = leftDelta <= shellHalf ? 0 : 1
|
|
653
|
+
const rightInside = rightDelta <= shellHalf ? 0 : 1
|
|
654
|
+
if (leftInside !== rightInside) return leftInside - rightInside
|
|
655
|
+
if (leftDelta !== rightDelta) return leftDelta - rightDelta
|
|
656
|
+
const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
|
|
657
|
+
const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
|
|
658
|
+
if (leftScore !== rightScore) return rightScore - leftScore
|
|
659
|
+
return left.node.id.localeCompare(right.node.id)
|
|
660
|
+
})
|
|
661
|
+
.slice(0, shellTarget)
|
|
662
|
+
.map((item) => item.node)
|
|
663
|
+
|
|
664
|
+
const merged = []
|
|
665
|
+
const ids = new Set()
|
|
666
|
+
const pushUnique = (node) => {
|
|
667
|
+
if (!node || ids.has(node.id)) return
|
|
668
|
+
ids.add(node.id)
|
|
669
|
+
merged.push(node)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (state.transform.scale >= layeredCoreScaleThreshold) {
|
|
673
|
+
pushUnique(hub)
|
|
674
|
+
}
|
|
675
|
+
for (let index = 0; index < coreNodes.length; index += 1) pushUnique(coreNodes[index])
|
|
676
|
+
for (let index = 0; index < shellNodes.length; index += 1) pushUnique(shellNodes[index])
|
|
677
|
+
|
|
678
|
+
return merged.length > 0 ? merged : sourceNodes
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const viewportCenterWorldPoint = () => {
|
|
682
|
+
const viewport = worldViewportBounds()
|
|
683
|
+
return {
|
|
684
|
+
x: (viewport.minX + viewport.maxX) / 2,
|
|
685
|
+
y: (viewport.minY + viewport.maxY) / 2
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const visibilityScaleBucket = (scale) => {
|
|
690
|
+
const safeScale = Math.max(zoomRange.min, scale)
|
|
691
|
+
if (safeScale < 0.01) return Math.round(safeScale * 300_000)
|
|
692
|
+
if (safeScale < 0.05) return Math.round(safeScale * 120_000)
|
|
693
|
+
if (safeScale < 0.2) return Math.round(safeScale * 40_000)
|
|
694
|
+
return Math.round(safeScale * 8_000)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const shouldRenderMacroGalaxyView = () => {
|
|
698
|
+
if (!galaxyDiscoveryEnabled) {
|
|
699
|
+
state.macroViewActive = false
|
|
700
|
+
return false
|
|
701
|
+
}
|
|
702
|
+
if (state.visibleNodes.length <= 1) {
|
|
703
|
+
state.macroViewActive = false
|
|
704
|
+
return false
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const enterThreshold = macroGalaxyZoomThreshold * macroGalaxyEnterHysteresis
|
|
708
|
+
const exitThreshold = macroGalaxyZoomThreshold * macroGalaxyExitHysteresis
|
|
709
|
+
const shouldRender = state.macroViewActive
|
|
710
|
+
? state.transform.scale <= exitThreshold
|
|
711
|
+
: state.transform.scale <= enterThreshold
|
|
712
|
+
state.macroViewActive = shouldRender
|
|
713
|
+
return shouldRender
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const mergeUniqueNodes = (leftNodes, rightNodes, limit) => {
|
|
717
|
+
const merged = []
|
|
718
|
+
const ids = new Set()
|
|
719
|
+
|
|
720
|
+
const push = (node) => {
|
|
721
|
+
if (!node || ids.has(node.id) || merged.length >= limit) {
|
|
722
|
+
return
|
|
723
|
+
}
|
|
724
|
+
ids.add(node.id)
|
|
725
|
+
merged.push(node)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
for (let index = 0; index < leftNodes.length && merged.length < limit; index += 1) {
|
|
729
|
+
push(leftNodes[index])
|
|
730
|
+
}
|
|
731
|
+
for (let index = 0; index < rightNodes.length && merged.length < limit; index += 1) {
|
|
732
|
+
push(rightNodes[index])
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return merged
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const selectStableSampleNodes = (sourceNodes, limit) => {
|
|
739
|
+
if (sourceNodes.length <= limit) {
|
|
740
|
+
return sourceNodes
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const now = performance.now()
|
|
744
|
+
const recentZoomFocus =
|
|
745
|
+
now - state.lastZoomFocus.at <= 1500
|
|
746
|
+
? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
|
|
747
|
+
: null
|
|
748
|
+
const anchor = recentZoomFocus ?? viewportCenterWorldPoint()
|
|
749
|
+
const previousIds = new Set(state.renderNodes.map((node) => node.id))
|
|
750
|
+
const preferAnchorDistance = state.visibleNodes.length > massiveGraphNodeThreshold && state.transform.scale >= 0.28
|
|
751
|
+
|
|
752
|
+
return [...sourceNodes]
|
|
753
|
+
.sort((left, right) => {
|
|
754
|
+
const leftWasVisible = previousIds.has(left.id) ? 1 : 0
|
|
755
|
+
const rightWasVisible = previousIds.has(right.id) ? 1 : 0
|
|
756
|
+
const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
|
|
757
|
+
const rightDistance = Math.hypot(right.x - anchor.x, right.y - anchor.y)
|
|
758
|
+
|
|
759
|
+
if (preferAnchorDistance) {
|
|
760
|
+
if (leftDistance !== rightDistance) return leftDistance - rightDistance
|
|
761
|
+
if (leftWasVisible !== rightWasVisible) return rightWasVisible - leftWasVisible
|
|
762
|
+
} else {
|
|
763
|
+
if (leftWasVisible !== rightWasVisible) return rightWasVisible - leftWasVisible
|
|
764
|
+
if (leftDistance !== rightDistance) return leftDistance - rightDistance
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const leftDegree = state.nodeDegrees.get(left.id) ?? 0
|
|
768
|
+
const rightDegree = state.nodeDegrees.get(right.id) ?? 0
|
|
769
|
+
if (leftDegree !== rightDegree) return rightDegree - leftDegree
|
|
770
|
+
|
|
771
|
+
return left.id.localeCompare(right.id)
|
|
772
|
+
})
|
|
773
|
+
.slice(0, limit)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const selectAccessBridgeNodes = (sourceNodes, limit) => {
|
|
777
|
+
if (limit <= 0 || sourceNodes.length === 0) {
|
|
778
|
+
return []
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const now = performance.now()
|
|
782
|
+
const recentZoomFocus =
|
|
783
|
+
now - state.lastZoomFocus.at <= 1200
|
|
784
|
+
? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
|
|
785
|
+
: null
|
|
786
|
+
const anchor = recentZoomFocus ?? viewportCenterWorldPoint()
|
|
787
|
+
return [...sourceNodes]
|
|
788
|
+
.sort((left, right) => {
|
|
789
|
+
const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
|
|
790
|
+
const rightDistance = Math.hypot(right.x - anchor.x, right.y - anchor.y)
|
|
791
|
+
if (leftDistance !== rightDistance) return leftDistance - rightDistance
|
|
792
|
+
const leftDegree = state.nodeDegrees.get(left.id) ?? 0
|
|
793
|
+
const rightDegree = state.nodeDegrees.get(right.id) ?? 0
|
|
794
|
+
if (leftDegree !== rightDegree) return rightDegree - leftDegree
|
|
795
|
+
return left.id.localeCompare(right.id)
|
|
796
|
+
})
|
|
797
|
+
.slice(0, limit)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const edgeIdentityKey = edge => {
|
|
801
|
+
if (!edge.target) return ''
|
|
802
|
+
const pair = edge.source < edge.target
|
|
803
|
+
? edge.source + '|' + edge.target
|
|
804
|
+
: edge.target + '|' + edge.source
|
|
805
|
+
return pair + '|' + (edge.inferred ? 'mesh' : 'real')
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const edgeRelevanceScore = edge => {
|
|
809
|
+
let score = edgeWeight(edge) * 10
|
|
810
|
+
if (!edge.inferred) {
|
|
811
|
+
score += 8
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const selectedId = state.selected?.id
|
|
815
|
+
if (selectedId && (edge.source === selectedId || edge.target === selectedId)) {
|
|
816
|
+
score += 120
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const hoveredId = state.hovered?.id
|
|
820
|
+
if (hoveredId && (edge.source === hoveredId || edge.target === hoveredId)) {
|
|
821
|
+
score += 70
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const hubId = state.primaryHub?.id
|
|
825
|
+
if (hubId && (edge.source === hubId || edge.target === hubId)) {
|
|
826
|
+
score += 42
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return score
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const collectVisibleEdgesForNodes = nodeIds => {
|
|
833
|
+
if (nodeIds.size === 0) {
|
|
834
|
+
return []
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const seen = new Set()
|
|
838
|
+
const candidates = []
|
|
839
|
+
const limit = edgeBudgetForCurrentFrame()
|
|
840
|
+
|
|
841
|
+
nodeIds.forEach(nodeId => {
|
|
842
|
+
const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
843
|
+
for (let index = 0; index < candidateEdges.length; index += 1) {
|
|
844
|
+
const edge = candidateEdges[index]
|
|
845
|
+
if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
|
846
|
+
continue
|
|
847
|
+
}
|
|
848
|
+
const key = edgeIdentityKey(edge)
|
|
849
|
+
if (seen.has(key)) continue
|
|
850
|
+
|
|
851
|
+
seen.add(key)
|
|
852
|
+
candidates.push(edge)
|
|
853
|
+
}
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
if (candidates.length <= limit) {
|
|
857
|
+
return candidates
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return candidates
|
|
861
|
+
.sort((left, right) => {
|
|
862
|
+
const scoreDelta = edgeRelevanceScore(right) - edgeRelevanceScore(left)
|
|
863
|
+
if (scoreDelta !== 0) {
|
|
864
|
+
return scoreDelta
|
|
865
|
+
}
|
|
866
|
+
const leftKey = edgeIdentityKey(left)
|
|
867
|
+
const rightKey = edgeIdentityKey(right)
|
|
868
|
+
return leftKey.localeCompare(rightKey)
|
|
869
|
+
})
|
|
870
|
+
.slice(0, limit)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const edgeOpacityForScale = (edge, scale) => {
|
|
874
|
+
if (edge.inferred) {
|
|
875
|
+
if (scale < 0.2) return 0.06
|
|
876
|
+
if (scale < 0.4) return 0.08
|
|
877
|
+
if (scale < 0.7) return 0.1
|
|
878
|
+
return 0.14
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (scale < 0.2) return 0.14
|
|
882
|
+
if (scale < 0.4) return 0.2
|
|
883
|
+
if (scale < 0.7) return 0.28
|
|
884
|
+
if (scale < 1.05) return 0.36
|
|
885
|
+
return 0.46
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const edgeStrokeFor = (edge, selectedEdge) => {
|
|
889
|
+
if (selectedEdge) {
|
|
890
|
+
return graphTheme.edgeActive
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const opacity = edgeOpacityForScale(edge, state.transform.scale)
|
|
894
|
+
return edge.inferred
|
|
895
|
+
? 'rgba(203, 213, 225, ' + opacity + ')'
|
|
896
|
+
: 'rgba(153, 165, 181, ' + opacity + ')'
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const edgeWidthFor = (edge, selectedEdge) => {
|
|
900
|
+
if (edge.inferred) {
|
|
901
|
+
return selectedEdge ? 1.22 : 0.84
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
return (selectedEdge ? 1.9 : 1.05) + Math.min(edgeWeight(edge) - 1, 8) * 0.24
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const drawGraphEdge = (edge) => {
|
|
908
|
+
const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
909
|
+
ctx.beginPath()
|
|
910
|
+
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
|
|
911
|
+
ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
|
|
912
|
+
ctx.strokeStyle = edgeStrokeFor(edge, selectedEdge)
|
|
913
|
+
ctx.lineWidth = edgeWidthFor(edge, selectedEdge)
|
|
914
|
+
ctx.stroke()
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const drawEdgeBatch = (edges, options) => {
|
|
918
|
+
if (edges.length === 0) {
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
ctx.beginPath()
|
|
923
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
924
|
+
const edge = edges[index]
|
|
925
|
+
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
|
|
926
|
+
ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
|
|
927
|
+
}
|
|
928
|
+
ctx.strokeStyle = options.strokeStyle
|
|
929
|
+
ctx.lineWidth = options.lineWidth
|
|
930
|
+
ctx.stroke()
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const drawGraphEdges = () => {
|
|
934
|
+
if (state.nodes.length > largeGraphNodeThreshold) {
|
|
935
|
+
const regularEdges = []
|
|
936
|
+
const inferredEdges = []
|
|
937
|
+
const selectedEdges = []
|
|
938
|
+
|
|
939
|
+
for (let index = 0; index < state.renderEdges.length; index += 1) {
|
|
940
|
+
const edge = state.renderEdges[index]
|
|
941
|
+
const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
942
|
+
if (isSelected) {
|
|
943
|
+
selectedEdges.push(edge)
|
|
944
|
+
} else if (edge.inferred) {
|
|
945
|
+
inferredEdges.push(edge)
|
|
946
|
+
} else {
|
|
947
|
+
regularEdges.push(edge)
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const scale = state.transform.scale
|
|
952
|
+
const regularOpacity = edgeOpacityForScale({ inferred: false }, scale)
|
|
953
|
+
const inferredOpacity = edgeOpacityForScale({ inferred: true }, scale)
|
|
954
|
+
drawEdgeBatch(regularEdges, {
|
|
955
|
+
strokeStyle: 'rgba(153, 165, 181, ' + regularOpacity + ')',
|
|
956
|
+
lineWidth: 1.05
|
|
957
|
+
})
|
|
958
|
+
drawEdgeBatch(inferredEdges, {
|
|
959
|
+
strokeStyle: 'rgba(203, 213, 225, ' + inferredOpacity + ')',
|
|
960
|
+
lineWidth: 0.84
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
for (let index = 0; index < selectedEdges.length; index += 1) {
|
|
964
|
+
drawGraphEdge(selectedEdges[index])
|
|
965
|
+
}
|
|
966
|
+
return
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const selectedEdges = []
|
|
970
|
+
const regularEdges = []
|
|
971
|
+
for (let index = 0; index < state.renderEdges.length; index += 1) {
|
|
972
|
+
const edge = state.renderEdges[index]
|
|
973
|
+
const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
974
|
+
if (isSelected) {
|
|
975
|
+
selectedEdges.push(edge)
|
|
976
|
+
} else {
|
|
977
|
+
regularEdges.push(edge)
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
for (let index = 0; index < regularEdges.length; index += 1) {
|
|
982
|
+
drawGraphEdge(regularEdges[index])
|
|
983
|
+
}
|
|
984
|
+
for (let index = 0; index < selectedEdges.length; index += 1) {
|
|
985
|
+
drawGraphEdge(selectedEdges[index])
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const shouldDrawNodeLabels = (node, isSelected, isHovered) =>
|
|
990
|
+
isSelected ||
|
|
991
|
+
isHovered ||
|
|
992
|
+
(state.nodes.length > largeGraphNodeThreshold && state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) ||
|
|
993
|
+
(state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
|
|
994
|
+
|
|
995
|
+
const drawSingleNode = (node, options = { drawLabel: true }) => {
|
|
996
|
+
const radius = nodeRadius(node)
|
|
997
|
+
const isSelected = state.selected?.id === node.id
|
|
998
|
+
const isHovered = state.hovered?.id === node.id
|
|
999
|
+
ctx.beginPath()
|
|
1000
|
+
ctx.arc(node.x, node.y, radius + (isSelected ? 7 : isHovered ? 4 : 0), 0, Math.PI * 2)
|
|
1001
|
+
ctx.fillStyle = isSelected || isHovered ? graphTheme.nodeHaloActive : graphTheme.nodeHalo
|
|
1002
|
+
ctx.fill()
|
|
1003
|
+
ctx.beginPath()
|
|
1004
|
+
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
|
|
1005
|
+
ctx.fillStyle = isSelected ? graphTheme.nodeSelected : isHovered ? graphTheme.nodeHover : graphTheme.node
|
|
1006
|
+
ctx.fill()
|
|
1007
|
+
ctx.lineWidth = isSelected ? 2.6 : 1.5
|
|
1008
|
+
ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
|
|
1009
|
+
ctx.stroke()
|
|
1010
|
+
|
|
1011
|
+
if (options.drawLabel && shouldDrawNodeLabels(node, isSelected, isHovered)) {
|
|
1012
|
+
ctx.fillStyle = graphTheme.label
|
|
1013
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
1014
|
+
ctx.textAlign = 'center'
|
|
1015
|
+
ctx.textBaseline = 'top'
|
|
1016
|
+
ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const drawNodeBatch = (nodes) => {
|
|
1021
|
+
if (nodes.length === 0) {
|
|
1022
|
+
return
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const drawHalos = state.renderNodes.length <= 1200 || state.transform.scale >= 0.45
|
|
1026
|
+
if (drawHalos) {
|
|
1027
|
+
ctx.beginPath()
|
|
1028
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
1029
|
+
const node = nodes[index]
|
|
1030
|
+
ctx.moveTo(node.x + nodeRadius(node) + 3, node.y)
|
|
1031
|
+
ctx.arc(node.x, node.y, nodeRadius(node) + 3, 0, Math.PI * 2)
|
|
1032
|
+
}
|
|
1033
|
+
ctx.fillStyle = graphTheme.nodeHalo
|
|
1034
|
+
ctx.fill()
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
ctx.beginPath()
|
|
1038
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
1039
|
+
const node = nodes[index]
|
|
1040
|
+
const radius = nodeRadius(node)
|
|
1041
|
+
ctx.moveTo(node.x + radius, node.y)
|
|
1042
|
+
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
|
|
1043
|
+
}
|
|
1044
|
+
ctx.fillStyle = graphTheme.node
|
|
1045
|
+
ctx.fill()
|
|
1046
|
+
ctx.lineWidth = 1.25
|
|
1047
|
+
ctx.strokeStyle = graphTheme.nodeStroke
|
|
1048
|
+
ctx.stroke()
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const drawGraphNodes = () => {
|
|
1052
|
+
if (state.nodes.length <= largeGraphNodeThreshold) {
|
|
1053
|
+
state.renderNodes.forEach(node => drawSingleNode(node))
|
|
1054
|
+
return
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const regularNodes = []
|
|
1058
|
+
const priorityNodes = []
|
|
1059
|
+
|
|
1060
|
+
for (let index = 0; index < state.renderNodes.length; index += 1) {
|
|
1061
|
+
const node = state.renderNodes[index]
|
|
1062
|
+
const isPriority =
|
|
1063
|
+
state.selected?.id === node.id ||
|
|
1064
|
+
state.hovered?.id === node.id
|
|
1065
|
+
if (isPriority) {
|
|
1066
|
+
priorityNodes.push(node)
|
|
1067
|
+
} else {
|
|
1068
|
+
regularNodes.push(node)
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
drawNodeBatch(regularNodes)
|
|
1073
|
+
|
|
1074
|
+
if (state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) {
|
|
1075
|
+
ctx.fillStyle = graphTheme.label
|
|
1076
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
1077
|
+
ctx.textAlign = 'center'
|
|
1078
|
+
ctx.textBaseline = 'top'
|
|
1079
|
+
for (let index = 0; index < regularNodes.length; index += 1) {
|
|
1080
|
+
const node = regularNodes[index]
|
|
1081
|
+
ctx.fillText(node.title.slice(0, 34), node.x, node.y + nodeRadius(node) + 8)
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
priorityNodes.forEach(node => drawSingleNode(node))
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const edgePairKey = (source, target) =>
|
|
1089
|
+
source < target ? source + '|' + target : target + '|' + source
|
|
1090
|
+
|
|
1091
|
+
const meshNeighborBuckets = (nodes, cellSize) => {
|
|
1092
|
+
const buckets = new Map()
|
|
1093
|
+
|
|
1094
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
1095
|
+
const node = nodes[index]
|
|
1096
|
+
const cellX = Math.floor(node.x / cellSize)
|
|
1097
|
+
const cellY = Math.floor(node.y / cellSize)
|
|
1098
|
+
const key = cellX + ':' + cellY
|
|
1099
|
+
const bucket = buckets.get(key)
|
|
1100
|
+
if (bucket) {
|
|
1101
|
+
bucket.push(node)
|
|
1102
|
+
} else {
|
|
1103
|
+
buckets.set(key, [node])
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
return buckets
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const meshCandidatesForNode = (node, buckets, cellSize) => {
|
|
1111
|
+
const cellX = Math.floor(node.x / cellSize)
|
|
1112
|
+
const cellY = Math.floor(node.y / cellSize)
|
|
1113
|
+
const candidates = []
|
|
1114
|
+
|
|
1115
|
+
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
|
1116
|
+
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
|
1117
|
+
const bucket = buckets.get((cellX + offsetX) + ':' + (cellY + offsetY))
|
|
1118
|
+
if (!bucket) continue
|
|
1119
|
+
for (let index = 0; index < bucket.length; index += 1) {
|
|
1120
|
+
const candidate = bucket[index]
|
|
1121
|
+
if (candidate.id !== node.id) {
|
|
1122
|
+
candidates.push(candidate)
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
return candidates
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const buildMeshEdgesForNodes = (nodes, existingEdges) => {
|
|
1132
|
+
if (nodes.length < 2 || state.transform.scale < meshEdgeScaleThreshold) {
|
|
1133
|
+
return []
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const existingKeys = new Set()
|
|
1137
|
+
for (let index = 0; index < existingEdges.length; index += 1) {
|
|
1138
|
+
const edge = existingEdges[index]
|
|
1139
|
+
if (edge.target) {
|
|
1140
|
+
existingKeys.add(edgePairKey(edge.source, edge.target))
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const desiredBudget = Math.min(
|
|
1145
|
+
meshEdgeMaxBudget,
|
|
1146
|
+
Math.max(meshEdgeMinBudget, Math.floor(edgeBudgetForCurrentFrame() * 0.62))
|
|
1147
|
+
)
|
|
1148
|
+
const perNodeNeighborCount =
|
|
1149
|
+
state.transform.scale >= 1.05 ? 4
|
|
1150
|
+
: state.transform.scale >= 0.62 ? 3
|
|
1151
|
+
: 2
|
|
1152
|
+
const cellSize = Math.max(120, 280 / Math.max(state.transform.scale, 0.0001))
|
|
1153
|
+
const maxDistance = 980
|
|
1154
|
+
const maxDistanceSquared = maxDistance * maxDistance
|
|
1155
|
+
const buckets = meshNeighborBuckets(nodes, cellSize)
|
|
1156
|
+
const meshEdges = []
|
|
1157
|
+
const meshKeys = new Set()
|
|
1158
|
+
|
|
1159
|
+
for (let index = 0; index < nodes.length && meshEdges.length < desiredBudget; index += 1) {
|
|
1160
|
+
const node = nodes[index]
|
|
1161
|
+
const candidates = meshCandidatesForNode(node, buckets, cellSize)
|
|
1162
|
+
.map((candidate) => ({
|
|
1163
|
+
node: candidate,
|
|
1164
|
+
distanceSquared: (candidate.x - node.x) ** 2 + (candidate.y - node.y) ** 2
|
|
1165
|
+
}))
|
|
1166
|
+
.filter((candidate) => candidate.distanceSquared <= maxDistanceSquared)
|
|
1167
|
+
.sort((left, right) => left.distanceSquared - right.distanceSquared)
|
|
1168
|
+
|
|
1169
|
+
let linked = 0
|
|
1170
|
+
for (let candidateIndex = 0; candidateIndex < candidates.length && linked < perNodeNeighborCount && meshEdges.length < desiredBudget; candidateIndex += 1) {
|
|
1171
|
+
const candidate = candidates[candidateIndex].node
|
|
1172
|
+
const key = edgePairKey(node.id, candidate.id)
|
|
1173
|
+
if (existingKeys.has(key) || meshKeys.has(key)) {
|
|
1174
|
+
continue
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
meshKeys.add(key)
|
|
1178
|
+
meshEdges.push({
|
|
1179
|
+
source: node.id,
|
|
1180
|
+
target: candidate.id,
|
|
1181
|
+
targetTitle: candidate.title,
|
|
1182
|
+
weight: 1,
|
|
1183
|
+
priority: 'normal',
|
|
1184
|
+
sourceNode: node,
|
|
1185
|
+
targetNode: candidate,
|
|
1186
|
+
inferred: true
|
|
1187
|
+
})
|
|
1188
|
+
linked += 1
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
return meshEdges
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const withMeshEdges = (nodes, edges) => {
|
|
1196
|
+
if (nodes.length === 0 || state.visibleNodes.length <= largeGraphNodeThreshold || state.transform.scale < meshEdgeScaleThreshold) {
|
|
1197
|
+
return edges
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const meshEdges = buildMeshEdgesForNodes(nodes, edges)
|
|
1201
|
+
return meshEdges.length > 0 ? edges.concat(meshEdges) : edges
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const fallbackViewportNodes = () => {
|
|
1205
|
+
const nodes = []
|
|
1206
|
+
const maxNodes = Math.min(renderNodeBudget, 220)
|
|
1207
|
+
const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
|
|
1208
|
+
|
|
1209
|
+
for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
|
|
1210
|
+
nodes.push(state.visibleNodes[index])
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
1214
|
+
nodes.push(state.selected)
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
return nodes
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
const sampleVisibleNodes = (limit = renderNodeBudget, sourceNodes = state.visibleNodes) => {
|
|
1221
|
+
if (sourceNodes.length === 0 || limit <= 0) {
|
|
1222
|
+
return []
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const nodes = []
|
|
1226
|
+
const maxNodes = Math.min(Math.max(limit, 1), sourceNodes.length)
|
|
1227
|
+
const step = Math.max(1, Math.ceil(sourceNodes.length / maxNodes))
|
|
1228
|
+
|
|
1229
|
+
for (let index = 0; index < sourceNodes.length && nodes.length < maxNodes; index += step) {
|
|
1230
|
+
nodes.push(sourceNodes[index])
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
1234
|
+
nodes.push(state.selected)
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
return nodes
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const enrichSampleWithNeighbors = (nodes) => {
|
|
1241
|
+
if (nodes.length === 0) {
|
|
1242
|
+
return {
|
|
1243
|
+
nodes,
|
|
1244
|
+
edges: []
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const maxNodes = Math.min(renderNodeBudget, nodes.length + 200)
|
|
1249
|
+
const expanded = [...nodes]
|
|
1250
|
+
const ids = new Set(expanded.map((node) => node.id))
|
|
1251
|
+
|
|
1252
|
+
for (let index = 0; index < nodes.length && expanded.length < maxNodes; index += 1) {
|
|
1253
|
+
const node = nodes[index]
|
|
1254
|
+
const candidates = [...(state.visibleEdgeByNode.get(node.id) ?? [])]
|
|
1255
|
+
.filter((edge) => edge.target)
|
|
1256
|
+
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
1257
|
+
.slice(0, 3)
|
|
1258
|
+
|
|
1259
|
+
for (let candidateIndex = 0; candidateIndex < candidates.length && expanded.length < maxNodes; candidateIndex += 1) {
|
|
1260
|
+
const edge = candidates[candidateIndex]
|
|
1261
|
+
const otherId = edge.source === node.id ? edge.target : edge.source
|
|
1262
|
+
|
|
1263
|
+
if (!otherId || ids.has(otherId)) {
|
|
1264
|
+
continue
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
const otherNode = state.nodeById.get(otherId)
|
|
1268
|
+
if (!otherNode) {
|
|
1269
|
+
continue
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
ids.add(otherId)
|
|
1273
|
+
expanded.push(otherNode)
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const edges = collectVisibleEdgesForNodes(ids)
|
|
1278
|
+
|
|
1279
|
+
return {
|
|
1280
|
+
nodes: expanded,
|
|
1281
|
+
edges
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const includeHubPreviewNeighborhood = (nodes, limit) => {
|
|
1286
|
+
const hub = state.primaryHub
|
|
1287
|
+
if (!hub) {
|
|
1288
|
+
return nodes
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const maxNodes = Math.max(1, Math.min(renderNodeBudget, limit))
|
|
1292
|
+
const merged = [...nodes]
|
|
1293
|
+
const ids = new Set(merged.map((node) => node.id))
|
|
1294
|
+
const protectedIds = new Set()
|
|
1295
|
+
|
|
1296
|
+
if (!ids.has(hub.id)) {
|
|
1297
|
+
if (merged.length < maxNodes) {
|
|
1298
|
+
merged.push(hub)
|
|
1299
|
+
ids.add(hub.id)
|
|
1300
|
+
} else {
|
|
1301
|
+
const replaceIndex = merged.findIndex((node) => node.id !== hub.id)
|
|
1302
|
+
if (replaceIndex >= 0) {
|
|
1303
|
+
ids.delete(merged[replaceIndex].id)
|
|
1304
|
+
merged[replaceIndex] = hub
|
|
1305
|
+
ids.add(hub.id)
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
protectedIds.add(hub.id)
|
|
1310
|
+
|
|
1311
|
+
const hubEdges = [...(state.visibleEdgeByNode.get(hub.id) ?? [])]
|
|
1312
|
+
.filter((edge) => edge.target && (edge.source === hub.id || edge.target === hub.id))
|
|
1313
|
+
.sort((left, right) => {
|
|
1314
|
+
const byWeight = edgeWeight(right) - edgeWeight(left)
|
|
1315
|
+
if (byWeight !== 0) return byWeight
|
|
1316
|
+
|
|
1317
|
+
const leftOtherId = left.source === hub.id ? left.target : left.source
|
|
1318
|
+
const rightOtherId = right.source === hub.id ? right.target : right.source
|
|
1319
|
+
const leftDegree = state.nodeDegrees.get(leftOtherId ?? '') ?? 0
|
|
1320
|
+
const rightDegree = state.nodeDegrees.get(rightOtherId ?? '') ?? 0
|
|
1321
|
+
if (leftDegree !== rightDegree) return rightDegree - leftDegree
|
|
1322
|
+
|
|
1323
|
+
return edgeIdentityKey(left).localeCompare(edgeIdentityKey(right))
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
for (let index = 0; index < hubEdges.length && merged.length < maxNodes; index += 1) {
|
|
1327
|
+
const edge = hubEdges[index]
|
|
1328
|
+
const otherId = edge.source === hub.id ? edge.target : edge.source
|
|
1329
|
+
if (!otherId || ids.has(otherId)) {
|
|
1330
|
+
continue
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
const otherNode = state.nodeById.get(otherId)
|
|
1334
|
+
if (!otherNode) {
|
|
1335
|
+
continue
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
if (merged.length < maxNodes) {
|
|
1339
|
+
ids.add(otherId)
|
|
1340
|
+
merged.push(otherNode)
|
|
1341
|
+
protectedIds.add(otherId)
|
|
1342
|
+
continue
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const replaceIndex = (() => {
|
|
1346
|
+
for (let cursor = merged.length - 1; cursor >= 0; cursor -= 1) {
|
|
1347
|
+
const candidateId = merged[cursor]?.id
|
|
1348
|
+
if (candidateId && !protectedIds.has(candidateId)) {
|
|
1349
|
+
return cursor
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
return -1
|
|
1353
|
+
})()
|
|
1354
|
+
if (replaceIndex >= 0) {
|
|
1355
|
+
const replacedId = merged[replaceIndex]?.id
|
|
1356
|
+
if (replacedId) {
|
|
1357
|
+
ids.delete(replacedId)
|
|
1358
|
+
}
|
|
1359
|
+
merged[replaceIndex] = otherNode
|
|
1360
|
+
ids.add(otherId)
|
|
1361
|
+
protectedIds.add(otherId)
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
return merged
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const ensureHubNodesInRenderedSet = (nodes) => {
|
|
1369
|
+
if (nodes.length === 0) {
|
|
1370
|
+
return nodes
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const maxNodes = Math.max(Math.min(renderNodeBudget, nodes.length), 1)
|
|
1374
|
+
const ids = new Set(nodes.map((node) => node.id))
|
|
1375
|
+
const hubs = rankedHubNodes()
|
|
1376
|
+
const merged = [...nodes]
|
|
1377
|
+
|
|
1378
|
+
for (let index = 0; index < hubs.length; index += 1) {
|
|
1379
|
+
const hub = hubs[index]
|
|
1380
|
+
if (ids.has(hub.id)) {
|
|
1381
|
+
continue
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
if (merged.length < maxNodes) {
|
|
1385
|
+
merged.push(hub)
|
|
1386
|
+
ids.add(hub.id)
|
|
1387
|
+
continue
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
const replacementIndex = merged.findIndex((node) => !hubs.some((candidate) => candidate.id === node.id))
|
|
1391
|
+
if (replacementIndex >= 0) {
|
|
1392
|
+
ids.delete(merged[replacementIndex].id)
|
|
1393
|
+
merged[replacementIndex] = hub
|
|
1394
|
+
ids.add(hub.id)
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
return merged
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const zoomCapByNodeCount = (nodeCount) => {
|
|
1402
|
+
if (nodeCount > 50000) return 2.6
|
|
1403
|
+
if (nodeCount > 20000) return 2.35
|
|
1404
|
+
if (nodeCount > 6000) return 2.1
|
|
1405
|
+
if (nodeCount > 2000) return 2.2
|
|
1406
|
+
return zoomRange.max
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const zoomCapByHubDistance = (distance) => {
|
|
1410
|
+
if (!Number.isFinite(distance) || distance <= 0) {
|
|
1411
|
+
return zoomRange.max
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const rect = canvas.getBoundingClientRect()
|
|
1415
|
+
const viewportWidth = Math.max(rect.width, 320)
|
|
1416
|
+
const viewportHeight = Math.max(rect.height, 320)
|
|
1417
|
+
const reference = Math.max(220, Math.min(viewportWidth, viewportHeight) * zoomCapTargetViewportShare)
|
|
1418
|
+
return Math.max(0.3, Math.min(zoomRange.max, reference / distance))
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
const currentZoomMax = () => {
|
|
1422
|
+
const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
|
|
1423
|
+
const hubDistanceCap = zoomCapByHubDistance(state.hubNeighborDistance)
|
|
1424
|
+
const minimumUsefulCap = nodeCount > massiveGraphNodeThreshold ? 1.9 : nodeCount > largeGraphNodeThreshold ? 1.35 : 0.8
|
|
1425
|
+
const capped = Math.min(zoomCapByNodeCount(nodeCount), Math.max(minimumUsefulCap, hubDistanceCap))
|
|
1426
|
+
return Math.max(zoomRange.min * 2, capped)
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const clampScale = value => Math.max(zoomRange.min, Math.min(currentZoomMax(), value))
|
|
1430
|
+
const isFiniteNumber = value => Number.isFinite(value)
|
|
1431
|
+
const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
|
|
1432
|
+
const clampTransformCoordinate = value => {
|
|
1433
|
+
if (!isFiniteNumber(value)) return 0
|
|
1434
|
+
if (value > transformCoordinateLimit) return transformCoordinateLimit
|
|
1435
|
+
if (value < -transformCoordinateLimit) return -transformCoordinateLimit
|
|
1436
|
+
return value
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
const graphBounds = nodes => {
|
|
1440
|
+
if (nodes.length === 0) return null
|
|
1441
|
+
let minX = Number.POSITIVE_INFINITY
|
|
1442
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
1443
|
+
let minY = Number.POSITIVE_INFINITY
|
|
1444
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
1445
|
+
|
|
1446
|
+
nodes.forEach(node => {
|
|
1447
|
+
const radius = baseNodeRadius(node)
|
|
1448
|
+
minX = Math.min(minX, node.x - radius)
|
|
1449
|
+
maxX = Math.max(maxX, node.x + radius)
|
|
1450
|
+
minY = Math.min(minY, node.y - radius)
|
|
1451
|
+
maxY = Math.max(maxY, node.y + radius)
|
|
1452
|
+
})
|
|
1453
|
+
|
|
1454
|
+
return {
|
|
1455
|
+
minX,
|
|
1456
|
+
maxX,
|
|
1457
|
+
minY,
|
|
1458
|
+
maxY,
|
|
1459
|
+
width: Math.max(maxX - minX, 1),
|
|
1460
|
+
height: Math.max(maxY - minY, 1)
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const fitScaleBiasByNodeCount = nodeCount => {
|
|
1465
|
+
if (nodeCount <= 6) return 1.22
|
|
1466
|
+
if (nodeCount <= 20) return 1.12
|
|
1467
|
+
if (nodeCount <= 60) return 1.04
|
|
1468
|
+
if (nodeCount <= 180) return 1
|
|
1469
|
+
if (nodeCount <= 600) return 0.94
|
|
1470
|
+
if (nodeCount <= 2000) return 0.82
|
|
1471
|
+
if (nodeCount <= 6000) return 0.68
|
|
1472
|
+
return 0.56
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
const autoFitScaleRangeByNodeCount = nodeCount => {
|
|
1476
|
+
if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
|
|
1477
|
+
if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
|
|
1478
|
+
if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
|
|
1479
|
+
if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
|
|
1480
|
+
if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
|
|
1481
|
+
if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
|
|
1482
|
+
if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
|
|
1483
|
+
return { min: 0.0012, max: 0.24 }
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
|
|
1487
|
+
const rect = canvas.getBoundingClientRect()
|
|
1488
|
+
const width = Math.max(rect.width, 320)
|
|
1489
|
+
const height = Math.max(rect.height, 320)
|
|
1490
|
+
const nodes = options.useFiltered ? filteredNodes() : state.nodes
|
|
1491
|
+
const bounds = graphBounds(nodes)
|
|
1492
|
+
|
|
1493
|
+
if (!bounds) {
|
|
1494
|
+
state.transform = { x: width / 2, y: height / 2, scale: 1 }
|
|
1495
|
+
state.offscreenFrameCount = 0
|
|
1496
|
+
state.recoveringViewport = false
|
|
1497
|
+
markRenderDirty()
|
|
1498
|
+
return
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
const paddingByNodeCount = nodeCount => {
|
|
1502
|
+
if (nodeCount <= 6) return 28
|
|
1503
|
+
if (nodeCount <= 20) return 44
|
|
1504
|
+
if (nodeCount <= 60) return 68
|
|
1505
|
+
if (nodeCount <= 180) return 86
|
|
1506
|
+
if (nodeCount <= 600) return 110
|
|
1507
|
+
if (nodeCount <= 2000) return 140
|
|
1508
|
+
return 180
|
|
1509
|
+
}
|
|
1510
|
+
const padding = paddingByNodeCount(nodes.length)
|
|
1511
|
+
const scaleX = width / (bounds.width + padding * 2)
|
|
1512
|
+
const scaleY = height / (bounds.height + padding * 2)
|
|
1513
|
+
const fitScale = Math.min(scaleX, scaleY)
|
|
1514
|
+
const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
|
|
1515
|
+
const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
|
|
1516
|
+
const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
|
|
1517
|
+
const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
|
|
1518
|
+
const scale = options.macro && nodes.length > 1
|
|
1519
|
+
? clampScale(Math.min(baselineScale, macroScale))
|
|
1520
|
+
: nodes.length > massiveGraphNodeThreshold
|
|
1521
|
+
? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
|
|
1522
|
+
: baselineScale
|
|
1523
|
+
const hubCenter =
|
|
1524
|
+
options.preferHubCenter && state.primaryHub && nodes.some((node) => node.id === state.primaryHub.id)
|
|
1525
|
+
? state.primaryHub
|
|
1526
|
+
: null
|
|
1527
|
+
const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
|
|
1528
|
+
const centerY = hubCenter ? hubCenter.y : (bounds.minY + bounds.maxY) / 2
|
|
1529
|
+
|
|
1530
|
+
state.transform = {
|
|
1531
|
+
x: clampTransformCoordinate(width / 2 - centerX * scale),
|
|
1532
|
+
y: clampTransformCoordinate(height / 2 - centerY * scale),
|
|
1533
|
+
scale: clampScale(scale)
|
|
1534
|
+
}
|
|
1535
|
+
state.offscreenFrameCount = 0
|
|
1536
|
+
state.recoveringViewport = false
|
|
1537
|
+
markRenderDirty()
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
const resetView = () => fitView({ useFiltered: false, macro: true, preferHubCenter: true })
|
|
1541
|
+
|
|
1542
|
+
const focusPrimaryHub = () => {
|
|
1543
|
+
const hub = state.primaryHub
|
|
1544
|
+
if (!hub) {
|
|
1545
|
+
fitView({ useFiltered: true, macro: false, preferHubCenter: true })
|
|
1546
|
+
return
|
|
1547
|
+
}
|
|
108
1548
|
|
|
109
|
-
const resetView = () => {
|
|
110
1549
|
const rect = canvas.getBoundingClientRect()
|
|
111
|
-
|
|
1550
|
+
const width = Math.max(rect.width, 320)
|
|
1551
|
+
const height = Math.max(rect.height, 320)
|
|
1552
|
+
const targetScale = clampScale(Math.max(0.78, state.transform.scale))
|
|
1553
|
+
|
|
1554
|
+
state.transform = {
|
|
1555
|
+
x: clampTransformCoordinate(width / 2 - hub.x * targetScale),
|
|
1556
|
+
y: clampTransformCoordinate(height / 2 - hub.y * targetScale),
|
|
1557
|
+
scale: targetScale
|
|
1558
|
+
}
|
|
1559
|
+
state.offscreenFrameCount = 0
|
|
1560
|
+
markRenderDirty()
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
const layoutDensityScaleForNodeCount = (nodeCount) => {
|
|
1564
|
+
if (nodeCount > 50000) return 0.56
|
|
1565
|
+
if (nodeCount > 20000) return 0.64
|
|
1566
|
+
if (nodeCount > 6000) return 0.76
|
|
1567
|
+
return 1
|
|
112
1568
|
}
|
|
113
1569
|
|
|
114
1570
|
const createLayout = graph => {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
1571
|
+
const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
|
|
1572
|
+
const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
|
|
1573
|
+
const densityScale = layoutDensityScaleForNodeCount(nodeRows.length)
|
|
1574
|
+
const nodes = nodeRows.map(node => {
|
|
1575
|
+
if (Array.isArray(node)) {
|
|
1576
|
+
const [id, title, x, y, group, segment] = node
|
|
1577
|
+
return {
|
|
1578
|
+
id: typeof id === 'string' ? id : '',
|
|
1579
|
+
title: typeof title === 'string' ? title : 'Untitled',
|
|
1580
|
+
path: '',
|
|
1581
|
+
tags: [],
|
|
1582
|
+
group: typeof group === 'string' ? group : 'root',
|
|
1583
|
+
segment: typeof segment === 'string' ? segment : 'root',
|
|
1584
|
+
x: Number.isFinite(x) ? x * densityScale : 0,
|
|
1585
|
+
y: Number.isFinite(y) ? y * densityScale : 0,
|
|
1586
|
+
vx: 0,
|
|
1587
|
+
vy: 0
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
return {
|
|
1592
|
+
...node,
|
|
1593
|
+
path: typeof node.path === 'string' ? node.path : '',
|
|
1594
|
+
tags: Array.isArray(node.tags) ? node.tags : [],
|
|
1595
|
+
x: Number.isFinite(node.x) ? node.x * densityScale : 0,
|
|
1596
|
+
y: Number.isFinite(node.y) ? node.y * densityScale : 0,
|
|
1597
|
+
vx: Number.isFinite(node.vx) ? node.vx : 0,
|
|
1598
|
+
vy: Number.isFinite(node.vy) ? node.vy : 0
|
|
1599
|
+
}
|
|
1600
|
+
})
|
|
120
1601
|
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
121
|
-
const edges =
|
|
1602
|
+
const edges = edgeRows
|
|
1603
|
+
.map(edge => {
|
|
1604
|
+
if (Array.isArray(edge)) {
|
|
1605
|
+
const [source, target, weight, priority] = edge
|
|
1606
|
+
return {
|
|
1607
|
+
source: typeof source === 'string' ? source : '',
|
|
1608
|
+
target: typeof target === 'string' ? target : null,
|
|
1609
|
+
targetTitle: '',
|
|
1610
|
+
weight: Number.isFinite(weight) ? weight : 1,
|
|
1611
|
+
priority: typeof priority === 'string' ? priority : 'normal'
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
return edge
|
|
1615
|
+
})
|
|
122
1616
|
.filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
|
|
123
1617
|
.map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
|
|
124
1618
|
return { nodes, edges }
|
|
125
1619
|
}
|
|
126
1620
|
|
|
127
|
-
const encodeEntityTag = (value) => {
|
|
128
|
-
const utf8 = new TextEncoder().encode(value)
|
|
129
|
-
let binary = ''
|
|
1621
|
+
const encodeEntityTag = (value) => {
|
|
1622
|
+
const utf8 = new TextEncoder().encode(value)
|
|
1623
|
+
let binary = ''
|
|
1624
|
+
|
|
1625
|
+
for (let index = 0; index < utf8.length; index += 1) {
|
|
1626
|
+
binary += String.fromCharCode(utf8[index])
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
const graphSignature = graph => JSON.stringify({
|
|
1633
|
+
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
|
|
1634
|
+
edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
|
|
1635
|
+
})
|
|
1636
|
+
|
|
1637
|
+
const resetContentFilter = () => {
|
|
1638
|
+
if (state.contentFilter.timer) {
|
|
1639
|
+
clearTimeout(state.contentFilter.timer)
|
|
1640
|
+
}
|
|
1641
|
+
state.contentFilter = {
|
|
1642
|
+
query: '',
|
|
1643
|
+
ids: null,
|
|
1644
|
+
token: state.contentFilter.token + 1,
|
|
1645
|
+
timer: null
|
|
1646
|
+
}
|
|
1647
|
+
recomputeVisibility()
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
const syncContentFilter = async (query, token) => {
|
|
1651
|
+
const response = await fetch(
|
|
1652
|
+
'/api/graph-filter?q=' +
|
|
1653
|
+
encodeURIComponent(query) +
|
|
1654
|
+
'&limit=' +
|
|
1655
|
+
encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
|
|
1656
|
+
agentQuery('&')
|
|
1657
|
+
)
|
|
1658
|
+
|
|
1659
|
+
if (!response.ok || token !== state.contentFilter.token) {
|
|
1660
|
+
return
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
const payload = await response.json()
|
|
1664
|
+
const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
|
|
1665
|
+
if (token !== state.contentFilter.token) {
|
|
1666
|
+
return
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
state.contentFilter.query = query
|
|
1670
|
+
const merged = new Set([...(state.contentFilter.ids instanceof Set ? state.contentFilter.ids : []), ...nodeIds])
|
|
1671
|
+
state.contentFilter.ids = merged
|
|
1672
|
+
recomputeVisibility()
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
const scheduleContentFilterSync = () => {
|
|
1676
|
+
const query = normalizeQuery(state.query)
|
|
1677
|
+
if (!query) {
|
|
1678
|
+
resetContentFilter()
|
|
1679
|
+
return
|
|
1680
|
+
}
|
|
130
1681
|
|
|
131
|
-
|
|
132
|
-
|
|
1682
|
+
if (state.contentFilter.timer) {
|
|
1683
|
+
clearTimeout(state.contentFilter.timer)
|
|
133
1684
|
}
|
|
134
1685
|
|
|
135
|
-
|
|
1686
|
+
const token = state.contentFilter.token + 1
|
|
1687
|
+
state.contentFilter = {
|
|
1688
|
+
query: state.contentFilter.query,
|
|
1689
|
+
ids: state.contentFilter.ids,
|
|
1690
|
+
token,
|
|
1691
|
+
timer: setTimeout(() => {
|
|
1692
|
+
if (state.filterWorker && state.filterReady) {
|
|
1693
|
+
state.filterWorker.postMessage({
|
|
1694
|
+
type: 'filter',
|
|
1695
|
+
query,
|
|
1696
|
+
token,
|
|
1697
|
+
limit: Math.max(state.nodes.length, 1)
|
|
1698
|
+
})
|
|
1699
|
+
}
|
|
1700
|
+
syncContentFilter(query, token).catch(() => {})
|
|
1701
|
+
}, 180)
|
|
1702
|
+
}
|
|
136
1703
|
}
|
|
137
1704
|
|
|
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
1705
|
const tick = delta => {
|
|
144
|
-
const nodes =
|
|
145
|
-
const
|
|
146
|
-
const
|
|
1706
|
+
const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
|
|
1707
|
+
const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
|
|
1708
|
+
const shouldRunPhysics =
|
|
1709
|
+
state.nodes.length <= 8000 &&
|
|
1710
|
+
nodes.length <= 320 &&
|
|
1711
|
+
state.transform.scale >= 0.08
|
|
1712
|
+
if (!shouldRunPhysics) {
|
|
1713
|
+
return
|
|
1714
|
+
}
|
|
147
1715
|
const strength = Math.min(delta / 16, 2)
|
|
148
1716
|
|
|
149
1717
|
edges.forEach(edge => {
|
|
150
1718
|
const source = edge.sourceNode
|
|
151
1719
|
const target = edge.targetNode
|
|
1720
|
+
source.vx = Number.isFinite(source.vx) ? source.vx : 0
|
|
1721
|
+
source.vy = Number.isFinite(source.vy) ? source.vy : 0
|
|
1722
|
+
target.vx = Number.isFinite(target.vx) ? target.vx : 0
|
|
1723
|
+
target.vy = Number.isFinite(target.vy) ? target.vy : 0
|
|
152
1724
|
const dx = target.x - source.x
|
|
153
1725
|
const dy = target.y - source.y
|
|
154
1726
|
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
155
1727
|
const force = (distance - 150) * 0.002 * strength
|
|
156
|
-
const fx = dx * force
|
|
157
|
-
const fy = dy * force
|
|
1728
|
+
const fx = (dx / distance) * force
|
|
1729
|
+
const fy = (dy / distance) * force
|
|
158
1730
|
source.vx += fx
|
|
159
1731
|
source.vy += fy
|
|
160
1732
|
target.vx -= fx
|
|
@@ -165,6 +1737,10 @@ const tick = delta => {
|
|
|
165
1737
|
for (let j = i + 1; j < nodes.length; j += 1) {
|
|
166
1738
|
const a = nodes[i]
|
|
167
1739
|
const b = nodes[j]
|
|
1740
|
+
a.vx = Number.isFinite(a.vx) ? a.vx : 0
|
|
1741
|
+
a.vy = Number.isFinite(a.vy) ? a.vy : 0
|
|
1742
|
+
b.vx = Number.isFinite(b.vx) ? b.vx : 0
|
|
1743
|
+
b.vy = Number.isFinite(b.vy) ? b.vy : 0
|
|
168
1744
|
const dx = b.x - a.x
|
|
169
1745
|
const dy = b.y - a.y
|
|
170
1746
|
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
@@ -179,6 +1755,10 @@ const tick = delta => {
|
|
|
179
1755
|
}
|
|
180
1756
|
|
|
181
1757
|
nodes.forEach(node => {
|
|
1758
|
+
node.vx = Number.isFinite(node.vx) ? node.vx : 0
|
|
1759
|
+
node.vy = Number.isFinite(node.vy) ? node.vy : 0
|
|
1760
|
+
node.x = Number.isFinite(node.x) ? node.x : 0
|
|
1761
|
+
node.y = Number.isFinite(node.y) ? node.y : 0
|
|
182
1762
|
if (state.pointer.dragNode === node) {
|
|
183
1763
|
node.vx = 0
|
|
184
1764
|
node.vy = 0
|
|
@@ -201,8 +1781,119 @@ const worldPoint = event => {
|
|
|
201
1781
|
}
|
|
202
1782
|
}
|
|
203
1783
|
|
|
1784
|
+
const connectedNodeIdsFor = (nodeId) => {
|
|
1785
|
+
const edges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
1786
|
+
const ids = new Set()
|
|
1787
|
+
|
|
1788
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
1789
|
+
const edge = edges[index]
|
|
1790
|
+
if (!edge.target) continue
|
|
1791
|
+
if (edge.source === nodeId) {
|
|
1792
|
+
ids.add(edge.target)
|
|
1793
|
+
} else if (edge.target === nodeId) {
|
|
1794
|
+
ids.add(edge.source)
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
return ids
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
const applyDragNeighborhoodAdjustment = (dragNode, deltaX, deltaY) => {
|
|
1802
|
+
if (!dragNode) return
|
|
1803
|
+
if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) return
|
|
1804
|
+
if (Math.abs(deltaX) + Math.abs(deltaY) <= 0.001) return
|
|
1805
|
+
|
|
1806
|
+
const scale = Math.max(state.transform.scale, 0.0001)
|
|
1807
|
+
const influenceRadius = Math.max(220, Math.min(920, 440 / scale))
|
|
1808
|
+
const influenceRadiusSquared = influenceRadius * influenceRadius
|
|
1809
|
+
const connectedIds = connectedNodeIdsFor(dragNode.id)
|
|
1810
|
+
const candidates = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
|
|
1811
|
+
let adjusted = 0
|
|
1812
|
+
|
|
1813
|
+
for (let index = 0; index < candidates.length && adjusted < dragNeighborhoodMaxAffected; index += 1) {
|
|
1814
|
+
const node = candidates[index]
|
|
1815
|
+
if (node.id === dragNode.id) continue
|
|
1816
|
+
|
|
1817
|
+
const isConnected = connectedIds.has(node.id)
|
|
1818
|
+
const dx = node.x - dragNode.x
|
|
1819
|
+
const dy = node.y - dragNode.y
|
|
1820
|
+
const distanceSquared = dx * dx + dy * dy
|
|
1821
|
+
const withinRadius = distanceSquared <= influenceRadiusSquared
|
|
1822
|
+
if (!isConnected && !withinRadius) continue
|
|
1823
|
+
|
|
1824
|
+
const distance = Math.max(Math.sqrt(distanceSquared), 0.0001)
|
|
1825
|
+
const proximity = withinRadius ? 1 - (distance / influenceRadius) : 0
|
|
1826
|
+
const coupledStrength = isConnected ? 0.28 : 0.12
|
|
1827
|
+
const influence = Math.min(0.46, coupledStrength + proximity * 0.34)
|
|
1828
|
+
node.x += deltaX * influence
|
|
1829
|
+
node.y += deltaY * influence
|
|
1830
|
+
node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + deltaX * influence * 0.06
|
|
1831
|
+
node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + deltaY * influence * 0.06
|
|
1832
|
+
adjusted += 1
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
const settleNeighborhoodAroundNode = (dragNode) => {
|
|
1837
|
+
if (!dragNode) return
|
|
1838
|
+
|
|
1839
|
+
const scale = Math.max(state.transform.scale, 0.0001)
|
|
1840
|
+
const settleRadius = Math.max(240, Math.min(980, 520 / scale))
|
|
1841
|
+
const settleRadiusSquared = settleRadius * settleRadius
|
|
1842
|
+
const connectedIds = connectedNodeIdsFor(dragNode.id)
|
|
1843
|
+
const candidates = (state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes)
|
|
1844
|
+
.filter((node) => {
|
|
1845
|
+
if (node.id === dragNode.id) return true
|
|
1846
|
+
const dx = node.x - dragNode.x
|
|
1847
|
+
const dy = node.y - dragNode.y
|
|
1848
|
+
const distanceSquared = dx * dx + dy * dy
|
|
1849
|
+
return connectedIds.has(node.id) || distanceSquared <= settleRadiusSquared
|
|
1850
|
+
})
|
|
1851
|
+
.slice(0, dragNeighborhoodMaxAffected)
|
|
1852
|
+
|
|
1853
|
+
if (candidates.length <= 1) return
|
|
1854
|
+
|
|
1855
|
+
for (let round = 0; round < dragSettleRounds; round += 1) {
|
|
1856
|
+
for (let leftIndex = 0; leftIndex < candidates.length; leftIndex += 1) {
|
|
1857
|
+
const left = candidates[leftIndex]
|
|
1858
|
+
for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex += 1) {
|
|
1859
|
+
const right = candidates[rightIndex]
|
|
1860
|
+
const dx = right.x - left.x
|
|
1861
|
+
const dy = right.y - left.y
|
|
1862
|
+
const distance = Math.max(Math.hypot(dx, dy), 0.001)
|
|
1863
|
+
const minDistance = baseNodeRadius(left) + baseNodeRadius(right) + 10
|
|
1864
|
+
if (distance >= minDistance) continue
|
|
1865
|
+
|
|
1866
|
+
const push = (minDistance - distance) * 0.36
|
|
1867
|
+
const ux = dx / distance
|
|
1868
|
+
const uy = dy / distance
|
|
1869
|
+
if (left.id !== dragNode.id) {
|
|
1870
|
+
left.x -= ux * push
|
|
1871
|
+
left.y -= uy * push
|
|
1872
|
+
}
|
|
1873
|
+
if (right.id !== dragNode.id) {
|
|
1874
|
+
right.x += ux * push
|
|
1875
|
+
right.y += uy * push
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
|
|
204
1882
|
const hitNode = point => {
|
|
205
|
-
|
|
1883
|
+
computeRenderVisibility()
|
|
1884
|
+
if (state.renderClusters.length > 0) {
|
|
1885
|
+
return null
|
|
1886
|
+
}
|
|
1887
|
+
const hitScaleFloor = state.nodes.length > massiveGraphNodeThreshold
|
|
1888
|
+
? 0.2
|
|
1889
|
+
: state.nodes.length > largeGraphNodeThreshold
|
|
1890
|
+
? 0.34
|
|
1891
|
+
: 0
|
|
1892
|
+
if (state.transform.scale < hitScaleFloor) {
|
|
1893
|
+
return null
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
const nodes = state.renderNodes
|
|
206
1897
|
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
207
1898
|
const node = nodes[index]
|
|
208
1899
|
const radius = nodeRadius(node)
|
|
@@ -211,17 +1902,379 @@ const hitNode = point => {
|
|
|
211
1902
|
return null
|
|
212
1903
|
}
|
|
213
1904
|
|
|
214
|
-
const
|
|
215
|
-
const degree = state.
|
|
1905
|
+
const baseNodeRadius = node => {
|
|
1906
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
216
1907
|
return 9 + Math.min(degree, 8) * 1.6
|
|
217
1908
|
}
|
|
218
1909
|
|
|
1910
|
+
const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
|
|
1911
|
+
|
|
1912
|
+
const worldViewportBounds = () => {
|
|
1913
|
+
const rect = canvas.getBoundingClientRect()
|
|
1914
|
+
const width = Math.max(rect.width, 320)
|
|
1915
|
+
const height = Math.max(rect.height, 320)
|
|
1916
|
+
const paddingMultiplier =
|
|
1917
|
+
state.nodes.length > massiveGraphNodeThreshold
|
|
1918
|
+
? (state.transform.scale >= 0.6 ? 2.8 : state.transform.scale >= 0.25 ? 2.35 : 1.9)
|
|
1919
|
+
: state.nodes.length > largeGraphNodeThreshold
|
|
1920
|
+
? 1.45
|
|
1921
|
+
: 1
|
|
1922
|
+
const padding = viewportPaddingPx * paddingMultiplier
|
|
1923
|
+
|
|
1924
|
+
return {
|
|
1925
|
+
minX: (-state.transform.x - padding) / state.transform.scale,
|
|
1926
|
+
maxX: (width - state.transform.x + padding) / state.transform.scale,
|
|
1927
|
+
minY: (-state.transform.y - padding) / state.transform.scale,
|
|
1928
|
+
maxY: (height - state.transform.y + padding) / state.transform.scale
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
const isNodeInViewport = (node, viewport) =>
|
|
1933
|
+
node.x >= viewport.minX &&
|
|
1934
|
+
node.x <= viewport.maxX &&
|
|
1935
|
+
node.y >= viewport.minY &&
|
|
1936
|
+
node.y <= viewport.maxY
|
|
1937
|
+
|
|
1938
|
+
const expandViewportBounds = (viewport, worldMargin) => ({
|
|
1939
|
+
minX: viewport.minX - worldMargin,
|
|
1940
|
+
maxX: viewport.maxX + worldMargin,
|
|
1941
|
+
minY: viewport.minY - worldMargin,
|
|
1942
|
+
maxY: viewport.maxY + worldMargin
|
|
1943
|
+
})
|
|
1944
|
+
|
|
1945
|
+
const viewportNodeStride = () => {
|
|
1946
|
+
if (state.nodes.length <= largeGraphNodeThreshold) {
|
|
1947
|
+
return 1
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
if (state.transform.scale >= 0.95) {
|
|
1951
|
+
return 1
|
|
1952
|
+
}
|
|
1953
|
+
if (state.transform.scale >= 0.7) {
|
|
1954
|
+
return 2
|
|
1955
|
+
}
|
|
1956
|
+
if (state.transform.scale >= 0.48) {
|
|
1957
|
+
return 3
|
|
1958
|
+
}
|
|
1959
|
+
if (state.transform.scale >= 0.28) {
|
|
1960
|
+
return 5
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
return 8
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
const shouldRenderClusters = viewportNodes =>
|
|
1967
|
+
state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
|
|
1968
|
+
|
|
1969
|
+
const clusterViewportNodes = viewportNodes => {
|
|
1970
|
+
if (!shouldRenderClusters(viewportNodes)) {
|
|
1971
|
+
return []
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
|
|
1975
|
+
const buckets = new Map()
|
|
1976
|
+
|
|
1977
|
+
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
1978
|
+
const node = viewportNodes[index]
|
|
1979
|
+
const keyX = Math.floor(node.x / worldCellSize)
|
|
1980
|
+
const keyY = Math.floor(node.y / worldCellSize)
|
|
1981
|
+
const key = keyX + ':' + keyY
|
|
1982
|
+
const current = buckets.get(key)
|
|
1983
|
+
if (current) {
|
|
1984
|
+
current.count += 1
|
|
1985
|
+
current.sumX += node.x
|
|
1986
|
+
current.sumY += node.y
|
|
1987
|
+
if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
|
|
1988
|
+
current.representative = node
|
|
1989
|
+
current.degree = state.nodeDegrees.get(node.id) ?? 0
|
|
1990
|
+
}
|
|
1991
|
+
continue
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
buckets.set(key, {
|
|
1995
|
+
id: key,
|
|
1996
|
+
count: 1,
|
|
1997
|
+
sumX: node.x,
|
|
1998
|
+
sumY: node.y,
|
|
1999
|
+
representative: node,
|
|
2000
|
+
degree: state.nodeDegrees.get(node.id) ?? 0
|
|
2001
|
+
})
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
return Array.from(buckets.values())
|
|
2005
|
+
.sort((left, right) => right.count - left.count)
|
|
2006
|
+
.slice(0, Math.min(renderNodeBudget, 900))
|
|
2007
|
+
.map((cluster) => ({
|
|
2008
|
+
id: cluster.id,
|
|
2009
|
+
x: cluster.sumX / Math.max(cluster.count, 1),
|
|
2010
|
+
y: cluster.sumY / Math.max(cluster.count, 1),
|
|
2011
|
+
count: cluster.count,
|
|
2012
|
+
representative: cluster.representative
|
|
2013
|
+
}))
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
const representativeNodesFromClusters = (clusters, limit) => {
|
|
2017
|
+
const representatives = clusters
|
|
2018
|
+
.map((cluster) => cluster.representative)
|
|
2019
|
+
.filter((node) => Boolean(node))
|
|
2020
|
+
const merged = mergeUniqueNodes(
|
|
2021
|
+
representatives,
|
|
2022
|
+
state.renderNodes ?? [],
|
|
2023
|
+
Math.max(1, limit)
|
|
2024
|
+
)
|
|
2025
|
+
return ensureHubNodesInRenderedSet(merged)
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
const computeRenderVisibility = () => {
|
|
2029
|
+
if (!hasValidTransform()) {
|
|
2030
|
+
fitView({ useFiltered: true })
|
|
2031
|
+
}
|
|
2032
|
+
const viewport = worldViewportBounds()
|
|
2033
|
+
const viewportKey =
|
|
2034
|
+
Math.round(viewport.minX * 10) + ':' +
|
|
2035
|
+
Math.round(viewport.maxX * 10) + ':' +
|
|
2036
|
+
Math.round(viewport.minY * 10) + ':' +
|
|
2037
|
+
Math.round(viewport.maxY * 10) + ':' +
|
|
2038
|
+
visibilityScaleBucket(state.transform.scale)
|
|
2039
|
+
|
|
2040
|
+
if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
|
|
2041
|
+
return
|
|
2042
|
+
}
|
|
2043
|
+
state.lastViewportKey = viewportKey
|
|
2044
|
+
state.renderVisibilityDirty = false
|
|
2045
|
+
|
|
2046
|
+
const shouldRenderMacroGalaxy = shouldRenderMacroGalaxyView()
|
|
2047
|
+
|
|
2048
|
+
if (shouldRenderMacroGalaxy) {
|
|
2049
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
2050
|
+
const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
|
|
2051
|
+
const representative = state.primaryHub ?? state.macroRepresentative ?? sourceNodes[0] ?? null
|
|
2052
|
+
if (representative) {
|
|
2053
|
+
state.renderClusters = [
|
|
2054
|
+
{
|
|
2055
|
+
id: 'macro-galaxy',
|
|
2056
|
+
x: state.macroCenter.x,
|
|
2057
|
+
y: state.macroCenter.y,
|
|
2058
|
+
count: sourceNodes.length,
|
|
2059
|
+
representative
|
|
2060
|
+
}
|
|
2061
|
+
]
|
|
2062
|
+
state.renderNodes = [representative]
|
|
2063
|
+
} else {
|
|
2064
|
+
state.renderClusters = []
|
|
2065
|
+
state.renderNodes = []
|
|
2066
|
+
}
|
|
2067
|
+
state.renderEdges = []
|
|
2068
|
+
return
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
if (state.visibleNodes.length <= 2000) {
|
|
2072
|
+
state.renderNodes = state.visibleNodes
|
|
2073
|
+
state.renderClusters = []
|
|
2074
|
+
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
2075
|
+
state.renderEdges = withMeshEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
|
|
2076
|
+
return
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
if (state.visibleNodes.length > massiveGraphNodeThreshold) {
|
|
2080
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
2081
|
+
if (state.transform.scale <= massiveOverviewClusterScaleThreshold) {
|
|
2082
|
+
const overviewLimit = Math.min(renderNodeBudget, massiveLowZoomNodeBudgetForScale(state.transform.scale))
|
|
2083
|
+
const overviewClusters = filterOverviewClustersByViewport(viewport)
|
|
2084
|
+
.sort((left, right) => right.count - left.count)
|
|
2085
|
+
.slice(0, overviewLimit)
|
|
2086
|
+
if (overviewClusters.length > 0) {
|
|
2087
|
+
const overviewNodes = representativeNodesFromClusters(
|
|
2088
|
+
overviewClusters,
|
|
2089
|
+
overviewLimit
|
|
2090
|
+
)
|
|
2091
|
+
const anchoredNodes = includeHubPreviewNeighborhood(
|
|
2092
|
+
overviewNodes,
|
|
2093
|
+
Math.min(renderNodeBudget, overviewLimit)
|
|
2094
|
+
)
|
|
2095
|
+
const enriched = enrichSampleWithNeighbors(anchoredNodes)
|
|
2096
|
+
const previewNodes = ensureHubNodesInRenderedSet(enriched.nodes)
|
|
2097
|
+
const previewIds = new Set(previewNodes.map((node) => node.id))
|
|
2098
|
+
const previewEdges = collectVisibleEdgesForNodes(previewIds)
|
|
2099
|
+
state.renderClusters = []
|
|
2100
|
+
state.renderNodes = previewNodes
|
|
2101
|
+
state.renderEdges = previewEdges
|
|
2102
|
+
return
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
|
|
2106
|
+
const sampleLimit = nodeBudgetForScale(state.transform.scale)
|
|
2107
|
+
const carryMargin = Math.max(240, Math.min(1200, 340 / Math.max(state.transform.scale, 0.0001)))
|
|
2108
|
+
const carryViewport = expandViewportBounds(viewport, carryMargin)
|
|
2109
|
+
const carryOverLimit = Math.max(180, Math.min(sampleLimit, Math.floor(sampleLimit * 0.5)))
|
|
2110
|
+
const carryOverNodes = (state.renderNodes ?? [])
|
|
2111
|
+
.filter((node) => isNodeInViewport(node, carryViewport))
|
|
2112
|
+
.slice(0, carryOverLimit)
|
|
2113
|
+
const sourceWithCarry = mergeUniqueNodes(
|
|
2114
|
+
sourceNodes,
|
|
2115
|
+
carryOverNodes,
|
|
2116
|
+
Math.max(sampleLimit * 7, carryOverLimit)
|
|
2117
|
+
)
|
|
2118
|
+
const sourceWithCarryIds = new Set(sourceWithCarry.map((node) => node.id))
|
|
2119
|
+
const sampledRaw = selectStableSampleNodes(
|
|
2120
|
+
sourceWithCarry,
|
|
2121
|
+
sampleLimit
|
|
2122
|
+
)
|
|
2123
|
+
const continuityBudget = Math.max(24, Math.min(sampleLimit - 8, Math.floor(sampleLimit * 0.42)))
|
|
2124
|
+
const previousVisibleNodes = (state.renderNodes ?? [])
|
|
2125
|
+
.filter((node) => sourceWithCarryIds.has(node.id))
|
|
2126
|
+
const continuityNodes = selectStableSampleNodes(previousVisibleNodes, continuityBudget)
|
|
2127
|
+
const sampled = mergeUniqueNodes(
|
|
2128
|
+
continuityNodes,
|
|
2129
|
+
sampledRaw,
|
|
2130
|
+
sampleLimit
|
|
2131
|
+
)
|
|
2132
|
+
let sampledNodes = ensureHubNodesInRenderedSet(sampled)
|
|
2133
|
+
if (state.transform.scale < 0.035) {
|
|
2134
|
+
sampledNodes = includeHubPreviewNeighborhood(
|
|
2135
|
+
sampledNodes,
|
|
2136
|
+
Math.min(renderNodeBudget, sampleLimit + 160)
|
|
2137
|
+
)
|
|
2138
|
+
}
|
|
2139
|
+
const sampledIds = new Set(sampledNodes.map((node) => node.id))
|
|
2140
|
+
let sampledEdges = collectVisibleEdgesForNodes(sampledIds)
|
|
2141
|
+
|
|
2142
|
+
if (state.transform.scale >= 0.035 && sampledEdges.length === 0) {
|
|
2143
|
+
const enriched = enrichSampleWithNeighbors(sampledNodes)
|
|
2144
|
+
sampledNodes = ensureHubNodesInRenderedSet(enriched.nodes)
|
|
2145
|
+
const sampledWithHubsIds = new Set(sampledNodes.map((node) => node.id))
|
|
2146
|
+
sampledEdges = collectVisibleEdgesForNodes(sampledWithHubsIds)
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
state.renderClusters = []
|
|
2150
|
+
state.renderNodes = sampledNodes
|
|
2151
|
+
state.renderEdges = withMeshEdges(sampledNodes, sampledEdges)
|
|
2152
|
+
return
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
if (state.transform.scale <= 0.0015) {
|
|
2156
|
+
const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
|
|
2157
|
+
const sampledIds = new Set(sampled.map((node) => node.id))
|
|
2158
|
+
state.renderClusters = []
|
|
2159
|
+
state.renderNodes = sampled
|
|
2160
|
+
state.renderEdges = withMeshEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
|
|
2161
|
+
return
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
2165
|
+
const clusters = clusterViewportNodes(viewportNodes)
|
|
2166
|
+
if (clusters.length > 0) {
|
|
2167
|
+
state.renderClusters = []
|
|
2168
|
+
state.renderNodes = representativeNodesFromClusters(clusters, Math.min(renderNodeBudget, 900))
|
|
2169
|
+
state.renderEdges = []
|
|
2170
|
+
return
|
|
2171
|
+
}
|
|
2172
|
+
state.renderClusters = []
|
|
2173
|
+
const stride = viewportNodeStride()
|
|
2174
|
+
const picked = []
|
|
2175
|
+
|
|
2176
|
+
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
2177
|
+
const node = viewportNodes[index]
|
|
2178
|
+
|
|
2179
|
+
const isPriority =
|
|
2180
|
+
node.id === state.selected?.id ||
|
|
2181
|
+
node.id === state.hovered?.id ||
|
|
2182
|
+
node.id === state.pointer.dragNode?.id
|
|
2183
|
+
if (isPriority || index % stride === 0) {
|
|
2184
|
+
picked.push(node)
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
const nodes = picked.length > renderNodeBudget
|
|
2189
|
+
? picked.slice(0, renderNodeBudget)
|
|
2190
|
+
: picked
|
|
2191
|
+
if (nodes.length === 0 && state.visibleNodes.length > 0) {
|
|
2192
|
+
const fallbackNodes = fallbackViewportNodes()
|
|
2193
|
+
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
2194
|
+
state.renderNodes = fallbackNodes
|
|
2195
|
+
state.renderClusters = []
|
|
2196
|
+
state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
|
|
2197
|
+
return
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
const normalizedNodes = ensureHubNodesInRenderedSet(nodes)
|
|
2201
|
+
const nodeIds = new Set(normalizedNodes.map((node) => node.id))
|
|
2202
|
+
const edges = collectVisibleEdgesForNodes(nodeIds)
|
|
2203
|
+
|
|
2204
|
+
state.renderNodes = normalizedNodes
|
|
2205
|
+
state.renderEdges = withMeshEdges(normalizedNodes, edges)
|
|
2206
|
+
|
|
2207
|
+
if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
|
|
2208
|
+
const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
|
|
2209
|
+
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
2210
|
+
state.renderClusters = []
|
|
2211
|
+
state.renderNodes = fallbackNodes
|
|
2212
|
+
state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
const isNodeVisibleOnScreen = (node, width, height) => {
|
|
2217
|
+
const radius = nodeRadius(node) * state.transform.scale
|
|
2218
|
+
const screenX = node.x * state.transform.scale + state.transform.x
|
|
2219
|
+
const screenY = node.y * state.transform.scale + state.transform.y
|
|
2220
|
+
|
|
2221
|
+
return (
|
|
2222
|
+
screenX + radius >= 0 &&
|
|
2223
|
+
screenX - radius <= width &&
|
|
2224
|
+
screenY + radius >= 0 &&
|
|
2225
|
+
screenY - radius <= height
|
|
2226
|
+
)
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
const hasValidTransform = () =>
|
|
2230
|
+
isFiniteNumber(state.transform.x) &&
|
|
2231
|
+
isFiniteNumber(state.transform.y) &&
|
|
2232
|
+
isFiniteNumber(state.transform.scale) &&
|
|
2233
|
+
Math.abs(state.transform.x) <= transformCoordinateLimit &&
|
|
2234
|
+
Math.abs(state.transform.y) <= transformCoordinateLimit &&
|
|
2235
|
+
state.transform.scale > 0
|
|
2236
|
+
|
|
2237
|
+
const sanitizeNodePosition = node => {
|
|
2238
|
+
if (!isReasonableCoordinate(node.x)) node.x = 0
|
|
2239
|
+
if (!isReasonableCoordinate(node.y)) node.y = 0
|
|
2240
|
+
if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
|
|
2241
|
+
if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
const sanitizeAllNodePositions = () => {
|
|
2245
|
+
state.nodes.forEach(sanitizeNodePosition)
|
|
2246
|
+
state.visibleNodes.forEach(sanitizeNodePosition)
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
const sanitizeGraphState = () => {
|
|
2250
|
+
state.renderNodes.forEach(sanitizeNodePosition)
|
|
2251
|
+
}
|
|
2252
|
+
|
|
219
2253
|
const render = now => {
|
|
220
2254
|
const delta = now - state.last
|
|
221
2255
|
state.last = now
|
|
2256
|
+
const backgroundFrameIntervalMs =
|
|
2257
|
+
state.nodes.length > massiveGraphNodeThreshold
|
|
2258
|
+
? (state.transform.scale < 0.035 ? 130 : state.transform.scale < 0.08 ? 110 : 86)
|
|
2259
|
+
: state.nodes.length > largeGraphNodeThreshold
|
|
2260
|
+
? 64
|
|
2261
|
+
: 16
|
|
2262
|
+
const isInteracting =
|
|
2263
|
+
state.pointer.down ||
|
|
2264
|
+
state.renderVisibilityDirty ||
|
|
2265
|
+
state.recoveringViewport
|
|
2266
|
+
const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
|
|
2267
|
+
if (delta < minFrameIntervalMs) {
|
|
2268
|
+
requestAnimationFrame(render)
|
|
2269
|
+
return
|
|
2270
|
+
}
|
|
222
2271
|
const rect = canvas.getBoundingClientRect()
|
|
223
2272
|
const width = Math.max(rect.width, 320)
|
|
224
2273
|
const height = Math.max(rect.height, 320)
|
|
2274
|
+
sanitizeGraphState()
|
|
2275
|
+
if (!hasValidTransform()) {
|
|
2276
|
+
resetView()
|
|
2277
|
+
}
|
|
225
2278
|
ctx.clearRect(0, 0, width, height)
|
|
226
2279
|
if (state.nodes.length === 0) {
|
|
227
2280
|
ctx.fillStyle = '#99a5b5'
|
|
@@ -235,42 +2288,81 @@ const render = now => {
|
|
|
235
2288
|
ctx.translate(state.transform.x, state.transform.y)
|
|
236
2289
|
ctx.scale(state.transform.scale, state.transform.scale)
|
|
237
2290
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
264
|
-
if (isSelected || isHovered || state.transform.scale > 1.18 || state.nodes.length <= 25) {
|
|
265
|
-
ctx.fillStyle = graphTheme.label
|
|
266
|
-
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
267
|
-
ctx.textAlign = 'center'
|
|
268
|
-
ctx.textBaseline = 'top'
|
|
269
|
-
ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
|
|
2291
|
+
computeRenderVisibility()
|
|
2292
|
+
tick(delta)
|
|
2293
|
+
const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
|
|
2294
|
+
const manualZoomGuardActive = now - state.lastManualZoomAt < zoomRecoveryGuardMs
|
|
2295
|
+
const allowViewportAutoRecovery = state.nodes.length <= massiveGraphNodeThreshold
|
|
2296
|
+
if (allowViewportAutoRecovery && !hasVisibleNodeOnScreen && state.renderNodes.length > 0 && !manualZoomGuardActive) {
|
|
2297
|
+
state.offscreenFrameCount += 1
|
|
2298
|
+
if (state.offscreenFrameCount >= 22 && !state.recoveringViewport) {
|
|
2299
|
+
state.recoveringViewport = true
|
|
2300
|
+
fitView({ useFiltered: true })
|
|
2301
|
+
state.offscreenFrameCount = 0
|
|
2302
|
+
requestAnimationFrame(() => {
|
|
2303
|
+
state.recoveringViewport = false
|
|
2304
|
+
})
|
|
270
2305
|
}
|
|
271
|
-
}
|
|
2306
|
+
} else {
|
|
2307
|
+
state.offscreenFrameCount = 0
|
|
2308
|
+
}
|
|
2309
|
+
const minimumEdgeScale =
|
|
2310
|
+
state.nodes.length > massiveGraphNodeThreshold
|
|
2311
|
+
? 0
|
|
2312
|
+
: state.renderNodes.length > 1300
|
|
2313
|
+
? 0.12
|
|
2314
|
+
: state.renderNodes.length > 900
|
|
2315
|
+
? 0.085
|
|
2316
|
+
: state.renderNodes.length > 500
|
|
2317
|
+
? 0.05
|
|
2318
|
+
: 0
|
|
2319
|
+
const drawEdges =
|
|
2320
|
+
state.renderClusters.length === 0 &&
|
|
2321
|
+
state.transform.scale >= minimumEdgeScale
|
|
2322
|
+
if (drawEdges) {
|
|
2323
|
+
drawGraphEdges()
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
if (state.renderClusters.length > 0) {
|
|
2327
|
+
const safeScale = Math.max(state.transform.scale, 0.0001)
|
|
2328
|
+
state.renderClusters.forEach(cluster => {
|
|
2329
|
+
const isMacro = cluster.id === 'macro-galaxy'
|
|
2330
|
+
const radiusPx = isMacro
|
|
2331
|
+
? 10
|
|
2332
|
+
: Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
|
|
2333
|
+
const radius = radiusPx / safeScale
|
|
2334
|
+
const haloRadius = (radiusPx + (isMacro ? 8 : 4)) / safeScale
|
|
2335
|
+
ctx.beginPath()
|
|
2336
|
+
ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
|
|
2337
|
+
ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
|
|
2338
|
+
ctx.fill()
|
|
2339
|
+
ctx.beginPath()
|
|
2340
|
+
ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
|
|
2341
|
+
ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
|
|
2342
|
+
ctx.fill()
|
|
2343
|
+
ctx.lineWidth = 1.4 / safeScale
|
|
2344
|
+
ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
|
|
2345
|
+
ctx.stroke()
|
|
2346
|
+
if (isMacro && cluster.representative?.title) {
|
|
2347
|
+
ctx.fillStyle = '#edf2f7'
|
|
2348
|
+
ctx.font = 12 / safeScale + 'px Inter, system-ui, sans-serif'
|
|
2349
|
+
ctx.textAlign = 'center'
|
|
2350
|
+
ctx.textBaseline = 'top'
|
|
2351
|
+
ctx.fillText(cluster.representative.title.slice(0, 28), cluster.x, cluster.y + (radiusPx + 9) / safeScale)
|
|
2352
|
+
}
|
|
2353
|
+
// Keep cluster markers minimal and faster to draw on large graphs.
|
|
2354
|
+
})
|
|
2355
|
+
} else {
|
|
2356
|
+
drawGraphNodes()
|
|
2357
|
+
}
|
|
272
2358
|
|
|
273
2359
|
ctx.restore()
|
|
2360
|
+
if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
|
|
2361
|
+
ctx.fillStyle = '#99a5b5'
|
|
2362
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
2363
|
+
ctx.textAlign = 'center'
|
|
2364
|
+
ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
|
|
2365
|
+
}
|
|
274
2366
|
requestAnimationFrame(render)
|
|
275
2367
|
}
|
|
276
2368
|
|
|
@@ -278,55 +2370,98 @@ const list = items => items.length
|
|
|
278
2370
|
? 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
2371
|
: '<li><small>No links found.</small></li>'
|
|
280
2372
|
|
|
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
|
-
}
|
|
2373
|
+
const linkedNodes = node => {
|
|
306
2374
|
const nodeById = new Map(state.nodes.map(item => [item.id, item]))
|
|
307
2375
|
const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
|
|
308
2376
|
...linkedNode,
|
|
309
2377
|
weight: edge.weight,
|
|
310
2378
|
priority: edge.priority
|
|
311
2379
|
} : null
|
|
312
|
-
const outgoing = state.
|
|
2380
|
+
const outgoing = state.edges
|
|
313
2381
|
.filter(edge => edge.source === node.id)
|
|
314
|
-
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' }, edge))
|
|
2382
|
+
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
|
|
315
2383
|
.filter(Boolean)
|
|
316
|
-
const incoming = state.
|
|
2384
|
+
const incoming = state.edges
|
|
317
2385
|
.filter(edge => edge.target === node.id)
|
|
318
2386
|
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
319
2387
|
.filter(Boolean)
|
|
320
2388
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
2389
|
+
return { outgoing, incoming }
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
const fetchNodeDetails = async node => {
|
|
2393
|
+
const cached = state.nodeDetails.get(node.id)
|
|
2394
|
+
if (cached) {
|
|
2395
|
+
return cached
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
|
|
2399
|
+
if (!response.ok) {
|
|
2400
|
+
throw new Error('Failed to load graph node details')
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
const payload = await response.json()
|
|
2404
|
+
const detail = payload?.node
|
|
2405
|
+
if (!detail || !detail.id) {
|
|
2406
|
+
throw new Error('Invalid graph node payload')
|
|
2407
|
+
}
|
|
2408
|
+
state.nodeDetails.set(detail.id, detail)
|
|
2409
|
+
return detail
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
2413
|
+
|
|
2414
|
+
const openContentDialog = async node => {
|
|
2415
|
+
if (!node) return
|
|
2416
|
+
elements.contentTitle.textContent = node.title || 'Loading...'
|
|
2417
|
+
elements.contentPath.textContent = node.path || 'Loading...'
|
|
2418
|
+
elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
|
|
324
2419
|
? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
325
2420
|
: '<span>No tags</span>'
|
|
326
|
-
|
|
327
|
-
elements.
|
|
328
|
-
elements.
|
|
329
|
-
|
|
2421
|
+
const initialLinks = linkedNodes(node)
|
|
2422
|
+
elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
|
|
2423
|
+
elements.contentIncoming.innerHTML = list(initialLinks.incoming)
|
|
2424
|
+
elements.contentBody.textContent = 'Loading note content...'
|
|
2425
|
+
if (!elements.contentDialog.open) {
|
|
2426
|
+
elements.contentDialog.showModal()
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
const applyDetailToDialog = detail => {
|
|
2430
|
+
elements.contentTitle.textContent = detail.title
|
|
2431
|
+
elements.contentPath.textContent = detail.path
|
|
2432
|
+
elements.contentTags.innerHTML = detail.tags.length
|
|
2433
|
+
? detail.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
2434
|
+
: '<span>No tags</span>'
|
|
2435
|
+
elements.contentBody.textContent = detail.content
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
try {
|
|
2439
|
+
const detailedNode = await fetchNodeDetails(node)
|
|
2440
|
+
if (state.selected?.id !== node.id) {
|
|
2441
|
+
return
|
|
2442
|
+
}
|
|
2443
|
+
applyDetailToDialog(detailedNode)
|
|
2444
|
+
} catch {
|
|
2445
|
+
try {
|
|
2446
|
+
await wait(120)
|
|
2447
|
+
const retriedNode = await fetchNodeDetails(node)
|
|
2448
|
+
if (state.selected?.id !== node.id) {
|
|
2449
|
+
return
|
|
2450
|
+
}
|
|
2451
|
+
applyDetailToDialog(retriedNode)
|
|
2452
|
+
} catch {
|
|
2453
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
const selectNode = (node, options = { openContent: false }) => {
|
|
2459
|
+
state.selected = node
|
|
2460
|
+
if (node && options.openContent) {
|
|
2461
|
+
openContentDialog(node).catch(() => {
|
|
2462
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
2463
|
+
})
|
|
2464
|
+
}
|
|
330
2465
|
}
|
|
331
2466
|
|
|
332
2467
|
const selectNodeById = id => {
|
|
@@ -334,45 +2469,142 @@ const selectNodeById = id => {
|
|
|
334
2469
|
if (node) selectNode(node, { openContent: true })
|
|
335
2470
|
}
|
|
336
2471
|
|
|
337
|
-
const
|
|
338
|
-
|
|
2472
|
+
const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
|
|
2473
|
+
const resolveZoomFactor = () => {
|
|
2474
|
+
if (state.nodes.length <= massiveGraphNodeThreshold) {
|
|
2475
|
+
return factor
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
const scale = state.transform.scale
|
|
2479
|
+
if (factor > 1) {
|
|
2480
|
+
if (scale < 0.006) return Math.max(factor, 1.48)
|
|
2481
|
+
if (scale < 0.02) return Math.max(factor, 1.34)
|
|
2482
|
+
if (scale < 0.08) return Math.max(factor, 1.22)
|
|
2483
|
+
return factor
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
if (scale < 0.006) return Math.min(factor, 0.68)
|
|
2487
|
+
if (scale < 0.02) return Math.min(factor, 0.78)
|
|
2488
|
+
if (scale < 0.08) return Math.min(factor, 0.86)
|
|
2489
|
+
return factor
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
state.lastManualZoomAt = performance.now()
|
|
2493
|
+
const effectiveFactor = resolveZoomFactor()
|
|
2494
|
+
const nextScale = clampScale(state.transform.scale * effectiveFactor)
|
|
2495
|
+
if (nextScale === state.transform.scale) {
|
|
2496
|
+
return
|
|
2497
|
+
}
|
|
2498
|
+
const worldX = (screenX - state.transform.x) / state.transform.scale
|
|
2499
|
+
const worldY = (screenY - state.transform.y) / state.transform.scale
|
|
2500
|
+
state.lastZoomFocus = {
|
|
2501
|
+
x: worldX,
|
|
2502
|
+
y: worldY,
|
|
2503
|
+
at: performance.now()
|
|
2504
|
+
}
|
|
2505
|
+
state.transform.scale = clampScale(nextScale)
|
|
2506
|
+
state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
|
|
2507
|
+
state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
|
|
2508
|
+
state.offscreenFrameCount = 0
|
|
2509
|
+
markRenderDirty()
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
const wheelZoomFactor = event => {
|
|
2513
|
+
const isModifierZoom = event.metaKey || event.ctrlKey
|
|
2514
|
+
const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
|
|
2515
|
+
const normalizedDelta = event.deltaY * deltaModeFactor
|
|
2516
|
+
|
|
2517
|
+
if (!Number.isFinite(normalizedDelta) || Math.abs(normalizedDelta) <= 0.0001) {
|
|
2518
|
+
return 1
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
const sensitivity = wheelZoomExponent * (isModifierZoom ? wheelZoomModifierBoost : 1)
|
|
2522
|
+
const exponent = Math.max(
|
|
2523
|
+
-wheelZoomExponentCap,
|
|
2524
|
+
Math.min(wheelZoomExponentCap, -normalizedDelta * sensitivity)
|
|
2525
|
+
)
|
|
2526
|
+
return Math.exp(exponent)
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
const handleWheelZoom = event => {
|
|
2530
|
+
if (elements.contentDialog?.open) {
|
|
2531
|
+
return
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
event.preventDefault()
|
|
2535
|
+
const rect = canvas.getBoundingClientRect()
|
|
2536
|
+
const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
|
|
2537
|
+
const rawCursorY = Number.isFinite(event.offsetY) ? event.offsetY : event.clientY - rect.top
|
|
2538
|
+
const cursorX = Math.max(0, Math.min(Math.max(rect.width, 320), rawCursorX))
|
|
2539
|
+
const cursorY = Math.max(0, Math.min(Math.max(rect.height, 320), rawCursorY))
|
|
2540
|
+
const factor = wheelZoomFactor(event)
|
|
2541
|
+
|
|
2542
|
+
if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
|
|
2543
|
+
return
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
zoomAtPoint(cursorX, cursorY, factor, 'wheel')
|
|
339
2547
|
}
|
|
340
2548
|
|
|
341
2549
|
const bindEvents = () => {
|
|
342
2550
|
window.addEventListener('resize', resize)
|
|
343
2551
|
elements.search.addEventListener('input', event => {
|
|
344
2552
|
state.query = event.target.value
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
: state.nodes.length + ' notes · ' + state.edges.length + ' links'
|
|
2553
|
+
recomputeVisibility()
|
|
2554
|
+
scheduleContentFilterSync()
|
|
348
2555
|
})
|
|
349
2556
|
elements.agent.addEventListener('change', event => {
|
|
350
2557
|
state.agentId = event.target.value
|
|
2558
|
+
writeStoredAgent(state.agentId)
|
|
2559
|
+
syncAgentInUrl(state.agentId)
|
|
351
2560
|
state.selected = null
|
|
2561
|
+
state.nodeDetails = new Map()
|
|
2562
|
+
resetContentFilter()
|
|
2563
|
+
recomputeVisibility()
|
|
2564
|
+
scheduleContentFilterSync()
|
|
352
2565
|
loadGraph({ reset: true }).catch(error => {
|
|
353
|
-
elements.stats.textContent = 'Failed to load agent graph'
|
|
354
2566
|
console.error(error)
|
|
355
2567
|
})
|
|
356
2568
|
})
|
|
357
|
-
elements.zoomIn.addEventListener('click', () =>
|
|
358
|
-
|
|
359
|
-
|
|
2569
|
+
elements.zoomIn.addEventListener('click', () => {
|
|
2570
|
+
const rect = canvas.getBoundingClientRect()
|
|
2571
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.14, 'button')
|
|
2572
|
+
})
|
|
2573
|
+
elements.zoomOut.addEventListener('click', () => {
|
|
2574
|
+
const rect = canvas.getBoundingClientRect()
|
|
2575
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.88, 'button')
|
|
2576
|
+
})
|
|
2577
|
+
if (elements.fit) {
|
|
2578
|
+
elements.fit.addEventListener('click', () => {
|
|
2579
|
+
focusPrimaryHub()
|
|
2580
|
+
})
|
|
2581
|
+
}
|
|
2582
|
+
elements.reset.addEventListener('click', () => {
|
|
2583
|
+
resetView()
|
|
2584
|
+
})
|
|
360
2585
|
elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
|
|
361
2586
|
elements.contentDialog.addEventListener('click', event => {
|
|
2587
|
+
const target = event.target
|
|
2588
|
+
if (target instanceof HTMLElement && target.dataset.nodeId) {
|
|
2589
|
+
selectNodeById(target.dataset.nodeId)
|
|
2590
|
+
return
|
|
2591
|
+
}
|
|
362
2592
|
if (event.target === elements.contentDialog) elements.contentDialog.close()
|
|
363
2593
|
})
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
2594
|
+
canvas.addEventListener('wheel', handleWheelZoom, { passive: false })
|
|
2595
|
+
canvas.addEventListener('dblclick', event => {
|
|
2596
|
+
const point = worldPoint(event)
|
|
2597
|
+
const node = hitNode(point)
|
|
2598
|
+
if (node) {
|
|
2599
|
+
selectNode(node, { openContent: true })
|
|
2600
|
+
return
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
const rect = canvas.getBoundingClientRect()
|
|
2604
|
+
const cursorX = event.clientX - rect.left
|
|
2605
|
+
const cursorY = event.clientY - rect.top
|
|
2606
|
+
zoomAtPoint(cursorX, cursorY, 1.12)
|
|
371
2607
|
})
|
|
372
|
-
canvas.addEventListener('wheel', event => {
|
|
373
|
-
event.preventDefault()
|
|
374
|
-
zoom(event.deltaY < 0 ? 1.08 : 0.92)
|
|
375
|
-
}, { passive: false })
|
|
376
2608
|
canvas.addEventListener('pointerdown', event => {
|
|
377
2609
|
const point = worldPoint(event)
|
|
378
2610
|
const node = hitNode(point)
|
|
@@ -380,12 +2612,24 @@ const bindEvents = () => {
|
|
|
380
2612
|
if (node) {
|
|
381
2613
|
node.x = point.x
|
|
382
2614
|
node.y = point.y
|
|
2615
|
+
markRenderDirty()
|
|
383
2616
|
}
|
|
384
2617
|
canvas.setPointerCapture(event.pointerId)
|
|
385
2618
|
})
|
|
386
2619
|
canvas.addEventListener('pointermove', event => {
|
|
387
2620
|
const point = worldPoint(event)
|
|
388
|
-
|
|
2621
|
+
const now = performance.now()
|
|
2622
|
+
const canHoverHitTest =
|
|
2623
|
+
!(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.06)
|
|
2624
|
+
const shouldHitTest = canHoverHitTest &&
|
|
2625
|
+
(state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
|
|
2626
|
+
if (shouldHitTest) {
|
|
2627
|
+
state.hovered = hitNode(point)
|
|
2628
|
+
state.lastHoverHitAt = now
|
|
2629
|
+
} else if (!canHoverHitTest) {
|
|
2630
|
+
state.hovered = null
|
|
2631
|
+
}
|
|
2632
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
389
2633
|
if (!state.pointer.down) return
|
|
390
2634
|
const dx = event.clientX - state.pointer.x
|
|
391
2635
|
const dy = event.clientY - state.pointer.y
|
|
@@ -393,36 +2637,83 @@ const bindEvents = () => {
|
|
|
393
2637
|
state.pointer.y = event.clientY
|
|
394
2638
|
state.pointer.moved = state.pointer.moved || Math.abs(dx) + Math.abs(dy) > 3
|
|
395
2639
|
if (state.pointer.dragNode) {
|
|
396
|
-
state.pointer.dragNode
|
|
397
|
-
|
|
2640
|
+
const dragNode = state.pointer.dragNode
|
|
2641
|
+
const previousX = dragNode.x
|
|
2642
|
+
const previousY = dragNode.y
|
|
2643
|
+
dragNode.x = point.x
|
|
2644
|
+
dragNode.y = point.y
|
|
2645
|
+
applyDragNeighborhoodAdjustment(dragNode, dragNode.x - previousX, dragNode.y - previousY)
|
|
2646
|
+
markRenderDirty()
|
|
398
2647
|
return
|
|
399
2648
|
}
|
|
400
2649
|
state.transform.x += dx
|
|
401
2650
|
state.transform.y += dy
|
|
2651
|
+
state.transform.x = clampTransformCoordinate(state.transform.x)
|
|
2652
|
+
state.transform.y = clampTransformCoordinate(state.transform.y)
|
|
2653
|
+
state.offscreenFrameCount = 0
|
|
2654
|
+
markRenderDirty()
|
|
402
2655
|
})
|
|
403
2656
|
canvas.addEventListener('pointerup', event => {
|
|
404
|
-
|
|
405
|
-
if (
|
|
2657
|
+
const draggedNode = state.pointer.dragNode
|
|
2658
|
+
if (draggedNode && state.pointer.moved) {
|
|
2659
|
+
settleNeighborhoodAroundNode(draggedNode)
|
|
2660
|
+
markRenderDirty()
|
|
2661
|
+
}
|
|
2662
|
+
if (draggedNode && !state.pointer.moved) selectNode(draggedNode, { openContent: false })
|
|
2663
|
+
if (!draggedNode && !state.pointer.moved) selectNode(state.hovered, { openContent: false })
|
|
406
2664
|
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
407
2665
|
canvas.releasePointerCapture(event.pointerId)
|
|
408
2666
|
})
|
|
2667
|
+
canvas.addEventListener('pointercancel', () => {
|
|
2668
|
+
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
2669
|
+
})
|
|
2670
|
+
canvas.addEventListener('pointerenter', event => {
|
|
2671
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
2672
|
+
})
|
|
2673
|
+
canvas.addEventListener('pointerleave', event => {
|
|
2674
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
|
|
2675
|
+
})
|
|
2676
|
+
window.addEventListener('keydown', event => {
|
|
2677
|
+
if (event.key === '+' || event.key === '=') {
|
|
2678
|
+
event.preventDefault()
|
|
2679
|
+
const rect = canvas.getBoundingClientRect()
|
|
2680
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.12)
|
|
2681
|
+
return
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
if (event.key === '-' || event.key === '_') {
|
|
2685
|
+
event.preventDefault()
|
|
2686
|
+
const rect = canvas.getBoundingClientRect()
|
|
2687
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.89)
|
|
2688
|
+
return
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
if (event.key === '0') {
|
|
2692
|
+
event.preventDefault()
|
|
2693
|
+
resetView()
|
|
2694
|
+
}
|
|
2695
|
+
})
|
|
409
2696
|
}
|
|
410
2697
|
|
|
411
2698
|
const loadAgents = async () => {
|
|
412
2699
|
const response = await fetch('/api/agents')
|
|
413
2700
|
const payload = await response.json()
|
|
414
2701
|
const agents = Array.isArray(payload.agents) ? payload.agents : []
|
|
415
|
-
const
|
|
2702
|
+
const preferredAgent = state.agentId || initialAgentFromUrl || readStoredAgent()
|
|
2703
|
+
const currentExists = agents.some(agent => agent.id === preferredAgent)
|
|
416
2704
|
const selected = currentExists
|
|
417
|
-
?
|
|
2705
|
+
? preferredAgent
|
|
418
2706
|
: (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
|
|
419
2707
|
const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
|
|
420
2708
|
|
|
421
2709
|
state.agentId = selected
|
|
2710
|
+
writeStoredAgent(selected)
|
|
2711
|
+
syncAgentInUrl(selected)
|
|
422
2712
|
if (signature !== state.agentsSignature) {
|
|
2713
|
+
const formatAgentLabel = (agent) => agent.id
|
|
423
2714
|
elements.agent.innerHTML = agents.length
|
|
424
|
-
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent
|
|
425
|
-
: '<option value="shared">shared
|
|
2715
|
+
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
|
|
2716
|
+
: '<option value="shared">shared</option>'
|
|
426
2717
|
state.agentsSignature = signature
|
|
427
2718
|
}
|
|
428
2719
|
elements.agent.value = selected
|
|
@@ -443,6 +2734,10 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
443
2734
|
|
|
444
2735
|
const payload = await response.json()
|
|
445
2736
|
const graph = payload?.layout ?? payload
|
|
2737
|
+
state.graphTotals = {
|
|
2738
|
+
nodes: Number.isFinite(payload?.totals?.nodes) ? payload.totals.nodes : (Array.isArray(graph.nodes) ? graph.nodes.length : 0),
|
|
2739
|
+
edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
|
|
2740
|
+
}
|
|
446
2741
|
const signature = payload?.signature ?? graphSignature(graph)
|
|
447
2742
|
if (!options.reset && signature === state.graphSignature) return
|
|
448
2743
|
const selectedId = state.selected?.id
|
|
@@ -450,18 +2745,37 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
450
2745
|
state.graphSignature = signature
|
|
451
2746
|
state.graph = graph
|
|
452
2747
|
state.nodes = layout.nodes
|
|
2748
|
+
state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
|
|
453
2749
|
state.edges = layout.edges
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
2750
|
+
state.nodeDegrees = state.edges.reduce((degrees, edge) => {
|
|
2751
|
+
degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
|
|
2752
|
+
if (edge.target) {
|
|
2753
|
+
degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
|
|
2754
|
+
}
|
|
2755
|
+
return degrees
|
|
2756
|
+
}, new Map())
|
|
2757
|
+
state.nodeDetails = new Map()
|
|
2758
|
+
pushNodesToFilterWorker()
|
|
2759
|
+
resetContentFilter()
|
|
2760
|
+
sanitizeAllNodePositions()
|
|
2761
|
+
recomputeVisibility()
|
|
2762
|
+
scheduleContentFilterSync()
|
|
2763
|
+
const tags = new Set(state.nodes.flatMap(node => node.tags))
|
|
2764
|
+
setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
|
|
2765
|
+
elements.nodeCount.textContent = state.graphTotals.nodes
|
|
2766
|
+
elements.edgeCount.textContent = state.graphTotals.edges
|
|
458
2767
|
elements.tagCount.textContent = tags.size
|
|
459
2768
|
resize()
|
|
460
2769
|
if (options.reset) resetView()
|
|
461
|
-
|
|
2770
|
+
const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
|
|
2771
|
+
selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
|
|
2772
|
+
if (!selectedNode && elements.contentDialog.open) {
|
|
2773
|
+
elements.contentDialog.close()
|
|
2774
|
+
}
|
|
462
2775
|
}
|
|
463
2776
|
|
|
464
2777
|
bindEvents()
|
|
2778
|
+
initFilterWorker()
|
|
465
2779
|
requestAnimationFrame(() => {
|
|
466
2780
|
resize()
|
|
467
2781
|
resetView()
|
|
@@ -492,7 +2806,6 @@ loadAgents()
|
|
|
492
2806
|
setInterval(refreshGraphLoop, pollIntervalMs)
|
|
493
2807
|
})
|
|
494
2808
|
.catch(error => {
|
|
495
|
-
elements.stats.textContent = 'Failed to load graph'
|
|
496
2809
|
console.error(error)
|
|
497
2810
|
})
|
|
498
2811
|
|