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