@andespindola/brainlink 0.1.0-beta.5 → 0.1.0-beta.50
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 +1332 -110
- 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 +20 -14
- 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,18 +1,60 @@
|
|
|
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 = 560
|
|
3
21
|
const state = {
|
|
4
22
|
graph: { nodes: [], edges: [] },
|
|
5
23
|
nodes: [],
|
|
6
24
|
edges: [],
|
|
25
|
+
visibleNodes: [],
|
|
26
|
+
visibleEdges: [],
|
|
27
|
+
renderNodes: [],
|
|
28
|
+
renderEdges: [],
|
|
29
|
+
renderClusters: [],
|
|
30
|
+
nodeDegrees: new Map(),
|
|
7
31
|
selected: null,
|
|
8
32
|
hovered: null,
|
|
9
33
|
query: '',
|
|
34
|
+
contentFilter: { query: '', ids: null, token: 0, timer: null },
|
|
10
35
|
agentId: '',
|
|
11
36
|
agentsSignature: '',
|
|
37
|
+
nodeDetails: new Map(),
|
|
12
38
|
transform: { x: 0, y: 0, scale: 1 },
|
|
13
39
|
pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
|
|
40
|
+
cursor: { x: 0, y: 0, inCanvas: false },
|
|
14
41
|
graphSignature: '',
|
|
15
|
-
|
|
42
|
+
graphStatus: '',
|
|
43
|
+
graphTotals: { nodes: 0, edges: 0 },
|
|
44
|
+
last: performance.now(),
|
|
45
|
+
offscreenFrameCount: 0,
|
|
46
|
+
recoveringViewport: false,
|
|
47
|
+
renderVisibilityDirty: true,
|
|
48
|
+
lastViewportKey: '',
|
|
49
|
+
visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
|
|
50
|
+
visibleEdgeByNode: new Map(),
|
|
51
|
+
overviewClusters: [],
|
|
52
|
+
macroCenter: { x: 0, y: 0 },
|
|
53
|
+
macroRepresentative: null,
|
|
54
|
+
filterWorker: null,
|
|
55
|
+
filterReady: false,
|
|
56
|
+
lastHoverHitAt: 0,
|
|
57
|
+
lastManualZoomAt: 0
|
|
16
58
|
}
|
|
17
59
|
|
|
18
60
|
const byId = id => document.getElementById(id)
|
|
@@ -23,25 +65,49 @@ const escapeHtml = value => String(value)
|
|
|
23
65
|
.replaceAll('"', '"')
|
|
24
66
|
.replaceAll("'", ''')
|
|
25
67
|
const elements = {
|
|
26
|
-
stats: byId('stats'),
|
|
27
68
|
search: byId('search'),
|
|
28
69
|
agent: byId('agent'),
|
|
29
|
-
title: byId('title'),
|
|
30
|
-
path: byId('path'),
|
|
31
|
-
tags: byId('tags'),
|
|
32
|
-
notes: byId('notes'),
|
|
33
|
-
content: byId('content'),
|
|
34
|
-
outgoing: byId('outgoing'),
|
|
35
|
-
incoming: byId('incoming'),
|
|
36
70
|
nodeCount: byId('nodeCount'),
|
|
37
71
|
edgeCount: byId('edgeCount'),
|
|
38
72
|
tagCount: byId('tagCount'),
|
|
39
73
|
zoomIn: byId('zoomIn'),
|
|
40
74
|
zoomOut: byId('zoomOut'),
|
|
41
|
-
|
|
75
|
+
fit: byId('fit'),
|
|
76
|
+
reset: byId('reset'),
|
|
77
|
+
contentDialog: byId('contentDialog'),
|
|
78
|
+
contentTitle: byId('contentTitle'),
|
|
79
|
+
contentPath: byId('contentPath'),
|
|
80
|
+
contentTags: byId('contentTags'),
|
|
81
|
+
contentOutgoing: byId('contentOutgoing'),
|
|
82
|
+
contentIncoming: byId('contentIncoming'),
|
|
83
|
+
contentBody: byId('contentBody'),
|
|
84
|
+
contentClose: byId('contentClose')
|
|
42
85
|
}
|
|
43
86
|
|
|
44
|
-
const
|
|
87
|
+
const zoomRange = {
|
|
88
|
+
min: 0.0002,
|
|
89
|
+
max: 4.5
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const initialAgentFromUrl = (() => {
|
|
93
|
+
try {
|
|
94
|
+
const raw = new URL(window.location.href).searchParams.get('agent')
|
|
95
|
+
const value = raw?.trim() ?? ''
|
|
96
|
+
return value.length > 0 ? value : ''
|
|
97
|
+
} catch {
|
|
98
|
+
return ''
|
|
99
|
+
}
|
|
100
|
+
})()
|
|
101
|
+
|
|
102
|
+
const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
|
|
103
|
+
|
|
104
|
+
const setGraphStatus = text => {
|
|
105
|
+
state.graphStatus = text
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const handleGraphRefreshError = error => {
|
|
109
|
+
console.error(error)
|
|
110
|
+
}
|
|
45
111
|
|
|
46
112
|
const graphTheme = {
|
|
47
113
|
node: '#aeb8c5',
|
|
@@ -56,6 +122,67 @@ const graphTheme = {
|
|
|
56
122
|
label: '#edf2f7'
|
|
57
123
|
}
|
|
58
124
|
|
|
125
|
+
const initFilterWorker = () => {
|
|
126
|
+
if (typeof Worker === 'undefined') {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const worker = new Worker('/app-worker.js')
|
|
131
|
+
worker.onmessage = event => {
|
|
132
|
+
const payload = event.data
|
|
133
|
+
if (!payload || typeof payload !== 'object') return
|
|
134
|
+
|
|
135
|
+
if (payload.type === 'ready') {
|
|
136
|
+
state.filterReady = true
|
|
137
|
+
if (state.nodes.length > 0) {
|
|
138
|
+
worker.postMessage({
|
|
139
|
+
type: 'load-nodes',
|
|
140
|
+
nodes: state.nodes.map(node => ({
|
|
141
|
+
id: node.id,
|
|
142
|
+
title: node.title,
|
|
143
|
+
path: node.path || '',
|
|
144
|
+
tags: Array.isArray(node.tags) ? node.tags : []
|
|
145
|
+
}))
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (payload.type === 'filter-result') {
|
|
152
|
+
const token = payload.token
|
|
153
|
+
if (token !== state.contentFilter.token) {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const ids = Array.isArray(payload.ids) ? payload.ids.filter(id => typeof id === 'string') : []
|
|
158
|
+
state.contentFilter.query = normalizeQuery(state.query)
|
|
159
|
+
state.contentFilter.ids = new Set(ids)
|
|
160
|
+
recomputeVisibility()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
state.filterWorker = worker
|
|
164
|
+
} catch {
|
|
165
|
+
state.filterWorker = null
|
|
166
|
+
state.filterReady = false
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const pushNodesToFilterWorker = () => {
|
|
171
|
+
if (!state.filterWorker || !state.filterReady) {
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
state.filterWorker.postMessage({
|
|
176
|
+
type: 'load-nodes',
|
|
177
|
+
nodes: state.nodes.map(node => ({
|
|
178
|
+
id: node.id,
|
|
179
|
+
title: node.title,
|
|
180
|
+
path: node.path || '',
|
|
181
|
+
tags: Array.isArray(node.tags) ? node.tags : []
|
|
182
|
+
}))
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
59
186
|
const resize = () => {
|
|
60
187
|
const rect = canvas.getBoundingClientRect()
|
|
61
188
|
const width = Math.max(rect.width, 320)
|
|
@@ -64,40 +191,524 @@ const resize = () => {
|
|
|
64
191
|
canvas.width = Math.floor(width * ratio)
|
|
65
192
|
canvas.height = Math.floor(height * ratio)
|
|
66
193
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
194
|
+
markRenderDirty()
|
|
67
195
|
}
|
|
68
196
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
197
|
+
const normalizeQuery = value => value.trim().toLowerCase()
|
|
198
|
+
const hubNodeRetentionLimit = 2
|
|
199
|
+
const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map|mapa)\b/i
|
|
200
|
+
|
|
201
|
+
const localFilteredNodes = query =>
|
|
202
|
+
state.nodes.filter(node =>
|
|
73
203
|
node.title.toLowerCase().includes(query) ||
|
|
74
|
-
node.path.toLowerCase().includes(query) ||
|
|
204
|
+
(node.path || '').toLowerCase().includes(query) ||
|
|
75
205
|
node.tags.some(tag => tag.toLowerCase().includes(query))
|
|
76
206
|
)
|
|
207
|
+
|
|
208
|
+
const rankedHubNodes = () => {
|
|
209
|
+
if (state.nodes.length === 0) {
|
|
210
|
+
return []
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const byTitleAndDegree = [...state.nodes]
|
|
214
|
+
.filter(node => hubNodePattern.test(node.title) || hubNodePattern.test(node.path) || node.tags.some(tag => hubNodePattern.test(tag)))
|
|
215
|
+
.sort((left, right) => {
|
|
216
|
+
const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
|
|
217
|
+
if (byDegree !== 0) return byDegree
|
|
218
|
+
return left.title.localeCompare(right.title)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
if (byTitleAndDegree.length > 0) {
|
|
222
|
+
return byTitleAndDegree.slice(0, hubNodeRetentionLimit)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return [...state.nodes]
|
|
226
|
+
.sort((left, right) => {
|
|
227
|
+
const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
|
|
228
|
+
if (byDegree !== 0) return byDegree
|
|
229
|
+
return left.title.localeCompare(right.title)
|
|
230
|
+
})
|
|
231
|
+
.slice(0, 1)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const withPersistentHubNodes = nodes => {
|
|
235
|
+
if (nodes.length === 0) {
|
|
236
|
+
return rankedHubNodes()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
240
|
+
const hubsToKeep = rankedHubNodes().filter(node => !ids.has(node.id))
|
|
241
|
+
return nodes.concat(hubsToKeep)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const filteredNodes = () => {
|
|
245
|
+
const query = normalizeQuery(state.query)
|
|
246
|
+
if (!query) return state.nodes
|
|
247
|
+
if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
|
|
248
|
+
const matched = state.nodes.filter(node => state.contentFilter.ids.has(node.id))
|
|
249
|
+
return withPersistentHubNodes(matched)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return withPersistentHubNodes(localFilteredNodes(query))
|
|
77
253
|
}
|
|
78
254
|
|
|
79
|
-
const
|
|
255
|
+
const resolveMacroRepresentative = (nodes) => {
|
|
256
|
+
if (nodes.length === 0) {
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let best = nodes[0]
|
|
261
|
+
let bestDegree = state.nodeDegrees.get(best.id) ?? 0
|
|
262
|
+
|
|
263
|
+
for (let index = 1; index < nodes.length; index += 1) {
|
|
264
|
+
const node = nodes[index]
|
|
265
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
266
|
+
if (degree > bestDegree) {
|
|
267
|
+
best = node
|
|
268
|
+
bestDegree = degree
|
|
269
|
+
}
|
|
270
|
+
}
|
|
80
271
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
272
|
+
return best
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const recomputeVisibility = () => {
|
|
276
|
+
const nodes = filteredNodes()
|
|
277
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
278
|
+
const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
279
|
+
const limitedEdges = state.nodes.length > largeGraphNodeThreshold
|
|
280
|
+
? [...edges]
|
|
281
|
+
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
282
|
+
.slice(0, largeGraphEdgeRenderLimit)
|
|
283
|
+
: edges
|
|
284
|
+
|
|
285
|
+
state.visibleNodes = nodes
|
|
286
|
+
state.visibleEdges = limitedEdges
|
|
287
|
+
state.visibleNodeSpatial = createSpatialIndex(nodes)
|
|
288
|
+
state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
|
|
289
|
+
state.overviewClusters = nodes.length > massiveGraphNodeThreshold ? buildOverviewClusters(nodes) : []
|
|
290
|
+
const bounds = graphBounds(nodes)
|
|
291
|
+
state.macroCenter = bounds
|
|
292
|
+
? {
|
|
293
|
+
x: (bounds.minX + bounds.maxX) / 2,
|
|
294
|
+
y: (bounds.minY + bounds.maxY) / 2
|
|
295
|
+
}
|
|
296
|
+
: { x: 0, y: 0 }
|
|
297
|
+
state.macroRepresentative = resolveMacroRepresentative(nodes)
|
|
298
|
+
markRenderDirty()
|
|
84
299
|
}
|
|
85
300
|
|
|
86
301
|
const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
|
|
302
|
+
const markRenderDirty = () => {
|
|
303
|
+
state.renderVisibilityDirty = true
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const createSpatialIndex = nodes => {
|
|
307
|
+
if (nodes.length === 0) {
|
|
308
|
+
return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const bounds = graphBounds(nodes)
|
|
312
|
+
if (!bounds) {
|
|
313
|
+
return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const targetNodesPerCell = 18
|
|
317
|
+
const approximateCellArea = Math.max((bounds.width * bounds.height) / Math.max(nodes.length / targetNodesPerCell, 1), 1)
|
|
318
|
+
const cellSize = Math.max(90, Math.min(2200, Math.sqrt(approximateCellArea)))
|
|
319
|
+
const buckets = new Map()
|
|
320
|
+
|
|
321
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
322
|
+
const node = nodes[index]
|
|
323
|
+
const cellX = Math.floor((node.x - bounds.minX) / cellSize)
|
|
324
|
+
const cellY = Math.floor((node.y - bounds.minY) / cellSize)
|
|
325
|
+
const key = cellX + ':' + cellY
|
|
326
|
+
const bucket = buckets.get(key)
|
|
327
|
+
if (bucket) {
|
|
328
|
+
bucket.push(node)
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
buckets.set(key, [node])
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
cellSize,
|
|
336
|
+
minX: bounds.minX,
|
|
337
|
+
minY: bounds.minY,
|
|
338
|
+
maxX: bounds.maxX,
|
|
339
|
+
maxY: bounds.maxY,
|
|
340
|
+
buckets
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const viewportNodesFromSpatialIndex = viewport => {
|
|
345
|
+
if (state.visibleNodes.length <= 2500) {
|
|
346
|
+
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const spatial = state.visibleNodeSpatial
|
|
350
|
+
if (!spatial || spatial.buckets.size === 0) {
|
|
351
|
+
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const minCellX = Math.floor((viewport.minX - spatial.minX) / spatial.cellSize)
|
|
355
|
+
const maxCellX = Math.floor((viewport.maxX - spatial.minX) / spatial.cellSize)
|
|
356
|
+
const minCellY = Math.floor((viewport.minY - spatial.minY) / spatial.cellSize)
|
|
357
|
+
const maxCellY = Math.floor((viewport.maxY - spatial.minY) / spatial.cellSize)
|
|
358
|
+
const nodes = []
|
|
359
|
+
|
|
360
|
+
for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
|
|
361
|
+
for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
|
|
362
|
+
const bucket = spatial.buckets.get(cellX + ':' + cellY)
|
|
363
|
+
if (!bucket) continue
|
|
364
|
+
|
|
365
|
+
for (let index = 0; index < bucket.length; index += 1) {
|
|
366
|
+
const node = bucket[index]
|
|
367
|
+
if (isNodeInViewport(node, viewport)) {
|
|
368
|
+
nodes.push(node)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return nodes
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const createVisibleEdgeLookup = edges => {
|
|
378
|
+
const lookup = new Map()
|
|
379
|
+
|
|
380
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
381
|
+
const edge = edges[index]
|
|
382
|
+
if (!edge.target) continue
|
|
383
|
+
|
|
384
|
+
const sourceList = lookup.get(edge.source)
|
|
385
|
+
if (sourceList) {
|
|
386
|
+
sourceList.push(edge)
|
|
387
|
+
} else {
|
|
388
|
+
lookup.set(edge.source, [edge])
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const targetList = lookup.get(edge.target)
|
|
392
|
+
if (targetList) {
|
|
393
|
+
targetList.push(edge)
|
|
394
|
+
} else {
|
|
395
|
+
lookup.set(edge.target, [edge])
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return lookup
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const buildOverviewClusters = nodes => {
|
|
403
|
+
if (nodes.length === 0) {
|
|
404
|
+
return []
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const bounds = graphBounds(nodes)
|
|
408
|
+
if (!bounds) {
|
|
409
|
+
return []
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const longest = Math.max(bounds.width, bounds.height, 1)
|
|
413
|
+
const cellSize = Math.max(longest / 56, 900)
|
|
414
|
+
const buckets = new Map()
|
|
415
|
+
|
|
416
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
417
|
+
const node = nodes[index]
|
|
418
|
+
const keyX = Math.floor((node.x - bounds.minX) / cellSize)
|
|
419
|
+
const keyY = Math.floor((node.y - bounds.minY) / cellSize)
|
|
420
|
+
const key = keyX + ':' + keyY
|
|
421
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
422
|
+
const current = buckets.get(key)
|
|
423
|
+
if (current) {
|
|
424
|
+
current.count += 1
|
|
425
|
+
current.sumX += node.x
|
|
426
|
+
current.sumY += node.y
|
|
427
|
+
if (degree > current.degree) {
|
|
428
|
+
current.representative = node
|
|
429
|
+
current.degree = degree
|
|
430
|
+
}
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
buckets.set(key, {
|
|
435
|
+
id: key,
|
|
436
|
+
count: 1,
|
|
437
|
+
sumX: node.x,
|
|
438
|
+
sumY: node.y,
|
|
439
|
+
representative: node,
|
|
440
|
+
degree
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return Array.from(buckets.values())
|
|
445
|
+
.sort((left, right) => right.count - left.count)
|
|
446
|
+
.slice(0, overviewClusterMaxCount)
|
|
447
|
+
.map((cluster) => ({
|
|
448
|
+
id: cluster.id,
|
|
449
|
+
x: cluster.sumX / Math.max(cluster.count, 1),
|
|
450
|
+
y: cluster.sumY / Math.max(cluster.count, 1),
|
|
451
|
+
count: cluster.count,
|
|
452
|
+
representative: cluster.representative
|
|
453
|
+
}))
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const filterOverviewClustersByViewport = viewport =>
|
|
457
|
+
state.overviewClusters.filter((cluster) =>
|
|
458
|
+
cluster.x >= viewport.minX &&
|
|
459
|
+
cluster.x <= viewport.maxX &&
|
|
460
|
+
cluster.y >= viewport.minY &&
|
|
461
|
+
cluster.y <= viewport.maxY
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
const edgeBudgetForCurrentFrame = () => {
|
|
465
|
+
const zoom = state.transform.scale
|
|
466
|
+
if (zoom < 0.12) return 380
|
|
467
|
+
if (zoom < 0.18) return 700
|
|
468
|
+
if (zoom < 0.28) return 1100
|
|
469
|
+
if (zoom < 0.45) return 1600
|
|
470
|
+
if (zoom < 0.7) return 2100
|
|
471
|
+
return renderEdgeBudget
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const clusterBudgetForScale = (scale) => {
|
|
475
|
+
if (scale < 0.008) return 90
|
|
476
|
+
if (scale < 0.014) return 150
|
|
477
|
+
if (scale < 0.022) return 240
|
|
478
|
+
if (scale < 0.035) return 360
|
|
479
|
+
return 520
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const nodeBudgetForScale = (scale) => {
|
|
483
|
+
if (scale < 0.035) return 220
|
|
484
|
+
if (scale < 0.06) return 360
|
|
485
|
+
if (scale < 0.09) return 520
|
|
486
|
+
if (scale < 0.14) return 720
|
|
487
|
+
return renderNodeBudget
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const collectVisibleEdgesForNodes = nodeIds => {
|
|
491
|
+
if (nodeIds.size === 0) {
|
|
492
|
+
return []
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const seen = new Set()
|
|
496
|
+
const collected = []
|
|
497
|
+
const limit = edgeBudgetForCurrentFrame()
|
|
498
|
+
|
|
499
|
+
nodeIds.forEach(nodeId => {
|
|
500
|
+
const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
501
|
+
for (let index = 0; index < candidateEdges.length; index += 1) {
|
|
502
|
+
const edge = candidateEdges[index]
|
|
503
|
+
if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
|
504
|
+
continue
|
|
505
|
+
}
|
|
506
|
+
const key = edge.source < edge.target
|
|
507
|
+
? edge.source + '|' + edge.target + '|' + edge.targetTitle
|
|
508
|
+
: edge.target + '|' + edge.source + '|' + edge.targetTitle
|
|
509
|
+
if (seen.has(key)) continue
|
|
510
|
+
|
|
511
|
+
seen.add(key)
|
|
512
|
+
collected.push(edge)
|
|
513
|
+
if (collected.length >= limit) {
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
return collected
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const fallbackViewportNodes = () => {
|
|
523
|
+
const nodes = []
|
|
524
|
+
const maxNodes = Math.min(renderNodeBudget, 220)
|
|
525
|
+
const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
|
|
526
|
+
|
|
527
|
+
for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
|
|
528
|
+
nodes.push(state.visibleNodes[index])
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
532
|
+
nodes.push(state.selected)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return nodes
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const sampleVisibleNodes = (limit = renderNodeBudget, sourceNodes = state.visibleNodes) => {
|
|
539
|
+
if (sourceNodes.length === 0 || limit <= 0) {
|
|
540
|
+
return []
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const nodes = []
|
|
544
|
+
const maxNodes = Math.min(Math.max(limit, 1), sourceNodes.length)
|
|
545
|
+
const step = Math.max(1, Math.ceil(sourceNodes.length / maxNodes))
|
|
546
|
+
|
|
547
|
+
for (let index = 0; index < sourceNodes.length && nodes.length < maxNodes; index += step) {
|
|
548
|
+
nodes.push(sourceNodes[index])
|
|
549
|
+
}
|
|
87
550
|
|
|
88
|
-
|
|
551
|
+
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
552
|
+
nodes.push(state.selected)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return nodes
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
|
|
559
|
+
const isFiniteNumber = value => Number.isFinite(value)
|
|
560
|
+
const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
|
|
561
|
+
const clampTransformCoordinate = value => {
|
|
562
|
+
if (!isFiniteNumber(value)) return 0
|
|
563
|
+
if (value > transformCoordinateLimit) return transformCoordinateLimit
|
|
564
|
+
if (value < -transformCoordinateLimit) return -transformCoordinateLimit
|
|
565
|
+
return value
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const graphBounds = nodes => {
|
|
569
|
+
if (nodes.length === 0) return null
|
|
570
|
+
let minX = Number.POSITIVE_INFINITY
|
|
571
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
572
|
+
let minY = Number.POSITIVE_INFINITY
|
|
573
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
574
|
+
|
|
575
|
+
nodes.forEach(node => {
|
|
576
|
+
const radius = baseNodeRadius(node)
|
|
577
|
+
minX = Math.min(minX, node.x - radius)
|
|
578
|
+
maxX = Math.max(maxX, node.x + radius)
|
|
579
|
+
minY = Math.min(minY, node.y - radius)
|
|
580
|
+
maxY = Math.max(maxY, node.y + radius)
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
minX,
|
|
585
|
+
maxX,
|
|
586
|
+
minY,
|
|
587
|
+
maxY,
|
|
588
|
+
width: Math.max(maxX - minX, 1),
|
|
589
|
+
height: Math.max(maxY - minY, 1)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const fitScaleBiasByNodeCount = nodeCount => {
|
|
594
|
+
if (nodeCount <= 6) return 1.22
|
|
595
|
+
if (nodeCount <= 20) return 1.12
|
|
596
|
+
if (nodeCount <= 60) return 1.04
|
|
597
|
+
if (nodeCount <= 180) return 1
|
|
598
|
+
if (nodeCount <= 600) return 0.94
|
|
599
|
+
if (nodeCount <= 2000) return 0.82
|
|
600
|
+
if (nodeCount <= 6000) return 0.68
|
|
601
|
+
return 0.56
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const autoFitScaleRangeByNodeCount = nodeCount => {
|
|
605
|
+
if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
|
|
606
|
+
if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
|
|
607
|
+
if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
|
|
608
|
+
if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
|
|
609
|
+
if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
|
|
610
|
+
if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
|
|
611
|
+
if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
|
|
612
|
+
return { min: 0.0008, max: 0.24 }
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const fitView = (options = { useFiltered: true, macro: false }) => {
|
|
89
616
|
const rect = canvas.getBoundingClientRect()
|
|
90
|
-
|
|
617
|
+
const width = Math.max(rect.width, 320)
|
|
618
|
+
const height = Math.max(rect.height, 320)
|
|
619
|
+
const nodes = options.useFiltered ? filteredNodes() : state.nodes
|
|
620
|
+
const bounds = graphBounds(nodes)
|
|
621
|
+
|
|
622
|
+
if (!bounds) {
|
|
623
|
+
state.transform = { x: width / 2, y: height / 2, scale: 1 }
|
|
624
|
+
state.offscreenFrameCount = 0
|
|
625
|
+
state.recoveringViewport = false
|
|
626
|
+
markRenderDirty()
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const paddingByNodeCount = nodeCount => {
|
|
631
|
+
if (nodeCount <= 6) return 28
|
|
632
|
+
if (nodeCount <= 20) return 44
|
|
633
|
+
if (nodeCount <= 60) return 68
|
|
634
|
+
if (nodeCount <= 180) return 86
|
|
635
|
+
if (nodeCount <= 600) return 110
|
|
636
|
+
if (nodeCount <= 2000) return 140
|
|
637
|
+
return 180
|
|
638
|
+
}
|
|
639
|
+
const padding = paddingByNodeCount(nodes.length)
|
|
640
|
+
const scaleX = width / (bounds.width + padding * 2)
|
|
641
|
+
const scaleY = height / (bounds.height + padding * 2)
|
|
642
|
+
const fitScale = Math.min(scaleX, scaleY)
|
|
643
|
+
const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
|
|
644
|
+
const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
|
|
645
|
+
const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
|
|
646
|
+
const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
|
|
647
|
+
const scale = options.macro && nodes.length > 1
|
|
648
|
+
? clampScale(Math.min(baselineScale, macroScale))
|
|
649
|
+
: nodes.length > massiveGraphNodeThreshold
|
|
650
|
+
? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
|
|
651
|
+
: baselineScale
|
|
652
|
+
const centerX = (bounds.minX + bounds.maxX) / 2
|
|
653
|
+
const centerY = (bounds.minY + bounds.maxY) / 2
|
|
654
|
+
|
|
655
|
+
state.transform = {
|
|
656
|
+
x: clampTransformCoordinate(width / 2 - centerX * scale),
|
|
657
|
+
y: clampTransformCoordinate(height / 2 - centerY * scale),
|
|
658
|
+
scale: clampScale(scale)
|
|
659
|
+
}
|
|
660
|
+
state.offscreenFrameCount = 0
|
|
661
|
+
state.recoveringViewport = false
|
|
662
|
+
markRenderDirty()
|
|
91
663
|
}
|
|
92
664
|
|
|
665
|
+
const resetView = () => fitView({ useFiltered: false, macro: true })
|
|
666
|
+
|
|
93
667
|
const createLayout = graph => {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
668
|
+
const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
|
|
669
|
+
const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
|
|
670
|
+
const nodes = nodeRows.map(node => {
|
|
671
|
+
if (Array.isArray(node)) {
|
|
672
|
+
const [id, title, x, y, group, segment] = node
|
|
673
|
+
return {
|
|
674
|
+
id: typeof id === 'string' ? id : '',
|
|
675
|
+
title: typeof title === 'string' ? title : 'Untitled',
|
|
676
|
+
path: '',
|
|
677
|
+
tags: [],
|
|
678
|
+
group: typeof group === 'string' ? group : 'root',
|
|
679
|
+
segment: typeof segment === 'string' ? segment : 'root',
|
|
680
|
+
x: Number.isFinite(x) ? x : 0,
|
|
681
|
+
y: Number.isFinite(y) ? y : 0,
|
|
682
|
+
vx: 0,
|
|
683
|
+
vy: 0
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return {
|
|
688
|
+
...node,
|
|
689
|
+
path: typeof node.path === 'string' ? node.path : '',
|
|
690
|
+
tags: Array.isArray(node.tags) ? node.tags : [],
|
|
691
|
+
x: Number.isFinite(node.x) ? node.x : 0,
|
|
692
|
+
y: Number.isFinite(node.y) ? node.y : 0,
|
|
693
|
+
vx: Number.isFinite(node.vx) ? node.vx : 0,
|
|
694
|
+
vy: Number.isFinite(node.vy) ? node.vy : 0
|
|
695
|
+
}
|
|
696
|
+
})
|
|
99
697
|
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
100
|
-
const edges =
|
|
698
|
+
const edges = edgeRows
|
|
699
|
+
.map(edge => {
|
|
700
|
+
if (Array.isArray(edge)) {
|
|
701
|
+
const [source, target, weight, priority] = edge
|
|
702
|
+
return {
|
|
703
|
+
source: typeof source === 'string' ? source : '',
|
|
704
|
+
target: typeof target === 'string' ? target : null,
|
|
705
|
+
targetTitle: '',
|
|
706
|
+
weight: Number.isFinite(weight) ? weight : 1,
|
|
707
|
+
priority: typeof priority === 'string' ? priority : 'normal'
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return edge
|
|
711
|
+
})
|
|
101
712
|
.filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
|
|
102
713
|
.map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
|
|
103
714
|
return { nodes, edges }
|
|
@@ -111,29 +722,107 @@ const encodeEntityTag = (value) => {
|
|
|
111
722
|
binary += String.fromCharCode(utf8[index])
|
|
112
723
|
}
|
|
113
724
|
|
|
114
|
-
return btoa(binary).
|
|
725
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
|
|
115
726
|
}
|
|
116
727
|
|
|
117
728
|
const graphSignature = graph => JSON.stringify({
|
|
118
|
-
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.
|
|
729
|
+
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
|
|
119
730
|
edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
|
|
120
731
|
})
|
|
121
732
|
|
|
733
|
+
const resetContentFilter = () => {
|
|
734
|
+
if (state.contentFilter.timer) {
|
|
735
|
+
clearTimeout(state.contentFilter.timer)
|
|
736
|
+
}
|
|
737
|
+
state.contentFilter = {
|
|
738
|
+
query: '',
|
|
739
|
+
ids: null,
|
|
740
|
+
token: state.contentFilter.token + 1,
|
|
741
|
+
timer: null
|
|
742
|
+
}
|
|
743
|
+
recomputeVisibility()
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const syncContentFilter = async (query, token) => {
|
|
747
|
+
const response = await fetch(
|
|
748
|
+
'/api/graph-filter?q=' +
|
|
749
|
+
encodeURIComponent(query) +
|
|
750
|
+
'&limit=' +
|
|
751
|
+
encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
|
|
752
|
+
agentQuery('&')
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
if (!response.ok || token !== state.contentFilter.token) {
|
|
756
|
+
return
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const payload = await response.json()
|
|
760
|
+
const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
|
|
761
|
+
if (token !== state.contentFilter.token) {
|
|
762
|
+
return
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
state.contentFilter.query = query
|
|
766
|
+
const merged = new Set([...(state.contentFilter.ids instanceof Set ? state.contentFilter.ids : []), ...nodeIds])
|
|
767
|
+
state.contentFilter.ids = merged
|
|
768
|
+
recomputeVisibility()
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const scheduleContentFilterSync = () => {
|
|
772
|
+
const query = normalizeQuery(state.query)
|
|
773
|
+
if (!query) {
|
|
774
|
+
resetContentFilter()
|
|
775
|
+
return
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (state.contentFilter.timer) {
|
|
779
|
+
clearTimeout(state.contentFilter.timer)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const token = state.contentFilter.token + 1
|
|
783
|
+
state.contentFilter = {
|
|
784
|
+
query: state.contentFilter.query,
|
|
785
|
+
ids: state.contentFilter.ids,
|
|
786
|
+
token,
|
|
787
|
+
timer: setTimeout(() => {
|
|
788
|
+
if (state.filterWorker && state.filterReady) {
|
|
789
|
+
state.filterWorker.postMessage({
|
|
790
|
+
type: 'filter',
|
|
791
|
+
query,
|
|
792
|
+
token,
|
|
793
|
+
limit: Math.max(state.nodes.length, 1)
|
|
794
|
+
})
|
|
795
|
+
}
|
|
796
|
+
syncContentFilter(query, token).catch(() => {})
|
|
797
|
+
}, 180)
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
122
801
|
const tick = delta => {
|
|
123
|
-
const nodes =
|
|
124
|
-
const
|
|
125
|
-
const
|
|
802
|
+
const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
|
|
803
|
+
const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
|
|
804
|
+
const shouldRunPhysics =
|
|
805
|
+
state.nodes.length <= 8000 &&
|
|
806
|
+
nodes.length <= 320 &&
|
|
807
|
+
state.transform.scale >= 0.08
|
|
808
|
+
if (!shouldRunPhysics) {
|
|
809
|
+
return
|
|
810
|
+
}
|
|
126
811
|
const strength = Math.min(delta / 16, 2)
|
|
127
812
|
|
|
128
813
|
edges.forEach(edge => {
|
|
129
814
|
const source = edge.sourceNode
|
|
130
815
|
const target = edge.targetNode
|
|
816
|
+
source.vx = Number.isFinite(source.vx) ? source.vx : 0
|
|
817
|
+
source.vy = Number.isFinite(source.vy) ? source.vy : 0
|
|
818
|
+
target.vx = Number.isFinite(target.vx) ? target.vx : 0
|
|
819
|
+
target.vy = Number.isFinite(target.vy) ? target.vy : 0
|
|
131
820
|
const dx = target.x - source.x
|
|
132
821
|
const dy = target.y - source.y
|
|
133
822
|
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
134
823
|
const force = (distance - 150) * 0.002 * strength
|
|
135
|
-
const fx = dx * force
|
|
136
|
-
const fy = dy * force
|
|
824
|
+
const fx = (dx / distance) * force
|
|
825
|
+
const fy = (dy / distance) * force
|
|
137
826
|
source.vx += fx
|
|
138
827
|
source.vy += fy
|
|
139
828
|
target.vx -= fx
|
|
@@ -144,6 +833,10 @@ const tick = delta => {
|
|
|
144
833
|
for (let j = i + 1; j < nodes.length; j += 1) {
|
|
145
834
|
const a = nodes[i]
|
|
146
835
|
const b = nodes[j]
|
|
836
|
+
a.vx = Number.isFinite(a.vx) ? a.vx : 0
|
|
837
|
+
a.vy = Number.isFinite(a.vy) ? a.vy : 0
|
|
838
|
+
b.vx = Number.isFinite(b.vx) ? b.vx : 0
|
|
839
|
+
b.vy = Number.isFinite(b.vy) ? b.vy : 0
|
|
147
840
|
const dx = b.x - a.x
|
|
148
841
|
const dy = b.y - a.y
|
|
149
842
|
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
@@ -158,6 +851,10 @@ const tick = delta => {
|
|
|
158
851
|
}
|
|
159
852
|
|
|
160
853
|
nodes.forEach(node => {
|
|
854
|
+
node.vx = Number.isFinite(node.vx) ? node.vx : 0
|
|
855
|
+
node.vy = Number.isFinite(node.vy) ? node.vy : 0
|
|
856
|
+
node.x = Number.isFinite(node.x) ? node.x : 0
|
|
857
|
+
node.y = Number.isFinite(node.y) ? node.y : 0
|
|
161
858
|
if (state.pointer.dragNode === node) {
|
|
162
859
|
node.vx = 0
|
|
163
860
|
node.vy = 0
|
|
@@ -181,7 +878,15 @@ const worldPoint = event => {
|
|
|
181
878
|
}
|
|
182
879
|
|
|
183
880
|
const hitNode = point => {
|
|
184
|
-
|
|
881
|
+
computeRenderVisibility()
|
|
882
|
+
if (state.renderClusters.length > 0) {
|
|
883
|
+
return null
|
|
884
|
+
}
|
|
885
|
+
if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.9) {
|
|
886
|
+
return null
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const nodes = state.renderNodes
|
|
185
890
|
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
186
891
|
const node = nodes[index]
|
|
187
892
|
const radius = nodeRadius(node)
|
|
@@ -190,17 +895,292 @@ const hitNode = point => {
|
|
|
190
895
|
return null
|
|
191
896
|
}
|
|
192
897
|
|
|
193
|
-
const
|
|
194
|
-
const degree = state.
|
|
898
|
+
const baseNodeRadius = node => {
|
|
899
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
195
900
|
return 9 + Math.min(degree, 8) * 1.6
|
|
196
901
|
}
|
|
197
902
|
|
|
903
|
+
const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
|
|
904
|
+
|
|
905
|
+
const worldViewportBounds = () => {
|
|
906
|
+
const rect = canvas.getBoundingClientRect()
|
|
907
|
+
const width = Math.max(rect.width, 320)
|
|
908
|
+
const height = Math.max(rect.height, 320)
|
|
909
|
+
const padding = viewportPaddingPx
|
|
910
|
+
|
|
911
|
+
return {
|
|
912
|
+
minX: (-state.transform.x - padding) / state.transform.scale,
|
|
913
|
+
maxX: (width - state.transform.x + padding) / state.transform.scale,
|
|
914
|
+
minY: (-state.transform.y - padding) / state.transform.scale,
|
|
915
|
+
maxY: (height - state.transform.y + padding) / state.transform.scale
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const isNodeInViewport = (node, viewport) =>
|
|
920
|
+
node.x >= viewport.minX &&
|
|
921
|
+
node.x <= viewport.maxX &&
|
|
922
|
+
node.y >= viewport.minY &&
|
|
923
|
+
node.y <= viewport.maxY
|
|
924
|
+
|
|
925
|
+
const viewportNodeStride = () => {
|
|
926
|
+
if (state.nodes.length <= largeGraphNodeThreshold) {
|
|
927
|
+
return 1
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (state.transform.scale >= 0.95) {
|
|
931
|
+
return 1
|
|
932
|
+
}
|
|
933
|
+
if (state.transform.scale >= 0.7) {
|
|
934
|
+
return 2
|
|
935
|
+
}
|
|
936
|
+
if (state.transform.scale >= 0.48) {
|
|
937
|
+
return 3
|
|
938
|
+
}
|
|
939
|
+
if (state.transform.scale >= 0.28) {
|
|
940
|
+
return 5
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return 8
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const shouldRenderClusters = viewportNodes =>
|
|
947
|
+
state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
|
|
948
|
+
|
|
949
|
+
const clusterViewportNodes = viewportNodes => {
|
|
950
|
+
if (!shouldRenderClusters(viewportNodes)) {
|
|
951
|
+
return []
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
|
|
955
|
+
const buckets = new Map()
|
|
956
|
+
|
|
957
|
+
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
958
|
+
const node = viewportNodes[index]
|
|
959
|
+
const keyX = Math.floor(node.x / worldCellSize)
|
|
960
|
+
const keyY = Math.floor(node.y / worldCellSize)
|
|
961
|
+
const key = keyX + ':' + keyY
|
|
962
|
+
const current = buckets.get(key)
|
|
963
|
+
if (current) {
|
|
964
|
+
current.count += 1
|
|
965
|
+
current.sumX += node.x
|
|
966
|
+
current.sumY += node.y
|
|
967
|
+
if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
|
|
968
|
+
current.representative = node
|
|
969
|
+
current.degree = state.nodeDegrees.get(node.id) ?? 0
|
|
970
|
+
}
|
|
971
|
+
continue
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
buckets.set(key, {
|
|
975
|
+
id: key,
|
|
976
|
+
count: 1,
|
|
977
|
+
sumX: node.x,
|
|
978
|
+
sumY: node.y,
|
|
979
|
+
representative: node,
|
|
980
|
+
degree: state.nodeDegrees.get(node.id) ?? 0
|
|
981
|
+
})
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
return Array.from(buckets.values())
|
|
985
|
+
.sort((left, right) => right.count - left.count)
|
|
986
|
+
.slice(0, Math.min(renderNodeBudget, 900))
|
|
987
|
+
.map((cluster) => ({
|
|
988
|
+
id: cluster.id,
|
|
989
|
+
x: cluster.sumX / Math.max(cluster.count, 1),
|
|
990
|
+
y: cluster.sumY / Math.max(cluster.count, 1),
|
|
991
|
+
count: cluster.count,
|
|
992
|
+
representative: cluster.representative
|
|
993
|
+
}))
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const computeRenderVisibility = () => {
|
|
997
|
+
if (!hasValidTransform()) {
|
|
998
|
+
fitView({ useFiltered: true })
|
|
999
|
+
}
|
|
1000
|
+
const viewport = worldViewportBounds()
|
|
1001
|
+
const viewportKey =
|
|
1002
|
+
Math.round(viewport.minX * 10) + ':' +
|
|
1003
|
+
Math.round(viewport.maxX * 10) + ':' +
|
|
1004
|
+
Math.round(viewport.minY * 10) + ':' +
|
|
1005
|
+
Math.round(viewport.maxY * 10) + ':' +
|
|
1006
|
+
Math.round(state.transform.scale * 1000)
|
|
1007
|
+
|
|
1008
|
+
if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
|
|
1009
|
+
return
|
|
1010
|
+
}
|
|
1011
|
+
state.lastViewportKey = viewportKey
|
|
1012
|
+
state.renderVisibilityDirty = false
|
|
1013
|
+
|
|
1014
|
+
const shouldRenderMacroGalaxy =
|
|
1015
|
+
state.transform.scale <= macroGalaxyZoomThreshold && state.visibleNodes.length > 1
|
|
1016
|
+
|
|
1017
|
+
if (shouldRenderMacroGalaxy) {
|
|
1018
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
1019
|
+
const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
|
|
1020
|
+
const representative = state.macroRepresentative ?? sourceNodes[0] ?? null
|
|
1021
|
+
if (representative) {
|
|
1022
|
+
state.renderClusters = [
|
|
1023
|
+
{
|
|
1024
|
+
id: 'macro-galaxy',
|
|
1025
|
+
x: state.macroCenter.x,
|
|
1026
|
+
y: state.macroCenter.y,
|
|
1027
|
+
count: sourceNodes.length,
|
|
1028
|
+
representative
|
|
1029
|
+
}
|
|
1030
|
+
]
|
|
1031
|
+
state.renderNodes = [representative]
|
|
1032
|
+
} else {
|
|
1033
|
+
state.renderClusters = []
|
|
1034
|
+
state.renderNodes = []
|
|
1035
|
+
}
|
|
1036
|
+
state.renderEdges = []
|
|
1037
|
+
return
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (state.visibleNodes.length <= 2000) {
|
|
1041
|
+
state.renderNodes = state.visibleNodes
|
|
1042
|
+
state.renderClusters = []
|
|
1043
|
+
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
1044
|
+
state.renderEdges = collectVisibleEdgesForNodes(ids)
|
|
1045
|
+
return
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
if (state.visibleNodes.length > massiveGraphNodeThreshold) {
|
|
1049
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
1050
|
+
const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
|
|
1051
|
+
const sampleLimit = nodeBudgetForScale(state.transform.scale)
|
|
1052
|
+
const sampled = sourceNodes.length > sampleLimit
|
|
1053
|
+
? sampleVisibleNodes(Math.min(sampleLimit, renderNodeBudget), sourceNodes)
|
|
1054
|
+
: sourceNodes.slice(0, Math.min(sourceNodes.length, renderNodeBudget))
|
|
1055
|
+
const sampledIds = new Set(sampled.map((node) => node.id))
|
|
1056
|
+
state.renderClusters = []
|
|
1057
|
+
state.renderNodes = sampled
|
|
1058
|
+
state.renderEdges = state.transform.scale >= 0.1 ? collectVisibleEdgesForNodes(sampledIds) : []
|
|
1059
|
+
return
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
if (state.transform.scale <= 0.0015) {
|
|
1063
|
+
const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
|
|
1064
|
+
const sampledIds = new Set(sampled.map((node) => node.id))
|
|
1065
|
+
state.renderClusters = []
|
|
1066
|
+
state.renderNodes = sampled
|
|
1067
|
+
state.renderEdges = collectVisibleEdgesForNodes(sampledIds)
|
|
1068
|
+
return
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
1072
|
+
const clusters = clusterViewportNodes(viewportNodes)
|
|
1073
|
+
if (clusters.length > 0) {
|
|
1074
|
+
state.renderClusters = clusters
|
|
1075
|
+
state.renderNodes = clusters.map(cluster => cluster.representative)
|
|
1076
|
+
state.renderEdges = []
|
|
1077
|
+
return
|
|
1078
|
+
}
|
|
1079
|
+
state.renderClusters = []
|
|
1080
|
+
const stride = viewportNodeStride()
|
|
1081
|
+
const picked = []
|
|
1082
|
+
|
|
1083
|
+
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
1084
|
+
const node = viewportNodes[index]
|
|
1085
|
+
|
|
1086
|
+
const isPriority =
|
|
1087
|
+
node.id === state.selected?.id ||
|
|
1088
|
+
node.id === state.hovered?.id ||
|
|
1089
|
+
node.id === state.pointer.dragNode?.id
|
|
1090
|
+
if (isPriority || index % stride === 0) {
|
|
1091
|
+
picked.push(node)
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const nodes = picked.length > renderNodeBudget
|
|
1096
|
+
? picked.slice(0, renderNodeBudget)
|
|
1097
|
+
: picked
|
|
1098
|
+
if (nodes.length === 0 && state.visibleNodes.length > 0) {
|
|
1099
|
+
const fallbackNodes = fallbackViewportNodes()
|
|
1100
|
+
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
1101
|
+
state.renderNodes = fallbackNodes
|
|
1102
|
+
state.renderClusters = []
|
|
1103
|
+
state.renderEdges = collectVisibleEdgesForNodes(fallbackIds)
|
|
1104
|
+
return
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const nodeIds = new Set(nodes.map((node) => node.id))
|
|
1108
|
+
const edges = collectVisibleEdgesForNodes(nodeIds)
|
|
1109
|
+
|
|
1110
|
+
state.renderNodes = nodes
|
|
1111
|
+
state.renderEdges = edges
|
|
1112
|
+
|
|
1113
|
+
if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
|
|
1114
|
+
const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
|
|
1115
|
+
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
1116
|
+
state.renderClusters = []
|
|
1117
|
+
state.renderNodes = fallbackNodes
|
|
1118
|
+
state.renderEdges = collectVisibleEdgesForNodes(fallbackIds)
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const isNodeVisibleOnScreen = (node, width, height) => {
|
|
1123
|
+
const radius = nodeRadius(node) * state.transform.scale
|
|
1124
|
+
const screenX = node.x * state.transform.scale + state.transform.x
|
|
1125
|
+
const screenY = node.y * state.transform.scale + state.transform.y
|
|
1126
|
+
|
|
1127
|
+
return (
|
|
1128
|
+
screenX + radius >= 0 &&
|
|
1129
|
+
screenX - radius <= width &&
|
|
1130
|
+
screenY + radius >= 0 &&
|
|
1131
|
+
screenY - radius <= height
|
|
1132
|
+
)
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const hasValidTransform = () =>
|
|
1136
|
+
isFiniteNumber(state.transform.x) &&
|
|
1137
|
+
isFiniteNumber(state.transform.y) &&
|
|
1138
|
+
isFiniteNumber(state.transform.scale) &&
|
|
1139
|
+
Math.abs(state.transform.x) <= transformCoordinateLimit &&
|
|
1140
|
+
Math.abs(state.transform.y) <= transformCoordinateLimit &&
|
|
1141
|
+
state.transform.scale > 0
|
|
1142
|
+
|
|
1143
|
+
const sanitizeNodePosition = node => {
|
|
1144
|
+
if (!isReasonableCoordinate(node.x)) node.x = 0
|
|
1145
|
+
if (!isReasonableCoordinate(node.y)) node.y = 0
|
|
1146
|
+
if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
|
|
1147
|
+
if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const sanitizeAllNodePositions = () => {
|
|
1151
|
+
state.nodes.forEach(sanitizeNodePosition)
|
|
1152
|
+
state.visibleNodes.forEach(sanitizeNodePosition)
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const sanitizeGraphState = () => {
|
|
1156
|
+
state.renderNodes.forEach(sanitizeNodePosition)
|
|
1157
|
+
}
|
|
1158
|
+
|
|
198
1159
|
const render = now => {
|
|
199
1160
|
const delta = now - state.last
|
|
200
1161
|
state.last = now
|
|
1162
|
+
const backgroundFrameIntervalMs =
|
|
1163
|
+
state.nodes.length > massiveGraphNodeThreshold
|
|
1164
|
+
? (state.transform.scale < 0.035 ? 130 : state.transform.scale < 0.08 ? 110 : 86)
|
|
1165
|
+
: state.nodes.length > largeGraphNodeThreshold
|
|
1166
|
+
? 64
|
|
1167
|
+
: 16
|
|
1168
|
+
const isInteracting =
|
|
1169
|
+
state.pointer.down ||
|
|
1170
|
+
state.renderVisibilityDirty ||
|
|
1171
|
+
state.recoveringViewport
|
|
1172
|
+
const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
|
|
1173
|
+
if (delta < minFrameIntervalMs) {
|
|
1174
|
+
requestAnimationFrame(render)
|
|
1175
|
+
return
|
|
1176
|
+
}
|
|
201
1177
|
const rect = canvas.getBoundingClientRect()
|
|
202
1178
|
const width = Math.max(rect.width, 320)
|
|
203
1179
|
const height = Math.max(rect.height, 320)
|
|
1180
|
+
sanitizeGraphState()
|
|
1181
|
+
if (!hasValidTransform()) {
|
|
1182
|
+
resetView()
|
|
1183
|
+
}
|
|
204
1184
|
ctx.clearRect(0, 0, width, height)
|
|
205
1185
|
if (state.nodes.length === 0) {
|
|
206
1186
|
ctx.fillStyle = '#99a5b5'
|
|
@@ -214,7 +1194,34 @@ const render = now => {
|
|
|
214
1194
|
ctx.translate(state.transform.x, state.transform.y)
|
|
215
1195
|
ctx.scale(state.transform.scale, state.transform.scale)
|
|
216
1196
|
|
|
217
|
-
|
|
1197
|
+
computeRenderVisibility()
|
|
1198
|
+
tick(delta)
|
|
1199
|
+
const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
|
|
1200
|
+
const manualZoomGuardActive = now - state.lastManualZoomAt < zoomRecoveryGuardMs
|
|
1201
|
+
if (!hasVisibleNodeOnScreen && state.renderNodes.length > 0 && !manualZoomGuardActive) {
|
|
1202
|
+
state.offscreenFrameCount += 1
|
|
1203
|
+
if (state.offscreenFrameCount >= 6 && !state.recoveringViewport) {
|
|
1204
|
+
state.recoveringViewport = true
|
|
1205
|
+
fitView({ useFiltered: true })
|
|
1206
|
+
state.offscreenFrameCount = 0
|
|
1207
|
+
requestAnimationFrame(() => {
|
|
1208
|
+
state.recoveringViewport = false
|
|
1209
|
+
})
|
|
1210
|
+
}
|
|
1211
|
+
} else {
|
|
1212
|
+
state.offscreenFrameCount = 0
|
|
1213
|
+
}
|
|
1214
|
+
const minimumEdgeScale =
|
|
1215
|
+
state.nodes.length > massiveGraphNodeThreshold
|
|
1216
|
+
? 0.1
|
|
1217
|
+
: state.nodes.length > largeGraphNodeThreshold
|
|
1218
|
+
? 0.16
|
|
1219
|
+
: 0
|
|
1220
|
+
const drawEdges =
|
|
1221
|
+
state.renderClusters.length === 0 &&
|
|
1222
|
+
state.transform.scale >= minimumEdgeScale
|
|
1223
|
+
if (drawEdges) {
|
|
1224
|
+
state.renderEdges.forEach(edge => {
|
|
218
1225
|
const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
219
1226
|
ctx.beginPath()
|
|
220
1227
|
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
|
|
@@ -222,9 +1229,33 @@ const render = now => {
|
|
|
222
1229
|
ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
|
|
223
1230
|
ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
|
|
224
1231
|
ctx.stroke()
|
|
225
|
-
|
|
1232
|
+
})
|
|
1233
|
+
}
|
|
226
1234
|
|
|
227
|
-
|
|
1235
|
+
if (state.renderClusters.length > 0) {
|
|
1236
|
+
const safeScale = Math.max(state.transform.scale, 0.0001)
|
|
1237
|
+
state.renderClusters.forEach(cluster => {
|
|
1238
|
+
const isMacro = cluster.id === 'macro-galaxy'
|
|
1239
|
+
const radiusPx = isMacro
|
|
1240
|
+
? 10
|
|
1241
|
+
: Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
|
|
1242
|
+
const radius = radiusPx / safeScale
|
|
1243
|
+
const haloRadius = (radiusPx + (isMacro ? 8 : 4)) / safeScale
|
|
1244
|
+
ctx.beginPath()
|
|
1245
|
+
ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
|
|
1246
|
+
ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
|
|
1247
|
+
ctx.fill()
|
|
1248
|
+
ctx.beginPath()
|
|
1249
|
+
ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
|
|
1250
|
+
ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
|
|
1251
|
+
ctx.fill()
|
|
1252
|
+
ctx.lineWidth = 1.4 / safeScale
|
|
1253
|
+
ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
|
|
1254
|
+
ctx.stroke()
|
|
1255
|
+
// Keep cluster markers minimal and faster to draw on large graphs.
|
|
1256
|
+
})
|
|
1257
|
+
} else {
|
|
1258
|
+
state.renderNodes.forEach(node => {
|
|
228
1259
|
const radius = nodeRadius(node)
|
|
229
1260
|
const isSelected = state.selected?.id === node.id
|
|
230
1261
|
const isHovered = state.hovered?.id === node.id
|
|
@@ -240,16 +1271,27 @@ const render = now => {
|
|
|
240
1271
|
ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
|
|
241
1272
|
ctx.stroke()
|
|
242
1273
|
|
|
243
|
-
|
|
1274
|
+
const shouldDrawLabels =
|
|
1275
|
+
isSelected ||
|
|
1276
|
+
isHovered ||
|
|
1277
|
+
(state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
|
|
1278
|
+
if (shouldDrawLabels) {
|
|
244
1279
|
ctx.fillStyle = graphTheme.label
|
|
245
1280
|
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
246
1281
|
ctx.textAlign = 'center'
|
|
247
1282
|
ctx.textBaseline = 'top'
|
|
248
1283
|
ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
|
|
249
1284
|
}
|
|
250
|
-
|
|
1285
|
+
})
|
|
1286
|
+
}
|
|
251
1287
|
|
|
252
1288
|
ctx.restore()
|
|
1289
|
+
if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
|
|
1290
|
+
ctx.fillStyle = '#99a5b5'
|
|
1291
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
1292
|
+
ctx.textAlign = 'center'
|
|
1293
|
+
ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
|
|
1294
|
+
}
|
|
253
1295
|
requestAnimationFrame(render)
|
|
254
1296
|
}
|
|
255
1297
|
|
|
@@ -257,88 +1299,205 @@ const list = items => items.length
|
|
|
257
1299
|
? 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('')
|
|
258
1300
|
: '<li><small>No links found.</small></li>'
|
|
259
1301
|
|
|
260
|
-
const
|
|
261
|
-
? 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('')
|
|
262
|
-
: '<li><small>No notes indexed.</small></li>'
|
|
263
|
-
|
|
264
|
-
const selectNode = node => {
|
|
265
|
-
state.selected = node
|
|
266
|
-
if (!node) {
|
|
267
|
-
elements.title.textContent = 'Graph Overview'
|
|
268
|
-
elements.path.textContent = state.nodes.length + ' notes and ' + state.graph.edges.length + ' links indexed.'
|
|
269
|
-
elements.tags.innerHTML = ''
|
|
270
|
-
elements.notes.innerHTML = allNotesList()
|
|
271
|
-
elements.content.textContent = 'Selecione uma nota no grafo ou na lista para ver o Markdown completo, backlinks e links de saida.'
|
|
272
|
-
elements.outgoing.innerHTML = '<li><small>Select a note to inspect outgoing links.</small></li>'
|
|
273
|
-
elements.incoming.innerHTML = '<li><small>Select a note to inspect backlinks.</small></li>'
|
|
274
|
-
return
|
|
275
|
-
}
|
|
1302
|
+
const linkedNodes = node => {
|
|
276
1303
|
const nodeById = new Map(state.nodes.map(item => [item.id, item]))
|
|
277
1304
|
const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
|
|
278
1305
|
...linkedNode,
|
|
279
1306
|
weight: edge.weight,
|
|
280
1307
|
priority: edge.priority
|
|
281
1308
|
} : null
|
|
282
|
-
const outgoing = state.
|
|
1309
|
+
const outgoing = state.edges
|
|
283
1310
|
.filter(edge => edge.source === node.id)
|
|
284
|
-
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' }, edge))
|
|
1311
|
+
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
|
|
285
1312
|
.filter(Boolean)
|
|
286
|
-
const incoming = state.
|
|
1313
|
+
const incoming = state.edges
|
|
287
1314
|
.filter(edge => edge.target === node.id)
|
|
288
1315
|
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
289
1316
|
.filter(Boolean)
|
|
290
1317
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
1318
|
+
return { outgoing, incoming }
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const fetchNodeDetails = async node => {
|
|
1322
|
+
const cached = state.nodeDetails.get(node.id)
|
|
1323
|
+
if (cached) {
|
|
1324
|
+
return cached
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
|
|
1328
|
+
if (!response.ok) {
|
|
1329
|
+
throw new Error('Failed to load graph node details')
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const payload = await response.json()
|
|
1333
|
+
const detail = payload?.node
|
|
1334
|
+
if (!detail || !detail.id) {
|
|
1335
|
+
throw new Error('Invalid graph node payload')
|
|
1336
|
+
}
|
|
1337
|
+
state.nodeDetails.set(detail.id, detail)
|
|
1338
|
+
return detail
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
1342
|
+
|
|
1343
|
+
const openContentDialog = async node => {
|
|
1344
|
+
if (!node) return
|
|
1345
|
+
elements.contentTitle.textContent = node.title || 'Loading...'
|
|
1346
|
+
elements.contentPath.textContent = node.path || 'Loading...'
|
|
1347
|
+
elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
|
|
294
1348
|
? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
295
1349
|
: '<span>No tags</span>'
|
|
296
|
-
|
|
297
|
-
elements.
|
|
298
|
-
elements.
|
|
299
|
-
elements.
|
|
1350
|
+
const initialLinks = linkedNodes(node)
|
|
1351
|
+
elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
|
|
1352
|
+
elements.contentIncoming.innerHTML = list(initialLinks.incoming)
|
|
1353
|
+
elements.contentBody.textContent = 'Loading note content...'
|
|
1354
|
+
if (!elements.contentDialog.open) {
|
|
1355
|
+
elements.contentDialog.showModal()
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const applyDetailToDialog = detail => {
|
|
1359
|
+
elements.contentTitle.textContent = detail.title
|
|
1360
|
+
elements.contentPath.textContent = detail.path
|
|
1361
|
+
elements.contentTags.innerHTML = detail.tags.length
|
|
1362
|
+
? detail.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
1363
|
+
: '<span>No tags</span>'
|
|
1364
|
+
elements.contentBody.textContent = detail.content
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
try {
|
|
1368
|
+
const detailedNode = await fetchNodeDetails(node)
|
|
1369
|
+
if (state.selected?.id !== node.id) {
|
|
1370
|
+
return
|
|
1371
|
+
}
|
|
1372
|
+
applyDetailToDialog(detailedNode)
|
|
1373
|
+
} catch {
|
|
1374
|
+
try {
|
|
1375
|
+
await wait(120)
|
|
1376
|
+
const retriedNode = await fetchNodeDetails(node)
|
|
1377
|
+
if (state.selected?.id !== node.id) {
|
|
1378
|
+
return
|
|
1379
|
+
}
|
|
1380
|
+
applyDetailToDialog(retriedNode)
|
|
1381
|
+
} catch {
|
|
1382
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const selectNode = (node, options = { openContent: false }) => {
|
|
1388
|
+
state.selected = node
|
|
1389
|
+
if (node && options.openContent) {
|
|
1390
|
+
openContentDialog(node).catch(() => {
|
|
1391
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
1392
|
+
})
|
|
1393
|
+
}
|
|
300
1394
|
}
|
|
301
1395
|
|
|
302
1396
|
const selectNodeById = id => {
|
|
303
1397
|
const node = state.nodes.find(item => item.id === id)
|
|
304
|
-
if (node) selectNode(node)
|
|
1398
|
+
if (node) selectNode(node, { openContent: true })
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
|
|
1402
|
+
const nextScale = clampScale(state.transform.scale * factor)
|
|
1403
|
+
if (nextScale === state.transform.scale) return
|
|
1404
|
+
const worldX = (screenX - state.transform.x) / state.transform.scale
|
|
1405
|
+
const worldY = (screenY - state.transform.y) / state.transform.scale
|
|
1406
|
+
state.transform.scale = clampScale(nextScale)
|
|
1407
|
+
state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
|
|
1408
|
+
state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
|
|
1409
|
+
state.offscreenFrameCount = 0
|
|
1410
|
+
if (source === 'wheel') {
|
|
1411
|
+
state.lastManualZoomAt = performance.now()
|
|
1412
|
+
}
|
|
1413
|
+
markRenderDirty()
|
|
305
1414
|
}
|
|
306
1415
|
|
|
307
|
-
const
|
|
308
|
-
|
|
1416
|
+
const wheelZoomFactor = event => {
|
|
1417
|
+
const isModifierZoom = event.metaKey || event.ctrlKey
|
|
1418
|
+
const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
|
|
1419
|
+
const absoluteDelta = Math.min(Math.abs(event.deltaY * deltaModeFactor), 1600)
|
|
1420
|
+
|
|
1421
|
+
if (absoluteDelta <= 0.0001) {
|
|
1422
|
+
return 1
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
const baseStep = Math.max(0.06, Math.min(0.45, absoluteDelta / 480))
|
|
1426
|
+
const adjustedStep = baseStep * (isModifierZoom ? 1.4 : 1)
|
|
1427
|
+
|
|
1428
|
+
return event.deltaY < 0 ? 1 + adjustedStep : 1 / (1 + adjustedStep)
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const handleWheelZoom = event => {
|
|
1432
|
+
if (elements.contentDialog?.open) {
|
|
1433
|
+
return
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
event.preventDefault()
|
|
1437
|
+
const rect = canvas.getBoundingClientRect()
|
|
1438
|
+
const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
|
|
1439
|
+
const rawCursorY = Number.isFinite(event.offsetY) ? event.offsetY : event.clientY - rect.top
|
|
1440
|
+
const cursorX = Math.max(0, Math.min(Math.max(rect.width, 320), rawCursorX))
|
|
1441
|
+
const cursorY = Math.max(0, Math.min(Math.max(rect.height, 320), rawCursorY))
|
|
1442
|
+
const factor = wheelZoomFactor(event)
|
|
1443
|
+
|
|
1444
|
+
if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
|
|
1445
|
+
return
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
zoomAtPoint(cursorX, cursorY, factor, 'wheel')
|
|
309
1449
|
}
|
|
310
1450
|
|
|
311
1451
|
const bindEvents = () => {
|
|
312
1452
|
window.addEventListener('resize', resize)
|
|
313
1453
|
elements.search.addEventListener('input', event => {
|
|
314
1454
|
state.query = event.target.value
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
: state.nodes.length + ' notes · ' + state.edges.length + ' links'
|
|
1455
|
+
recomputeVisibility()
|
|
1456
|
+
scheduleContentFilterSync()
|
|
318
1457
|
})
|
|
319
1458
|
elements.agent.addEventListener('change', event => {
|
|
320
1459
|
state.agentId = event.target.value
|
|
321
1460
|
state.selected = null
|
|
1461
|
+
state.nodeDetails = new Map()
|
|
1462
|
+
resetContentFilter()
|
|
1463
|
+
recomputeVisibility()
|
|
1464
|
+
scheduleContentFilterSync()
|
|
322
1465
|
loadGraph({ reset: true }).catch(error => {
|
|
323
|
-
elements.stats.textContent = 'Failed to load agent graph'
|
|
324
1466
|
console.error(error)
|
|
325
1467
|
})
|
|
326
1468
|
})
|
|
327
|
-
elements.zoomIn.addEventListener('click', () =>
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
1469
|
+
elements.zoomIn.addEventListener('click', () => {
|
|
1470
|
+
const rect = canvas.getBoundingClientRect()
|
|
1471
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.3)
|
|
1472
|
+
})
|
|
1473
|
+
elements.zoomOut.addEventListener('click', () => {
|
|
1474
|
+
const rect = canvas.getBoundingClientRect()
|
|
1475
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.77)
|
|
1476
|
+
})
|
|
1477
|
+
if (elements.fit) {
|
|
1478
|
+
elements.fit.addEventListener('click', () => {
|
|
1479
|
+
fitView({ useFiltered: true })
|
|
336
1480
|
})
|
|
1481
|
+
}
|
|
1482
|
+
elements.reset.addEventListener('click', () => {
|
|
1483
|
+
resetView()
|
|
1484
|
+
})
|
|
1485
|
+
elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
|
|
1486
|
+
elements.contentDialog.addEventListener('click', event => {
|
|
1487
|
+
const target = event.target
|
|
1488
|
+
if (target instanceof HTMLElement && target.dataset.nodeId) {
|
|
1489
|
+
selectNodeById(target.dataset.nodeId)
|
|
1490
|
+
return
|
|
1491
|
+
}
|
|
1492
|
+
if (event.target === elements.contentDialog) elements.contentDialog.close()
|
|
1493
|
+
})
|
|
1494
|
+
canvas.addEventListener('wheel', handleWheelZoom, { passive: false })
|
|
1495
|
+
canvas.addEventListener('dblclick', event => {
|
|
1496
|
+
const rect = canvas.getBoundingClientRect()
|
|
1497
|
+
const cursorX = event.clientX - rect.left
|
|
1498
|
+
const cursorY = event.clientY - rect.top
|
|
1499
|
+
zoomAtPoint(cursorX, cursorY, 1.25)
|
|
337
1500
|
})
|
|
338
|
-
canvas.addEventListener('wheel', event => {
|
|
339
|
-
event.preventDefault()
|
|
340
|
-
zoom(event.deltaY < 0 ? 1.08 : 0.92)
|
|
341
|
-
}, { passive: false })
|
|
342
1501
|
canvas.addEventListener('pointerdown', event => {
|
|
343
1502
|
const point = worldPoint(event)
|
|
344
1503
|
const node = hitNode(point)
|
|
@@ -346,12 +1505,24 @@ const bindEvents = () => {
|
|
|
346
1505
|
if (node) {
|
|
347
1506
|
node.x = point.x
|
|
348
1507
|
node.y = point.y
|
|
1508
|
+
markRenderDirty()
|
|
349
1509
|
}
|
|
350
1510
|
canvas.setPointerCapture(event.pointerId)
|
|
351
1511
|
})
|
|
352
1512
|
canvas.addEventListener('pointermove', event => {
|
|
353
1513
|
const point = worldPoint(event)
|
|
354
|
-
|
|
1514
|
+
const now = performance.now()
|
|
1515
|
+
const canHoverHitTest =
|
|
1516
|
+
!(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.12)
|
|
1517
|
+
const shouldHitTest = canHoverHitTest &&
|
|
1518
|
+
(state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
|
|
1519
|
+
if (shouldHitTest) {
|
|
1520
|
+
state.hovered = hitNode(point)
|
|
1521
|
+
state.lastHoverHitAt = now
|
|
1522
|
+
} else if (!canHoverHitTest) {
|
|
1523
|
+
state.hovered = null
|
|
1524
|
+
}
|
|
1525
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
355
1526
|
if (!state.pointer.down) return
|
|
356
1527
|
const dx = event.clientX - state.pointer.x
|
|
357
1528
|
const dy = event.clientY - state.pointer.y
|
|
@@ -361,34 +1532,70 @@ const bindEvents = () => {
|
|
|
361
1532
|
if (state.pointer.dragNode) {
|
|
362
1533
|
state.pointer.dragNode.x = point.x
|
|
363
1534
|
state.pointer.dragNode.y = point.y
|
|
1535
|
+
markRenderDirty()
|
|
364
1536
|
return
|
|
365
1537
|
}
|
|
366
1538
|
state.transform.x += dx
|
|
367
1539
|
state.transform.y += dy
|
|
1540
|
+
state.transform.x = clampTransformCoordinate(state.transform.x)
|
|
1541
|
+
state.transform.y = clampTransformCoordinate(state.transform.y)
|
|
1542
|
+
state.offscreenFrameCount = 0
|
|
1543
|
+
markRenderDirty()
|
|
368
1544
|
})
|
|
369
1545
|
canvas.addEventListener('pointerup', event => {
|
|
370
|
-
if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode)
|
|
371
|
-
if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered)
|
|
1546
|
+
if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
|
|
1547
|
+
if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
|
|
372
1548
|
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
373
1549
|
canvas.releasePointerCapture(event.pointerId)
|
|
374
1550
|
})
|
|
1551
|
+
canvas.addEventListener('pointercancel', () => {
|
|
1552
|
+
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
1553
|
+
})
|
|
1554
|
+
canvas.addEventListener('pointerenter', event => {
|
|
1555
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
1556
|
+
})
|
|
1557
|
+
canvas.addEventListener('pointerleave', event => {
|
|
1558
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
|
|
1559
|
+
})
|
|
1560
|
+
window.addEventListener('keydown', event => {
|
|
1561
|
+
if (event.key === '+' || event.key === '=') {
|
|
1562
|
+
event.preventDefault()
|
|
1563
|
+
const rect = canvas.getBoundingClientRect()
|
|
1564
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.25)
|
|
1565
|
+
return
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
if (event.key === '-' || event.key === '_') {
|
|
1569
|
+
event.preventDefault()
|
|
1570
|
+
const rect = canvas.getBoundingClientRect()
|
|
1571
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.8)
|
|
1572
|
+
return
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
if (event.key === '0') {
|
|
1576
|
+
event.preventDefault()
|
|
1577
|
+
resetView()
|
|
1578
|
+
}
|
|
1579
|
+
})
|
|
375
1580
|
}
|
|
376
1581
|
|
|
377
1582
|
const loadAgents = async () => {
|
|
378
1583
|
const response = await fetch('/api/agents')
|
|
379
1584
|
const payload = await response.json()
|
|
380
1585
|
const agents = Array.isArray(payload.agents) ? payload.agents : []
|
|
381
|
-
const
|
|
1586
|
+
const preferredAgent = state.agentId || initialAgentFromUrl
|
|
1587
|
+
const currentExists = agents.some(agent => agent.id === preferredAgent)
|
|
382
1588
|
const selected = currentExists
|
|
383
|
-
?
|
|
1589
|
+
? preferredAgent
|
|
384
1590
|
: (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
|
|
385
1591
|
const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
|
|
386
1592
|
|
|
387
1593
|
state.agentId = selected
|
|
388
1594
|
if (signature !== state.agentsSignature) {
|
|
1595
|
+
const formatAgentLabel = (agent) => agent.id
|
|
389
1596
|
elements.agent.innerHTML = agents.length
|
|
390
|
-
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent
|
|
391
|
-
: '<option value="shared">shared
|
|
1597
|
+
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
|
|
1598
|
+
: '<option value="shared">shared</option>'
|
|
392
1599
|
state.agentsSignature = signature
|
|
393
1600
|
}
|
|
394
1601
|
elements.agent.value = selected
|
|
@@ -409,6 +1616,10 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
409
1616
|
|
|
410
1617
|
const payload = await response.json()
|
|
411
1618
|
const graph = payload?.layout ?? payload
|
|
1619
|
+
state.graphTotals = {
|
|
1620
|
+
nodes: Number.isFinite(payload?.totals?.nodes) ? payload.totals.nodes : (Array.isArray(graph.nodes) ? graph.nodes.length : 0),
|
|
1621
|
+
edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
|
|
1622
|
+
}
|
|
412
1623
|
const signature = payload?.signature ?? graphSignature(graph)
|
|
413
1624
|
if (!options.reset && signature === state.graphSignature) return
|
|
414
1625
|
const selectedId = state.selected?.id
|
|
@@ -417,17 +1628,35 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
417
1628
|
state.graph = graph
|
|
418
1629
|
state.nodes = layout.nodes
|
|
419
1630
|
state.edges = layout.edges
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
1631
|
+
state.nodeDegrees = state.edges.reduce((degrees, edge) => {
|
|
1632
|
+
degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
|
|
1633
|
+
if (edge.target) {
|
|
1634
|
+
degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
|
|
1635
|
+
}
|
|
1636
|
+
return degrees
|
|
1637
|
+
}, new Map())
|
|
1638
|
+
state.nodeDetails = new Map()
|
|
1639
|
+
pushNodesToFilterWorker()
|
|
1640
|
+
resetContentFilter()
|
|
1641
|
+
sanitizeAllNodePositions()
|
|
1642
|
+
recomputeVisibility()
|
|
1643
|
+
scheduleContentFilterSync()
|
|
1644
|
+
const tags = new Set(state.nodes.flatMap(node => node.tags))
|
|
1645
|
+
setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
|
|
1646
|
+
elements.nodeCount.textContent = state.graphTotals.nodes
|
|
1647
|
+
elements.edgeCount.textContent = state.graphTotals.edges
|
|
424
1648
|
elements.tagCount.textContent = tags.size
|
|
425
1649
|
resize()
|
|
426
1650
|
if (options.reset) resetView()
|
|
427
|
-
|
|
1651
|
+
const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
|
|
1652
|
+
selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
|
|
1653
|
+
if (!selectedNode && elements.contentDialog.open) {
|
|
1654
|
+
elements.contentDialog.close()
|
|
1655
|
+
}
|
|
428
1656
|
}
|
|
429
1657
|
|
|
430
1658
|
bindEvents()
|
|
1659
|
+
initFilterWorker()
|
|
431
1660
|
requestAnimationFrame(() => {
|
|
432
1661
|
resize()
|
|
433
1662
|
resetView()
|
|
@@ -441,10 +1670,7 @@ const refreshGraphLoop = () => {
|
|
|
441
1670
|
return
|
|
442
1671
|
}
|
|
443
1672
|
|
|
444
|
-
loadGraph().catch(
|
|
445
|
-
elements.stats.textContent = 'Failed to refresh graph'
|
|
446
|
-
console.error(error)
|
|
447
|
-
})
|
|
1673
|
+
loadGraph().catch(handleGraphRefreshError)
|
|
448
1674
|
|
|
449
1675
|
tickCounter += 1
|
|
450
1676
|
if (tickCounter % 3 === 0) {
|
|
@@ -461,7 +1687,6 @@ loadAgents()
|
|
|
461
1687
|
setInterval(refreshGraphLoop, pollIntervalMs)
|
|
462
1688
|
})
|
|
463
1689
|
.catch(error => {
|
|
464
|
-
elements.stats.textContent = 'Failed to load graph'
|
|
465
1690
|
console.error(error)
|
|
466
1691
|
})
|
|
467
1692
|
|
|
@@ -470,9 +1695,6 @@ document.addEventListener('visibilitychange', () => {
|
|
|
470
1695
|
return
|
|
471
1696
|
}
|
|
472
1697
|
|
|
473
|
-
loadGraph({ reset: true }).catch(
|
|
474
|
-
elements.stats.textContent = 'Failed to refresh graph'
|
|
475
|
-
console.error(error)
|
|
476
|
-
})
|
|
1698
|
+
loadGraph({ reset: true }).catch(handleGraphRefreshError)
|
|
477
1699
|
})
|
|
478
1700
|
`;
|