@andespindola/brainlink 0.1.0-beta.4 → 0.1.0-beta.40
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 +43 -2
- package/CONTRIBUTING.md +2 -2
- package/COPYRIGHT.md +5 -0
- package/README.md +213 -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 +656 -94
- 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 +669 -9
- 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 +328 -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 +27 -2
- package/dist/mcp/startup.js +35 -0
- package/dist/mcp/tools.js +633 -19
- package/docs/AGENT_USAGE.md +144 -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,36 @@
|
|
|
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
|
|
8
|
+
const worldCoordinateLimit = 5_000_000
|
|
9
|
+
const transformCoordinateLimit = 20_000_000
|
|
3
10
|
const state = {
|
|
4
11
|
graph: { nodes: [], edges: [] },
|
|
5
12
|
nodes: [],
|
|
6
13
|
edges: [],
|
|
14
|
+
visibleNodes: [],
|
|
15
|
+
visibleEdges: [],
|
|
16
|
+
renderNodes: [],
|
|
17
|
+
renderEdges: [],
|
|
18
|
+
nodeDegrees: new Map(),
|
|
7
19
|
selected: null,
|
|
8
20
|
hovered: null,
|
|
9
21
|
query: '',
|
|
22
|
+
contentFilter: { query: '', ids: null, token: 0, timer: null },
|
|
10
23
|
agentId: '',
|
|
11
24
|
agentsSignature: '',
|
|
25
|
+
nodeDetails: new Map(),
|
|
12
26
|
transform: { x: 0, y: 0, scale: 1 },
|
|
13
27
|
pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
|
|
28
|
+
cursor: { x: 0, y: 0, inCanvas: false },
|
|
14
29
|
graphSignature: '',
|
|
15
|
-
|
|
30
|
+
graphStatus: '',
|
|
31
|
+
last: performance.now(),
|
|
32
|
+
offscreenFrameCount: 0,
|
|
33
|
+
recoveringViewport: false
|
|
16
34
|
}
|
|
17
35
|
|
|
18
36
|
const byId = id => document.getElementById(id)
|
|
@@ -23,25 +41,39 @@ const escapeHtml = value => String(value)
|
|
|
23
41
|
.replaceAll('"', '"')
|
|
24
42
|
.replaceAll("'", ''')
|
|
25
43
|
const elements = {
|
|
26
|
-
stats: byId('stats'),
|
|
27
44
|
search: byId('search'),
|
|
28
45
|
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
46
|
nodeCount: byId('nodeCount'),
|
|
37
47
|
edgeCount: byId('edgeCount'),
|
|
38
48
|
tagCount: byId('tagCount'),
|
|
39
49
|
zoomIn: byId('zoomIn'),
|
|
40
50
|
zoomOut: byId('zoomOut'),
|
|
41
|
-
|
|
51
|
+
fit: byId('fit'),
|
|
52
|
+
reset: byId('reset'),
|
|
53
|
+
contentDialog: byId('contentDialog'),
|
|
54
|
+
contentTitle: byId('contentTitle'),
|
|
55
|
+
contentPath: byId('contentPath'),
|
|
56
|
+
contentTags: byId('contentTags'),
|
|
57
|
+
contentOutgoing: byId('contentOutgoing'),
|
|
58
|
+
contentIncoming: byId('contentIncoming'),
|
|
59
|
+
contentBody: byId('contentBody'),
|
|
60
|
+
contentClose: byId('contentClose')
|
|
42
61
|
}
|
|
43
62
|
|
|
44
|
-
const
|
|
63
|
+
const zoomRange = {
|
|
64
|
+
min: 0.05,
|
|
65
|
+
max: 4.5
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
|
|
69
|
+
|
|
70
|
+
const setGraphStatus = text => {
|
|
71
|
+
state.graphStatus = text
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const handleGraphRefreshError = error => {
|
|
75
|
+
console.error(error)
|
|
76
|
+
}
|
|
45
77
|
|
|
46
78
|
const graphTheme = {
|
|
47
79
|
node: '#aeb8c5',
|
|
@@ -66,35 +98,178 @@ const resize = () => {
|
|
|
66
98
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
67
99
|
}
|
|
68
100
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
101
|
+
const normalizeQuery = value => value.trim().toLowerCase()
|
|
102
|
+
const hubNodeRetentionLimit = 2
|
|
103
|
+
const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map|mapa)\b/i
|
|
104
|
+
|
|
105
|
+
const localFilteredNodes = query =>
|
|
106
|
+
state.nodes.filter(node =>
|
|
73
107
|
node.title.toLowerCase().includes(query) ||
|
|
74
108
|
node.path.toLowerCase().includes(query) ||
|
|
75
109
|
node.tags.some(tag => tag.toLowerCase().includes(query))
|
|
76
110
|
)
|
|
111
|
+
|
|
112
|
+
const rankedHubNodes = () => {
|
|
113
|
+
if (state.nodes.length === 0) {
|
|
114
|
+
return []
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const byTitleAndDegree = [...state.nodes]
|
|
118
|
+
.filter(node => hubNodePattern.test(node.title) || hubNodePattern.test(node.path) || node.tags.some(tag => hubNodePattern.test(tag)))
|
|
119
|
+
.sort((left, right) => {
|
|
120
|
+
const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
|
|
121
|
+
if (byDegree !== 0) return byDegree
|
|
122
|
+
return left.title.localeCompare(right.title)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
if (byTitleAndDegree.length > 0) {
|
|
126
|
+
return byTitleAndDegree.slice(0, hubNodeRetentionLimit)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return [...state.nodes]
|
|
130
|
+
.sort((left, right) => {
|
|
131
|
+
const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
|
|
132
|
+
if (byDegree !== 0) return byDegree
|
|
133
|
+
return left.title.localeCompare(right.title)
|
|
134
|
+
})
|
|
135
|
+
.slice(0, 1)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const withPersistentHubNodes = nodes => {
|
|
139
|
+
if (nodes.length === 0) {
|
|
140
|
+
return rankedHubNodes()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
144
|
+
const hubsToKeep = rankedHubNodes().filter(node => !ids.has(node.id))
|
|
145
|
+
return nodes.concat(hubsToKeep)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const filteredNodes = () => {
|
|
149
|
+
const query = normalizeQuery(state.query)
|
|
150
|
+
if (!query) return state.nodes
|
|
151
|
+
if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
|
|
152
|
+
const matched = state.nodes.filter(node => state.contentFilter.ids.has(node.id))
|
|
153
|
+
return withPersistentHubNodes(matched)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return withPersistentHubNodes(localFilteredNodes(query))
|
|
77
157
|
}
|
|
78
158
|
|
|
79
|
-
const
|
|
159
|
+
const recomputeVisibility = () => {
|
|
160
|
+
const nodes = filteredNodes()
|
|
161
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
162
|
+
const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
163
|
+
const limitedEdges = state.nodes.length > largeGraphNodeThreshold
|
|
164
|
+
? [...edges]
|
|
165
|
+
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
166
|
+
.slice(0, largeGraphEdgeRenderLimit)
|
|
167
|
+
: edges
|
|
80
168
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
169
|
+
state.visibleNodes = nodes
|
|
170
|
+
state.visibleEdges = limitedEdges
|
|
84
171
|
}
|
|
85
172
|
|
|
86
173
|
const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
|
|
87
174
|
|
|
88
|
-
const
|
|
175
|
+
const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
|
|
176
|
+
const isFiniteNumber = value => Number.isFinite(value)
|
|
177
|
+
const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
|
|
178
|
+
|
|
179
|
+
const graphBounds = nodes => {
|
|
180
|
+
if (nodes.length === 0) return null
|
|
181
|
+
let minX = Number.POSITIVE_INFINITY
|
|
182
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
183
|
+
let minY = Number.POSITIVE_INFINITY
|
|
184
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
185
|
+
|
|
186
|
+
nodes.forEach(node => {
|
|
187
|
+
const radius = baseNodeRadius(node)
|
|
188
|
+
minX = Math.min(minX, node.x - radius)
|
|
189
|
+
maxX = Math.max(maxX, node.x + radius)
|
|
190
|
+
minY = Math.min(minY, node.y - radius)
|
|
191
|
+
maxY = Math.max(maxY, node.y + radius)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
minX,
|
|
196
|
+
maxX,
|
|
197
|
+
minY,
|
|
198
|
+
maxY,
|
|
199
|
+
width: Math.max(maxX - minX, 1),
|
|
200
|
+
height: Math.max(maxY - minY, 1)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const fitScaleBiasByNodeCount = nodeCount => {
|
|
205
|
+
if (nodeCount <= 6) return 1.22
|
|
206
|
+
if (nodeCount <= 20) return 1.12
|
|
207
|
+
if (nodeCount <= 60) return 1.04
|
|
208
|
+
if (nodeCount <= 180) return 1
|
|
209
|
+
if (nodeCount <= 600) return 0.94
|
|
210
|
+
if (nodeCount <= 2000) return 0.82
|
|
211
|
+
if (nodeCount <= 6000) return 0.68
|
|
212
|
+
return 0.56
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const autoFitScaleRangeByNodeCount = nodeCount => {
|
|
216
|
+
if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
|
|
217
|
+
if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
|
|
218
|
+
if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
|
|
219
|
+
if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
|
|
220
|
+
if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
|
|
221
|
+
if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
|
|
222
|
+
if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
|
|
223
|
+
return { min: zoomRange.min, max: 0.24 }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const fitView = (options = { useFiltered: true }) => {
|
|
89
227
|
const rect = canvas.getBoundingClientRect()
|
|
90
|
-
|
|
228
|
+
const width = Math.max(rect.width, 320)
|
|
229
|
+
const height = Math.max(rect.height, 320)
|
|
230
|
+
const nodes = options.useFiltered ? filteredNodes() : state.nodes
|
|
231
|
+
const bounds = graphBounds(nodes)
|
|
232
|
+
|
|
233
|
+
if (!bounds) {
|
|
234
|
+
state.transform = { x: width / 2, y: height / 2, scale: 1 }
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const paddingByNodeCount = nodeCount => {
|
|
239
|
+
if (nodeCount <= 6) return 28
|
|
240
|
+
if (nodeCount <= 20) return 44
|
|
241
|
+
if (nodeCount <= 60) return 68
|
|
242
|
+
if (nodeCount <= 180) return 86
|
|
243
|
+
if (nodeCount <= 600) return 110
|
|
244
|
+
if (nodeCount <= 2000) return 140
|
|
245
|
+
return 180
|
|
246
|
+
}
|
|
247
|
+
const padding = paddingByNodeCount(nodes.length)
|
|
248
|
+
const scaleX = width / (bounds.width + padding * 2)
|
|
249
|
+
const scaleY = height / (bounds.height + padding * 2)
|
|
250
|
+
const fitScale = Math.min(scaleX, scaleY)
|
|
251
|
+
const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
|
|
252
|
+
const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
|
|
253
|
+
const scale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
|
|
254
|
+
const centerX = (bounds.minX + bounds.maxX) / 2
|
|
255
|
+
const centerY = (bounds.minY + bounds.maxY) / 2
|
|
256
|
+
|
|
257
|
+
state.transform = {
|
|
258
|
+
x: width / 2 - centerX * scale,
|
|
259
|
+
y: height / 2 - centerY * scale,
|
|
260
|
+
scale
|
|
261
|
+
}
|
|
91
262
|
}
|
|
92
263
|
|
|
264
|
+
const resetView = () => fitView({ useFiltered: false })
|
|
265
|
+
|
|
93
266
|
const createLayout = graph => {
|
|
94
267
|
const nodes = graph.nodes.map(node => ({
|
|
95
268
|
...node,
|
|
96
269
|
x: Number.isFinite(node.x) ? node.x : 0,
|
|
97
|
-
y: Number.isFinite(node.y) ? node.y : 0
|
|
270
|
+
y: Number.isFinite(node.y) ? node.y : 0,
|
|
271
|
+
vx: Number.isFinite(node.vx) ? node.vx : 0,
|
|
272
|
+
vy: Number.isFinite(node.vy) ? node.vy : 0
|
|
98
273
|
}))
|
|
99
274
|
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
100
275
|
const edges = graph.edges
|
|
@@ -111,29 +286,94 @@ const encodeEntityTag = (value) => {
|
|
|
111
286
|
binary += String.fromCharCode(utf8[index])
|
|
112
287
|
}
|
|
113
288
|
|
|
114
|
-
return btoa(binary).
|
|
289
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
|
|
115
290
|
}
|
|
116
291
|
|
|
117
292
|
const graphSignature = graph => JSON.stringify({
|
|
118
|
-
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.
|
|
293
|
+
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
|
|
119
294
|
edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
|
|
120
295
|
})
|
|
121
296
|
|
|
297
|
+
const resetContentFilter = () => {
|
|
298
|
+
if (state.contentFilter.timer) {
|
|
299
|
+
clearTimeout(state.contentFilter.timer)
|
|
300
|
+
}
|
|
301
|
+
state.contentFilter = {
|
|
302
|
+
query: '',
|
|
303
|
+
ids: null,
|
|
304
|
+
token: state.contentFilter.token + 1,
|
|
305
|
+
timer: null
|
|
306
|
+
}
|
|
307
|
+
recomputeVisibility()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const syncContentFilter = async (query, token) => {
|
|
311
|
+
const response = await fetch(
|
|
312
|
+
'/api/graph-filter?q=' +
|
|
313
|
+
encodeURIComponent(query) +
|
|
314
|
+
'&limit=' +
|
|
315
|
+
encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
|
|
316
|
+
agentQuery('&')
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if (!response.ok || token !== state.contentFilter.token) {
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const payload = await response.json()
|
|
324
|
+
const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
|
|
325
|
+
if (token !== state.contentFilter.token) {
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
state.contentFilter.query = query
|
|
330
|
+
state.contentFilter.ids = new Set(nodeIds)
|
|
331
|
+
recomputeVisibility()
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const scheduleContentFilterSync = () => {
|
|
335
|
+
const query = normalizeQuery(state.query)
|
|
336
|
+
if (!query) {
|
|
337
|
+
resetContentFilter()
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (state.contentFilter.timer) {
|
|
342
|
+
clearTimeout(state.contentFilter.timer)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const token = state.contentFilter.token + 1
|
|
346
|
+
state.contentFilter = {
|
|
347
|
+
query: state.contentFilter.query,
|
|
348
|
+
ids: state.contentFilter.ids,
|
|
349
|
+
token,
|
|
350
|
+
timer: setTimeout(() => {
|
|
351
|
+
syncContentFilter(query, token).catch(() => {})
|
|
352
|
+
}, 180)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
122
356
|
const tick = delta => {
|
|
123
|
-
const nodes =
|
|
124
|
-
const
|
|
125
|
-
|
|
357
|
+
const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
|
|
358
|
+
const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
|
|
359
|
+
if (nodes.length > 1200) {
|
|
360
|
+
return
|
|
361
|
+
}
|
|
126
362
|
const strength = Math.min(delta / 16, 2)
|
|
127
363
|
|
|
128
364
|
edges.forEach(edge => {
|
|
129
365
|
const source = edge.sourceNode
|
|
130
366
|
const target = edge.targetNode
|
|
367
|
+
source.vx = Number.isFinite(source.vx) ? source.vx : 0
|
|
368
|
+
source.vy = Number.isFinite(source.vy) ? source.vy : 0
|
|
369
|
+
target.vx = Number.isFinite(target.vx) ? target.vx : 0
|
|
370
|
+
target.vy = Number.isFinite(target.vy) ? target.vy : 0
|
|
131
371
|
const dx = target.x - source.x
|
|
132
372
|
const dy = target.y - source.y
|
|
133
373
|
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
134
374
|
const force = (distance - 150) * 0.002 * strength
|
|
135
|
-
const fx = dx * force
|
|
136
|
-
const fy = dy * force
|
|
375
|
+
const fx = (dx / distance) * force
|
|
376
|
+
const fy = (dy / distance) * force
|
|
137
377
|
source.vx += fx
|
|
138
378
|
source.vy += fy
|
|
139
379
|
target.vx -= fx
|
|
@@ -144,6 +384,10 @@ const tick = delta => {
|
|
|
144
384
|
for (let j = i + 1; j < nodes.length; j += 1) {
|
|
145
385
|
const a = nodes[i]
|
|
146
386
|
const b = nodes[j]
|
|
387
|
+
a.vx = Number.isFinite(a.vx) ? a.vx : 0
|
|
388
|
+
a.vy = Number.isFinite(a.vy) ? a.vy : 0
|
|
389
|
+
b.vx = Number.isFinite(b.vx) ? b.vx : 0
|
|
390
|
+
b.vy = Number.isFinite(b.vy) ? b.vy : 0
|
|
147
391
|
const dx = b.x - a.x
|
|
148
392
|
const dy = b.y - a.y
|
|
149
393
|
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
@@ -158,6 +402,10 @@ const tick = delta => {
|
|
|
158
402
|
}
|
|
159
403
|
|
|
160
404
|
nodes.forEach(node => {
|
|
405
|
+
node.vx = Number.isFinite(node.vx) ? node.vx : 0
|
|
406
|
+
node.vy = Number.isFinite(node.vy) ? node.vy : 0
|
|
407
|
+
node.x = Number.isFinite(node.x) ? node.x : 0
|
|
408
|
+
node.y = Number.isFinite(node.y) ? node.y : 0
|
|
161
409
|
if (state.pointer.dragNode === node) {
|
|
162
410
|
node.vx = 0
|
|
163
411
|
node.vy = 0
|
|
@@ -181,7 +429,11 @@ const worldPoint = event => {
|
|
|
181
429
|
}
|
|
182
430
|
|
|
183
431
|
const hitNode = point => {
|
|
184
|
-
|
|
432
|
+
if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
|
|
433
|
+
return null
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const nodes = state.renderNodes
|
|
185
437
|
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
186
438
|
const node = nodes[index]
|
|
187
439
|
const radius = nodeRadius(node)
|
|
@@ -190,17 +442,159 @@ const hitNode = point => {
|
|
|
190
442
|
return null
|
|
191
443
|
}
|
|
192
444
|
|
|
193
|
-
const
|
|
194
|
-
const degree = state.
|
|
445
|
+
const baseNodeRadius = node => {
|
|
446
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
195
447
|
return 9 + Math.min(degree, 8) * 1.6
|
|
196
448
|
}
|
|
197
449
|
|
|
450
|
+
const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
|
|
451
|
+
|
|
452
|
+
const worldViewportBounds = () => {
|
|
453
|
+
const rect = canvas.getBoundingClientRect()
|
|
454
|
+
const width = Math.max(rect.width, 320)
|
|
455
|
+
const height = Math.max(rect.height, 320)
|
|
456
|
+
const padding = viewportPaddingPx
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
minX: (-state.transform.x - padding) / state.transform.scale,
|
|
460
|
+
maxX: (width - state.transform.x + padding) / state.transform.scale,
|
|
461
|
+
minY: (-state.transform.y - padding) / state.transform.scale,
|
|
462
|
+
maxY: (height - state.transform.y + padding) / state.transform.scale
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const isNodeInViewport = (node, viewport) =>
|
|
467
|
+
node.x >= viewport.minX &&
|
|
468
|
+
node.x <= viewport.maxX &&
|
|
469
|
+
node.y >= viewport.minY &&
|
|
470
|
+
node.y <= viewport.maxY
|
|
471
|
+
|
|
472
|
+
const viewportNodeStride = () => {
|
|
473
|
+
if (state.nodes.length <= largeGraphNodeThreshold) {
|
|
474
|
+
return 1
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (state.transform.scale >= 0.95) {
|
|
478
|
+
return 1
|
|
479
|
+
}
|
|
480
|
+
if (state.transform.scale >= 0.7) {
|
|
481
|
+
return 2
|
|
482
|
+
}
|
|
483
|
+
if (state.transform.scale >= 0.48) {
|
|
484
|
+
return 3
|
|
485
|
+
}
|
|
486
|
+
if (state.transform.scale >= 0.28) {
|
|
487
|
+
return 5
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return 8
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const computeRenderVisibility = () => {
|
|
494
|
+
if (state.visibleNodes.length <= 2000) {
|
|
495
|
+
state.renderNodes = state.visibleNodes
|
|
496
|
+
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
497
|
+
state.renderEdges = state.visibleEdges.filter((edge) => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const viewport = worldViewportBounds()
|
|
502
|
+
const stride = viewportNodeStride()
|
|
503
|
+
const picked = []
|
|
504
|
+
|
|
505
|
+
for (let index = 0; index < state.visibleNodes.length; index += 1) {
|
|
506
|
+
const node = state.visibleNodes[index]
|
|
507
|
+
if (!isNodeInViewport(node, viewport)) {
|
|
508
|
+
continue
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const isPriority =
|
|
512
|
+
node.id === state.selected?.id ||
|
|
513
|
+
node.id === state.hovered?.id ||
|
|
514
|
+
node.id === state.pointer.dragNode?.id
|
|
515
|
+
if (isPriority || index % stride === 0) {
|
|
516
|
+
picked.push(node)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const nodes = picked.length > renderNodeBudget
|
|
521
|
+
? picked.slice(0, renderNodeBudget)
|
|
522
|
+
: picked
|
|
523
|
+
if (nodes.length === 0 && state.visibleNodes.length > 0) {
|
|
524
|
+
const centerX = (viewport.minX + viewport.maxX) / 2
|
|
525
|
+
const centerY = (viewport.minY + viewport.maxY) / 2
|
|
526
|
+
const closest = [...state.visibleNodes]
|
|
527
|
+
.sort((left, right) => {
|
|
528
|
+
const leftDistance = (left.x - centerX) ** 2 + (left.y - centerY) ** 2
|
|
529
|
+
const rightDistance = (right.x - centerX) ** 2 + (right.y - centerY) ** 2
|
|
530
|
+
return leftDistance - rightDistance
|
|
531
|
+
})
|
|
532
|
+
.slice(0, Math.min(renderNodeBudget, 180))
|
|
533
|
+
const closestIds = new Set(closest.map((node) => node.id))
|
|
534
|
+
|
|
535
|
+
state.renderNodes = closest
|
|
536
|
+
state.renderEdges = state.visibleEdges.filter(
|
|
537
|
+
(edge) => closestIds.has(edge.source) && edge.target && closestIds.has(edge.target)
|
|
538
|
+
)
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const nodeIds = new Set(nodes.map((node) => node.id))
|
|
543
|
+
const edges = state.visibleEdges.filter((edge) => nodeIds.has(edge.source) && edge.target && nodeIds.has(edge.target))
|
|
544
|
+
|
|
545
|
+
state.renderNodes = nodes
|
|
546
|
+
state.renderEdges = edges
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const isNodeVisibleOnScreen = (node, width, height) => {
|
|
550
|
+
const radius = nodeRadius(node) * state.transform.scale
|
|
551
|
+
const screenX = node.x * state.transform.scale + state.transform.x
|
|
552
|
+
const screenY = node.y * state.transform.scale + state.transform.y
|
|
553
|
+
|
|
554
|
+
return (
|
|
555
|
+
screenX + radius >= 0 &&
|
|
556
|
+
screenX - radius <= width &&
|
|
557
|
+
screenY + radius >= 0 &&
|
|
558
|
+
screenY - radius <= height
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const hasValidTransform = () =>
|
|
563
|
+
isFiniteNumber(state.transform.x) &&
|
|
564
|
+
isFiniteNumber(state.transform.y) &&
|
|
565
|
+
isFiniteNumber(state.transform.scale) &&
|
|
566
|
+
Math.abs(state.transform.x) <= transformCoordinateLimit &&
|
|
567
|
+
Math.abs(state.transform.y) <= transformCoordinateLimit &&
|
|
568
|
+
state.transform.scale > 0
|
|
569
|
+
|
|
570
|
+
const sanitizeNodePosition = node => {
|
|
571
|
+
if (!isReasonableCoordinate(node.x)) node.x = 0
|
|
572
|
+
if (!isReasonableCoordinate(node.y)) node.y = 0
|
|
573
|
+
if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
|
|
574
|
+
if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const sanitizeGraphState = () => {
|
|
578
|
+
state.nodes.forEach(sanitizeNodePosition)
|
|
579
|
+
state.visibleNodes.forEach(sanitizeNodePosition)
|
|
580
|
+
state.renderNodes.forEach(sanitizeNodePosition)
|
|
581
|
+
}
|
|
582
|
+
|
|
198
583
|
const render = now => {
|
|
199
584
|
const delta = now - state.last
|
|
200
585
|
state.last = now
|
|
586
|
+
const minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 48 : 16
|
|
587
|
+
if (delta < minFrameIntervalMs) {
|
|
588
|
+
requestAnimationFrame(render)
|
|
589
|
+
return
|
|
590
|
+
}
|
|
201
591
|
const rect = canvas.getBoundingClientRect()
|
|
202
592
|
const width = Math.max(rect.width, 320)
|
|
203
593
|
const height = Math.max(rect.height, 320)
|
|
594
|
+
sanitizeGraphState()
|
|
595
|
+
if (!hasValidTransform()) {
|
|
596
|
+
resetView()
|
|
597
|
+
}
|
|
204
598
|
ctx.clearRect(0, 0, width, height)
|
|
205
599
|
if (state.nodes.length === 0) {
|
|
206
600
|
ctx.fillStyle = '#99a5b5'
|
|
@@ -214,7 +608,25 @@ const render = now => {
|
|
|
214
608
|
ctx.translate(state.transform.x, state.transform.y)
|
|
215
609
|
ctx.scale(state.transform.scale, state.transform.scale)
|
|
216
610
|
|
|
217
|
-
|
|
611
|
+
computeRenderVisibility()
|
|
612
|
+
tick(delta)
|
|
613
|
+
const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
|
|
614
|
+
if (!hasVisibleNodeOnScreen && state.renderNodes.length > 0) {
|
|
615
|
+
state.offscreenFrameCount += 1
|
|
616
|
+
if (state.offscreenFrameCount >= 6 && !state.recoveringViewport) {
|
|
617
|
+
state.recoveringViewport = true
|
|
618
|
+
fitView({ useFiltered: true })
|
|
619
|
+
state.offscreenFrameCount = 0
|
|
620
|
+
requestAnimationFrame(() => {
|
|
621
|
+
state.recoveringViewport = false
|
|
622
|
+
})
|
|
623
|
+
}
|
|
624
|
+
} else {
|
|
625
|
+
state.offscreenFrameCount = 0
|
|
626
|
+
}
|
|
627
|
+
const drawEdges = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
|
|
628
|
+
if (drawEdges) {
|
|
629
|
+
state.renderEdges.forEach(edge => {
|
|
218
630
|
const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
219
631
|
ctx.beginPath()
|
|
220
632
|
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
|
|
@@ -222,9 +634,10 @@ const render = now => {
|
|
|
222
634
|
ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
|
|
223
635
|
ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
|
|
224
636
|
ctx.stroke()
|
|
225
|
-
|
|
637
|
+
})
|
|
638
|
+
}
|
|
226
639
|
|
|
227
|
-
|
|
640
|
+
state.renderNodes.forEach(node => {
|
|
228
641
|
const radius = nodeRadius(node)
|
|
229
642
|
const isSelected = state.selected?.id === node.id
|
|
230
643
|
const isHovered = state.hovered?.id === node.id
|
|
@@ -240,7 +653,11 @@ const render = now => {
|
|
|
240
653
|
ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
|
|
241
654
|
ctx.stroke()
|
|
242
655
|
|
|
243
|
-
|
|
656
|
+
const shouldDrawLabels =
|
|
657
|
+
isSelected ||
|
|
658
|
+
isHovered ||
|
|
659
|
+
(state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
|
|
660
|
+
if (shouldDrawLabels) {
|
|
244
661
|
ctx.fillStyle = graphTheme.label
|
|
245
662
|
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
246
663
|
ctx.textAlign = 'center'
|
|
@@ -250,6 +667,12 @@ const render = now => {
|
|
|
250
667
|
})
|
|
251
668
|
|
|
252
669
|
ctx.restore()
|
|
670
|
+
if (state.renderNodes.length === 0) {
|
|
671
|
+
ctx.fillStyle = '#99a5b5'
|
|
672
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
673
|
+
ctx.textAlign = 'center'
|
|
674
|
+
ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
|
|
675
|
+
}
|
|
253
676
|
requestAnimationFrame(render)
|
|
254
677
|
}
|
|
255
678
|
|
|
@@ -257,22 +680,7 @@ const list = items => items.length
|
|
|
257
680
|
? 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
681
|
: '<li><small>No links found.</small></li>'
|
|
259
682
|
|
|
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
|
-
}
|
|
683
|
+
const linkedNodes = node => {
|
|
276
684
|
const nodeById = new Map(state.nodes.map(item => [item.id, item]))
|
|
277
685
|
const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
|
|
278
686
|
...linkedNode,
|
|
@@ -288,57 +696,172 @@ const selectNode = node => {
|
|
|
288
696
|
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
289
697
|
.filter(Boolean)
|
|
290
698
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
699
|
+
return { outgoing, incoming }
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const fetchNodeDetails = async node => {
|
|
703
|
+
const cached = state.nodeDetails.get(node.id)
|
|
704
|
+
if (cached) {
|
|
705
|
+
return cached
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
|
|
709
|
+
if (!response.ok) {
|
|
710
|
+
throw new Error('Failed to load graph node details')
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const payload = await response.json()
|
|
714
|
+
const detail = payload?.node
|
|
715
|
+
if (!detail || !detail.id) {
|
|
716
|
+
throw new Error('Invalid graph node payload')
|
|
717
|
+
}
|
|
718
|
+
state.nodeDetails.set(detail.id, detail)
|
|
719
|
+
return detail
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const openContentDialog = async node => {
|
|
723
|
+
if (!node) return
|
|
724
|
+
const { outgoing, incoming } = linkedNodes(node)
|
|
725
|
+
elements.contentTitle.textContent = node.title
|
|
726
|
+
elements.contentPath.textContent = node.path
|
|
727
|
+
elements.contentTags.innerHTML = node.tags.length
|
|
294
728
|
? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
295
729
|
: '<span>No tags</span>'
|
|
296
|
-
elements.
|
|
297
|
-
elements.
|
|
298
|
-
elements.
|
|
299
|
-
elements.
|
|
730
|
+
elements.contentOutgoing.innerHTML = list(outgoing)
|
|
731
|
+
elements.contentIncoming.innerHTML = list(incoming)
|
|
732
|
+
elements.contentBody.textContent = 'Loading note content...'
|
|
733
|
+
if (!elements.contentDialog.open) {
|
|
734
|
+
elements.contentDialog.showModal()
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
try {
|
|
738
|
+
const detailedNode = await fetchNodeDetails(node)
|
|
739
|
+
if (state.selected?.id !== node.id) {
|
|
740
|
+
return
|
|
741
|
+
}
|
|
742
|
+
elements.contentBody.textContent = detailedNode.content
|
|
743
|
+
} catch {
|
|
744
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const selectNode = (node, options = { openContent: false }) => {
|
|
749
|
+
state.selected = node
|
|
750
|
+
if (node && options.openContent) {
|
|
751
|
+
openContentDialog(node).catch(() => {
|
|
752
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
753
|
+
})
|
|
754
|
+
}
|
|
300
755
|
}
|
|
301
756
|
|
|
302
757
|
const selectNodeById = id => {
|
|
303
758
|
const node = state.nodes.find(item => item.id === id)
|
|
304
|
-
if (node) selectNode(node)
|
|
759
|
+
if (node) selectNode(node, { openContent: true })
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const zoomAtPoint = (screenX, screenY, factor) => {
|
|
763
|
+
const nextScale = clampScale(state.transform.scale * factor)
|
|
764
|
+
if (nextScale === state.transform.scale) return
|
|
765
|
+
const worldX = (screenX - state.transform.x) / state.transform.scale
|
|
766
|
+
const worldY = (screenY - state.transform.y) / state.transform.scale
|
|
767
|
+
state.transform.scale = nextScale
|
|
768
|
+
state.transform.x = screenX - worldX * nextScale
|
|
769
|
+
state.transform.y = screenY - worldY * nextScale
|
|
305
770
|
}
|
|
306
771
|
|
|
307
|
-
const
|
|
308
|
-
|
|
772
|
+
const wheelZoomFactor = event => {
|
|
773
|
+
const isModifierZoom = event.metaKey || event.ctrlKey
|
|
774
|
+
const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
|
|
775
|
+
const absoluteDelta = Math.min(Math.abs(event.deltaY * deltaModeFactor), 1600)
|
|
776
|
+
|
|
777
|
+
if (absoluteDelta <= 0.0001) {
|
|
778
|
+
return 1
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const baseStep = Math.max(0.06, Math.min(0.45, absoluteDelta / 480))
|
|
782
|
+
const adjustedStep = baseStep * (isModifierZoom ? 1.4 : 1)
|
|
783
|
+
|
|
784
|
+
return event.deltaY < 0 ? 1 + adjustedStep : 1 / (1 + adjustedStep)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const isScreenPointInsideCanvas = (screenX, screenY) => {
|
|
788
|
+
const rect = canvas.getBoundingClientRect()
|
|
789
|
+
|
|
790
|
+
return screenX >= rect.left && screenX <= rect.right && screenY >= rect.top && screenY <= rect.bottom
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const handleWheelZoom = event => {
|
|
794
|
+
if (elements.contentDialog?.open) {
|
|
795
|
+
return
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (!isScreenPointInsideCanvas(event.clientX, event.clientY)) {
|
|
799
|
+
return
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
event.preventDefault()
|
|
803
|
+
const rect = canvas.getBoundingClientRect()
|
|
804
|
+
const cursorX = event.clientX - rect.left
|
|
805
|
+
const cursorY = event.clientY - rect.top
|
|
806
|
+
const factor = wheelZoomFactor(event)
|
|
807
|
+
|
|
808
|
+
if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
zoomAtPoint(cursorX, cursorY, factor)
|
|
309
813
|
}
|
|
310
814
|
|
|
311
815
|
const bindEvents = () => {
|
|
312
816
|
window.addEventListener('resize', resize)
|
|
313
817
|
elements.search.addEventListener('input', event => {
|
|
314
818
|
state.query = event.target.value
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
: state.nodes.length + ' notes · ' + state.edges.length + ' links'
|
|
819
|
+
recomputeVisibility()
|
|
820
|
+
scheduleContentFilterSync()
|
|
318
821
|
})
|
|
319
822
|
elements.agent.addEventListener('change', event => {
|
|
320
823
|
state.agentId = event.target.value
|
|
321
824
|
state.selected = null
|
|
825
|
+
state.nodeDetails = new Map()
|
|
826
|
+
resetContentFilter()
|
|
827
|
+
recomputeVisibility()
|
|
828
|
+
scheduleContentFilterSync()
|
|
322
829
|
loadGraph({ reset: true }).catch(error => {
|
|
323
|
-
elements.stats.textContent = 'Failed to load agent graph'
|
|
324
830
|
console.error(error)
|
|
325
831
|
})
|
|
326
832
|
})
|
|
327
|
-
elements.zoomIn.addEventListener('click', () =>
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
833
|
+
elements.zoomIn.addEventListener('click', () => {
|
|
834
|
+
const rect = canvas.getBoundingClientRect()
|
|
835
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.3)
|
|
836
|
+
})
|
|
837
|
+
elements.zoomOut.addEventListener('click', () => {
|
|
838
|
+
const rect = canvas.getBoundingClientRect()
|
|
839
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.77)
|
|
840
|
+
})
|
|
841
|
+
if (elements.fit) {
|
|
842
|
+
elements.fit.addEventListener('click', () => {
|
|
843
|
+
fitView({ useFiltered: true })
|
|
336
844
|
})
|
|
845
|
+
}
|
|
846
|
+
elements.reset.addEventListener('click', () => {
|
|
847
|
+
resetView()
|
|
848
|
+
})
|
|
849
|
+
elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
|
|
850
|
+
elements.contentDialog.addEventListener('click', event => {
|
|
851
|
+
const target = event.target
|
|
852
|
+
if (target instanceof HTMLElement && target.dataset.nodeId) {
|
|
853
|
+
selectNodeById(target.dataset.nodeId)
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
if (event.target === elements.contentDialog) elements.contentDialog.close()
|
|
857
|
+
})
|
|
858
|
+
window.addEventListener('wheel', handleWheelZoom, { passive: false })
|
|
859
|
+
canvas.addEventListener('dblclick', event => {
|
|
860
|
+
const rect = canvas.getBoundingClientRect()
|
|
861
|
+
const cursorX = event.clientX - rect.left
|
|
862
|
+
const cursorY = event.clientY - rect.top
|
|
863
|
+
zoomAtPoint(cursorX, cursorY, 1.25)
|
|
337
864
|
})
|
|
338
|
-
canvas.addEventListener('wheel', event => {
|
|
339
|
-
event.preventDefault()
|
|
340
|
-
zoom(event.deltaY < 0 ? 1.08 : 0.92)
|
|
341
|
-
}, { passive: false })
|
|
342
865
|
canvas.addEventListener('pointerdown', event => {
|
|
343
866
|
const point = worldPoint(event)
|
|
344
867
|
const node = hitNode(point)
|
|
@@ -352,6 +875,7 @@ const bindEvents = () => {
|
|
|
352
875
|
canvas.addEventListener('pointermove', event => {
|
|
353
876
|
const point = worldPoint(event)
|
|
354
877
|
state.hovered = hitNode(point)
|
|
878
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
355
879
|
if (!state.pointer.down) return
|
|
356
880
|
const dx = event.clientX - state.pointer.x
|
|
357
881
|
const dy = event.clientY - state.pointer.y
|
|
@@ -367,11 +891,40 @@ const bindEvents = () => {
|
|
|
367
891
|
state.transform.y += dy
|
|
368
892
|
})
|
|
369
893
|
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)
|
|
894
|
+
if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
|
|
895
|
+
if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
|
|
372
896
|
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
373
897
|
canvas.releasePointerCapture(event.pointerId)
|
|
374
898
|
})
|
|
899
|
+
canvas.addEventListener('pointercancel', () => {
|
|
900
|
+
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
901
|
+
})
|
|
902
|
+
canvas.addEventListener('pointerenter', event => {
|
|
903
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
904
|
+
})
|
|
905
|
+
canvas.addEventListener('pointerleave', event => {
|
|
906
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
|
|
907
|
+
})
|
|
908
|
+
window.addEventListener('keydown', event => {
|
|
909
|
+
if (event.key === '+' || event.key === '=') {
|
|
910
|
+
event.preventDefault()
|
|
911
|
+
const rect = canvas.getBoundingClientRect()
|
|
912
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.25)
|
|
913
|
+
return
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (event.key === '-' || event.key === '_') {
|
|
917
|
+
event.preventDefault()
|
|
918
|
+
const rect = canvas.getBoundingClientRect()
|
|
919
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.8)
|
|
920
|
+
return
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (event.key === '0') {
|
|
924
|
+
event.preventDefault()
|
|
925
|
+
resetView()
|
|
926
|
+
}
|
|
927
|
+
})
|
|
375
928
|
}
|
|
376
929
|
|
|
377
930
|
const loadAgents = async () => {
|
|
@@ -386,9 +939,10 @@ const loadAgents = async () => {
|
|
|
386
939
|
|
|
387
940
|
state.agentId = selected
|
|
388
941
|
if (signature !== state.agentsSignature) {
|
|
942
|
+
const formatAgentLabel = (agent) => agent.id
|
|
389
943
|
elements.agent.innerHTML = agents.length
|
|
390
|
-
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent
|
|
391
|
-
: '<option value="shared">shared
|
|
944
|
+
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
|
|
945
|
+
: '<option value="shared">shared</option>'
|
|
392
946
|
state.agentsSignature = signature
|
|
393
947
|
}
|
|
394
948
|
elements.agent.value = selected
|
|
@@ -417,14 +971,29 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
417
971
|
state.graph = graph
|
|
418
972
|
state.nodes = layout.nodes
|
|
419
973
|
state.edges = layout.edges
|
|
974
|
+
state.nodeDegrees = state.edges.reduce((degrees, edge) => {
|
|
975
|
+
degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
|
|
976
|
+
if (edge.target) {
|
|
977
|
+
degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
|
|
978
|
+
}
|
|
979
|
+
return degrees
|
|
980
|
+
}, new Map())
|
|
981
|
+
state.nodeDetails = new Map()
|
|
982
|
+
resetContentFilter()
|
|
983
|
+
recomputeVisibility()
|
|
984
|
+
scheduleContentFilterSync()
|
|
420
985
|
const tags = new Set(graph.nodes.flatMap(node => node.tags))
|
|
421
|
-
|
|
986
|
+
setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
|
|
422
987
|
elements.nodeCount.textContent = graph.nodes.length
|
|
423
988
|
elements.edgeCount.textContent = graph.edges.length
|
|
424
989
|
elements.tagCount.textContent = tags.size
|
|
425
990
|
resize()
|
|
426
991
|
if (options.reset) resetView()
|
|
427
|
-
|
|
992
|
+
const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
|
|
993
|
+
selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
|
|
994
|
+
if (!selectedNode && elements.contentDialog.open) {
|
|
995
|
+
elements.contentDialog.close()
|
|
996
|
+
}
|
|
428
997
|
}
|
|
429
998
|
|
|
430
999
|
bindEvents()
|
|
@@ -441,10 +1010,7 @@ const refreshGraphLoop = () => {
|
|
|
441
1010
|
return
|
|
442
1011
|
}
|
|
443
1012
|
|
|
444
|
-
loadGraph().catch(
|
|
445
|
-
elements.stats.textContent = 'Failed to refresh graph'
|
|
446
|
-
console.error(error)
|
|
447
|
-
})
|
|
1013
|
+
loadGraph().catch(handleGraphRefreshError)
|
|
448
1014
|
|
|
449
1015
|
tickCounter += 1
|
|
450
1016
|
if (tickCounter % 3 === 0) {
|
|
@@ -461,7 +1027,6 @@ loadAgents()
|
|
|
461
1027
|
setInterval(refreshGraphLoop, pollIntervalMs)
|
|
462
1028
|
})
|
|
463
1029
|
.catch(error => {
|
|
464
|
-
elements.stats.textContent = 'Failed to load graph'
|
|
465
1030
|
console.error(error)
|
|
466
1031
|
})
|
|
467
1032
|
|
|
@@ -470,9 +1035,6 @@ document.addEventListener('visibilitychange', () => {
|
|
|
470
1035
|
return
|
|
471
1036
|
}
|
|
472
1037
|
|
|
473
|
-
loadGraph({ reset: true }).catch(
|
|
474
|
-
elements.stats.textContent = 'Failed to refresh graph'
|
|
475
|
-
console.error(error)
|
|
476
|
-
})
|
|
1038
|
+
loadGraph({ reset: true }).catch(handleGraphRefreshError)
|
|
477
1039
|
})
|
|
478
1040
|
`;
|