@andespindola/brainlink 0.1.0-beta.6 → 0.1.0-beta.60
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 +214 -100
- package/dist/application/frontend/client-html.js +60 -45
- package/dist/application/frontend/client-js.js +1765 -117
- 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 +91 -0
- 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 +989 -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 +62 -15
- 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 +30 -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 +178 -16
- 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,67 @@
|
|
|
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 renderEdgeBudget = 2400
|
|
8
|
+
const clusterActivationNodeThreshold = 600
|
|
9
|
+
const clusterZoomThreshold = 0.18
|
|
10
|
+
const macroGalaxyZoomThreshold = 0.012
|
|
11
|
+
const massiveAutoFitMacroScale = 0.006
|
|
12
|
+
const defaultMacroScale = 0.006
|
|
13
|
+
const clusterCellPixelSize = 64
|
|
14
|
+
const minNodePixelRadius = 2.3
|
|
15
|
+
const viewportPaddingPx = 280
|
|
16
|
+
const worldCoordinateLimit = 5_000_000
|
|
17
|
+
const transformCoordinateLimit = 20_000_000
|
|
18
|
+
const hoverHitTestIntervalMs = 64
|
|
19
|
+
const overviewClusterMaxCount = 1400
|
|
20
|
+
const zoomRecoveryGuardMs = 1500
|
|
21
|
+
const zoomCapTargetViewportShare = 0.72
|
|
22
|
+
const meshEdgeScaleThreshold = 0.09
|
|
23
|
+
const meshEdgeMinBudget = 140
|
|
24
|
+
const meshEdgeMaxBudget = 1400
|
|
3
25
|
const state = {
|
|
4
26
|
graph: { nodes: [], edges: [] },
|
|
5
27
|
nodes: [],
|
|
28
|
+
nodeById: new Map(),
|
|
6
29
|
edges: [],
|
|
30
|
+
visibleNodes: [],
|
|
31
|
+
visibleEdges: [],
|
|
32
|
+
renderNodes: [],
|
|
33
|
+
renderEdges: [],
|
|
34
|
+
renderClusters: [],
|
|
35
|
+
nodeDegrees: new Map(),
|
|
7
36
|
selected: null,
|
|
8
37
|
hovered: null,
|
|
9
38
|
query: '',
|
|
39
|
+
contentFilter: { query: '', ids: null, token: 0, timer: null },
|
|
10
40
|
agentId: '',
|
|
11
41
|
agentsSignature: '',
|
|
42
|
+
nodeDetails: new Map(),
|
|
12
43
|
transform: { x: 0, y: 0, scale: 1 },
|
|
13
44
|
pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
|
|
45
|
+
cursor: { x: 0, y: 0, inCanvas: false },
|
|
14
46
|
graphSignature: '',
|
|
15
47
|
graphStatus: '',
|
|
16
|
-
|
|
48
|
+
graphTotals: { nodes: 0, edges: 0 },
|
|
49
|
+
last: performance.now(),
|
|
50
|
+
offscreenFrameCount: 0,
|
|
51
|
+
recoveringViewport: false,
|
|
52
|
+
renderVisibilityDirty: true,
|
|
53
|
+
lastViewportKey: '',
|
|
54
|
+
visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
|
|
55
|
+
visibleEdgeByNode: new Map(),
|
|
56
|
+
overviewClusters: [],
|
|
57
|
+
macroCenter: { x: 0, y: 0 },
|
|
58
|
+
macroRepresentative: null,
|
|
59
|
+
primaryHub: null,
|
|
60
|
+
hubNeighborDistance: Number.POSITIVE_INFINITY,
|
|
61
|
+
filterWorker: null,
|
|
62
|
+
filterReady: false,
|
|
63
|
+
lastHoverHitAt: 0,
|
|
64
|
+
lastManualZoomAt: 0
|
|
17
65
|
}
|
|
18
66
|
|
|
19
67
|
const byId = id => document.getElementById(id)
|
|
@@ -24,39 +72,80 @@ const escapeHtml = value => String(value)
|
|
|
24
72
|
.replaceAll('"', '"')
|
|
25
73
|
.replaceAll("'", ''')
|
|
26
74
|
const elements = {
|
|
27
|
-
stats: byId('stats'),
|
|
28
75
|
search: byId('search'),
|
|
29
76
|
agent: byId('agent'),
|
|
30
|
-
title: byId('title'),
|
|
31
|
-
path: byId('path'),
|
|
32
|
-
tags: byId('tags'),
|
|
33
|
-
notes: byId('notes'),
|
|
34
|
-
content: byId('content'),
|
|
35
|
-
outgoing: byId('outgoing'),
|
|
36
|
-
incoming: byId('incoming'),
|
|
37
77
|
nodeCount: byId('nodeCount'),
|
|
38
78
|
edgeCount: byId('edgeCount'),
|
|
39
79
|
tagCount: byId('tagCount'),
|
|
40
80
|
zoomIn: byId('zoomIn'),
|
|
41
81
|
zoomOut: byId('zoomOut'),
|
|
42
|
-
|
|
82
|
+
fit: byId('fit'),
|
|
83
|
+
reset: byId('reset'),
|
|
84
|
+
contentDialog: byId('contentDialog'),
|
|
85
|
+
contentTitle: byId('contentTitle'),
|
|
86
|
+
contentPath: byId('contentPath'),
|
|
87
|
+
contentTags: byId('contentTags'),
|
|
88
|
+
contentOutgoing: byId('contentOutgoing'),
|
|
89
|
+
contentIncoming: byId('contentIncoming'),
|
|
90
|
+
contentBody: byId('contentBody'),
|
|
91
|
+
contentClose: byId('contentClose')
|
|
43
92
|
}
|
|
44
93
|
|
|
45
|
-
const
|
|
94
|
+
const zoomRange = {
|
|
95
|
+
min: 0.0002,
|
|
96
|
+
max: 4.5
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const initialAgentFromUrl = (() => {
|
|
100
|
+
try {
|
|
101
|
+
const raw = new URL(window.location.href).searchParams.get('agent')
|
|
102
|
+
const value = raw?.trim() ?? ''
|
|
103
|
+
return value.length > 0 ? value : ''
|
|
104
|
+
} catch {
|
|
105
|
+
return ''
|
|
106
|
+
}
|
|
107
|
+
})()
|
|
108
|
+
|
|
109
|
+
const selectedAgentStorageKey = 'brainlink:selected-agent'
|
|
110
|
+
|
|
111
|
+
const readStoredAgent = () => {
|
|
112
|
+
try {
|
|
113
|
+
const value = window.localStorage.getItem(selectedAgentStorageKey)?.trim() ?? ''
|
|
114
|
+
return value.length > 0 ? value : ''
|
|
115
|
+
} catch {
|
|
116
|
+
return ''
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const writeStoredAgent = (agentId) => {
|
|
121
|
+
try {
|
|
122
|
+
if (!agentId) {
|
|
123
|
+
window.localStorage.removeItem(selectedAgentStorageKey)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
window.localStorage.setItem(selectedAgentStorageKey, agentId)
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const syncAgentInUrl = (agentId) => {
|
|
131
|
+
try {
|
|
132
|
+
const url = new URL(window.location.href)
|
|
133
|
+
if (agentId && agentId.trim().length > 0) {
|
|
134
|
+
url.searchParams.set('agent', agentId)
|
|
135
|
+
} else {
|
|
136
|
+
url.searchParams.delete('agent')
|
|
137
|
+
}
|
|
138
|
+
window.history.replaceState({}, '', url.toString())
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
|
|
46
143
|
|
|
47
144
|
const setGraphStatus = text => {
|
|
48
145
|
state.graphStatus = text
|
|
49
|
-
elements.stats.textContent = text
|
|
50
146
|
}
|
|
51
147
|
|
|
52
148
|
const handleGraphRefreshError = error => {
|
|
53
|
-
if (state.graphSignature) {
|
|
54
|
-
elements.stats.textContent = state.graphStatus
|
|
55
|
-
console.error(error)
|
|
56
|
-
return
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
elements.stats.textContent = 'Failed to load graph'
|
|
60
149
|
console.error(error)
|
|
61
150
|
}
|
|
62
151
|
|
|
@@ -73,6 +162,67 @@ const graphTheme = {
|
|
|
73
162
|
label: '#edf2f7'
|
|
74
163
|
}
|
|
75
164
|
|
|
165
|
+
const initFilterWorker = () => {
|
|
166
|
+
if (typeof Worker === 'undefined') {
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const worker = new Worker('/app-worker.js')
|
|
171
|
+
worker.onmessage = event => {
|
|
172
|
+
const payload = event.data
|
|
173
|
+
if (!payload || typeof payload !== 'object') return
|
|
174
|
+
|
|
175
|
+
if (payload.type === 'ready') {
|
|
176
|
+
state.filterReady = true
|
|
177
|
+
if (state.nodes.length > 0) {
|
|
178
|
+
worker.postMessage({
|
|
179
|
+
type: 'load-nodes',
|
|
180
|
+
nodes: state.nodes.map(node => ({
|
|
181
|
+
id: node.id,
|
|
182
|
+
title: node.title,
|
|
183
|
+
path: node.path || '',
|
|
184
|
+
tags: Array.isArray(node.tags) ? node.tags : []
|
|
185
|
+
}))
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (payload.type === 'filter-result') {
|
|
192
|
+
const token = payload.token
|
|
193
|
+
if (token !== state.contentFilter.token) {
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const ids = Array.isArray(payload.ids) ? payload.ids.filter(id => typeof id === 'string') : []
|
|
198
|
+
state.contentFilter.query = normalizeQuery(state.query)
|
|
199
|
+
state.contentFilter.ids = new Set(ids)
|
|
200
|
+
recomputeVisibility()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
state.filterWorker = worker
|
|
204
|
+
} catch {
|
|
205
|
+
state.filterWorker = null
|
|
206
|
+
state.filterReady = false
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const pushNodesToFilterWorker = () => {
|
|
211
|
+
if (!state.filterWorker || !state.filterReady) {
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
state.filterWorker.postMessage({
|
|
216
|
+
type: 'load-nodes',
|
|
217
|
+
nodes: state.nodes.map(node => ({
|
|
218
|
+
id: node.id,
|
|
219
|
+
title: node.title,
|
|
220
|
+
path: node.path || '',
|
|
221
|
+
tags: Array.isArray(node.tags) ? node.tags : []
|
|
222
|
+
}))
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
76
226
|
const resize = () => {
|
|
77
227
|
const rect = canvas.getBoundingClientRect()
|
|
78
228
|
const width = Math.max(rect.width, 320)
|
|
@@ -81,40 +231,896 @@ const resize = () => {
|
|
|
81
231
|
canvas.width = Math.floor(width * ratio)
|
|
82
232
|
canvas.height = Math.floor(height * ratio)
|
|
83
233
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
234
|
+
markRenderDirty()
|
|
84
235
|
}
|
|
85
236
|
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
237
|
+
const normalizeQuery = value => value.trim().toLowerCase()
|
|
238
|
+
const hubNodeRetentionLimit = 2
|
|
239
|
+
const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map|mapa)\b/i
|
|
240
|
+
const memoryHubPathPattern = /\bmemory[-_\s]*hub\b/i
|
|
241
|
+
|
|
242
|
+
const hubNodeScore = node => {
|
|
243
|
+
const title = node.title.trim().toLowerCase()
|
|
244
|
+
if (title === 'memory hub') return 6
|
|
245
|
+
if (title === 'knowledge hub') return 5
|
|
246
|
+
if (memoryHubPathPattern.test(node.path || '')) return 4
|
|
247
|
+
if (node.tags.some(tag => tag.trim().toLowerCase() === 'memory-hub')) return 3
|
|
248
|
+
if (/\bmoc\b/i.test(node.title)) return 2
|
|
249
|
+
return hubNodePattern.test(node.title) || hubNodePattern.test(node.path || '') || node.tags.some(tag => hubNodePattern.test(tag))
|
|
250
|
+
? 1
|
|
251
|
+
: 0
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const localFilteredNodes = query =>
|
|
255
|
+
state.nodes.filter(node =>
|
|
90
256
|
node.title.toLowerCase().includes(query) ||
|
|
91
|
-
node.path.toLowerCase().includes(query) ||
|
|
257
|
+
(node.path || '').toLowerCase().includes(query) ||
|
|
92
258
|
node.tags.some(tag => tag.toLowerCase().includes(query))
|
|
93
259
|
)
|
|
260
|
+
|
|
261
|
+
const rankedHubNodes = () => {
|
|
262
|
+
if (state.nodes.length === 0) {
|
|
263
|
+
return []
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const byTitleAndDegree = [...state.nodes]
|
|
267
|
+
.filter(node => hubNodeScore(node) > 0)
|
|
268
|
+
.sort((left, right) => {
|
|
269
|
+
const byHubScore = hubNodeScore(right) - hubNodeScore(left)
|
|
270
|
+
if (byHubScore !== 0) return byHubScore
|
|
271
|
+
const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
|
|
272
|
+
if (byDegree !== 0) return byDegree
|
|
273
|
+
return left.title.localeCompare(right.title)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
if (byTitleAndDegree.length > 0) {
|
|
277
|
+
return byTitleAndDegree.slice(0, hubNodeRetentionLimit)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return [...state.nodes]
|
|
281
|
+
.sort((left, right) => {
|
|
282
|
+
const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
|
|
283
|
+
if (byDegree !== 0) return byDegree
|
|
284
|
+
return left.title.localeCompare(right.title)
|
|
285
|
+
})
|
|
286
|
+
.slice(0, 1)
|
|
94
287
|
}
|
|
95
288
|
|
|
96
|
-
const
|
|
289
|
+
const withPersistentHubNodes = nodes => {
|
|
290
|
+
if (nodes.length === 0) {
|
|
291
|
+
return rankedHubNodes()
|
|
292
|
+
}
|
|
97
293
|
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
return
|
|
294
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
295
|
+
const hubsToKeep = rankedHubNodes().filter(node => !ids.has(node.id))
|
|
296
|
+
return nodes.concat(hubsToKeep)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const filteredNodes = () => {
|
|
300
|
+
const query = normalizeQuery(state.query)
|
|
301
|
+
if (!query) return state.nodes
|
|
302
|
+
if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
|
|
303
|
+
const matched = state.nodes.filter(node => state.contentFilter.ids.has(node.id))
|
|
304
|
+
return withPersistentHubNodes(matched)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return withPersistentHubNodes(localFilteredNodes(query))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const resolveMacroRepresentative = (nodes) => {
|
|
311
|
+
if (nodes.length === 0) {
|
|
312
|
+
return null
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const hubCandidate = state.primaryHub && nodes.some(node => node.id === state.primaryHub.id)
|
|
316
|
+
? state.primaryHub
|
|
317
|
+
: null
|
|
318
|
+
let best = hubCandidate ?? nodes[0]
|
|
319
|
+
let bestDegree = state.nodeDegrees.get(best.id) ?? 0
|
|
320
|
+
|
|
321
|
+
for (let index = 1; index < nodes.length; index += 1) {
|
|
322
|
+
const node = nodes[index]
|
|
323
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
324
|
+
if (degree > bestDegree) {
|
|
325
|
+
best = node
|
|
326
|
+
bestDegree = degree
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return best
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const nearestHubNeighborDistance = (hub, nodes) => {
|
|
334
|
+
if (!hub || nodes.length <= 1) {
|
|
335
|
+
return Number.POSITIVE_INFINITY
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
let minimum = Number.POSITIVE_INFINITY
|
|
339
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
340
|
+
const node = nodes[index]
|
|
341
|
+
if (node.id === hub.id) continue
|
|
342
|
+
const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
|
|
343
|
+
if (distance < minimum) {
|
|
344
|
+
minimum = distance
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return minimum
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const recomputeVisibility = () => {
|
|
352
|
+
const nodes = filteredNodes()
|
|
353
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
354
|
+
const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
355
|
+
const limitedEdges = state.nodes.length > largeGraphNodeThreshold
|
|
356
|
+
? [...edges]
|
|
357
|
+
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
358
|
+
.slice(0, largeGraphEdgeRenderLimit)
|
|
359
|
+
: edges
|
|
360
|
+
|
|
361
|
+
state.visibleNodes = nodes
|
|
362
|
+
state.visibleEdges = limitedEdges
|
|
363
|
+
state.visibleNodeSpatial = createSpatialIndex(nodes)
|
|
364
|
+
state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
|
|
365
|
+
state.overviewClusters = nodes.length > massiveGraphNodeThreshold ? buildOverviewClusters(nodes) : []
|
|
366
|
+
const primaryHub = rankedHubNodes()[0] ?? null
|
|
367
|
+
state.primaryHub = primaryHub
|
|
368
|
+
state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
|
|
369
|
+
const bounds = graphBounds(nodes)
|
|
370
|
+
state.macroCenter = bounds
|
|
371
|
+
? {
|
|
372
|
+
x: primaryHub ? primaryHub.x : (bounds.minX + bounds.maxX) / 2,
|
|
373
|
+
y: primaryHub ? primaryHub.y : (bounds.minY + bounds.maxY) / 2
|
|
374
|
+
}
|
|
375
|
+
: { x: 0, y: 0 }
|
|
376
|
+
state.macroRepresentative = resolveMacroRepresentative(nodes)
|
|
377
|
+
markRenderDirty()
|
|
101
378
|
}
|
|
102
379
|
|
|
103
380
|
const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
|
|
381
|
+
const markRenderDirty = () => {
|
|
382
|
+
state.renderVisibilityDirty = true
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const createSpatialIndex = nodes => {
|
|
386
|
+
if (nodes.length === 0) {
|
|
387
|
+
return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const bounds = graphBounds(nodes)
|
|
391
|
+
if (!bounds) {
|
|
392
|
+
return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const targetNodesPerCell = 18
|
|
396
|
+
const approximateCellArea = Math.max((bounds.width * bounds.height) / Math.max(nodes.length / targetNodesPerCell, 1), 1)
|
|
397
|
+
const cellSize = Math.max(90, Math.min(2200, Math.sqrt(approximateCellArea)))
|
|
398
|
+
const buckets = new Map()
|
|
399
|
+
|
|
400
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
401
|
+
const node = nodes[index]
|
|
402
|
+
const cellX = Math.floor((node.x - bounds.minX) / cellSize)
|
|
403
|
+
const cellY = Math.floor((node.y - bounds.minY) / cellSize)
|
|
404
|
+
const key = cellX + ':' + cellY
|
|
405
|
+
const bucket = buckets.get(key)
|
|
406
|
+
if (bucket) {
|
|
407
|
+
bucket.push(node)
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
buckets.set(key, [node])
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
cellSize,
|
|
415
|
+
minX: bounds.minX,
|
|
416
|
+
minY: bounds.minY,
|
|
417
|
+
maxX: bounds.maxX,
|
|
418
|
+
maxY: bounds.maxY,
|
|
419
|
+
buckets
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const viewportNodesFromSpatialIndex = viewport => {
|
|
424
|
+
if (state.visibleNodes.length <= 2500) {
|
|
425
|
+
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const spatial = state.visibleNodeSpatial
|
|
429
|
+
if (!spatial || spatial.buckets.size === 0) {
|
|
430
|
+
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const minCellX = Math.floor((viewport.minX - spatial.minX) / spatial.cellSize)
|
|
434
|
+
const maxCellX = Math.floor((viewport.maxX - spatial.minX) / spatial.cellSize)
|
|
435
|
+
const minCellY = Math.floor((viewport.minY - spatial.minY) / spatial.cellSize)
|
|
436
|
+
const maxCellY = Math.floor((viewport.maxY - spatial.minY) / spatial.cellSize)
|
|
437
|
+
const nodes = []
|
|
438
|
+
|
|
439
|
+
for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
|
|
440
|
+
for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
|
|
441
|
+
const bucket = spatial.buckets.get(cellX + ':' + cellY)
|
|
442
|
+
if (!bucket) continue
|
|
443
|
+
|
|
444
|
+
for (let index = 0; index < bucket.length; index += 1) {
|
|
445
|
+
const node = bucket[index]
|
|
446
|
+
if (isNodeInViewport(node, viewport)) {
|
|
447
|
+
nodes.push(node)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return nodes
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const createVisibleEdgeLookup = edges => {
|
|
457
|
+
const lookup = new Map()
|
|
458
|
+
|
|
459
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
460
|
+
const edge = edges[index]
|
|
461
|
+
if (!edge.target) continue
|
|
462
|
+
|
|
463
|
+
const sourceList = lookup.get(edge.source)
|
|
464
|
+
if (sourceList) {
|
|
465
|
+
sourceList.push(edge)
|
|
466
|
+
} else {
|
|
467
|
+
lookup.set(edge.source, [edge])
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const targetList = lookup.get(edge.target)
|
|
471
|
+
if (targetList) {
|
|
472
|
+
targetList.push(edge)
|
|
473
|
+
} else {
|
|
474
|
+
lookup.set(edge.target, [edge])
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return lookup
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const buildOverviewClusters = nodes => {
|
|
482
|
+
if (nodes.length === 0) {
|
|
483
|
+
return []
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const bounds = graphBounds(nodes)
|
|
487
|
+
if (!bounds) {
|
|
488
|
+
return []
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const longest = Math.max(bounds.width, bounds.height, 1)
|
|
492
|
+
const cellSize = Math.max(longest / 56, 900)
|
|
493
|
+
const buckets = new Map()
|
|
494
|
+
|
|
495
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
496
|
+
const node = nodes[index]
|
|
497
|
+
const keyX = Math.floor((node.x - bounds.minX) / cellSize)
|
|
498
|
+
const keyY = Math.floor((node.y - bounds.minY) / cellSize)
|
|
499
|
+
const key = keyX + ':' + keyY
|
|
500
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
501
|
+
const current = buckets.get(key)
|
|
502
|
+
if (current) {
|
|
503
|
+
current.count += 1
|
|
504
|
+
current.sumX += node.x
|
|
505
|
+
current.sumY += node.y
|
|
506
|
+
if (degree > current.degree) {
|
|
507
|
+
current.representative = node
|
|
508
|
+
current.degree = degree
|
|
509
|
+
}
|
|
510
|
+
continue
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
buckets.set(key, {
|
|
514
|
+
id: key,
|
|
515
|
+
count: 1,
|
|
516
|
+
sumX: node.x,
|
|
517
|
+
sumY: node.y,
|
|
518
|
+
representative: node,
|
|
519
|
+
degree
|
|
520
|
+
})
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return Array.from(buckets.values())
|
|
524
|
+
.sort((left, right) => right.count - left.count)
|
|
525
|
+
.slice(0, overviewClusterMaxCount)
|
|
526
|
+
.map((cluster) => ({
|
|
527
|
+
id: cluster.id,
|
|
528
|
+
x: cluster.sumX / Math.max(cluster.count, 1),
|
|
529
|
+
y: cluster.sumY / Math.max(cluster.count, 1),
|
|
530
|
+
count: cluster.count,
|
|
531
|
+
representative: cluster.representative
|
|
532
|
+
}))
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const filterOverviewClustersByViewport = viewport =>
|
|
536
|
+
state.overviewClusters.filter((cluster) =>
|
|
537
|
+
cluster.x >= viewport.minX &&
|
|
538
|
+
cluster.x <= viewport.maxX &&
|
|
539
|
+
cluster.y >= viewport.minY &&
|
|
540
|
+
cluster.y <= viewport.maxY
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
const edgeBudgetForCurrentFrame = () => {
|
|
544
|
+
const zoom = state.transform.scale
|
|
545
|
+
if (zoom < 0.12) return 380
|
|
546
|
+
if (zoom < 0.18) return 900
|
|
547
|
+
if (zoom < 0.28) return 1700
|
|
548
|
+
if (zoom < 0.45) return 2800
|
|
549
|
+
if (zoom < 0.7) return 4200
|
|
550
|
+
if (zoom < 1.05) return 5600
|
|
551
|
+
return 7600
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const clusterBudgetForScale = (scale) => {
|
|
555
|
+
if (scale < 0.008) return 90
|
|
556
|
+
if (scale < 0.014) return 150
|
|
557
|
+
if (scale < 0.022) return 240
|
|
558
|
+
if (scale < 0.035) return 360
|
|
559
|
+
return 520
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const nodeBudgetForScale = (scale) => {
|
|
563
|
+
if (scale < 0.035) return 220
|
|
564
|
+
if (scale < 0.06) return 360
|
|
565
|
+
if (scale < 0.09) return 520
|
|
566
|
+
if (scale < 0.14) return 720
|
|
567
|
+
return renderNodeBudget
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const edgeIdentityKey = edge => {
|
|
571
|
+
if (!edge.target) return ''
|
|
572
|
+
const pair = edge.source < edge.target
|
|
573
|
+
? edge.source + '|' + edge.target
|
|
574
|
+
: edge.target + '|' + edge.source
|
|
575
|
+
return pair + '|' + (edge.inferred ? 'mesh' : 'real')
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const edgeRelevanceScore = edge => {
|
|
579
|
+
let score = edgeWeight(edge) * 10
|
|
580
|
+
if (!edge.inferred) {
|
|
581
|
+
score += 8
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const selectedId = state.selected?.id
|
|
585
|
+
if (selectedId && (edge.source === selectedId || edge.target === selectedId)) {
|
|
586
|
+
score += 120
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const hoveredId = state.hovered?.id
|
|
590
|
+
if (hoveredId && (edge.source === hoveredId || edge.target === hoveredId)) {
|
|
591
|
+
score += 70
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const hubId = state.primaryHub?.id
|
|
595
|
+
if (hubId && (edge.source === hubId || edge.target === hubId)) {
|
|
596
|
+
score += 42
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return score
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const collectVisibleEdgesForNodes = nodeIds => {
|
|
603
|
+
if (nodeIds.size === 0) {
|
|
604
|
+
return []
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const seen = new Set()
|
|
608
|
+
const candidates = []
|
|
609
|
+
const limit = edgeBudgetForCurrentFrame()
|
|
610
|
+
|
|
611
|
+
nodeIds.forEach(nodeId => {
|
|
612
|
+
const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
613
|
+
for (let index = 0; index < candidateEdges.length; index += 1) {
|
|
614
|
+
const edge = candidateEdges[index]
|
|
615
|
+
if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
|
616
|
+
continue
|
|
617
|
+
}
|
|
618
|
+
const key = edgeIdentityKey(edge)
|
|
619
|
+
if (seen.has(key)) continue
|
|
620
|
+
|
|
621
|
+
seen.add(key)
|
|
622
|
+
candidates.push(edge)
|
|
623
|
+
}
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
if (candidates.length <= limit) {
|
|
627
|
+
return candidates
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return candidates
|
|
631
|
+
.sort((left, right) => {
|
|
632
|
+
const scoreDelta = edgeRelevanceScore(right) - edgeRelevanceScore(left)
|
|
633
|
+
if (scoreDelta !== 0) {
|
|
634
|
+
return scoreDelta
|
|
635
|
+
}
|
|
636
|
+
const leftKey = edgeIdentityKey(left)
|
|
637
|
+
const rightKey = edgeIdentityKey(right)
|
|
638
|
+
return leftKey.localeCompare(rightKey)
|
|
639
|
+
})
|
|
640
|
+
.slice(0, limit)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const edgeOpacityForScale = (edge, scale) => {
|
|
644
|
+
if (edge.inferred) {
|
|
645
|
+
if (scale < 0.2) return 0.06
|
|
646
|
+
if (scale < 0.4) return 0.08
|
|
647
|
+
if (scale < 0.7) return 0.1
|
|
648
|
+
return 0.14
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (scale < 0.2) return 0.14
|
|
652
|
+
if (scale < 0.4) return 0.2
|
|
653
|
+
if (scale < 0.7) return 0.28
|
|
654
|
+
if (scale < 1.05) return 0.36
|
|
655
|
+
return 0.46
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const edgeStrokeFor = (edge, selectedEdge) => {
|
|
659
|
+
if (selectedEdge) {
|
|
660
|
+
return graphTheme.edgeActive
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const opacity = edgeOpacityForScale(edge, state.transform.scale)
|
|
664
|
+
return edge.inferred
|
|
665
|
+
? 'rgba(203, 213, 225, ' + opacity + ')'
|
|
666
|
+
: 'rgba(153, 165, 181, ' + opacity + ')'
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const edgeWidthFor = (edge, selectedEdge) => {
|
|
670
|
+
if (edge.inferred) {
|
|
671
|
+
return selectedEdge ? 1.22 : 0.84
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return (selectedEdge ? 1.9 : 1.05) + Math.min(edgeWeight(edge) - 1, 8) * 0.24
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const drawGraphEdge = (edge) => {
|
|
678
|
+
const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
679
|
+
ctx.beginPath()
|
|
680
|
+
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
|
|
681
|
+
ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
|
|
682
|
+
ctx.strokeStyle = edgeStrokeFor(edge, selectedEdge)
|
|
683
|
+
ctx.lineWidth = edgeWidthFor(edge, selectedEdge)
|
|
684
|
+
ctx.stroke()
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const drawGraphEdges = () => {
|
|
688
|
+
const selectedEdges = []
|
|
689
|
+
const regularEdges = []
|
|
690
|
+
for (let index = 0; index < state.renderEdges.length; index += 1) {
|
|
691
|
+
const edge = state.renderEdges[index]
|
|
692
|
+
const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
693
|
+
if (isSelected) {
|
|
694
|
+
selectedEdges.push(edge)
|
|
695
|
+
} else {
|
|
696
|
+
regularEdges.push(edge)
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
for (let index = 0; index < regularEdges.length; index += 1) {
|
|
701
|
+
drawGraphEdge(regularEdges[index])
|
|
702
|
+
}
|
|
703
|
+
for (let index = 0; index < selectedEdges.length; index += 1) {
|
|
704
|
+
drawGraphEdge(selectedEdges[index])
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const edgePairKey = (source, target) =>
|
|
709
|
+
source < target ? source + '|' + target : target + '|' + source
|
|
710
|
+
|
|
711
|
+
const meshNeighborBuckets = (nodes, cellSize) => {
|
|
712
|
+
const buckets = new Map()
|
|
713
|
+
|
|
714
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
715
|
+
const node = nodes[index]
|
|
716
|
+
const cellX = Math.floor(node.x / cellSize)
|
|
717
|
+
const cellY = Math.floor(node.y / cellSize)
|
|
718
|
+
const key = cellX + ':' + cellY
|
|
719
|
+
const bucket = buckets.get(key)
|
|
720
|
+
if (bucket) {
|
|
721
|
+
bucket.push(node)
|
|
722
|
+
} else {
|
|
723
|
+
buckets.set(key, [node])
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return buckets
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const meshCandidatesForNode = (node, buckets, cellSize) => {
|
|
731
|
+
const cellX = Math.floor(node.x / cellSize)
|
|
732
|
+
const cellY = Math.floor(node.y / cellSize)
|
|
733
|
+
const candidates = []
|
|
734
|
+
|
|
735
|
+
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
|
736
|
+
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
|
737
|
+
const bucket = buckets.get((cellX + offsetX) + ':' + (cellY + offsetY))
|
|
738
|
+
if (!bucket) continue
|
|
739
|
+
for (let index = 0; index < bucket.length; index += 1) {
|
|
740
|
+
const candidate = bucket[index]
|
|
741
|
+
if (candidate.id !== node.id) {
|
|
742
|
+
candidates.push(candidate)
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return candidates
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const buildMeshEdgesForNodes = (nodes, existingEdges) => {
|
|
752
|
+
if (nodes.length < 2 || state.transform.scale < meshEdgeScaleThreshold) {
|
|
753
|
+
return []
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const existingKeys = new Set()
|
|
757
|
+
for (let index = 0; index < existingEdges.length; index += 1) {
|
|
758
|
+
const edge = existingEdges[index]
|
|
759
|
+
if (edge.target) {
|
|
760
|
+
existingKeys.add(edgePairKey(edge.source, edge.target))
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const desiredBudget = Math.min(
|
|
765
|
+
meshEdgeMaxBudget,
|
|
766
|
+
Math.max(meshEdgeMinBudget, Math.floor(edgeBudgetForCurrentFrame() * 0.62))
|
|
767
|
+
)
|
|
768
|
+
const perNodeNeighborCount =
|
|
769
|
+
state.transform.scale >= 1.05 ? 4
|
|
770
|
+
: state.transform.scale >= 0.62 ? 3
|
|
771
|
+
: 2
|
|
772
|
+
const cellSize = Math.max(120, 280 / Math.max(state.transform.scale, 0.0001))
|
|
773
|
+
const maxDistance = 980
|
|
774
|
+
const maxDistanceSquared = maxDistance * maxDistance
|
|
775
|
+
const buckets = meshNeighborBuckets(nodes, cellSize)
|
|
776
|
+
const meshEdges = []
|
|
777
|
+
const meshKeys = new Set()
|
|
778
|
+
|
|
779
|
+
for (let index = 0; index < nodes.length && meshEdges.length < desiredBudget; index += 1) {
|
|
780
|
+
const node = nodes[index]
|
|
781
|
+
const candidates = meshCandidatesForNode(node, buckets, cellSize)
|
|
782
|
+
.map((candidate) => ({
|
|
783
|
+
node: candidate,
|
|
784
|
+
distanceSquared: (candidate.x - node.x) ** 2 + (candidate.y - node.y) ** 2
|
|
785
|
+
}))
|
|
786
|
+
.filter((candidate) => candidate.distanceSquared <= maxDistanceSquared)
|
|
787
|
+
.sort((left, right) => left.distanceSquared - right.distanceSquared)
|
|
788
|
+
|
|
789
|
+
let linked = 0
|
|
790
|
+
for (let candidateIndex = 0; candidateIndex < candidates.length && linked < perNodeNeighborCount && meshEdges.length < desiredBudget; candidateIndex += 1) {
|
|
791
|
+
const candidate = candidates[candidateIndex].node
|
|
792
|
+
const key = edgePairKey(node.id, candidate.id)
|
|
793
|
+
if (existingKeys.has(key) || meshKeys.has(key)) {
|
|
794
|
+
continue
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
meshKeys.add(key)
|
|
798
|
+
meshEdges.push({
|
|
799
|
+
source: node.id,
|
|
800
|
+
target: candidate.id,
|
|
801
|
+
targetTitle: candidate.title,
|
|
802
|
+
weight: 1,
|
|
803
|
+
priority: 'normal',
|
|
804
|
+
sourceNode: node,
|
|
805
|
+
targetNode: candidate,
|
|
806
|
+
inferred: true
|
|
807
|
+
})
|
|
808
|
+
linked += 1
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return meshEdges
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const withMeshEdges = (nodes, edges) => {
|
|
816
|
+
if (nodes.length === 0 || state.visibleNodes.length <= largeGraphNodeThreshold || state.transform.scale < meshEdgeScaleThreshold) {
|
|
817
|
+
return edges
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const meshEdges = buildMeshEdgesForNodes(nodes, edges)
|
|
821
|
+
return meshEdges.length > 0 ? edges.concat(meshEdges) : edges
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const fallbackViewportNodes = () => {
|
|
825
|
+
const nodes = []
|
|
826
|
+
const maxNodes = Math.min(renderNodeBudget, 220)
|
|
827
|
+
const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
|
|
828
|
+
|
|
829
|
+
for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
|
|
830
|
+
nodes.push(state.visibleNodes[index])
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
834
|
+
nodes.push(state.selected)
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return nodes
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const sampleVisibleNodes = (limit = renderNodeBudget, sourceNodes = state.visibleNodes) => {
|
|
841
|
+
if (sourceNodes.length === 0 || limit <= 0) {
|
|
842
|
+
return []
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const nodes = []
|
|
846
|
+
const maxNodes = Math.min(Math.max(limit, 1), sourceNodes.length)
|
|
847
|
+
const step = Math.max(1, Math.ceil(sourceNodes.length / maxNodes))
|
|
848
|
+
|
|
849
|
+
for (let index = 0; index < sourceNodes.length && nodes.length < maxNodes; index += step) {
|
|
850
|
+
nodes.push(sourceNodes[index])
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
854
|
+
nodes.push(state.selected)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
return nodes
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const enrichSampleWithNeighbors = (nodes) => {
|
|
861
|
+
if (nodes.length === 0) {
|
|
862
|
+
return {
|
|
863
|
+
nodes,
|
|
864
|
+
edges: []
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const maxNodes = Math.min(renderNodeBudget, nodes.length + 200)
|
|
869
|
+
const expanded = [...nodes]
|
|
870
|
+
const ids = new Set(expanded.map((node) => node.id))
|
|
871
|
+
|
|
872
|
+
for (let index = 0; index < nodes.length && expanded.length < maxNodes; index += 1) {
|
|
873
|
+
const node = nodes[index]
|
|
874
|
+
const candidates = [...(state.visibleEdgeByNode.get(node.id) ?? [])]
|
|
875
|
+
.filter((edge) => edge.target)
|
|
876
|
+
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
877
|
+
.slice(0, 3)
|
|
878
|
+
|
|
879
|
+
for (let candidateIndex = 0; candidateIndex < candidates.length && expanded.length < maxNodes; candidateIndex += 1) {
|
|
880
|
+
const edge = candidates[candidateIndex]
|
|
881
|
+
const otherId = edge.source === node.id ? edge.target : edge.source
|
|
882
|
+
|
|
883
|
+
if (!otherId || ids.has(otherId)) {
|
|
884
|
+
continue
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const otherNode = state.nodeById.get(otherId)
|
|
888
|
+
if (!otherNode) {
|
|
889
|
+
continue
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
ids.add(otherId)
|
|
893
|
+
expanded.push(otherNode)
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const edges = collectVisibleEdgesForNodes(ids)
|
|
898
|
+
|
|
899
|
+
return {
|
|
900
|
+
nodes: expanded,
|
|
901
|
+
edges
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const ensureHubNodesInRenderedSet = (nodes) => {
|
|
906
|
+
if (nodes.length === 0) {
|
|
907
|
+
return nodes
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const maxNodes = Math.max(Math.min(renderNodeBudget, nodes.length), 1)
|
|
911
|
+
const ids = new Set(nodes.map((node) => node.id))
|
|
912
|
+
const hubs = rankedHubNodes()
|
|
913
|
+
const merged = [...nodes]
|
|
914
|
+
|
|
915
|
+
for (let index = 0; index < hubs.length; index += 1) {
|
|
916
|
+
const hub = hubs[index]
|
|
917
|
+
if (ids.has(hub.id)) {
|
|
918
|
+
continue
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (merged.length < maxNodes) {
|
|
922
|
+
merged.push(hub)
|
|
923
|
+
ids.add(hub.id)
|
|
924
|
+
continue
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const replacementIndex = merged.findIndex((node) => !hubs.some((candidate) => candidate.id === node.id))
|
|
928
|
+
if (replacementIndex >= 0) {
|
|
929
|
+
ids.delete(merged[replacementIndex].id)
|
|
930
|
+
merged[replacementIndex] = hub
|
|
931
|
+
ids.add(hub.id)
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
return merged
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const zoomCapByNodeCount = (nodeCount) => {
|
|
939
|
+
if (nodeCount > 50000) return 2.6
|
|
940
|
+
if (nodeCount > 20000) return 2.35
|
|
941
|
+
if (nodeCount > 6000) return 2.1
|
|
942
|
+
if (nodeCount > 2000) return 2.2
|
|
943
|
+
return zoomRange.max
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const zoomCapByHubDistance = (distance) => {
|
|
947
|
+
if (!Number.isFinite(distance) || distance <= 0) {
|
|
948
|
+
return zoomRange.max
|
|
949
|
+
}
|
|
104
950
|
|
|
105
|
-
const resetView = () => {
|
|
106
951
|
const rect = canvas.getBoundingClientRect()
|
|
107
|
-
|
|
952
|
+
const viewportWidth = Math.max(rect.width, 320)
|
|
953
|
+
const viewportHeight = Math.max(rect.height, 320)
|
|
954
|
+
const reference = Math.max(220, Math.min(viewportWidth, viewportHeight) * zoomCapTargetViewportShare)
|
|
955
|
+
return Math.max(0.3, Math.min(zoomRange.max, reference / distance))
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const currentZoomMax = () => {
|
|
959
|
+
const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
|
|
960
|
+
const hubDistanceCap = zoomCapByHubDistance(state.hubNeighborDistance)
|
|
961
|
+
const minimumUsefulCap = nodeCount > massiveGraphNodeThreshold ? 1.9 : nodeCount > largeGraphNodeThreshold ? 1.35 : 0.8
|
|
962
|
+
const capped = Math.min(zoomCapByNodeCount(nodeCount), Math.max(minimumUsefulCap, hubDistanceCap))
|
|
963
|
+
return Math.max(zoomRange.min * 2, capped)
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const clampScale = value => Math.max(zoomRange.min, Math.min(currentZoomMax(), value))
|
|
967
|
+
const isFiniteNumber = value => Number.isFinite(value)
|
|
968
|
+
const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
|
|
969
|
+
const clampTransformCoordinate = value => {
|
|
970
|
+
if (!isFiniteNumber(value)) return 0
|
|
971
|
+
if (value > transformCoordinateLimit) return transformCoordinateLimit
|
|
972
|
+
if (value < -transformCoordinateLimit) return -transformCoordinateLimit
|
|
973
|
+
return value
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const graphBounds = nodes => {
|
|
977
|
+
if (nodes.length === 0) return null
|
|
978
|
+
let minX = Number.POSITIVE_INFINITY
|
|
979
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
980
|
+
let minY = Number.POSITIVE_INFINITY
|
|
981
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
982
|
+
|
|
983
|
+
nodes.forEach(node => {
|
|
984
|
+
const radius = baseNodeRadius(node)
|
|
985
|
+
minX = Math.min(minX, node.x - radius)
|
|
986
|
+
maxX = Math.max(maxX, node.x + radius)
|
|
987
|
+
minY = Math.min(minY, node.y - radius)
|
|
988
|
+
maxY = Math.max(maxY, node.y + radius)
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
return {
|
|
992
|
+
minX,
|
|
993
|
+
maxX,
|
|
994
|
+
minY,
|
|
995
|
+
maxY,
|
|
996
|
+
width: Math.max(maxX - minX, 1),
|
|
997
|
+
height: Math.max(maxY - minY, 1)
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const fitScaleBiasByNodeCount = nodeCount => {
|
|
1002
|
+
if (nodeCount <= 6) return 1.22
|
|
1003
|
+
if (nodeCount <= 20) return 1.12
|
|
1004
|
+
if (nodeCount <= 60) return 1.04
|
|
1005
|
+
if (nodeCount <= 180) return 1
|
|
1006
|
+
if (nodeCount <= 600) return 0.94
|
|
1007
|
+
if (nodeCount <= 2000) return 0.82
|
|
1008
|
+
if (nodeCount <= 6000) return 0.68
|
|
1009
|
+
return 0.56
|
|
108
1010
|
}
|
|
109
1011
|
|
|
1012
|
+
const autoFitScaleRangeByNodeCount = nodeCount => {
|
|
1013
|
+
if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
|
|
1014
|
+
if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
|
|
1015
|
+
if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
|
|
1016
|
+
if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
|
|
1017
|
+
if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
|
|
1018
|
+
if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
|
|
1019
|
+
if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
|
|
1020
|
+
return { min: 0.0008, max: 0.24 }
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
|
|
1024
|
+
const rect = canvas.getBoundingClientRect()
|
|
1025
|
+
const width = Math.max(rect.width, 320)
|
|
1026
|
+
const height = Math.max(rect.height, 320)
|
|
1027
|
+
const nodes = options.useFiltered ? filteredNodes() : state.nodes
|
|
1028
|
+
const bounds = graphBounds(nodes)
|
|
1029
|
+
|
|
1030
|
+
if (!bounds) {
|
|
1031
|
+
state.transform = { x: width / 2, y: height / 2, scale: 1 }
|
|
1032
|
+
state.offscreenFrameCount = 0
|
|
1033
|
+
state.recoveringViewport = false
|
|
1034
|
+
markRenderDirty()
|
|
1035
|
+
return
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const paddingByNodeCount = nodeCount => {
|
|
1039
|
+
if (nodeCount <= 6) return 28
|
|
1040
|
+
if (nodeCount <= 20) return 44
|
|
1041
|
+
if (nodeCount <= 60) return 68
|
|
1042
|
+
if (nodeCount <= 180) return 86
|
|
1043
|
+
if (nodeCount <= 600) return 110
|
|
1044
|
+
if (nodeCount <= 2000) return 140
|
|
1045
|
+
return 180
|
|
1046
|
+
}
|
|
1047
|
+
const padding = paddingByNodeCount(nodes.length)
|
|
1048
|
+
const scaleX = width / (bounds.width + padding * 2)
|
|
1049
|
+
const scaleY = height / (bounds.height + padding * 2)
|
|
1050
|
+
const fitScale = Math.min(scaleX, scaleY)
|
|
1051
|
+
const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
|
|
1052
|
+
const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
|
|
1053
|
+
const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
|
|
1054
|
+
const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
|
|
1055
|
+
const scale = options.macro && nodes.length > 1
|
|
1056
|
+
? clampScale(Math.min(baselineScale, macroScale))
|
|
1057
|
+
: nodes.length > massiveGraphNodeThreshold
|
|
1058
|
+
? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
|
|
1059
|
+
: baselineScale
|
|
1060
|
+
const hubCenter =
|
|
1061
|
+
options.preferHubCenter && state.primaryHub && nodes.some((node) => node.id === state.primaryHub.id)
|
|
1062
|
+
? state.primaryHub
|
|
1063
|
+
: null
|
|
1064
|
+
const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
|
|
1065
|
+
const centerY = hubCenter ? hubCenter.y : (bounds.minY + bounds.maxY) / 2
|
|
1066
|
+
|
|
1067
|
+
state.transform = {
|
|
1068
|
+
x: clampTransformCoordinate(width / 2 - centerX * scale),
|
|
1069
|
+
y: clampTransformCoordinate(height / 2 - centerY * scale),
|
|
1070
|
+
scale: clampScale(scale)
|
|
1071
|
+
}
|
|
1072
|
+
state.offscreenFrameCount = 0
|
|
1073
|
+
state.recoveringViewport = false
|
|
1074
|
+
markRenderDirty()
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const resetView = () => fitView({ useFiltered: false, macro: true, preferHubCenter: true })
|
|
1078
|
+
|
|
110
1079
|
const createLayout = graph => {
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
1080
|
+
const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
|
|
1081
|
+
const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
|
|
1082
|
+
const nodes = nodeRows.map(node => {
|
|
1083
|
+
if (Array.isArray(node)) {
|
|
1084
|
+
const [id, title, x, y, group, segment] = node
|
|
1085
|
+
return {
|
|
1086
|
+
id: typeof id === 'string' ? id : '',
|
|
1087
|
+
title: typeof title === 'string' ? title : 'Untitled',
|
|
1088
|
+
path: '',
|
|
1089
|
+
tags: [],
|
|
1090
|
+
group: typeof group === 'string' ? group : 'root',
|
|
1091
|
+
segment: typeof segment === 'string' ? segment : 'root',
|
|
1092
|
+
x: Number.isFinite(x) ? x : 0,
|
|
1093
|
+
y: Number.isFinite(y) ? y : 0,
|
|
1094
|
+
vx: 0,
|
|
1095
|
+
vy: 0
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
return {
|
|
1100
|
+
...node,
|
|
1101
|
+
path: typeof node.path === 'string' ? node.path : '',
|
|
1102
|
+
tags: Array.isArray(node.tags) ? node.tags : [],
|
|
1103
|
+
x: Number.isFinite(node.x) ? node.x : 0,
|
|
1104
|
+
y: Number.isFinite(node.y) ? node.y : 0,
|
|
1105
|
+
vx: Number.isFinite(node.vx) ? node.vx : 0,
|
|
1106
|
+
vy: Number.isFinite(node.vy) ? node.vy : 0
|
|
1107
|
+
}
|
|
1108
|
+
})
|
|
116
1109
|
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
117
|
-
const edges =
|
|
1110
|
+
const edges = edgeRows
|
|
1111
|
+
.map(edge => {
|
|
1112
|
+
if (Array.isArray(edge)) {
|
|
1113
|
+
const [source, target, weight, priority] = edge
|
|
1114
|
+
return {
|
|
1115
|
+
source: typeof source === 'string' ? source : '',
|
|
1116
|
+
target: typeof target === 'string' ? target : null,
|
|
1117
|
+
targetTitle: '',
|
|
1118
|
+
weight: Number.isFinite(weight) ? weight : 1,
|
|
1119
|
+
priority: typeof priority === 'string' ? priority : 'normal'
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
return edge
|
|
1123
|
+
})
|
|
118
1124
|
.filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
|
|
119
1125
|
.map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
|
|
120
1126
|
return { nodes, edges }
|
|
@@ -128,29 +1134,107 @@ const encodeEntityTag = (value) => {
|
|
|
128
1134
|
binary += String.fromCharCode(utf8[index])
|
|
129
1135
|
}
|
|
130
1136
|
|
|
131
|
-
return btoa(binary).
|
|
1137
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
|
|
132
1138
|
}
|
|
133
1139
|
|
|
134
1140
|
const graphSignature = graph => JSON.stringify({
|
|
135
|
-
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.
|
|
1141
|
+
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
|
|
136
1142
|
edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
|
|
137
1143
|
})
|
|
138
1144
|
|
|
1145
|
+
const resetContentFilter = () => {
|
|
1146
|
+
if (state.contentFilter.timer) {
|
|
1147
|
+
clearTimeout(state.contentFilter.timer)
|
|
1148
|
+
}
|
|
1149
|
+
state.contentFilter = {
|
|
1150
|
+
query: '',
|
|
1151
|
+
ids: null,
|
|
1152
|
+
token: state.contentFilter.token + 1,
|
|
1153
|
+
timer: null
|
|
1154
|
+
}
|
|
1155
|
+
recomputeVisibility()
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const syncContentFilter = async (query, token) => {
|
|
1159
|
+
const response = await fetch(
|
|
1160
|
+
'/api/graph-filter?q=' +
|
|
1161
|
+
encodeURIComponent(query) +
|
|
1162
|
+
'&limit=' +
|
|
1163
|
+
encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
|
|
1164
|
+
agentQuery('&')
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
if (!response.ok || token !== state.contentFilter.token) {
|
|
1168
|
+
return
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const payload = await response.json()
|
|
1172
|
+
const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
|
|
1173
|
+
if (token !== state.contentFilter.token) {
|
|
1174
|
+
return
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
state.contentFilter.query = query
|
|
1178
|
+
const merged = new Set([...(state.contentFilter.ids instanceof Set ? state.contentFilter.ids : []), ...nodeIds])
|
|
1179
|
+
state.contentFilter.ids = merged
|
|
1180
|
+
recomputeVisibility()
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const scheduleContentFilterSync = () => {
|
|
1184
|
+
const query = normalizeQuery(state.query)
|
|
1185
|
+
if (!query) {
|
|
1186
|
+
resetContentFilter()
|
|
1187
|
+
return
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (state.contentFilter.timer) {
|
|
1191
|
+
clearTimeout(state.contentFilter.timer)
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const token = state.contentFilter.token + 1
|
|
1195
|
+
state.contentFilter = {
|
|
1196
|
+
query: state.contentFilter.query,
|
|
1197
|
+
ids: state.contentFilter.ids,
|
|
1198
|
+
token,
|
|
1199
|
+
timer: setTimeout(() => {
|
|
1200
|
+
if (state.filterWorker && state.filterReady) {
|
|
1201
|
+
state.filterWorker.postMessage({
|
|
1202
|
+
type: 'filter',
|
|
1203
|
+
query,
|
|
1204
|
+
token,
|
|
1205
|
+
limit: Math.max(state.nodes.length, 1)
|
|
1206
|
+
})
|
|
1207
|
+
}
|
|
1208
|
+
syncContentFilter(query, token).catch(() => {})
|
|
1209
|
+
}, 180)
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
139
1213
|
const tick = delta => {
|
|
140
|
-
const nodes =
|
|
141
|
-
const
|
|
142
|
-
const
|
|
1214
|
+
const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
|
|
1215
|
+
const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
|
|
1216
|
+
const shouldRunPhysics =
|
|
1217
|
+
state.nodes.length <= 8000 &&
|
|
1218
|
+
nodes.length <= 320 &&
|
|
1219
|
+
state.transform.scale >= 0.08
|
|
1220
|
+
if (!shouldRunPhysics) {
|
|
1221
|
+
return
|
|
1222
|
+
}
|
|
143
1223
|
const strength = Math.min(delta / 16, 2)
|
|
144
1224
|
|
|
145
1225
|
edges.forEach(edge => {
|
|
146
1226
|
const source = edge.sourceNode
|
|
147
1227
|
const target = edge.targetNode
|
|
1228
|
+
source.vx = Number.isFinite(source.vx) ? source.vx : 0
|
|
1229
|
+
source.vy = Number.isFinite(source.vy) ? source.vy : 0
|
|
1230
|
+
target.vx = Number.isFinite(target.vx) ? target.vx : 0
|
|
1231
|
+
target.vy = Number.isFinite(target.vy) ? target.vy : 0
|
|
148
1232
|
const dx = target.x - source.x
|
|
149
1233
|
const dy = target.y - source.y
|
|
150
1234
|
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
151
1235
|
const force = (distance - 150) * 0.002 * strength
|
|
152
|
-
const fx = dx * force
|
|
153
|
-
const fy = dy * force
|
|
1236
|
+
const fx = (dx / distance) * force
|
|
1237
|
+
const fy = (dy / distance) * force
|
|
154
1238
|
source.vx += fx
|
|
155
1239
|
source.vy += fy
|
|
156
1240
|
target.vx -= fx
|
|
@@ -161,6 +1245,10 @@ const tick = delta => {
|
|
|
161
1245
|
for (let j = i + 1; j < nodes.length; j += 1) {
|
|
162
1246
|
const a = nodes[i]
|
|
163
1247
|
const b = nodes[j]
|
|
1248
|
+
a.vx = Number.isFinite(a.vx) ? a.vx : 0
|
|
1249
|
+
a.vy = Number.isFinite(a.vy) ? a.vy : 0
|
|
1250
|
+
b.vx = Number.isFinite(b.vx) ? b.vx : 0
|
|
1251
|
+
b.vy = Number.isFinite(b.vy) ? b.vy : 0
|
|
164
1252
|
const dx = b.x - a.x
|
|
165
1253
|
const dy = b.y - a.y
|
|
166
1254
|
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
@@ -175,6 +1263,10 @@ const tick = delta => {
|
|
|
175
1263
|
}
|
|
176
1264
|
|
|
177
1265
|
nodes.forEach(node => {
|
|
1266
|
+
node.vx = Number.isFinite(node.vx) ? node.vx : 0
|
|
1267
|
+
node.vy = Number.isFinite(node.vy) ? node.vy : 0
|
|
1268
|
+
node.x = Number.isFinite(node.x) ? node.x : 0
|
|
1269
|
+
node.y = Number.isFinite(node.y) ? node.y : 0
|
|
178
1270
|
if (state.pointer.dragNode === node) {
|
|
179
1271
|
node.vx = 0
|
|
180
1272
|
node.vy = 0
|
|
@@ -198,7 +1290,20 @@ const worldPoint = event => {
|
|
|
198
1290
|
}
|
|
199
1291
|
|
|
200
1292
|
const hitNode = point => {
|
|
201
|
-
|
|
1293
|
+
computeRenderVisibility()
|
|
1294
|
+
if (state.renderClusters.length > 0) {
|
|
1295
|
+
return null
|
|
1296
|
+
}
|
|
1297
|
+
const hitScaleFloor = state.nodes.length > massiveGraphNodeThreshold
|
|
1298
|
+
? 0.2
|
|
1299
|
+
: state.nodes.length > largeGraphNodeThreshold
|
|
1300
|
+
? 0.34
|
|
1301
|
+
: 0
|
|
1302
|
+
if (state.transform.scale < hitScaleFloor) {
|
|
1303
|
+
return null
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const nodes = state.renderNodes
|
|
202
1307
|
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
203
1308
|
const node = nodes[index]
|
|
204
1309
|
const radius = nodeRadius(node)
|
|
@@ -207,17 +1312,303 @@ const hitNode = point => {
|
|
|
207
1312
|
return null
|
|
208
1313
|
}
|
|
209
1314
|
|
|
210
|
-
const
|
|
211
|
-
const degree = state.
|
|
1315
|
+
const baseNodeRadius = node => {
|
|
1316
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
212
1317
|
return 9 + Math.min(degree, 8) * 1.6
|
|
213
1318
|
}
|
|
214
1319
|
|
|
1320
|
+
const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
|
|
1321
|
+
|
|
1322
|
+
const worldViewportBounds = () => {
|
|
1323
|
+
const rect = canvas.getBoundingClientRect()
|
|
1324
|
+
const width = Math.max(rect.width, 320)
|
|
1325
|
+
const height = Math.max(rect.height, 320)
|
|
1326
|
+
const padding = viewportPaddingPx
|
|
1327
|
+
|
|
1328
|
+
return {
|
|
1329
|
+
minX: (-state.transform.x - padding) / state.transform.scale,
|
|
1330
|
+
maxX: (width - state.transform.x + padding) / state.transform.scale,
|
|
1331
|
+
minY: (-state.transform.y - padding) / state.transform.scale,
|
|
1332
|
+
maxY: (height - state.transform.y + padding) / state.transform.scale
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const isNodeInViewport = (node, viewport) =>
|
|
1337
|
+
node.x >= viewport.minX &&
|
|
1338
|
+
node.x <= viewport.maxX &&
|
|
1339
|
+
node.y >= viewport.minY &&
|
|
1340
|
+
node.y <= viewport.maxY
|
|
1341
|
+
|
|
1342
|
+
const viewportNodeStride = () => {
|
|
1343
|
+
if (state.nodes.length <= largeGraphNodeThreshold) {
|
|
1344
|
+
return 1
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if (state.transform.scale >= 0.95) {
|
|
1348
|
+
return 1
|
|
1349
|
+
}
|
|
1350
|
+
if (state.transform.scale >= 0.7) {
|
|
1351
|
+
return 2
|
|
1352
|
+
}
|
|
1353
|
+
if (state.transform.scale >= 0.48) {
|
|
1354
|
+
return 3
|
|
1355
|
+
}
|
|
1356
|
+
if (state.transform.scale >= 0.28) {
|
|
1357
|
+
return 5
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
return 8
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
const shouldRenderClusters = viewportNodes =>
|
|
1364
|
+
state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
|
|
1365
|
+
|
|
1366
|
+
const clusterViewportNodes = viewportNodes => {
|
|
1367
|
+
if (!shouldRenderClusters(viewportNodes)) {
|
|
1368
|
+
return []
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
|
|
1372
|
+
const buckets = new Map()
|
|
1373
|
+
|
|
1374
|
+
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
1375
|
+
const node = viewportNodes[index]
|
|
1376
|
+
const keyX = Math.floor(node.x / worldCellSize)
|
|
1377
|
+
const keyY = Math.floor(node.y / worldCellSize)
|
|
1378
|
+
const key = keyX + ':' + keyY
|
|
1379
|
+
const current = buckets.get(key)
|
|
1380
|
+
if (current) {
|
|
1381
|
+
current.count += 1
|
|
1382
|
+
current.sumX += node.x
|
|
1383
|
+
current.sumY += node.y
|
|
1384
|
+
if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
|
|
1385
|
+
current.representative = node
|
|
1386
|
+
current.degree = state.nodeDegrees.get(node.id) ?? 0
|
|
1387
|
+
}
|
|
1388
|
+
continue
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
buckets.set(key, {
|
|
1392
|
+
id: key,
|
|
1393
|
+
count: 1,
|
|
1394
|
+
sumX: node.x,
|
|
1395
|
+
sumY: node.y,
|
|
1396
|
+
representative: node,
|
|
1397
|
+
degree: state.nodeDegrees.get(node.id) ?? 0
|
|
1398
|
+
})
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
return Array.from(buckets.values())
|
|
1402
|
+
.sort((left, right) => right.count - left.count)
|
|
1403
|
+
.slice(0, Math.min(renderNodeBudget, 900))
|
|
1404
|
+
.map((cluster) => ({
|
|
1405
|
+
id: cluster.id,
|
|
1406
|
+
x: cluster.sumX / Math.max(cluster.count, 1),
|
|
1407
|
+
y: cluster.sumY / Math.max(cluster.count, 1),
|
|
1408
|
+
count: cluster.count,
|
|
1409
|
+
representative: cluster.representative
|
|
1410
|
+
}))
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const computeRenderVisibility = () => {
|
|
1414
|
+
if (!hasValidTransform()) {
|
|
1415
|
+
fitView({ useFiltered: true })
|
|
1416
|
+
}
|
|
1417
|
+
const viewport = worldViewportBounds()
|
|
1418
|
+
const viewportKey =
|
|
1419
|
+
Math.round(viewport.minX * 10) + ':' +
|
|
1420
|
+
Math.round(viewport.maxX * 10) + ':' +
|
|
1421
|
+
Math.round(viewport.minY * 10) + ':' +
|
|
1422
|
+
Math.round(viewport.maxY * 10) + ':' +
|
|
1423
|
+
Math.round(state.transform.scale * 1000)
|
|
1424
|
+
|
|
1425
|
+
if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
|
|
1426
|
+
return
|
|
1427
|
+
}
|
|
1428
|
+
state.lastViewportKey = viewportKey
|
|
1429
|
+
state.renderVisibilityDirty = false
|
|
1430
|
+
|
|
1431
|
+
const shouldRenderMacroGalaxy =
|
|
1432
|
+
state.transform.scale <= macroGalaxyZoomThreshold && state.visibleNodes.length > 1
|
|
1433
|
+
|
|
1434
|
+
if (shouldRenderMacroGalaxy) {
|
|
1435
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
1436
|
+
const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
|
|
1437
|
+
const representative = state.primaryHub ?? state.macroRepresentative ?? sourceNodes[0] ?? null
|
|
1438
|
+
if (representative) {
|
|
1439
|
+
state.renderClusters = [
|
|
1440
|
+
{
|
|
1441
|
+
id: 'macro-galaxy',
|
|
1442
|
+
x: state.macroCenter.x,
|
|
1443
|
+
y: state.macroCenter.y,
|
|
1444
|
+
count: sourceNodes.length,
|
|
1445
|
+
representative
|
|
1446
|
+
}
|
|
1447
|
+
]
|
|
1448
|
+
state.renderNodes = [representative]
|
|
1449
|
+
} else {
|
|
1450
|
+
state.renderClusters = []
|
|
1451
|
+
state.renderNodes = []
|
|
1452
|
+
}
|
|
1453
|
+
state.renderEdges = []
|
|
1454
|
+
return
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
if (state.visibleNodes.length <= 2000) {
|
|
1458
|
+
state.renderNodes = state.visibleNodes
|
|
1459
|
+
state.renderClusters = []
|
|
1460
|
+
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
1461
|
+
state.renderEdges = withMeshEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
|
|
1462
|
+
return
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
if (state.visibleNodes.length > massiveGraphNodeThreshold) {
|
|
1466
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
1467
|
+
const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
|
|
1468
|
+
const sampleLimit = nodeBudgetForScale(state.transform.scale)
|
|
1469
|
+
const sampled = sourceNodes.length > sampleLimit
|
|
1470
|
+
? sampleVisibleNodes(Math.min(sampleLimit, renderNodeBudget), sourceNodes)
|
|
1471
|
+
: sourceNodes.slice(0, Math.min(sourceNodes.length, renderNodeBudget))
|
|
1472
|
+
const sampledIds = new Set(sampled.map((node) => node.id))
|
|
1473
|
+
let sampledEdges = state.transform.scale >= 0.035 ? collectVisibleEdgesForNodes(sampledIds) : []
|
|
1474
|
+
let sampledNodes = ensureHubNodesInRenderedSet(sampled)
|
|
1475
|
+
|
|
1476
|
+
if (state.transform.scale >= 0.035 && sampledEdges.length === 0) {
|
|
1477
|
+
const enriched = enrichSampleWithNeighbors(sampledNodes)
|
|
1478
|
+
sampledNodes = ensureHubNodesInRenderedSet(enriched.nodes)
|
|
1479
|
+
const sampledWithHubsIds = new Set(sampledNodes.map((node) => node.id))
|
|
1480
|
+
sampledEdges = collectVisibleEdgesForNodes(sampledWithHubsIds)
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
state.renderClusters = []
|
|
1484
|
+
state.renderNodes = sampledNodes
|
|
1485
|
+
state.renderEdges = withMeshEdges(sampledNodes, sampledEdges)
|
|
1486
|
+
return
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
if (state.transform.scale <= 0.0015) {
|
|
1490
|
+
const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
|
|
1491
|
+
const sampledIds = new Set(sampled.map((node) => node.id))
|
|
1492
|
+
state.renderClusters = []
|
|
1493
|
+
state.renderNodes = sampled
|
|
1494
|
+
state.renderEdges = withMeshEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
|
|
1495
|
+
return
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
1499
|
+
const clusters = clusterViewportNodes(viewportNodes)
|
|
1500
|
+
if (clusters.length > 0) {
|
|
1501
|
+
state.renderClusters = clusters
|
|
1502
|
+
state.renderNodes = clusters.map(cluster => cluster.representative)
|
|
1503
|
+
state.renderEdges = []
|
|
1504
|
+
return
|
|
1505
|
+
}
|
|
1506
|
+
state.renderClusters = []
|
|
1507
|
+
const stride = viewportNodeStride()
|
|
1508
|
+
const picked = []
|
|
1509
|
+
|
|
1510
|
+
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
1511
|
+
const node = viewportNodes[index]
|
|
1512
|
+
|
|
1513
|
+
const isPriority =
|
|
1514
|
+
node.id === state.selected?.id ||
|
|
1515
|
+
node.id === state.hovered?.id ||
|
|
1516
|
+
node.id === state.pointer.dragNode?.id
|
|
1517
|
+
if (isPriority || index % stride === 0) {
|
|
1518
|
+
picked.push(node)
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const nodes = picked.length > renderNodeBudget
|
|
1523
|
+
? picked.slice(0, renderNodeBudget)
|
|
1524
|
+
: picked
|
|
1525
|
+
if (nodes.length === 0 && state.visibleNodes.length > 0) {
|
|
1526
|
+
const fallbackNodes = fallbackViewportNodes()
|
|
1527
|
+
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
1528
|
+
state.renderNodes = fallbackNodes
|
|
1529
|
+
state.renderClusters = []
|
|
1530
|
+
state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
|
|
1531
|
+
return
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
const normalizedNodes = ensureHubNodesInRenderedSet(nodes)
|
|
1535
|
+
const nodeIds = new Set(normalizedNodes.map((node) => node.id))
|
|
1536
|
+
const edges = collectVisibleEdgesForNodes(nodeIds)
|
|
1537
|
+
|
|
1538
|
+
state.renderNodes = normalizedNodes
|
|
1539
|
+
state.renderEdges = withMeshEdges(normalizedNodes, edges)
|
|
1540
|
+
|
|
1541
|
+
if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
|
|
1542
|
+
const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
|
|
1543
|
+
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
1544
|
+
state.renderClusters = []
|
|
1545
|
+
state.renderNodes = fallbackNodes
|
|
1546
|
+
state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
const isNodeVisibleOnScreen = (node, width, height) => {
|
|
1551
|
+
const radius = nodeRadius(node) * state.transform.scale
|
|
1552
|
+
const screenX = node.x * state.transform.scale + state.transform.x
|
|
1553
|
+
const screenY = node.y * state.transform.scale + state.transform.y
|
|
1554
|
+
|
|
1555
|
+
return (
|
|
1556
|
+
screenX + radius >= 0 &&
|
|
1557
|
+
screenX - radius <= width &&
|
|
1558
|
+
screenY + radius >= 0 &&
|
|
1559
|
+
screenY - radius <= height
|
|
1560
|
+
)
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
const hasValidTransform = () =>
|
|
1564
|
+
isFiniteNumber(state.transform.x) &&
|
|
1565
|
+
isFiniteNumber(state.transform.y) &&
|
|
1566
|
+
isFiniteNumber(state.transform.scale) &&
|
|
1567
|
+
Math.abs(state.transform.x) <= transformCoordinateLimit &&
|
|
1568
|
+
Math.abs(state.transform.y) <= transformCoordinateLimit &&
|
|
1569
|
+
state.transform.scale > 0
|
|
1570
|
+
|
|
1571
|
+
const sanitizeNodePosition = node => {
|
|
1572
|
+
if (!isReasonableCoordinate(node.x)) node.x = 0
|
|
1573
|
+
if (!isReasonableCoordinate(node.y)) node.y = 0
|
|
1574
|
+
if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
|
|
1575
|
+
if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
const sanitizeAllNodePositions = () => {
|
|
1579
|
+
state.nodes.forEach(sanitizeNodePosition)
|
|
1580
|
+
state.visibleNodes.forEach(sanitizeNodePosition)
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
const sanitizeGraphState = () => {
|
|
1584
|
+
state.renderNodes.forEach(sanitizeNodePosition)
|
|
1585
|
+
}
|
|
1586
|
+
|
|
215
1587
|
const render = now => {
|
|
216
1588
|
const delta = now - state.last
|
|
217
1589
|
state.last = now
|
|
1590
|
+
const backgroundFrameIntervalMs =
|
|
1591
|
+
state.nodes.length > massiveGraphNodeThreshold
|
|
1592
|
+
? (state.transform.scale < 0.035 ? 130 : state.transform.scale < 0.08 ? 110 : 86)
|
|
1593
|
+
: state.nodes.length > largeGraphNodeThreshold
|
|
1594
|
+
? 64
|
|
1595
|
+
: 16
|
|
1596
|
+
const isInteracting =
|
|
1597
|
+
state.pointer.down ||
|
|
1598
|
+
state.renderVisibilityDirty ||
|
|
1599
|
+
state.recoveringViewport
|
|
1600
|
+
const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
|
|
1601
|
+
if (delta < minFrameIntervalMs) {
|
|
1602
|
+
requestAnimationFrame(render)
|
|
1603
|
+
return
|
|
1604
|
+
}
|
|
218
1605
|
const rect = canvas.getBoundingClientRect()
|
|
219
1606
|
const width = Math.max(rect.width, 320)
|
|
220
1607
|
const height = Math.max(rect.height, 320)
|
|
1608
|
+
sanitizeGraphState()
|
|
1609
|
+
if (!hasValidTransform()) {
|
|
1610
|
+
resetView()
|
|
1611
|
+
}
|
|
221
1612
|
ctx.clearRect(0, 0, width, height)
|
|
222
1613
|
if (state.nodes.length === 0) {
|
|
223
1614
|
ctx.fillStyle = '#99a5b5'
|
|
@@ -231,17 +1622,69 @@ const render = now => {
|
|
|
231
1622
|
ctx.translate(state.transform.x, state.transform.y)
|
|
232
1623
|
ctx.scale(state.transform.scale, state.transform.scale)
|
|
233
1624
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
1625
|
+
computeRenderVisibility()
|
|
1626
|
+
tick(delta)
|
|
1627
|
+
const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
|
|
1628
|
+
const manualZoomGuardActive = now - state.lastManualZoomAt < zoomRecoveryGuardMs
|
|
1629
|
+
if (!hasVisibleNodeOnScreen && state.renderNodes.length > 0 && !manualZoomGuardActive) {
|
|
1630
|
+
state.offscreenFrameCount += 1
|
|
1631
|
+
if (state.offscreenFrameCount >= 6 && !state.recoveringViewport) {
|
|
1632
|
+
state.recoveringViewport = true
|
|
1633
|
+
fitView({ useFiltered: true })
|
|
1634
|
+
state.offscreenFrameCount = 0
|
|
1635
|
+
requestAnimationFrame(() => {
|
|
1636
|
+
state.recoveringViewport = false
|
|
1637
|
+
})
|
|
1638
|
+
}
|
|
1639
|
+
} else {
|
|
1640
|
+
state.offscreenFrameCount = 0
|
|
1641
|
+
}
|
|
1642
|
+
const minimumEdgeScale =
|
|
1643
|
+
state.renderNodes.length > 1300
|
|
1644
|
+
? 0.12
|
|
1645
|
+
: state.renderNodes.length > 900
|
|
1646
|
+
? 0.085
|
|
1647
|
+
: state.renderNodes.length > 500
|
|
1648
|
+
? 0.05
|
|
1649
|
+
: 0
|
|
1650
|
+
const drawEdges =
|
|
1651
|
+
state.renderClusters.length === 0 &&
|
|
1652
|
+
state.transform.scale >= minimumEdgeScale
|
|
1653
|
+
if (drawEdges) {
|
|
1654
|
+
drawGraphEdges()
|
|
1655
|
+
}
|
|
243
1656
|
|
|
244
|
-
|
|
1657
|
+
if (state.renderClusters.length > 0) {
|
|
1658
|
+
const safeScale = Math.max(state.transform.scale, 0.0001)
|
|
1659
|
+
state.renderClusters.forEach(cluster => {
|
|
1660
|
+
const isMacro = cluster.id === 'macro-galaxy'
|
|
1661
|
+
const radiusPx = isMacro
|
|
1662
|
+
? 10
|
|
1663
|
+
: Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
|
|
1664
|
+
const radius = radiusPx / safeScale
|
|
1665
|
+
const haloRadius = (radiusPx + (isMacro ? 8 : 4)) / safeScale
|
|
1666
|
+
ctx.beginPath()
|
|
1667
|
+
ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
|
|
1668
|
+
ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
|
|
1669
|
+
ctx.fill()
|
|
1670
|
+
ctx.beginPath()
|
|
1671
|
+
ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
|
|
1672
|
+
ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
|
|
1673
|
+
ctx.fill()
|
|
1674
|
+
ctx.lineWidth = 1.4 / safeScale
|
|
1675
|
+
ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
|
|
1676
|
+
ctx.stroke()
|
|
1677
|
+
if (isMacro && cluster.representative?.title) {
|
|
1678
|
+
ctx.fillStyle = '#edf2f7'
|
|
1679
|
+
ctx.font = 12 / safeScale + 'px Inter, system-ui, sans-serif'
|
|
1680
|
+
ctx.textAlign = 'center'
|
|
1681
|
+
ctx.textBaseline = 'top'
|
|
1682
|
+
ctx.fillText(cluster.representative.title.slice(0, 28), cluster.x, cluster.y + (radiusPx + 9) / safeScale)
|
|
1683
|
+
}
|
|
1684
|
+
// Keep cluster markers minimal and faster to draw on large graphs.
|
|
1685
|
+
})
|
|
1686
|
+
} else {
|
|
1687
|
+
state.renderNodes.forEach(node => {
|
|
245
1688
|
const radius = nodeRadius(node)
|
|
246
1689
|
const isSelected = state.selected?.id === node.id
|
|
247
1690
|
const isHovered = state.hovered?.id === node.id
|
|
@@ -257,16 +1700,28 @@ const render = now => {
|
|
|
257
1700
|
ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
|
|
258
1701
|
ctx.stroke()
|
|
259
1702
|
|
|
260
|
-
|
|
1703
|
+
const shouldDrawLabels =
|
|
1704
|
+
isSelected ||
|
|
1705
|
+
isHovered ||
|
|
1706
|
+
(state.nodes.length > largeGraphNodeThreshold && state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) ||
|
|
1707
|
+
(state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
|
|
1708
|
+
if (shouldDrawLabels) {
|
|
261
1709
|
ctx.fillStyle = graphTheme.label
|
|
262
1710
|
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
263
1711
|
ctx.textAlign = 'center'
|
|
264
1712
|
ctx.textBaseline = 'top'
|
|
265
1713
|
ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
|
|
266
1714
|
}
|
|
267
|
-
|
|
1715
|
+
})
|
|
1716
|
+
}
|
|
268
1717
|
|
|
269
1718
|
ctx.restore()
|
|
1719
|
+
if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
|
|
1720
|
+
ctx.fillStyle = '#99a5b5'
|
|
1721
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
1722
|
+
ctx.textAlign = 'center'
|
|
1723
|
+
ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
|
|
1724
|
+
}
|
|
270
1725
|
requestAnimationFrame(render)
|
|
271
1726
|
}
|
|
272
1727
|
|
|
@@ -274,88 +1729,209 @@ const list = items => items.length
|
|
|
274
1729
|
? 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('')
|
|
275
1730
|
: '<li><small>No links found.</small></li>'
|
|
276
1731
|
|
|
277
|
-
const
|
|
278
|
-
? 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('')
|
|
279
|
-
: '<li><small>No notes indexed.</small></li>'
|
|
280
|
-
|
|
281
|
-
const selectNode = node => {
|
|
282
|
-
state.selected = node
|
|
283
|
-
if (!node) {
|
|
284
|
-
elements.title.textContent = 'Graph Overview'
|
|
285
|
-
elements.path.textContent = state.nodes.length + ' notes and ' + state.graph.edges.length + ' links indexed.'
|
|
286
|
-
elements.tags.innerHTML = ''
|
|
287
|
-
elements.notes.innerHTML = allNotesList()
|
|
288
|
-
elements.content.textContent = 'Selecione uma nota no grafo ou na lista para ver o Markdown completo, backlinks e links de saida.'
|
|
289
|
-
elements.outgoing.innerHTML = '<li><small>Select a note to inspect outgoing links.</small></li>'
|
|
290
|
-
elements.incoming.innerHTML = '<li><small>Select a note to inspect backlinks.</small></li>'
|
|
291
|
-
return
|
|
292
|
-
}
|
|
1732
|
+
const linkedNodes = node => {
|
|
293
1733
|
const nodeById = new Map(state.nodes.map(item => [item.id, item]))
|
|
294
1734
|
const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
|
|
295
1735
|
...linkedNode,
|
|
296
1736
|
weight: edge.weight,
|
|
297
1737
|
priority: edge.priority
|
|
298
1738
|
} : null
|
|
299
|
-
const outgoing = state.
|
|
1739
|
+
const outgoing = state.edges
|
|
300
1740
|
.filter(edge => edge.source === node.id)
|
|
301
|
-
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' }, edge))
|
|
1741
|
+
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
|
|
302
1742
|
.filter(Boolean)
|
|
303
|
-
const incoming = state.
|
|
1743
|
+
const incoming = state.edges
|
|
304
1744
|
.filter(edge => edge.target === node.id)
|
|
305
1745
|
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
306
1746
|
.filter(Boolean)
|
|
307
1747
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
1748
|
+
return { outgoing, incoming }
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
const fetchNodeDetails = async node => {
|
|
1752
|
+
const cached = state.nodeDetails.get(node.id)
|
|
1753
|
+
if (cached) {
|
|
1754
|
+
return cached
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
|
|
1758
|
+
if (!response.ok) {
|
|
1759
|
+
throw new Error('Failed to load graph node details')
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
const payload = await response.json()
|
|
1763
|
+
const detail = payload?.node
|
|
1764
|
+
if (!detail || !detail.id) {
|
|
1765
|
+
throw new Error('Invalid graph node payload')
|
|
1766
|
+
}
|
|
1767
|
+
state.nodeDetails.set(detail.id, detail)
|
|
1768
|
+
return detail
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
1772
|
+
|
|
1773
|
+
const openContentDialog = async node => {
|
|
1774
|
+
if (!node) return
|
|
1775
|
+
elements.contentTitle.textContent = node.title || 'Loading...'
|
|
1776
|
+
elements.contentPath.textContent = node.path || 'Loading...'
|
|
1777
|
+
elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
|
|
311
1778
|
? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
312
1779
|
: '<span>No tags</span>'
|
|
313
|
-
|
|
314
|
-
elements.
|
|
315
|
-
elements.
|
|
316
|
-
elements.
|
|
1780
|
+
const initialLinks = linkedNodes(node)
|
|
1781
|
+
elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
|
|
1782
|
+
elements.contentIncoming.innerHTML = list(initialLinks.incoming)
|
|
1783
|
+
elements.contentBody.textContent = 'Loading note content...'
|
|
1784
|
+
if (!elements.contentDialog.open) {
|
|
1785
|
+
elements.contentDialog.showModal()
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
const applyDetailToDialog = detail => {
|
|
1789
|
+
elements.contentTitle.textContent = detail.title
|
|
1790
|
+
elements.contentPath.textContent = detail.path
|
|
1791
|
+
elements.contentTags.innerHTML = detail.tags.length
|
|
1792
|
+
? detail.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
1793
|
+
: '<span>No tags</span>'
|
|
1794
|
+
elements.contentBody.textContent = detail.content
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
try {
|
|
1798
|
+
const detailedNode = await fetchNodeDetails(node)
|
|
1799
|
+
if (state.selected?.id !== node.id) {
|
|
1800
|
+
return
|
|
1801
|
+
}
|
|
1802
|
+
applyDetailToDialog(detailedNode)
|
|
1803
|
+
} catch {
|
|
1804
|
+
try {
|
|
1805
|
+
await wait(120)
|
|
1806
|
+
const retriedNode = await fetchNodeDetails(node)
|
|
1807
|
+
if (state.selected?.id !== node.id) {
|
|
1808
|
+
return
|
|
1809
|
+
}
|
|
1810
|
+
applyDetailToDialog(retriedNode)
|
|
1811
|
+
} catch {
|
|
1812
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
const selectNode = (node, options = { openContent: false }) => {
|
|
1818
|
+
state.selected = node
|
|
1819
|
+
if (node && options.openContent) {
|
|
1820
|
+
openContentDialog(node).catch(() => {
|
|
1821
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
1822
|
+
})
|
|
1823
|
+
}
|
|
317
1824
|
}
|
|
318
1825
|
|
|
319
1826
|
const selectNodeById = id => {
|
|
320
1827
|
const node = state.nodes.find(item => item.id === id)
|
|
321
|
-
if (node) selectNode(node)
|
|
1828
|
+
if (node) selectNode(node, { openContent: true })
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
|
|
1832
|
+
if (source === 'wheel') {
|
|
1833
|
+
state.lastManualZoomAt = performance.now()
|
|
1834
|
+
}
|
|
1835
|
+
const nextScale = clampScale(state.transform.scale * factor)
|
|
1836
|
+
if (nextScale === state.transform.scale) {
|
|
1837
|
+
return
|
|
1838
|
+
}
|
|
1839
|
+
const worldX = (screenX - state.transform.x) / state.transform.scale
|
|
1840
|
+
const worldY = (screenY - state.transform.y) / state.transform.scale
|
|
1841
|
+
state.transform.scale = clampScale(nextScale)
|
|
1842
|
+
state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
|
|
1843
|
+
state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
|
|
1844
|
+
state.offscreenFrameCount = 0
|
|
1845
|
+
markRenderDirty()
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
const wheelZoomFactor = event => {
|
|
1849
|
+
const isModifierZoom = event.metaKey || event.ctrlKey
|
|
1850
|
+
const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
|
|
1851
|
+
const absoluteDelta = Math.min(Math.abs(event.deltaY * deltaModeFactor), 1600)
|
|
1852
|
+
|
|
1853
|
+
if (absoluteDelta <= 0.0001) {
|
|
1854
|
+
return 1
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
const baseStep = Math.max(0.06, Math.min(0.45, absoluteDelta / 480))
|
|
1858
|
+
const adjustedStep = baseStep * (isModifierZoom ? 1.4 : 1)
|
|
1859
|
+
|
|
1860
|
+
return event.deltaY < 0 ? 1 + adjustedStep : 1 / (1 + adjustedStep)
|
|
322
1861
|
}
|
|
323
1862
|
|
|
324
|
-
const
|
|
325
|
-
|
|
1863
|
+
const handleWheelZoom = event => {
|
|
1864
|
+
if (elements.contentDialog?.open) {
|
|
1865
|
+
return
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
event.preventDefault()
|
|
1869
|
+
const rect = canvas.getBoundingClientRect()
|
|
1870
|
+
const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
|
|
1871
|
+
const rawCursorY = Number.isFinite(event.offsetY) ? event.offsetY : event.clientY - rect.top
|
|
1872
|
+
const cursorX = Math.max(0, Math.min(Math.max(rect.width, 320), rawCursorX))
|
|
1873
|
+
const cursorY = Math.max(0, Math.min(Math.max(rect.height, 320), rawCursorY))
|
|
1874
|
+
const factor = wheelZoomFactor(event)
|
|
1875
|
+
|
|
1876
|
+
if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
|
|
1877
|
+
return
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
zoomAtPoint(cursorX, cursorY, factor, 'wheel')
|
|
326
1881
|
}
|
|
327
1882
|
|
|
328
1883
|
const bindEvents = () => {
|
|
329
1884
|
window.addEventListener('resize', resize)
|
|
330
1885
|
elements.search.addEventListener('input', event => {
|
|
331
1886
|
state.query = event.target.value
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
: state.nodes.length + ' notes · ' + state.edges.length + ' links'
|
|
1887
|
+
recomputeVisibility()
|
|
1888
|
+
scheduleContentFilterSync()
|
|
335
1889
|
})
|
|
336
1890
|
elements.agent.addEventListener('change', event => {
|
|
337
1891
|
state.agentId = event.target.value
|
|
1892
|
+
writeStoredAgent(state.agentId)
|
|
1893
|
+
syncAgentInUrl(state.agentId)
|
|
338
1894
|
state.selected = null
|
|
1895
|
+
state.nodeDetails = new Map()
|
|
1896
|
+
resetContentFilter()
|
|
1897
|
+
recomputeVisibility()
|
|
1898
|
+
scheduleContentFilterSync()
|
|
339
1899
|
loadGraph({ reset: true }).catch(error => {
|
|
340
|
-
elements.stats.textContent = 'Failed to load agent graph'
|
|
341
1900
|
console.error(error)
|
|
342
1901
|
})
|
|
343
1902
|
})
|
|
344
|
-
elements.zoomIn.addEventListener('click', () =>
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
1903
|
+
elements.zoomIn.addEventListener('click', () => {
|
|
1904
|
+
const rect = canvas.getBoundingClientRect()
|
|
1905
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.3)
|
|
1906
|
+
})
|
|
1907
|
+
elements.zoomOut.addEventListener('click', () => {
|
|
1908
|
+
const rect = canvas.getBoundingClientRect()
|
|
1909
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.77)
|
|
1910
|
+
})
|
|
1911
|
+
if (elements.fit) {
|
|
1912
|
+
elements.fit.addEventListener('click', () => {
|
|
1913
|
+
fitView({ useFiltered: true })
|
|
353
1914
|
})
|
|
1915
|
+
}
|
|
1916
|
+
elements.reset.addEventListener('click', () => {
|
|
1917
|
+
resetView()
|
|
1918
|
+
})
|
|
1919
|
+
elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
|
|
1920
|
+
elements.contentDialog.addEventListener('click', event => {
|
|
1921
|
+
const target = event.target
|
|
1922
|
+
if (target instanceof HTMLElement && target.dataset.nodeId) {
|
|
1923
|
+
selectNodeById(target.dataset.nodeId)
|
|
1924
|
+
return
|
|
1925
|
+
}
|
|
1926
|
+
if (event.target === elements.contentDialog) elements.contentDialog.close()
|
|
1927
|
+
})
|
|
1928
|
+
canvas.addEventListener('wheel', handleWheelZoom, { passive: false })
|
|
1929
|
+
canvas.addEventListener('dblclick', event => {
|
|
1930
|
+
const rect = canvas.getBoundingClientRect()
|
|
1931
|
+
const cursorX = event.clientX - rect.left
|
|
1932
|
+
const cursorY = event.clientY - rect.top
|
|
1933
|
+
zoomAtPoint(cursorX, cursorY, 1.25)
|
|
354
1934
|
})
|
|
355
|
-
canvas.addEventListener('wheel', event => {
|
|
356
|
-
event.preventDefault()
|
|
357
|
-
zoom(event.deltaY < 0 ? 1.08 : 0.92)
|
|
358
|
-
}, { passive: false })
|
|
359
1935
|
canvas.addEventListener('pointerdown', event => {
|
|
360
1936
|
const point = worldPoint(event)
|
|
361
1937
|
const node = hitNode(point)
|
|
@@ -363,12 +1939,24 @@ const bindEvents = () => {
|
|
|
363
1939
|
if (node) {
|
|
364
1940
|
node.x = point.x
|
|
365
1941
|
node.y = point.y
|
|
1942
|
+
markRenderDirty()
|
|
366
1943
|
}
|
|
367
1944
|
canvas.setPointerCapture(event.pointerId)
|
|
368
1945
|
})
|
|
369
1946
|
canvas.addEventListener('pointermove', event => {
|
|
370
1947
|
const point = worldPoint(event)
|
|
371
|
-
|
|
1948
|
+
const now = performance.now()
|
|
1949
|
+
const canHoverHitTest =
|
|
1950
|
+
!(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.06)
|
|
1951
|
+
const shouldHitTest = canHoverHitTest &&
|
|
1952
|
+
(state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
|
|
1953
|
+
if (shouldHitTest) {
|
|
1954
|
+
state.hovered = hitNode(point)
|
|
1955
|
+
state.lastHoverHitAt = now
|
|
1956
|
+
} else if (!canHoverHitTest) {
|
|
1957
|
+
state.hovered = null
|
|
1958
|
+
}
|
|
1959
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
372
1960
|
if (!state.pointer.down) return
|
|
373
1961
|
const dx = event.clientX - state.pointer.x
|
|
374
1962
|
const dy = event.clientY - state.pointer.y
|
|
@@ -378,34 +1966,72 @@ const bindEvents = () => {
|
|
|
378
1966
|
if (state.pointer.dragNode) {
|
|
379
1967
|
state.pointer.dragNode.x = point.x
|
|
380
1968
|
state.pointer.dragNode.y = point.y
|
|
1969
|
+
markRenderDirty()
|
|
381
1970
|
return
|
|
382
1971
|
}
|
|
383
1972
|
state.transform.x += dx
|
|
384
1973
|
state.transform.y += dy
|
|
1974
|
+
state.transform.x = clampTransformCoordinate(state.transform.x)
|
|
1975
|
+
state.transform.y = clampTransformCoordinate(state.transform.y)
|
|
1976
|
+
state.offscreenFrameCount = 0
|
|
1977
|
+
markRenderDirty()
|
|
385
1978
|
})
|
|
386
1979
|
canvas.addEventListener('pointerup', event => {
|
|
387
|
-
if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode)
|
|
388
|
-
if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered)
|
|
1980
|
+
if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
|
|
1981
|
+
if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
|
|
389
1982
|
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
390
1983
|
canvas.releasePointerCapture(event.pointerId)
|
|
391
1984
|
})
|
|
1985
|
+
canvas.addEventListener('pointercancel', () => {
|
|
1986
|
+
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
1987
|
+
})
|
|
1988
|
+
canvas.addEventListener('pointerenter', event => {
|
|
1989
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
1990
|
+
})
|
|
1991
|
+
canvas.addEventListener('pointerleave', event => {
|
|
1992
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
|
|
1993
|
+
})
|
|
1994
|
+
window.addEventListener('keydown', event => {
|
|
1995
|
+
if (event.key === '+' || event.key === '=') {
|
|
1996
|
+
event.preventDefault()
|
|
1997
|
+
const rect = canvas.getBoundingClientRect()
|
|
1998
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.25)
|
|
1999
|
+
return
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
if (event.key === '-' || event.key === '_') {
|
|
2003
|
+
event.preventDefault()
|
|
2004
|
+
const rect = canvas.getBoundingClientRect()
|
|
2005
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.8)
|
|
2006
|
+
return
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
if (event.key === '0') {
|
|
2010
|
+
event.preventDefault()
|
|
2011
|
+
resetView()
|
|
2012
|
+
}
|
|
2013
|
+
})
|
|
392
2014
|
}
|
|
393
2015
|
|
|
394
2016
|
const loadAgents = async () => {
|
|
395
2017
|
const response = await fetch('/api/agents')
|
|
396
2018
|
const payload = await response.json()
|
|
397
2019
|
const agents = Array.isArray(payload.agents) ? payload.agents : []
|
|
398
|
-
const
|
|
2020
|
+
const preferredAgent = state.agentId || initialAgentFromUrl || readStoredAgent()
|
|
2021
|
+
const currentExists = agents.some(agent => agent.id === preferredAgent)
|
|
399
2022
|
const selected = currentExists
|
|
400
|
-
?
|
|
2023
|
+
? preferredAgent
|
|
401
2024
|
: (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
|
|
402
2025
|
const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
|
|
403
2026
|
|
|
404
2027
|
state.agentId = selected
|
|
2028
|
+
writeStoredAgent(selected)
|
|
2029
|
+
syncAgentInUrl(selected)
|
|
405
2030
|
if (signature !== state.agentsSignature) {
|
|
2031
|
+
const formatAgentLabel = (agent) => agent.id
|
|
406
2032
|
elements.agent.innerHTML = agents.length
|
|
407
|
-
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent
|
|
408
|
-
: '<option value="shared">shared
|
|
2033
|
+
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
|
|
2034
|
+
: '<option value="shared">shared</option>'
|
|
409
2035
|
state.agentsSignature = signature
|
|
410
2036
|
}
|
|
411
2037
|
elements.agent.value = selected
|
|
@@ -426,6 +2052,10 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
426
2052
|
|
|
427
2053
|
const payload = await response.json()
|
|
428
2054
|
const graph = payload?.layout ?? payload
|
|
2055
|
+
state.graphTotals = {
|
|
2056
|
+
nodes: Number.isFinite(payload?.totals?.nodes) ? payload.totals.nodes : (Array.isArray(graph.nodes) ? graph.nodes.length : 0),
|
|
2057
|
+
edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
|
|
2058
|
+
}
|
|
429
2059
|
const signature = payload?.signature ?? graphSignature(graph)
|
|
430
2060
|
if (!options.reset && signature === state.graphSignature) return
|
|
431
2061
|
const selectedId = state.selected?.id
|
|
@@ -433,18 +2063,37 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
433
2063
|
state.graphSignature = signature
|
|
434
2064
|
state.graph = graph
|
|
435
2065
|
state.nodes = layout.nodes
|
|
2066
|
+
state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
|
|
436
2067
|
state.edges = layout.edges
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
2068
|
+
state.nodeDegrees = state.edges.reduce((degrees, edge) => {
|
|
2069
|
+
degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
|
|
2070
|
+
if (edge.target) {
|
|
2071
|
+
degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
|
|
2072
|
+
}
|
|
2073
|
+
return degrees
|
|
2074
|
+
}, new Map())
|
|
2075
|
+
state.nodeDetails = new Map()
|
|
2076
|
+
pushNodesToFilterWorker()
|
|
2077
|
+
resetContentFilter()
|
|
2078
|
+
sanitizeAllNodePositions()
|
|
2079
|
+
recomputeVisibility()
|
|
2080
|
+
scheduleContentFilterSync()
|
|
2081
|
+
const tags = new Set(state.nodes.flatMap(node => node.tags))
|
|
2082
|
+
setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
|
|
2083
|
+
elements.nodeCount.textContent = state.graphTotals.nodes
|
|
2084
|
+
elements.edgeCount.textContent = state.graphTotals.edges
|
|
441
2085
|
elements.tagCount.textContent = tags.size
|
|
442
2086
|
resize()
|
|
443
2087
|
if (options.reset) resetView()
|
|
444
|
-
|
|
2088
|
+
const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
|
|
2089
|
+
selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
|
|
2090
|
+
if (!selectedNode && elements.contentDialog.open) {
|
|
2091
|
+
elements.contentDialog.close()
|
|
2092
|
+
}
|
|
445
2093
|
}
|
|
446
2094
|
|
|
447
2095
|
bindEvents()
|
|
2096
|
+
initFilterWorker()
|
|
448
2097
|
requestAnimationFrame(() => {
|
|
449
2098
|
resize()
|
|
450
2099
|
resetView()
|
|
@@ -475,7 +2124,6 @@ loadAgents()
|
|
|
475
2124
|
setInterval(refreshGraphLoop, pollIntervalMs)
|
|
476
2125
|
})
|
|
477
2126
|
.catch(error => {
|
|
478
|
-
elements.stats.textContent = 'Failed to load graph'
|
|
479
2127
|
console.error(error)
|
|
480
2128
|
})
|
|
481
2129
|
|