@andespindola/brainlink 0.1.0-beta.3 → 0.1.0-beta.31
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 +5 -5
- package/CHANGELOG.md +37 -3
- package/CONTRIBUTING.md +2 -2
- package/COPYRIGHT.md +5 -0
- package/README.md +172 -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/frontend/client-css.js +214 -100
- package/dist/application/frontend/client-html.js +60 -45
- package/dist/application/frontend/client-js.js +553 -91
- package/dist/application/get-graph-layout.js +22 -7
- 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 +11 -4
- 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/search-graph-node-ids.js +12 -0
- package/dist/application/search-knowledge.js +75 -5
- package/dist/application/server/routes.js +27 -1
- package/dist/benchmarks/large-vault.js +1 -1
- package/dist/cli/commands/agent-commands.js +412 -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 +205 -4
- 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 +94 -8
- package/dist/infrastructure/file-index.js +294 -0
- package/dist/infrastructure/file-system-vault.js +15 -0
- package/dist/infrastructure/paths.js +9 -1
- package/dist/infrastructure/private-pack-codec.js +73 -0
- package/dist/infrastructure/search-packs.js +348 -0
- package/dist/infrastructure/session-state.js +172 -0
- package/dist/mcp/main.js +11 -3
- package/dist/mcp/server.js +17 -2
- package/dist/mcp/startup.js +35 -0
- package/dist/mcp/tools.js +571 -19
- package/docs/AGENT_USAGE.md +112 -16
- package/docs/ARCHITECTURE.md +37 -26
- package/docs/QUICKSTART.md +111 -0
- package/package.json +2 -3
- 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,17 +1,31 @@
|
|
|
1
1
|
export const createClientJs = () => `const canvas = document.getElementById('graph')
|
|
2
2
|
const ctx = canvas.getContext('2d')
|
|
3
|
+
const largeGraphNodeThreshold = 4000
|
|
4
|
+
const largeGraphEdgeRenderLimit = 16000
|
|
5
|
+
const renderNodeBudget = 1800
|
|
6
|
+
const minNodePixelRadius = 1.8
|
|
7
|
+
const viewportPaddingPx = 280
|
|
3
8
|
const state = {
|
|
4
9
|
graph: { nodes: [], edges: [] },
|
|
5
10
|
nodes: [],
|
|
6
11
|
edges: [],
|
|
12
|
+
visibleNodes: [],
|
|
13
|
+
visibleEdges: [],
|
|
14
|
+
renderNodes: [],
|
|
15
|
+
renderEdges: [],
|
|
16
|
+
nodeDegrees: new Map(),
|
|
7
17
|
selected: null,
|
|
8
18
|
hovered: null,
|
|
9
19
|
query: '',
|
|
20
|
+
contentFilter: { query: '', ids: null, token: 0, timer: null },
|
|
10
21
|
agentId: '',
|
|
11
22
|
agentsSignature: '',
|
|
23
|
+
nodeDetails: new Map(),
|
|
12
24
|
transform: { x: 0, y: 0, scale: 1 },
|
|
13
25
|
pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
|
|
26
|
+
cursor: { x: 0, y: 0, inCanvas: false },
|
|
14
27
|
graphSignature: '',
|
|
28
|
+
graphStatus: '',
|
|
15
29
|
last: performance.now()
|
|
16
30
|
}
|
|
17
31
|
|
|
@@ -23,26 +37,40 @@ const escapeHtml = value => String(value)
|
|
|
23
37
|
.replaceAll('"', '"')
|
|
24
38
|
.replaceAll("'", ''')
|
|
25
39
|
const elements = {
|
|
26
|
-
stats: byId('stats'),
|
|
27
40
|
search: byId('search'),
|
|
28
41
|
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
42
|
nodeCount: byId('nodeCount'),
|
|
37
43
|
edgeCount: byId('edgeCount'),
|
|
38
44
|
tagCount: byId('tagCount'),
|
|
39
45
|
zoomIn: byId('zoomIn'),
|
|
40
46
|
zoomOut: byId('zoomOut'),
|
|
41
|
-
|
|
47
|
+
fit: byId('fit'),
|
|
48
|
+
reset: byId('reset'),
|
|
49
|
+
contentDialog: byId('contentDialog'),
|
|
50
|
+
contentTitle: byId('contentTitle'),
|
|
51
|
+
contentPath: byId('contentPath'),
|
|
52
|
+
contentTags: byId('contentTags'),
|
|
53
|
+
contentOutgoing: byId('contentOutgoing'),
|
|
54
|
+
contentIncoming: byId('contentIncoming'),
|
|
55
|
+
contentBody: byId('contentBody'),
|
|
56
|
+
contentClose: byId('contentClose')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const zoomRange = {
|
|
60
|
+
min: 0.05,
|
|
61
|
+
max: 4.5
|
|
42
62
|
}
|
|
43
63
|
|
|
44
64
|
const agentQuery = () => state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''
|
|
45
65
|
|
|
66
|
+
const setGraphStatus = text => {
|
|
67
|
+
state.graphStatus = text
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const handleGraphRefreshError = error => {
|
|
71
|
+
console.error(error)
|
|
72
|
+
}
|
|
73
|
+
|
|
46
74
|
const graphTheme = {
|
|
47
75
|
node: '#aeb8c5',
|
|
48
76
|
nodeSelected: '#f3f7fb',
|
|
@@ -66,35 +94,138 @@ const resize = () => {
|
|
|
66
94
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
67
95
|
}
|
|
68
96
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
97
|
+
const normalizeQuery = value => value.trim().toLowerCase()
|
|
98
|
+
|
|
99
|
+
const localFilteredNodes = query =>
|
|
100
|
+
state.nodes.filter(node =>
|
|
73
101
|
node.title.toLowerCase().includes(query) ||
|
|
74
102
|
node.path.toLowerCase().includes(query) ||
|
|
75
103
|
node.tags.some(tag => tag.toLowerCase().includes(query))
|
|
76
104
|
)
|
|
77
|
-
}
|
|
78
105
|
|
|
79
|
-
const
|
|
106
|
+
const filteredNodes = () => {
|
|
107
|
+
const query = normalizeQuery(state.query)
|
|
108
|
+
if (!query) return state.nodes
|
|
109
|
+
if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
|
|
110
|
+
return state.nodes.filter(node => state.contentFilter.ids.has(node.id))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return localFilteredNodes(query)
|
|
114
|
+
}
|
|
80
115
|
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
116
|
+
const recomputeVisibility = () => {
|
|
117
|
+
const nodes = filteredNodes()
|
|
118
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
119
|
+
const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
120
|
+
const limitedEdges = state.nodes.length > largeGraphNodeThreshold
|
|
121
|
+
? [...edges]
|
|
122
|
+
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
123
|
+
.slice(0, largeGraphEdgeRenderLimit)
|
|
124
|
+
: edges
|
|
125
|
+
|
|
126
|
+
state.visibleNodes = nodes
|
|
127
|
+
state.visibleEdges = limitedEdges
|
|
84
128
|
}
|
|
85
129
|
|
|
86
130
|
const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
|
|
87
131
|
|
|
88
|
-
const
|
|
132
|
+
const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
|
|
133
|
+
|
|
134
|
+
const graphBounds = nodes => {
|
|
135
|
+
if (nodes.length === 0) return null
|
|
136
|
+
let minX = Number.POSITIVE_INFINITY
|
|
137
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
138
|
+
let minY = Number.POSITIVE_INFINITY
|
|
139
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
140
|
+
|
|
141
|
+
nodes.forEach(node => {
|
|
142
|
+
const radius = baseNodeRadius(node)
|
|
143
|
+
minX = Math.min(minX, node.x - radius)
|
|
144
|
+
maxX = Math.max(maxX, node.x + radius)
|
|
145
|
+
minY = Math.min(minY, node.y - radius)
|
|
146
|
+
maxY = Math.max(maxY, node.y + radius)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
minX,
|
|
151
|
+
maxX,
|
|
152
|
+
minY,
|
|
153
|
+
maxY,
|
|
154
|
+
width: Math.max(maxX - minX, 1),
|
|
155
|
+
height: Math.max(maxY - minY, 1)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const fitScaleBiasByNodeCount = nodeCount => {
|
|
160
|
+
if (nodeCount <= 6) return 2.8
|
|
161
|
+
if (nodeCount <= 20) return 2.2
|
|
162
|
+
if (nodeCount <= 60) return 1.72
|
|
163
|
+
if (nodeCount <= 180) return 1.34
|
|
164
|
+
if (nodeCount <= 600) return 1.08
|
|
165
|
+
if (nodeCount <= 2000) return 0.9
|
|
166
|
+
if (nodeCount <= 6000) return 0.72
|
|
167
|
+
return 0.58
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const fitView = (options = { useFiltered: true }) => {
|
|
89
171
|
const rect = canvas.getBoundingClientRect()
|
|
90
|
-
|
|
172
|
+
const width = Math.max(rect.width, 320)
|
|
173
|
+
const height = Math.max(rect.height, 320)
|
|
174
|
+
const nodes = options.useFiltered ? filteredNodes() : state.nodes
|
|
175
|
+
const bounds = graphBounds(nodes)
|
|
176
|
+
|
|
177
|
+
if (!bounds) {
|
|
178
|
+
state.transform = { x: width / 2, y: height / 2, scale: 1 }
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const paddingByNodeCount = nodeCount => {
|
|
183
|
+
if (nodeCount <= 6) return 28
|
|
184
|
+
if (nodeCount <= 20) return 44
|
|
185
|
+
if (nodeCount <= 60) return 68
|
|
186
|
+
if (nodeCount <= 180) return 86
|
|
187
|
+
if (nodeCount <= 600) return 110
|
|
188
|
+
if (nodeCount <= 2000) return 140
|
|
189
|
+
return 180
|
|
190
|
+
}
|
|
191
|
+
const minFitScaleByNodeCount = nodeCount => {
|
|
192
|
+
if (nodeCount <= 6) return 2.4
|
|
193
|
+
if (nodeCount <= 20) return 1.8
|
|
194
|
+
if (nodeCount <= 60) return 1.2
|
|
195
|
+
if (nodeCount <= 180) return 0.86
|
|
196
|
+
if (nodeCount <= 600) return 0.58
|
|
197
|
+
if (nodeCount <= 2000) return 0.34
|
|
198
|
+
if (nodeCount <= 6000) return 0.2
|
|
199
|
+
return 0.13
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const padding = paddingByNodeCount(nodes.length)
|
|
203
|
+
const scaleX = width / (bounds.width + padding * 2)
|
|
204
|
+
const scaleY = height / (bounds.height + padding * 2)
|
|
205
|
+
const fitScale = clampScale(Math.min(scaleX, scaleY))
|
|
206
|
+
const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
|
|
207
|
+
const minimumScale = minFitScaleByNodeCount(nodes.length)
|
|
208
|
+
const minimumLargeGraphScale = nodes.length > largeGraphNodeThreshold ? 0.13 : zoomRange.min
|
|
209
|
+
const scale = Math.max(biasedScale, minimumScale, minimumLargeGraphScale)
|
|
210
|
+
const centerX = (bounds.minX + bounds.maxX) / 2
|
|
211
|
+
const centerY = (bounds.minY + bounds.maxY) / 2
|
|
212
|
+
|
|
213
|
+
state.transform = {
|
|
214
|
+
x: width / 2 - centerX * scale,
|
|
215
|
+
y: height / 2 - centerY * scale,
|
|
216
|
+
scale
|
|
217
|
+
}
|
|
91
218
|
}
|
|
92
219
|
|
|
220
|
+
const resetView = () => fitView({ useFiltered: false })
|
|
221
|
+
|
|
93
222
|
const createLayout = graph => {
|
|
94
223
|
const nodes = graph.nodes.map(node => ({
|
|
95
224
|
...node,
|
|
96
225
|
x: Number.isFinite(node.x) ? node.x : 0,
|
|
97
|
-
y: Number.isFinite(node.y) ? node.y : 0
|
|
226
|
+
y: Number.isFinite(node.y) ? node.y : 0,
|
|
227
|
+
vx: Number.isFinite(node.vx) ? node.vx : 0,
|
|
228
|
+
vy: Number.isFinite(node.vy) ? node.vy : 0
|
|
98
229
|
}))
|
|
99
230
|
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
100
231
|
const edges = graph.edges
|
|
@@ -111,23 +242,88 @@ const encodeEntityTag = (value) => {
|
|
|
111
242
|
binary += String.fromCharCode(utf8[index])
|
|
112
243
|
}
|
|
113
244
|
|
|
114
|
-
return btoa(binary).
|
|
245
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
|
|
115
246
|
}
|
|
116
247
|
|
|
117
248
|
const graphSignature = graph => JSON.stringify({
|
|
118
|
-
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.
|
|
249
|
+
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
|
|
119
250
|
edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
|
|
120
251
|
})
|
|
121
252
|
|
|
253
|
+
const resetContentFilter = () => {
|
|
254
|
+
if (state.contentFilter.timer) {
|
|
255
|
+
clearTimeout(state.contentFilter.timer)
|
|
256
|
+
}
|
|
257
|
+
state.contentFilter = {
|
|
258
|
+
query: '',
|
|
259
|
+
ids: null,
|
|
260
|
+
token: state.contentFilter.token + 1,
|
|
261
|
+
timer: null
|
|
262
|
+
}
|
|
263
|
+
recomputeVisibility()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const syncContentFilter = async (query, token) => {
|
|
267
|
+
const response = await fetch(
|
|
268
|
+
'/api/graph-filter?q=' +
|
|
269
|
+
encodeURIComponent(query) +
|
|
270
|
+
'&limit=' +
|
|
271
|
+
encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
|
|
272
|
+
agentQuery()
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if (!response.ok || token !== state.contentFilter.token) {
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const payload = await response.json()
|
|
280
|
+
const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
|
|
281
|
+
if (token !== state.contentFilter.token) {
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
state.contentFilter.query = query
|
|
286
|
+
state.contentFilter.ids = new Set(nodeIds)
|
|
287
|
+
recomputeVisibility()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const scheduleContentFilterSync = () => {
|
|
291
|
+
const query = normalizeQuery(state.query)
|
|
292
|
+
if (!query) {
|
|
293
|
+
resetContentFilter()
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (state.contentFilter.timer) {
|
|
298
|
+
clearTimeout(state.contentFilter.timer)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const token = state.contentFilter.token + 1
|
|
302
|
+
state.contentFilter = {
|
|
303
|
+
query: state.contentFilter.query,
|
|
304
|
+
ids: state.contentFilter.ids,
|
|
305
|
+
token,
|
|
306
|
+
timer: setTimeout(() => {
|
|
307
|
+
syncContentFilter(query, token).catch(() => {})
|
|
308
|
+
}, 180)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
122
312
|
const tick = delta => {
|
|
123
|
-
const nodes =
|
|
124
|
-
const
|
|
125
|
-
|
|
313
|
+
const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
|
|
314
|
+
const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
|
|
315
|
+
if (nodes.length > 1200) {
|
|
316
|
+
return
|
|
317
|
+
}
|
|
126
318
|
const strength = Math.min(delta / 16, 2)
|
|
127
319
|
|
|
128
320
|
edges.forEach(edge => {
|
|
129
321
|
const source = edge.sourceNode
|
|
130
322
|
const target = edge.targetNode
|
|
323
|
+
source.vx = Number.isFinite(source.vx) ? source.vx : 0
|
|
324
|
+
source.vy = Number.isFinite(source.vy) ? source.vy : 0
|
|
325
|
+
target.vx = Number.isFinite(target.vx) ? target.vx : 0
|
|
326
|
+
target.vy = Number.isFinite(target.vy) ? target.vy : 0
|
|
131
327
|
const dx = target.x - source.x
|
|
132
328
|
const dy = target.y - source.y
|
|
133
329
|
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
@@ -144,6 +340,10 @@ const tick = delta => {
|
|
|
144
340
|
for (let j = i + 1; j < nodes.length; j += 1) {
|
|
145
341
|
const a = nodes[i]
|
|
146
342
|
const b = nodes[j]
|
|
343
|
+
a.vx = Number.isFinite(a.vx) ? a.vx : 0
|
|
344
|
+
a.vy = Number.isFinite(a.vy) ? a.vy : 0
|
|
345
|
+
b.vx = Number.isFinite(b.vx) ? b.vx : 0
|
|
346
|
+
b.vy = Number.isFinite(b.vy) ? b.vy : 0
|
|
147
347
|
const dx = b.x - a.x
|
|
148
348
|
const dy = b.y - a.y
|
|
149
349
|
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
@@ -158,6 +358,10 @@ const tick = delta => {
|
|
|
158
358
|
}
|
|
159
359
|
|
|
160
360
|
nodes.forEach(node => {
|
|
361
|
+
node.vx = Number.isFinite(node.vx) ? node.vx : 0
|
|
362
|
+
node.vy = Number.isFinite(node.vy) ? node.vy : 0
|
|
363
|
+
node.x = Number.isFinite(node.x) ? node.x : 0
|
|
364
|
+
node.y = Number.isFinite(node.y) ? node.y : 0
|
|
161
365
|
if (state.pointer.dragNode === node) {
|
|
162
366
|
node.vx = 0
|
|
163
367
|
node.vy = 0
|
|
@@ -181,7 +385,11 @@ const worldPoint = event => {
|
|
|
181
385
|
}
|
|
182
386
|
|
|
183
387
|
const hitNode = point => {
|
|
184
|
-
|
|
388
|
+
if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
|
|
389
|
+
return null
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const nodes = state.renderNodes
|
|
185
393
|
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
186
394
|
const node = nodes[index]
|
|
187
395
|
const radius = nodeRadius(node)
|
|
@@ -190,14 +398,118 @@ const hitNode = point => {
|
|
|
190
398
|
return null
|
|
191
399
|
}
|
|
192
400
|
|
|
193
|
-
const
|
|
194
|
-
const degree = state.
|
|
401
|
+
const baseNodeRadius = node => {
|
|
402
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
195
403
|
return 9 + Math.min(degree, 8) * 1.6
|
|
196
404
|
}
|
|
197
405
|
|
|
406
|
+
const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
|
|
407
|
+
|
|
408
|
+
const worldViewportBounds = () => {
|
|
409
|
+
const rect = canvas.getBoundingClientRect()
|
|
410
|
+
const width = Math.max(rect.width, 320)
|
|
411
|
+
const height = Math.max(rect.height, 320)
|
|
412
|
+
const padding = viewportPaddingPx
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
minX: (-state.transform.x - padding) / state.transform.scale,
|
|
416
|
+
maxX: (width - state.transform.x + padding) / state.transform.scale,
|
|
417
|
+
minY: (-state.transform.y - padding) / state.transform.scale,
|
|
418
|
+
maxY: (height - state.transform.y + padding) / state.transform.scale
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const isNodeInViewport = (node, viewport) =>
|
|
423
|
+
node.x >= viewport.minX &&
|
|
424
|
+
node.x <= viewport.maxX &&
|
|
425
|
+
node.y >= viewport.minY &&
|
|
426
|
+
node.y <= viewport.maxY
|
|
427
|
+
|
|
428
|
+
const viewportNodeStride = () => {
|
|
429
|
+
if (state.nodes.length <= largeGraphNodeThreshold) {
|
|
430
|
+
return 1
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (state.transform.scale >= 0.95) {
|
|
434
|
+
return 1
|
|
435
|
+
}
|
|
436
|
+
if (state.transform.scale >= 0.7) {
|
|
437
|
+
return 2
|
|
438
|
+
}
|
|
439
|
+
if (state.transform.scale >= 0.48) {
|
|
440
|
+
return 3
|
|
441
|
+
}
|
|
442
|
+
if (state.transform.scale >= 0.28) {
|
|
443
|
+
return 5
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return 8
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const computeRenderVisibility = () => {
|
|
450
|
+
if (state.visibleNodes.length <= 2000) {
|
|
451
|
+
state.renderNodes = state.visibleNodes
|
|
452
|
+
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
453
|
+
state.renderEdges = state.visibleEdges.filter((edge) => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const viewport = worldViewportBounds()
|
|
458
|
+
const stride = viewportNodeStride()
|
|
459
|
+
const picked = []
|
|
460
|
+
|
|
461
|
+
for (let index = 0; index < state.visibleNodes.length; index += 1) {
|
|
462
|
+
const node = state.visibleNodes[index]
|
|
463
|
+
if (!isNodeInViewport(node, viewport)) {
|
|
464
|
+
continue
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const isPriority =
|
|
468
|
+
node.id === state.selected?.id ||
|
|
469
|
+
node.id === state.hovered?.id ||
|
|
470
|
+
node.id === state.pointer.dragNode?.id
|
|
471
|
+
if (isPriority || index % stride === 0) {
|
|
472
|
+
picked.push(node)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const nodes = picked.length > renderNodeBudget
|
|
477
|
+
? picked.slice(0, renderNodeBudget)
|
|
478
|
+
: picked
|
|
479
|
+
if (nodes.length === 0 && state.visibleNodes.length > 0) {
|
|
480
|
+
const centerX = (viewport.minX + viewport.maxX) / 2
|
|
481
|
+
const centerY = (viewport.minY + viewport.maxY) / 2
|
|
482
|
+
const closest = [...state.visibleNodes]
|
|
483
|
+
.sort((left, right) => {
|
|
484
|
+
const leftDistance = (left.x - centerX) ** 2 + (left.y - centerY) ** 2
|
|
485
|
+
const rightDistance = (right.x - centerX) ** 2 + (right.y - centerY) ** 2
|
|
486
|
+
return leftDistance - rightDistance
|
|
487
|
+
})
|
|
488
|
+
.slice(0, Math.min(renderNodeBudget, 180))
|
|
489
|
+
const closestIds = new Set(closest.map((node) => node.id))
|
|
490
|
+
|
|
491
|
+
state.renderNodes = closest
|
|
492
|
+
state.renderEdges = state.visibleEdges.filter(
|
|
493
|
+
(edge) => closestIds.has(edge.source) && edge.target && closestIds.has(edge.target)
|
|
494
|
+
)
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const nodeIds = new Set(nodes.map((node) => node.id))
|
|
499
|
+
const edges = state.visibleEdges.filter((edge) => nodeIds.has(edge.source) && edge.target && nodeIds.has(edge.target))
|
|
500
|
+
|
|
501
|
+
state.renderNodes = nodes
|
|
502
|
+
state.renderEdges = edges
|
|
503
|
+
}
|
|
504
|
+
|
|
198
505
|
const render = now => {
|
|
199
506
|
const delta = now - state.last
|
|
200
507
|
state.last = now
|
|
508
|
+
const minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 48 : 16
|
|
509
|
+
if (delta < minFrameIntervalMs) {
|
|
510
|
+
requestAnimationFrame(render)
|
|
511
|
+
return
|
|
512
|
+
}
|
|
201
513
|
const rect = canvas.getBoundingClientRect()
|
|
202
514
|
const width = Math.max(rect.width, 320)
|
|
203
515
|
const height = Math.max(rect.height, 320)
|
|
@@ -214,7 +526,11 @@ const render = now => {
|
|
|
214
526
|
ctx.translate(state.transform.x, state.transform.y)
|
|
215
527
|
ctx.scale(state.transform.scale, state.transform.scale)
|
|
216
528
|
|
|
217
|
-
|
|
529
|
+
computeRenderVisibility()
|
|
530
|
+
tick(delta)
|
|
531
|
+
const drawEdges = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
|
|
532
|
+
if (drawEdges) {
|
|
533
|
+
state.renderEdges.forEach(edge => {
|
|
218
534
|
const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
219
535
|
ctx.beginPath()
|
|
220
536
|
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
|
|
@@ -222,9 +538,10 @@ const render = now => {
|
|
|
222
538
|
ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
|
|
223
539
|
ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
|
|
224
540
|
ctx.stroke()
|
|
225
|
-
|
|
541
|
+
})
|
|
542
|
+
}
|
|
226
543
|
|
|
227
|
-
|
|
544
|
+
state.renderNodes.forEach(node => {
|
|
228
545
|
const radius = nodeRadius(node)
|
|
229
546
|
const isSelected = state.selected?.id === node.id
|
|
230
547
|
const isHovered = state.hovered?.id === node.id
|
|
@@ -240,7 +557,11 @@ const render = now => {
|
|
|
240
557
|
ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
|
|
241
558
|
ctx.stroke()
|
|
242
559
|
|
|
243
|
-
|
|
560
|
+
const shouldDrawLabels =
|
|
561
|
+
isSelected ||
|
|
562
|
+
isHovered ||
|
|
563
|
+
(state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
|
|
564
|
+
if (shouldDrawLabels) {
|
|
244
565
|
ctx.fillStyle = graphTheme.label
|
|
245
566
|
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
246
567
|
ctx.textAlign = 'center'
|
|
@@ -250,6 +571,12 @@ const render = now => {
|
|
|
250
571
|
})
|
|
251
572
|
|
|
252
573
|
ctx.restore()
|
|
574
|
+
if (state.renderNodes.length === 0) {
|
|
575
|
+
ctx.fillStyle = '#99a5b5'
|
|
576
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
577
|
+
ctx.textAlign = 'center'
|
|
578
|
+
ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
|
|
579
|
+
}
|
|
253
580
|
requestAnimationFrame(render)
|
|
254
581
|
}
|
|
255
582
|
|
|
@@ -257,22 +584,7 @@ const list = items => items.length
|
|
|
257
584
|
? 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
585
|
: '<li><small>No links found.</small></li>'
|
|
259
586
|
|
|
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
|
-
}
|
|
587
|
+
const linkedNodes = node => {
|
|
276
588
|
const nodeById = new Map(state.nodes.map(item => [item.id, item]))
|
|
277
589
|
const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
|
|
278
590
|
...linkedNode,
|
|
@@ -288,57 +600,168 @@ const selectNode = node => {
|
|
|
288
600
|
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
289
601
|
.filter(Boolean)
|
|
290
602
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
603
|
+
return { outgoing, incoming }
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const fetchNodeDetails = async node => {
|
|
607
|
+
const cached = state.nodeDetails.get(node.id)
|
|
608
|
+
if (cached) {
|
|
609
|
+
return cached
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery())
|
|
613
|
+
if (!response.ok) {
|
|
614
|
+
throw new Error('Failed to load graph node details')
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const payload = await response.json()
|
|
618
|
+
const detail = payload?.node
|
|
619
|
+
if (!detail || !detail.id) {
|
|
620
|
+
throw new Error('Invalid graph node payload')
|
|
621
|
+
}
|
|
622
|
+
state.nodeDetails.set(detail.id, detail)
|
|
623
|
+
return detail
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const openContentDialog = async node => {
|
|
627
|
+
if (!node) return
|
|
628
|
+
const { outgoing, incoming } = linkedNodes(node)
|
|
629
|
+
elements.contentTitle.textContent = node.title
|
|
630
|
+
elements.contentPath.textContent = node.path
|
|
631
|
+
elements.contentTags.innerHTML = node.tags.length
|
|
294
632
|
? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
295
633
|
: '<span>No tags</span>'
|
|
296
|
-
elements.
|
|
297
|
-
elements.
|
|
298
|
-
elements.
|
|
299
|
-
elements.
|
|
634
|
+
elements.contentOutgoing.innerHTML = list(outgoing)
|
|
635
|
+
elements.contentIncoming.innerHTML = list(incoming)
|
|
636
|
+
elements.contentBody.textContent = 'Loading note content...'
|
|
637
|
+
if (!elements.contentDialog.open) {
|
|
638
|
+
elements.contentDialog.showModal()
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
try {
|
|
642
|
+
const detailedNode = await fetchNodeDetails(node)
|
|
643
|
+
if (state.selected?.id !== node.id) {
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
elements.contentBody.textContent = detailedNode.content
|
|
647
|
+
} catch {
|
|
648
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const selectNode = (node, options = { openContent: false }) => {
|
|
653
|
+
state.selected = node
|
|
654
|
+
if (node && options.openContent) {
|
|
655
|
+
openContentDialog(node).catch(() => {
|
|
656
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
657
|
+
})
|
|
658
|
+
}
|
|
300
659
|
}
|
|
301
660
|
|
|
302
661
|
const selectNodeById = id => {
|
|
303
662
|
const node = state.nodes.find(item => item.id === id)
|
|
304
|
-
if (node) selectNode(node)
|
|
663
|
+
if (node) selectNode(node, { openContent: true })
|
|
305
664
|
}
|
|
306
665
|
|
|
307
|
-
const
|
|
308
|
-
|
|
666
|
+
const zoomAtPoint = (screenX, screenY, factor) => {
|
|
667
|
+
const nextScale = clampScale(state.transform.scale * factor)
|
|
668
|
+
if (nextScale === state.transform.scale) return
|
|
669
|
+
const worldX = (screenX - state.transform.x) / state.transform.scale
|
|
670
|
+
const worldY = (screenY - state.transform.y) / state.transform.scale
|
|
671
|
+
state.transform.scale = nextScale
|
|
672
|
+
state.transform.x = screenX - worldX * nextScale
|
|
673
|
+
state.transform.y = screenY - worldY * nextScale
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const wheelZoomFactor = event => {
|
|
677
|
+
const isModifierZoom = event.metaKey || event.ctrlKey
|
|
678
|
+
const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
|
|
679
|
+
const absoluteDelta = Math.min(Math.abs(event.deltaY * deltaModeFactor), 1600)
|
|
680
|
+
|
|
681
|
+
if (absoluteDelta <= 0.0001) {
|
|
682
|
+
return 1
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const baseStep = Math.max(0.06, Math.min(0.45, absoluteDelta / 480))
|
|
686
|
+
const adjustedStep = baseStep * (isModifierZoom ? 1.4 : 1)
|
|
687
|
+
|
|
688
|
+
return event.deltaY < 0 ? 1 + adjustedStep : 1 / (1 + adjustedStep)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const isScreenPointInsideCanvas = (screenX, screenY) => {
|
|
692
|
+
const rect = canvas.getBoundingClientRect()
|
|
693
|
+
|
|
694
|
+
return screenX >= rect.left && screenX <= rect.right && screenY >= rect.top && screenY <= rect.bottom
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const handleWheelZoom = event => {
|
|
698
|
+
if (!isScreenPointInsideCanvas(event.clientX, event.clientY)) {
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
event.preventDefault()
|
|
703
|
+
const rect = canvas.getBoundingClientRect()
|
|
704
|
+
const cursorX = event.clientX - rect.left
|
|
705
|
+
const cursorY = event.clientY - rect.top
|
|
706
|
+
const factor = wheelZoomFactor(event)
|
|
707
|
+
|
|
708
|
+
if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
|
|
709
|
+
return
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
zoomAtPoint(cursorX, cursorY, factor)
|
|
309
713
|
}
|
|
310
714
|
|
|
311
715
|
const bindEvents = () => {
|
|
312
716
|
window.addEventListener('resize', resize)
|
|
313
717
|
elements.search.addEventListener('input', event => {
|
|
314
718
|
state.query = event.target.value
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
: state.nodes.length + ' notes · ' + state.edges.length + ' links'
|
|
719
|
+
recomputeVisibility()
|
|
720
|
+
scheduleContentFilterSync()
|
|
318
721
|
})
|
|
319
722
|
elements.agent.addEventListener('change', event => {
|
|
320
723
|
state.agentId = event.target.value
|
|
321
724
|
state.selected = null
|
|
725
|
+
state.nodeDetails = new Map()
|
|
726
|
+
resetContentFilter()
|
|
727
|
+
recomputeVisibility()
|
|
728
|
+
scheduleContentFilterSync()
|
|
322
729
|
loadGraph({ reset: true }).catch(error => {
|
|
323
|
-
elements.stats.textContent = 'Failed to load agent graph'
|
|
324
730
|
console.error(error)
|
|
325
731
|
})
|
|
326
732
|
})
|
|
327
|
-
elements.zoomIn.addEventListener('click', () =>
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
733
|
+
elements.zoomIn.addEventListener('click', () => {
|
|
734
|
+
const rect = canvas.getBoundingClientRect()
|
|
735
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.3)
|
|
736
|
+
})
|
|
737
|
+
elements.zoomOut.addEventListener('click', () => {
|
|
738
|
+
const rect = canvas.getBoundingClientRect()
|
|
739
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.77)
|
|
740
|
+
})
|
|
741
|
+
if (elements.fit) {
|
|
742
|
+
elements.fit.addEventListener('click', () => {
|
|
743
|
+
fitView({ useFiltered: true })
|
|
336
744
|
})
|
|
745
|
+
}
|
|
746
|
+
elements.reset.addEventListener('click', () => {
|
|
747
|
+
resetView()
|
|
748
|
+
})
|
|
749
|
+
elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
|
|
750
|
+
elements.contentDialog.addEventListener('click', event => {
|
|
751
|
+
const target = event.target
|
|
752
|
+
if (target instanceof HTMLElement && target.dataset.nodeId) {
|
|
753
|
+
selectNodeById(target.dataset.nodeId)
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
if (event.target === elements.contentDialog) elements.contentDialog.close()
|
|
757
|
+
})
|
|
758
|
+
window.addEventListener('wheel', handleWheelZoom, { passive: false })
|
|
759
|
+
canvas.addEventListener('dblclick', event => {
|
|
760
|
+
const rect = canvas.getBoundingClientRect()
|
|
761
|
+
const cursorX = event.clientX - rect.left
|
|
762
|
+
const cursorY = event.clientY - rect.top
|
|
763
|
+
zoomAtPoint(cursorX, cursorY, 1.25)
|
|
337
764
|
})
|
|
338
|
-
canvas.addEventListener('wheel', event => {
|
|
339
|
-
event.preventDefault()
|
|
340
|
-
zoom(event.deltaY < 0 ? 1.08 : 0.92)
|
|
341
|
-
}, { passive: false })
|
|
342
765
|
canvas.addEventListener('pointerdown', event => {
|
|
343
766
|
const point = worldPoint(event)
|
|
344
767
|
const node = hitNode(point)
|
|
@@ -352,6 +775,7 @@ const bindEvents = () => {
|
|
|
352
775
|
canvas.addEventListener('pointermove', event => {
|
|
353
776
|
const point = worldPoint(event)
|
|
354
777
|
state.hovered = hitNode(point)
|
|
778
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
355
779
|
if (!state.pointer.down) return
|
|
356
780
|
const dx = event.clientX - state.pointer.x
|
|
357
781
|
const dy = event.clientY - state.pointer.y
|
|
@@ -367,11 +791,40 @@ const bindEvents = () => {
|
|
|
367
791
|
state.transform.y += dy
|
|
368
792
|
})
|
|
369
793
|
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)
|
|
794
|
+
if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
|
|
795
|
+
if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
|
|
372
796
|
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
373
797
|
canvas.releasePointerCapture(event.pointerId)
|
|
374
798
|
})
|
|
799
|
+
canvas.addEventListener('pointercancel', () => {
|
|
800
|
+
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
801
|
+
})
|
|
802
|
+
canvas.addEventListener('pointerenter', event => {
|
|
803
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
804
|
+
})
|
|
805
|
+
canvas.addEventListener('pointerleave', event => {
|
|
806
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
|
|
807
|
+
})
|
|
808
|
+
window.addEventListener('keydown', event => {
|
|
809
|
+
if (event.key === '+' || event.key === '=') {
|
|
810
|
+
event.preventDefault()
|
|
811
|
+
const rect = canvas.getBoundingClientRect()
|
|
812
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.25)
|
|
813
|
+
return
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (event.key === '-' || event.key === '_') {
|
|
817
|
+
event.preventDefault()
|
|
818
|
+
const rect = canvas.getBoundingClientRect()
|
|
819
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.8)
|
|
820
|
+
return
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (event.key === '0') {
|
|
824
|
+
event.preventDefault()
|
|
825
|
+
resetView()
|
|
826
|
+
}
|
|
827
|
+
})
|
|
375
828
|
}
|
|
376
829
|
|
|
377
830
|
const loadAgents = async () => {
|
|
@@ -386,9 +839,10 @@ const loadAgents = async () => {
|
|
|
386
839
|
|
|
387
840
|
state.agentId = selected
|
|
388
841
|
if (signature !== state.agentsSignature) {
|
|
842
|
+
const formatAgentLabel = (agent) => agent.id
|
|
389
843
|
elements.agent.innerHTML = agents.length
|
|
390
|
-
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent
|
|
391
|
-
: '<option value="shared">shared
|
|
844
|
+
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
|
|
845
|
+
: '<option value="shared">shared</option>'
|
|
392
846
|
state.agentsSignature = signature
|
|
393
847
|
}
|
|
394
848
|
elements.agent.value = selected
|
|
@@ -417,14 +871,29 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
417
871
|
state.graph = graph
|
|
418
872
|
state.nodes = layout.nodes
|
|
419
873
|
state.edges = layout.edges
|
|
874
|
+
state.nodeDegrees = state.edges.reduce((degrees, edge) => {
|
|
875
|
+
degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
|
|
876
|
+
if (edge.target) {
|
|
877
|
+
degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
|
|
878
|
+
}
|
|
879
|
+
return degrees
|
|
880
|
+
}, new Map())
|
|
881
|
+
state.nodeDetails = new Map()
|
|
882
|
+
resetContentFilter()
|
|
883
|
+
recomputeVisibility()
|
|
884
|
+
scheduleContentFilterSync()
|
|
420
885
|
const tags = new Set(graph.nodes.flatMap(node => node.tags))
|
|
421
|
-
|
|
886
|
+
setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
|
|
422
887
|
elements.nodeCount.textContent = graph.nodes.length
|
|
423
888
|
elements.edgeCount.textContent = graph.edges.length
|
|
424
889
|
elements.tagCount.textContent = tags.size
|
|
425
890
|
resize()
|
|
426
891
|
if (options.reset) resetView()
|
|
427
|
-
|
|
892
|
+
const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
|
|
893
|
+
selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
|
|
894
|
+
if (!selectedNode && elements.contentDialog.open) {
|
|
895
|
+
elements.contentDialog.close()
|
|
896
|
+
}
|
|
428
897
|
}
|
|
429
898
|
|
|
430
899
|
bindEvents()
|
|
@@ -441,10 +910,7 @@ const refreshGraphLoop = () => {
|
|
|
441
910
|
return
|
|
442
911
|
}
|
|
443
912
|
|
|
444
|
-
loadGraph().catch(
|
|
445
|
-
elements.stats.textContent = 'Failed to refresh graph'
|
|
446
|
-
console.error(error)
|
|
447
|
-
})
|
|
913
|
+
loadGraph().catch(handleGraphRefreshError)
|
|
448
914
|
|
|
449
915
|
tickCounter += 1
|
|
450
916
|
if (tickCounter % 3 === 0) {
|
|
@@ -461,7 +927,6 @@ loadAgents()
|
|
|
461
927
|
setInterval(refreshGraphLoop, pollIntervalMs)
|
|
462
928
|
})
|
|
463
929
|
.catch(error => {
|
|
464
|
-
elements.stats.textContent = 'Failed to load graph'
|
|
465
930
|
console.error(error)
|
|
466
931
|
})
|
|
467
932
|
|
|
@@ -470,9 +935,6 @@ document.addEventListener('visibilitychange', () => {
|
|
|
470
935
|
return
|
|
471
936
|
}
|
|
472
937
|
|
|
473
|
-
loadGraph({ reset: true }).catch(
|
|
474
|
-
elements.stats.textContent = 'Failed to refresh graph'
|
|
475
|
-
console.error(error)
|
|
476
|
-
})
|
|
938
|
+
loadGraph({ reset: true }).catch(handleGraphRefreshError)
|
|
477
939
|
})
|
|
478
940
|
`;
|