@andespindola/brainlink 0.1.0-beta.4 → 0.1.0-beta.41
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 +45 -2
- package/CONTRIBUTING.md +2 -2
- package/COPYRIGHT.md +5 -0
- package/README.md +216 -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 +818 -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 +143 -20
- 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 +358 -0
- package/dist/infrastructure/file-system-vault.js +30 -0
- package/dist/infrastructure/index-state.js +50 -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,41 @@
|
|
|
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 renderEdgeBudget = 5200
|
|
7
|
+
const minNodePixelRadius = 1.8
|
|
8
|
+
const viewportPaddingPx = 280
|
|
9
|
+
const worldCoordinateLimit = 5_000_000
|
|
10
|
+
const transformCoordinateLimit = 20_000_000
|
|
3
11
|
const state = {
|
|
4
12
|
graph: { nodes: [], edges: [] },
|
|
5
13
|
nodes: [],
|
|
6
14
|
edges: [],
|
|
15
|
+
visibleNodes: [],
|
|
16
|
+
visibleEdges: [],
|
|
17
|
+
renderNodes: [],
|
|
18
|
+
renderEdges: [],
|
|
19
|
+
nodeDegrees: new Map(),
|
|
7
20
|
selected: null,
|
|
8
21
|
hovered: null,
|
|
9
22
|
query: '',
|
|
23
|
+
contentFilter: { query: '', ids: null, token: 0, timer: null },
|
|
10
24
|
agentId: '',
|
|
11
25
|
agentsSignature: '',
|
|
26
|
+
nodeDetails: new Map(),
|
|
12
27
|
transform: { x: 0, y: 0, scale: 1 },
|
|
13
28
|
pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
|
|
29
|
+
cursor: { x: 0, y: 0, inCanvas: false },
|
|
14
30
|
graphSignature: '',
|
|
15
|
-
|
|
31
|
+
graphStatus: '',
|
|
32
|
+
last: performance.now(),
|
|
33
|
+
offscreenFrameCount: 0,
|
|
34
|
+
recoveringViewport: false,
|
|
35
|
+
renderVisibilityDirty: true,
|
|
36
|
+
lastViewportKey: '',
|
|
37
|
+
visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
|
|
38
|
+
visibleEdgeByNode: new Map()
|
|
16
39
|
}
|
|
17
40
|
|
|
18
41
|
const byId = id => document.getElementById(id)
|
|
@@ -23,25 +46,39 @@ const escapeHtml = value => String(value)
|
|
|
23
46
|
.replaceAll('"', '"')
|
|
24
47
|
.replaceAll("'", ''')
|
|
25
48
|
const elements = {
|
|
26
|
-
stats: byId('stats'),
|
|
27
49
|
search: byId('search'),
|
|
28
50
|
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
51
|
nodeCount: byId('nodeCount'),
|
|
37
52
|
edgeCount: byId('edgeCount'),
|
|
38
53
|
tagCount: byId('tagCount'),
|
|
39
54
|
zoomIn: byId('zoomIn'),
|
|
40
55
|
zoomOut: byId('zoomOut'),
|
|
41
|
-
|
|
56
|
+
fit: byId('fit'),
|
|
57
|
+
reset: byId('reset'),
|
|
58
|
+
contentDialog: byId('contentDialog'),
|
|
59
|
+
contentTitle: byId('contentTitle'),
|
|
60
|
+
contentPath: byId('contentPath'),
|
|
61
|
+
contentTags: byId('contentTags'),
|
|
62
|
+
contentOutgoing: byId('contentOutgoing'),
|
|
63
|
+
contentIncoming: byId('contentIncoming'),
|
|
64
|
+
contentBody: byId('contentBody'),
|
|
65
|
+
contentClose: byId('contentClose')
|
|
42
66
|
}
|
|
43
67
|
|
|
44
|
-
const
|
|
68
|
+
const zoomRange = {
|
|
69
|
+
min: 0.05,
|
|
70
|
+
max: 4.5
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
|
|
74
|
+
|
|
75
|
+
const setGraphStatus = text => {
|
|
76
|
+
state.graphStatus = text
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const handleGraphRefreshError = error => {
|
|
80
|
+
console.error(error)
|
|
81
|
+
}
|
|
45
82
|
|
|
46
83
|
const graphTheme = {
|
|
47
84
|
node: '#aeb8c5',
|
|
@@ -64,37 +101,332 @@ const resize = () => {
|
|
|
64
101
|
canvas.width = Math.floor(width * ratio)
|
|
65
102
|
canvas.height = Math.floor(height * ratio)
|
|
66
103
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
104
|
+
markRenderDirty()
|
|
67
105
|
}
|
|
68
106
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
107
|
+
const normalizeQuery = value => value.trim().toLowerCase()
|
|
108
|
+
const hubNodeRetentionLimit = 2
|
|
109
|
+
const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map|mapa)\b/i
|
|
110
|
+
|
|
111
|
+
const localFilteredNodes = query =>
|
|
112
|
+
state.nodes.filter(node =>
|
|
73
113
|
node.title.toLowerCase().includes(query) ||
|
|
74
114
|
node.path.toLowerCase().includes(query) ||
|
|
75
115
|
node.tags.some(tag => tag.toLowerCase().includes(query))
|
|
76
116
|
)
|
|
117
|
+
|
|
118
|
+
const rankedHubNodes = () => {
|
|
119
|
+
if (state.nodes.length === 0) {
|
|
120
|
+
return []
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const byTitleAndDegree = [...state.nodes]
|
|
124
|
+
.filter(node => hubNodePattern.test(node.title) || hubNodePattern.test(node.path) || node.tags.some(tag => hubNodePattern.test(tag)))
|
|
125
|
+
.sort((left, right) => {
|
|
126
|
+
const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
|
|
127
|
+
if (byDegree !== 0) return byDegree
|
|
128
|
+
return left.title.localeCompare(right.title)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
if (byTitleAndDegree.length > 0) {
|
|
132
|
+
return byTitleAndDegree.slice(0, hubNodeRetentionLimit)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return [...state.nodes]
|
|
136
|
+
.sort((left, right) => {
|
|
137
|
+
const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
|
|
138
|
+
if (byDegree !== 0) return byDegree
|
|
139
|
+
return left.title.localeCompare(right.title)
|
|
140
|
+
})
|
|
141
|
+
.slice(0, 1)
|
|
77
142
|
}
|
|
78
143
|
|
|
79
|
-
const
|
|
144
|
+
const withPersistentHubNodes = nodes => {
|
|
145
|
+
if (nodes.length === 0) {
|
|
146
|
+
return rankedHubNodes()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
150
|
+
const hubsToKeep = rankedHubNodes().filter(node => !ids.has(node.id))
|
|
151
|
+
return nodes.concat(hubsToKeep)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const filteredNodes = () => {
|
|
155
|
+
const query = normalizeQuery(state.query)
|
|
156
|
+
if (!query) return state.nodes
|
|
157
|
+
if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
|
|
158
|
+
const matched = state.nodes.filter(node => state.contentFilter.ids.has(node.id))
|
|
159
|
+
return withPersistentHubNodes(matched)
|
|
160
|
+
}
|
|
80
161
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
162
|
+
return withPersistentHubNodes(localFilteredNodes(query))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const recomputeVisibility = () => {
|
|
166
|
+
const nodes = filteredNodes()
|
|
167
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
168
|
+
const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
169
|
+
const limitedEdges = state.nodes.length > largeGraphNodeThreshold
|
|
170
|
+
? [...edges]
|
|
171
|
+
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
172
|
+
.slice(0, largeGraphEdgeRenderLimit)
|
|
173
|
+
: edges
|
|
174
|
+
|
|
175
|
+
state.visibleNodes = nodes
|
|
176
|
+
state.visibleEdges = limitedEdges
|
|
177
|
+
state.visibleNodeSpatial = createSpatialIndex(nodes)
|
|
178
|
+
state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
|
|
179
|
+
markRenderDirty()
|
|
84
180
|
}
|
|
85
181
|
|
|
86
182
|
const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
|
|
183
|
+
const markRenderDirty = () => {
|
|
184
|
+
state.renderVisibilityDirty = true
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const createSpatialIndex = nodes => {
|
|
188
|
+
if (nodes.length === 0) {
|
|
189
|
+
return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const bounds = graphBounds(nodes)
|
|
193
|
+
if (!bounds) {
|
|
194
|
+
return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const targetNodesPerCell = 18
|
|
198
|
+
const approximateCellArea = Math.max((bounds.width * bounds.height) / Math.max(nodes.length / targetNodesPerCell, 1), 1)
|
|
199
|
+
const cellSize = Math.max(90, Math.min(2200, Math.sqrt(approximateCellArea)))
|
|
200
|
+
const buckets = new Map()
|
|
201
|
+
|
|
202
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
203
|
+
const node = nodes[index]
|
|
204
|
+
const cellX = Math.floor((node.x - bounds.minX) / cellSize)
|
|
205
|
+
const cellY = Math.floor((node.y - bounds.minY) / cellSize)
|
|
206
|
+
const key = cellX + ':' + cellY
|
|
207
|
+
const bucket = buckets.get(key)
|
|
208
|
+
if (bucket) {
|
|
209
|
+
bucket.push(node)
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
buckets.set(key, [node])
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
cellSize,
|
|
217
|
+
minX: bounds.minX,
|
|
218
|
+
minY: bounds.minY,
|
|
219
|
+
maxX: bounds.maxX,
|
|
220
|
+
maxY: bounds.maxY,
|
|
221
|
+
buckets
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const viewportNodesFromSpatialIndex = viewport => {
|
|
226
|
+
if (state.visibleNodes.length <= 2500) {
|
|
227
|
+
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const spatial = state.visibleNodeSpatial
|
|
231
|
+
if (!spatial || spatial.buckets.size === 0) {
|
|
232
|
+
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const minCellX = Math.floor((viewport.minX - spatial.minX) / spatial.cellSize)
|
|
236
|
+
const maxCellX = Math.floor((viewport.maxX - spatial.minX) / spatial.cellSize)
|
|
237
|
+
const minCellY = Math.floor((viewport.minY - spatial.minY) / spatial.cellSize)
|
|
238
|
+
const maxCellY = Math.floor((viewport.maxY - spatial.minY) / spatial.cellSize)
|
|
239
|
+
const nodes = []
|
|
240
|
+
|
|
241
|
+
for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
|
|
242
|
+
for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
|
|
243
|
+
const bucket = spatial.buckets.get(cellX + ':' + cellY)
|
|
244
|
+
if (!bucket) continue
|
|
245
|
+
|
|
246
|
+
for (let index = 0; index < bucket.length; index += 1) {
|
|
247
|
+
const node = bucket[index]
|
|
248
|
+
if (isNodeInViewport(node, viewport)) {
|
|
249
|
+
nodes.push(node)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return nodes
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const createVisibleEdgeLookup = edges => {
|
|
259
|
+
const lookup = new Map()
|
|
260
|
+
|
|
261
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
262
|
+
const edge = edges[index]
|
|
263
|
+
if (!edge.target) continue
|
|
264
|
+
|
|
265
|
+
const sourceList = lookup.get(edge.source)
|
|
266
|
+
if (sourceList) {
|
|
267
|
+
sourceList.push(edge)
|
|
268
|
+
} else {
|
|
269
|
+
lookup.set(edge.source, [edge])
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const targetList = lookup.get(edge.target)
|
|
273
|
+
if (targetList) {
|
|
274
|
+
targetList.push(edge)
|
|
275
|
+
} else {
|
|
276
|
+
lookup.set(edge.target, [edge])
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return lookup
|
|
281
|
+
}
|
|
87
282
|
|
|
88
|
-
const
|
|
283
|
+
const collectVisibleEdgesForNodes = nodeIds => {
|
|
284
|
+
if (nodeIds.size === 0) {
|
|
285
|
+
return []
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const seen = new Set()
|
|
289
|
+
const collected = []
|
|
290
|
+
|
|
291
|
+
nodeIds.forEach(nodeId => {
|
|
292
|
+
const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
293
|
+
for (let index = 0; index < candidateEdges.length; index += 1) {
|
|
294
|
+
const edge = candidateEdges[index]
|
|
295
|
+
if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
const key = edge.source < edge.target
|
|
299
|
+
? edge.source + '|' + edge.target + '|' + edge.targetTitle
|
|
300
|
+
: edge.target + '|' + edge.source + '|' + edge.targetTitle
|
|
301
|
+
if (seen.has(key)) continue
|
|
302
|
+
|
|
303
|
+
seen.add(key)
|
|
304
|
+
collected.push(edge)
|
|
305
|
+
if (collected.length >= renderEdgeBudget) {
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
return collected
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const fallbackViewportNodes = () => {
|
|
315
|
+
const nodes = []
|
|
316
|
+
const maxNodes = Math.min(renderNodeBudget, 220)
|
|
317
|
+
const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
|
|
318
|
+
|
|
319
|
+
for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
|
|
320
|
+
nodes.push(state.visibleNodes[index])
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
324
|
+
nodes.push(state.selected)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return nodes
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
|
|
331
|
+
const isFiniteNumber = value => Number.isFinite(value)
|
|
332
|
+
const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
|
|
333
|
+
|
|
334
|
+
const graphBounds = nodes => {
|
|
335
|
+
if (nodes.length === 0) return null
|
|
336
|
+
let minX = Number.POSITIVE_INFINITY
|
|
337
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
338
|
+
let minY = Number.POSITIVE_INFINITY
|
|
339
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
340
|
+
|
|
341
|
+
nodes.forEach(node => {
|
|
342
|
+
const radius = baseNodeRadius(node)
|
|
343
|
+
minX = Math.min(minX, node.x - radius)
|
|
344
|
+
maxX = Math.max(maxX, node.x + radius)
|
|
345
|
+
minY = Math.min(minY, node.y - radius)
|
|
346
|
+
maxY = Math.max(maxY, node.y + radius)
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
minX,
|
|
351
|
+
maxX,
|
|
352
|
+
minY,
|
|
353
|
+
maxY,
|
|
354
|
+
width: Math.max(maxX - minX, 1),
|
|
355
|
+
height: Math.max(maxY - minY, 1)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const fitScaleBiasByNodeCount = nodeCount => {
|
|
360
|
+
if (nodeCount <= 6) return 1.22
|
|
361
|
+
if (nodeCount <= 20) return 1.12
|
|
362
|
+
if (nodeCount <= 60) return 1.04
|
|
363
|
+
if (nodeCount <= 180) return 1
|
|
364
|
+
if (nodeCount <= 600) return 0.94
|
|
365
|
+
if (nodeCount <= 2000) return 0.82
|
|
366
|
+
if (nodeCount <= 6000) return 0.68
|
|
367
|
+
return 0.56
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const autoFitScaleRangeByNodeCount = nodeCount => {
|
|
371
|
+
if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
|
|
372
|
+
if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
|
|
373
|
+
if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
|
|
374
|
+
if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
|
|
375
|
+
if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
|
|
376
|
+
if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
|
|
377
|
+
if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
|
|
378
|
+
return { min: zoomRange.min, max: 0.24 }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const fitView = (options = { useFiltered: true }) => {
|
|
89
382
|
const rect = canvas.getBoundingClientRect()
|
|
90
|
-
|
|
383
|
+
const width = Math.max(rect.width, 320)
|
|
384
|
+
const height = Math.max(rect.height, 320)
|
|
385
|
+
const nodes = options.useFiltered ? filteredNodes() : state.nodes
|
|
386
|
+
const bounds = graphBounds(nodes)
|
|
387
|
+
|
|
388
|
+
if (!bounds) {
|
|
389
|
+
state.transform = { x: width / 2, y: height / 2, scale: 1 }
|
|
390
|
+
markRenderDirty()
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const paddingByNodeCount = nodeCount => {
|
|
395
|
+
if (nodeCount <= 6) return 28
|
|
396
|
+
if (nodeCount <= 20) return 44
|
|
397
|
+
if (nodeCount <= 60) return 68
|
|
398
|
+
if (nodeCount <= 180) return 86
|
|
399
|
+
if (nodeCount <= 600) return 110
|
|
400
|
+
if (nodeCount <= 2000) return 140
|
|
401
|
+
return 180
|
|
402
|
+
}
|
|
403
|
+
const padding = paddingByNodeCount(nodes.length)
|
|
404
|
+
const scaleX = width / (bounds.width + padding * 2)
|
|
405
|
+
const scaleY = height / (bounds.height + padding * 2)
|
|
406
|
+
const fitScale = Math.min(scaleX, scaleY)
|
|
407
|
+
const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
|
|
408
|
+
const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
|
|
409
|
+
const scale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
|
|
410
|
+
const centerX = (bounds.minX + bounds.maxX) / 2
|
|
411
|
+
const centerY = (bounds.minY + bounds.maxY) / 2
|
|
412
|
+
|
|
413
|
+
state.transform = {
|
|
414
|
+
x: width / 2 - centerX * scale,
|
|
415
|
+
y: height / 2 - centerY * scale,
|
|
416
|
+
scale
|
|
417
|
+
}
|
|
418
|
+
markRenderDirty()
|
|
91
419
|
}
|
|
92
420
|
|
|
421
|
+
const resetView = () => fitView({ useFiltered: false })
|
|
422
|
+
|
|
93
423
|
const createLayout = graph => {
|
|
94
424
|
const nodes = graph.nodes.map(node => ({
|
|
95
425
|
...node,
|
|
96
426
|
x: Number.isFinite(node.x) ? node.x : 0,
|
|
97
|
-
y: Number.isFinite(node.y) ? node.y : 0
|
|
427
|
+
y: Number.isFinite(node.y) ? node.y : 0,
|
|
428
|
+
vx: Number.isFinite(node.vx) ? node.vx : 0,
|
|
429
|
+
vy: Number.isFinite(node.vy) ? node.vy : 0
|
|
98
430
|
}))
|
|
99
431
|
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
100
432
|
const edges = graph.edges
|
|
@@ -111,29 +443,94 @@ const encodeEntityTag = (value) => {
|
|
|
111
443
|
binary += String.fromCharCode(utf8[index])
|
|
112
444
|
}
|
|
113
445
|
|
|
114
|
-
return btoa(binary).
|
|
446
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
|
|
115
447
|
}
|
|
116
448
|
|
|
117
449
|
const graphSignature = graph => JSON.stringify({
|
|
118
|
-
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.
|
|
450
|
+
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
|
|
119
451
|
edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
|
|
120
452
|
})
|
|
121
453
|
|
|
454
|
+
const resetContentFilter = () => {
|
|
455
|
+
if (state.contentFilter.timer) {
|
|
456
|
+
clearTimeout(state.contentFilter.timer)
|
|
457
|
+
}
|
|
458
|
+
state.contentFilter = {
|
|
459
|
+
query: '',
|
|
460
|
+
ids: null,
|
|
461
|
+
token: state.contentFilter.token + 1,
|
|
462
|
+
timer: null
|
|
463
|
+
}
|
|
464
|
+
recomputeVisibility()
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const syncContentFilter = async (query, token) => {
|
|
468
|
+
const response = await fetch(
|
|
469
|
+
'/api/graph-filter?q=' +
|
|
470
|
+
encodeURIComponent(query) +
|
|
471
|
+
'&limit=' +
|
|
472
|
+
encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
|
|
473
|
+
agentQuery('&')
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
if (!response.ok || token !== state.contentFilter.token) {
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const payload = await response.json()
|
|
481
|
+
const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
|
|
482
|
+
if (token !== state.contentFilter.token) {
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
state.contentFilter.query = query
|
|
487
|
+
state.contentFilter.ids = new Set(nodeIds)
|
|
488
|
+
recomputeVisibility()
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const scheduleContentFilterSync = () => {
|
|
492
|
+
const query = normalizeQuery(state.query)
|
|
493
|
+
if (!query) {
|
|
494
|
+
resetContentFilter()
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (state.contentFilter.timer) {
|
|
499
|
+
clearTimeout(state.contentFilter.timer)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const token = state.contentFilter.token + 1
|
|
503
|
+
state.contentFilter = {
|
|
504
|
+
query: state.contentFilter.query,
|
|
505
|
+
ids: state.contentFilter.ids,
|
|
506
|
+
token,
|
|
507
|
+
timer: setTimeout(() => {
|
|
508
|
+
syncContentFilter(query, token).catch(() => {})
|
|
509
|
+
}, 180)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
122
513
|
const tick = delta => {
|
|
123
|
-
const nodes =
|
|
124
|
-
const
|
|
125
|
-
|
|
514
|
+
const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
|
|
515
|
+
const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
|
|
516
|
+
if (nodes.length > 1200) {
|
|
517
|
+
return
|
|
518
|
+
}
|
|
126
519
|
const strength = Math.min(delta / 16, 2)
|
|
127
520
|
|
|
128
521
|
edges.forEach(edge => {
|
|
129
522
|
const source = edge.sourceNode
|
|
130
523
|
const target = edge.targetNode
|
|
524
|
+
source.vx = Number.isFinite(source.vx) ? source.vx : 0
|
|
525
|
+
source.vy = Number.isFinite(source.vy) ? source.vy : 0
|
|
526
|
+
target.vx = Number.isFinite(target.vx) ? target.vx : 0
|
|
527
|
+
target.vy = Number.isFinite(target.vy) ? target.vy : 0
|
|
131
528
|
const dx = target.x - source.x
|
|
132
529
|
const dy = target.y - source.y
|
|
133
530
|
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
134
531
|
const force = (distance - 150) * 0.002 * strength
|
|
135
|
-
const fx = dx * force
|
|
136
|
-
const fy = dy * force
|
|
532
|
+
const fx = (dx / distance) * force
|
|
533
|
+
const fy = (dy / distance) * force
|
|
137
534
|
source.vx += fx
|
|
138
535
|
source.vy += fy
|
|
139
536
|
target.vx -= fx
|
|
@@ -144,6 +541,10 @@ const tick = delta => {
|
|
|
144
541
|
for (let j = i + 1; j < nodes.length; j += 1) {
|
|
145
542
|
const a = nodes[i]
|
|
146
543
|
const b = nodes[j]
|
|
544
|
+
a.vx = Number.isFinite(a.vx) ? a.vx : 0
|
|
545
|
+
a.vy = Number.isFinite(a.vy) ? a.vy : 0
|
|
546
|
+
b.vx = Number.isFinite(b.vx) ? b.vx : 0
|
|
547
|
+
b.vy = Number.isFinite(b.vy) ? b.vy : 0
|
|
147
548
|
const dx = b.x - a.x
|
|
148
549
|
const dy = b.y - a.y
|
|
149
550
|
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
@@ -158,6 +559,10 @@ const tick = delta => {
|
|
|
158
559
|
}
|
|
159
560
|
|
|
160
561
|
nodes.forEach(node => {
|
|
562
|
+
node.vx = Number.isFinite(node.vx) ? node.vx : 0
|
|
563
|
+
node.vy = Number.isFinite(node.vy) ? node.vy : 0
|
|
564
|
+
node.x = Number.isFinite(node.x) ? node.x : 0
|
|
565
|
+
node.y = Number.isFinite(node.y) ? node.y : 0
|
|
161
566
|
if (state.pointer.dragNode === node) {
|
|
162
567
|
node.vx = 0
|
|
163
568
|
node.vy = 0
|
|
@@ -181,7 +586,12 @@ const worldPoint = event => {
|
|
|
181
586
|
}
|
|
182
587
|
|
|
183
588
|
const hitNode = point => {
|
|
184
|
-
|
|
589
|
+
computeRenderVisibility()
|
|
590
|
+
if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
|
|
591
|
+
return null
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const nodes = state.renderNodes
|
|
185
595
|
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
186
596
|
const node = nodes[index]
|
|
187
597
|
const radius = nodeRadius(node)
|
|
@@ -190,17 +600,159 @@ const hitNode = point => {
|
|
|
190
600
|
return null
|
|
191
601
|
}
|
|
192
602
|
|
|
193
|
-
const
|
|
194
|
-
const degree = state.
|
|
603
|
+
const baseNodeRadius = node => {
|
|
604
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
195
605
|
return 9 + Math.min(degree, 8) * 1.6
|
|
196
606
|
}
|
|
197
607
|
|
|
608
|
+
const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
|
|
609
|
+
|
|
610
|
+
const worldViewportBounds = () => {
|
|
611
|
+
const rect = canvas.getBoundingClientRect()
|
|
612
|
+
const width = Math.max(rect.width, 320)
|
|
613
|
+
const height = Math.max(rect.height, 320)
|
|
614
|
+
const padding = viewportPaddingPx
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
minX: (-state.transform.x - padding) / state.transform.scale,
|
|
618
|
+
maxX: (width - state.transform.x + padding) / state.transform.scale,
|
|
619
|
+
minY: (-state.transform.y - padding) / state.transform.scale,
|
|
620
|
+
maxY: (height - state.transform.y + padding) / state.transform.scale
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const isNodeInViewport = (node, viewport) =>
|
|
625
|
+
node.x >= viewport.minX &&
|
|
626
|
+
node.x <= viewport.maxX &&
|
|
627
|
+
node.y >= viewport.minY &&
|
|
628
|
+
node.y <= viewport.maxY
|
|
629
|
+
|
|
630
|
+
const viewportNodeStride = () => {
|
|
631
|
+
if (state.nodes.length <= largeGraphNodeThreshold) {
|
|
632
|
+
return 1
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (state.transform.scale >= 0.95) {
|
|
636
|
+
return 1
|
|
637
|
+
}
|
|
638
|
+
if (state.transform.scale >= 0.7) {
|
|
639
|
+
return 2
|
|
640
|
+
}
|
|
641
|
+
if (state.transform.scale >= 0.48) {
|
|
642
|
+
return 3
|
|
643
|
+
}
|
|
644
|
+
if (state.transform.scale >= 0.28) {
|
|
645
|
+
return 5
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
return 8
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const computeRenderVisibility = () => {
|
|
652
|
+
const viewport = worldViewportBounds()
|
|
653
|
+
const viewportKey =
|
|
654
|
+
Math.round(viewport.minX * 10) + ':' +
|
|
655
|
+
Math.round(viewport.maxX * 10) + ':' +
|
|
656
|
+
Math.round(viewport.minY * 10) + ':' +
|
|
657
|
+
Math.round(viewport.maxY * 10) + ':' +
|
|
658
|
+
Math.round(state.transform.scale * 1000)
|
|
659
|
+
|
|
660
|
+
if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
state.lastViewportKey = viewportKey
|
|
664
|
+
state.renderVisibilityDirty = false
|
|
665
|
+
|
|
666
|
+
if (state.visibleNodes.length <= 2000) {
|
|
667
|
+
state.renderNodes = state.visibleNodes
|
|
668
|
+
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
669
|
+
state.renderEdges = collectVisibleEdgesForNodes(ids)
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
674
|
+
const stride = viewportNodeStride()
|
|
675
|
+
const picked = []
|
|
676
|
+
|
|
677
|
+
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
678
|
+
const node = viewportNodes[index]
|
|
679
|
+
|
|
680
|
+
const isPriority =
|
|
681
|
+
node.id === state.selected?.id ||
|
|
682
|
+
node.id === state.hovered?.id ||
|
|
683
|
+
node.id === state.pointer.dragNode?.id
|
|
684
|
+
if (isPriority || index % stride === 0) {
|
|
685
|
+
picked.push(node)
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const nodes = picked.length > renderNodeBudget
|
|
690
|
+
? picked.slice(0, renderNodeBudget)
|
|
691
|
+
: picked
|
|
692
|
+
if (nodes.length === 0 && state.visibleNodes.length > 0) {
|
|
693
|
+
const fallbackNodes = fallbackViewportNodes()
|
|
694
|
+
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
695
|
+
state.renderNodes = fallbackNodes
|
|
696
|
+
state.renderEdges = collectVisibleEdgesForNodes(fallbackIds)
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const nodeIds = new Set(nodes.map((node) => node.id))
|
|
701
|
+
const edges = collectVisibleEdgesForNodes(nodeIds)
|
|
702
|
+
|
|
703
|
+
state.renderNodes = nodes
|
|
704
|
+
state.renderEdges = edges
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const isNodeVisibleOnScreen = (node, width, height) => {
|
|
708
|
+
const radius = nodeRadius(node) * state.transform.scale
|
|
709
|
+
const screenX = node.x * state.transform.scale + state.transform.x
|
|
710
|
+
const screenY = node.y * state.transform.scale + state.transform.y
|
|
711
|
+
|
|
712
|
+
return (
|
|
713
|
+
screenX + radius >= 0 &&
|
|
714
|
+
screenX - radius <= width &&
|
|
715
|
+
screenY + radius >= 0 &&
|
|
716
|
+
screenY - radius <= height
|
|
717
|
+
)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const hasValidTransform = () =>
|
|
721
|
+
isFiniteNumber(state.transform.x) &&
|
|
722
|
+
isFiniteNumber(state.transform.y) &&
|
|
723
|
+
isFiniteNumber(state.transform.scale) &&
|
|
724
|
+
Math.abs(state.transform.x) <= transformCoordinateLimit &&
|
|
725
|
+
Math.abs(state.transform.y) <= transformCoordinateLimit &&
|
|
726
|
+
state.transform.scale > 0
|
|
727
|
+
|
|
728
|
+
const sanitizeNodePosition = node => {
|
|
729
|
+
if (!isReasonableCoordinate(node.x)) node.x = 0
|
|
730
|
+
if (!isReasonableCoordinate(node.y)) node.y = 0
|
|
731
|
+
if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
|
|
732
|
+
if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const sanitizeGraphState = () => {
|
|
736
|
+
state.nodes.forEach(sanitizeNodePosition)
|
|
737
|
+
state.visibleNodes.forEach(sanitizeNodePosition)
|
|
738
|
+
state.renderNodes.forEach(sanitizeNodePosition)
|
|
739
|
+
}
|
|
740
|
+
|
|
198
741
|
const render = now => {
|
|
199
742
|
const delta = now - state.last
|
|
200
743
|
state.last = now
|
|
744
|
+
const minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 48 : 16
|
|
745
|
+
if (delta < minFrameIntervalMs) {
|
|
746
|
+
requestAnimationFrame(render)
|
|
747
|
+
return
|
|
748
|
+
}
|
|
201
749
|
const rect = canvas.getBoundingClientRect()
|
|
202
750
|
const width = Math.max(rect.width, 320)
|
|
203
751
|
const height = Math.max(rect.height, 320)
|
|
752
|
+
sanitizeGraphState()
|
|
753
|
+
if (!hasValidTransform()) {
|
|
754
|
+
resetView()
|
|
755
|
+
}
|
|
204
756
|
ctx.clearRect(0, 0, width, height)
|
|
205
757
|
if (state.nodes.length === 0) {
|
|
206
758
|
ctx.fillStyle = '#99a5b5'
|
|
@@ -214,7 +766,25 @@ const render = now => {
|
|
|
214
766
|
ctx.translate(state.transform.x, state.transform.y)
|
|
215
767
|
ctx.scale(state.transform.scale, state.transform.scale)
|
|
216
768
|
|
|
217
|
-
|
|
769
|
+
computeRenderVisibility()
|
|
770
|
+
tick(delta)
|
|
771
|
+
const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
|
|
772
|
+
if (!hasVisibleNodeOnScreen && state.renderNodes.length > 0) {
|
|
773
|
+
state.offscreenFrameCount += 1
|
|
774
|
+
if (state.offscreenFrameCount >= 6 && !state.recoveringViewport) {
|
|
775
|
+
state.recoveringViewport = true
|
|
776
|
+
fitView({ useFiltered: true })
|
|
777
|
+
state.offscreenFrameCount = 0
|
|
778
|
+
requestAnimationFrame(() => {
|
|
779
|
+
state.recoveringViewport = false
|
|
780
|
+
})
|
|
781
|
+
}
|
|
782
|
+
} else {
|
|
783
|
+
state.offscreenFrameCount = 0
|
|
784
|
+
}
|
|
785
|
+
const drawEdges = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
|
|
786
|
+
if (drawEdges) {
|
|
787
|
+
state.renderEdges.forEach(edge => {
|
|
218
788
|
const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
219
789
|
ctx.beginPath()
|
|
220
790
|
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
|
|
@@ -222,9 +792,10 @@ const render = now => {
|
|
|
222
792
|
ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
|
|
223
793
|
ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
|
|
224
794
|
ctx.stroke()
|
|
225
|
-
|
|
795
|
+
})
|
|
796
|
+
}
|
|
226
797
|
|
|
227
|
-
|
|
798
|
+
state.renderNodes.forEach(node => {
|
|
228
799
|
const radius = nodeRadius(node)
|
|
229
800
|
const isSelected = state.selected?.id === node.id
|
|
230
801
|
const isHovered = state.hovered?.id === node.id
|
|
@@ -240,7 +811,11 @@ const render = now => {
|
|
|
240
811
|
ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
|
|
241
812
|
ctx.stroke()
|
|
242
813
|
|
|
243
|
-
|
|
814
|
+
const shouldDrawLabels =
|
|
815
|
+
isSelected ||
|
|
816
|
+
isHovered ||
|
|
817
|
+
(state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
|
|
818
|
+
if (shouldDrawLabels) {
|
|
244
819
|
ctx.fillStyle = graphTheme.label
|
|
245
820
|
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
246
821
|
ctx.textAlign = 'center'
|
|
@@ -250,6 +825,12 @@ const render = now => {
|
|
|
250
825
|
})
|
|
251
826
|
|
|
252
827
|
ctx.restore()
|
|
828
|
+
if (state.renderNodes.length === 0) {
|
|
829
|
+
ctx.fillStyle = '#99a5b5'
|
|
830
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
831
|
+
ctx.textAlign = 'center'
|
|
832
|
+
ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
|
|
833
|
+
}
|
|
253
834
|
requestAnimationFrame(render)
|
|
254
835
|
}
|
|
255
836
|
|
|
@@ -257,22 +838,7 @@ const list = items => items.length
|
|
|
257
838
|
? 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
839
|
: '<li><small>No links found.</small></li>'
|
|
259
840
|
|
|
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
|
-
}
|
|
841
|
+
const linkedNodes = node => {
|
|
276
842
|
const nodeById = new Map(state.nodes.map(item => [item.id, item]))
|
|
277
843
|
const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
|
|
278
844
|
...linkedNode,
|
|
@@ -288,57 +854,173 @@ const selectNode = node => {
|
|
|
288
854
|
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
289
855
|
.filter(Boolean)
|
|
290
856
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
857
|
+
return { outgoing, incoming }
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const fetchNodeDetails = async node => {
|
|
861
|
+
const cached = state.nodeDetails.get(node.id)
|
|
862
|
+
if (cached) {
|
|
863
|
+
return cached
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
|
|
867
|
+
if (!response.ok) {
|
|
868
|
+
throw new Error('Failed to load graph node details')
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const payload = await response.json()
|
|
872
|
+
const detail = payload?.node
|
|
873
|
+
if (!detail || !detail.id) {
|
|
874
|
+
throw new Error('Invalid graph node payload')
|
|
875
|
+
}
|
|
876
|
+
state.nodeDetails.set(detail.id, detail)
|
|
877
|
+
return detail
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const openContentDialog = async node => {
|
|
881
|
+
if (!node) return
|
|
882
|
+
const { outgoing, incoming } = linkedNodes(node)
|
|
883
|
+
elements.contentTitle.textContent = node.title
|
|
884
|
+
elements.contentPath.textContent = node.path
|
|
885
|
+
elements.contentTags.innerHTML = node.tags.length
|
|
294
886
|
? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
295
887
|
: '<span>No tags</span>'
|
|
296
|
-
elements.
|
|
297
|
-
elements.
|
|
298
|
-
elements.
|
|
299
|
-
elements.
|
|
888
|
+
elements.contentOutgoing.innerHTML = list(outgoing)
|
|
889
|
+
elements.contentIncoming.innerHTML = list(incoming)
|
|
890
|
+
elements.contentBody.textContent = 'Loading note content...'
|
|
891
|
+
if (!elements.contentDialog.open) {
|
|
892
|
+
elements.contentDialog.showModal()
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
try {
|
|
896
|
+
const detailedNode = await fetchNodeDetails(node)
|
|
897
|
+
if (state.selected?.id !== node.id) {
|
|
898
|
+
return
|
|
899
|
+
}
|
|
900
|
+
elements.contentBody.textContent = detailedNode.content
|
|
901
|
+
} catch {
|
|
902
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const selectNode = (node, options = { openContent: false }) => {
|
|
907
|
+
state.selected = node
|
|
908
|
+
if (node && options.openContent) {
|
|
909
|
+
openContentDialog(node).catch(() => {
|
|
910
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
911
|
+
})
|
|
912
|
+
}
|
|
300
913
|
}
|
|
301
914
|
|
|
302
915
|
const selectNodeById = id => {
|
|
303
916
|
const node = state.nodes.find(item => item.id === id)
|
|
304
|
-
if (node) selectNode(node)
|
|
917
|
+
if (node) selectNode(node, { openContent: true })
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const zoomAtPoint = (screenX, screenY, factor) => {
|
|
921
|
+
const nextScale = clampScale(state.transform.scale * factor)
|
|
922
|
+
if (nextScale === state.transform.scale) return
|
|
923
|
+
const worldX = (screenX - state.transform.x) / state.transform.scale
|
|
924
|
+
const worldY = (screenY - state.transform.y) / state.transform.scale
|
|
925
|
+
state.transform.scale = nextScale
|
|
926
|
+
state.transform.x = screenX - worldX * nextScale
|
|
927
|
+
state.transform.y = screenY - worldY * nextScale
|
|
928
|
+
markRenderDirty()
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const wheelZoomFactor = event => {
|
|
932
|
+
const isModifierZoom = event.metaKey || event.ctrlKey
|
|
933
|
+
const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
|
|
934
|
+
const absoluteDelta = Math.min(Math.abs(event.deltaY * deltaModeFactor), 1600)
|
|
935
|
+
|
|
936
|
+
if (absoluteDelta <= 0.0001) {
|
|
937
|
+
return 1
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const baseStep = Math.max(0.06, Math.min(0.45, absoluteDelta / 480))
|
|
941
|
+
const adjustedStep = baseStep * (isModifierZoom ? 1.4 : 1)
|
|
942
|
+
|
|
943
|
+
return event.deltaY < 0 ? 1 + adjustedStep : 1 / (1 + adjustedStep)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const isScreenPointInsideCanvas = (screenX, screenY) => {
|
|
947
|
+
const rect = canvas.getBoundingClientRect()
|
|
948
|
+
|
|
949
|
+
return screenX >= rect.left && screenX <= rect.right && screenY >= rect.top && screenY <= rect.bottom
|
|
305
950
|
}
|
|
306
951
|
|
|
307
|
-
const
|
|
308
|
-
|
|
952
|
+
const handleWheelZoom = event => {
|
|
953
|
+
if (elements.contentDialog?.open) {
|
|
954
|
+
return
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (!isScreenPointInsideCanvas(event.clientX, event.clientY)) {
|
|
958
|
+
return
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
event.preventDefault()
|
|
962
|
+
const rect = canvas.getBoundingClientRect()
|
|
963
|
+
const cursorX = event.clientX - rect.left
|
|
964
|
+
const cursorY = event.clientY - rect.top
|
|
965
|
+
const factor = wheelZoomFactor(event)
|
|
966
|
+
|
|
967
|
+
if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
|
|
968
|
+
return
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
zoomAtPoint(cursorX, cursorY, factor)
|
|
309
972
|
}
|
|
310
973
|
|
|
311
974
|
const bindEvents = () => {
|
|
312
975
|
window.addEventListener('resize', resize)
|
|
313
976
|
elements.search.addEventListener('input', event => {
|
|
314
977
|
state.query = event.target.value
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
: state.nodes.length + ' notes · ' + state.edges.length + ' links'
|
|
978
|
+
recomputeVisibility()
|
|
979
|
+
scheduleContentFilterSync()
|
|
318
980
|
})
|
|
319
981
|
elements.agent.addEventListener('change', event => {
|
|
320
982
|
state.agentId = event.target.value
|
|
321
983
|
state.selected = null
|
|
984
|
+
state.nodeDetails = new Map()
|
|
985
|
+
resetContentFilter()
|
|
986
|
+
recomputeVisibility()
|
|
987
|
+
scheduleContentFilterSync()
|
|
322
988
|
loadGraph({ reset: true }).catch(error => {
|
|
323
|
-
elements.stats.textContent = 'Failed to load agent graph'
|
|
324
989
|
console.error(error)
|
|
325
990
|
})
|
|
326
991
|
})
|
|
327
|
-
elements.zoomIn.addEventListener('click', () =>
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
992
|
+
elements.zoomIn.addEventListener('click', () => {
|
|
993
|
+
const rect = canvas.getBoundingClientRect()
|
|
994
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.3)
|
|
995
|
+
})
|
|
996
|
+
elements.zoomOut.addEventListener('click', () => {
|
|
997
|
+
const rect = canvas.getBoundingClientRect()
|
|
998
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.77)
|
|
999
|
+
})
|
|
1000
|
+
if (elements.fit) {
|
|
1001
|
+
elements.fit.addEventListener('click', () => {
|
|
1002
|
+
fitView({ useFiltered: true })
|
|
336
1003
|
})
|
|
1004
|
+
}
|
|
1005
|
+
elements.reset.addEventListener('click', () => {
|
|
1006
|
+
resetView()
|
|
1007
|
+
})
|
|
1008
|
+
elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
|
|
1009
|
+
elements.contentDialog.addEventListener('click', event => {
|
|
1010
|
+
const target = event.target
|
|
1011
|
+
if (target instanceof HTMLElement && target.dataset.nodeId) {
|
|
1012
|
+
selectNodeById(target.dataset.nodeId)
|
|
1013
|
+
return
|
|
1014
|
+
}
|
|
1015
|
+
if (event.target === elements.contentDialog) elements.contentDialog.close()
|
|
1016
|
+
})
|
|
1017
|
+
window.addEventListener('wheel', handleWheelZoom, { passive: false })
|
|
1018
|
+
canvas.addEventListener('dblclick', event => {
|
|
1019
|
+
const rect = canvas.getBoundingClientRect()
|
|
1020
|
+
const cursorX = event.clientX - rect.left
|
|
1021
|
+
const cursorY = event.clientY - rect.top
|
|
1022
|
+
zoomAtPoint(cursorX, cursorY, 1.25)
|
|
337
1023
|
})
|
|
338
|
-
canvas.addEventListener('wheel', event => {
|
|
339
|
-
event.preventDefault()
|
|
340
|
-
zoom(event.deltaY < 0 ? 1.08 : 0.92)
|
|
341
|
-
}, { passive: false })
|
|
342
1024
|
canvas.addEventListener('pointerdown', event => {
|
|
343
1025
|
const point = worldPoint(event)
|
|
344
1026
|
const node = hitNode(point)
|
|
@@ -346,12 +1028,14 @@ const bindEvents = () => {
|
|
|
346
1028
|
if (node) {
|
|
347
1029
|
node.x = point.x
|
|
348
1030
|
node.y = point.y
|
|
1031
|
+
markRenderDirty()
|
|
349
1032
|
}
|
|
350
1033
|
canvas.setPointerCapture(event.pointerId)
|
|
351
1034
|
})
|
|
352
1035
|
canvas.addEventListener('pointermove', event => {
|
|
353
1036
|
const point = worldPoint(event)
|
|
354
1037
|
state.hovered = hitNode(point)
|
|
1038
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
355
1039
|
if (!state.pointer.down) return
|
|
356
1040
|
const dx = event.clientX - state.pointer.x
|
|
357
1041
|
const dy = event.clientY - state.pointer.y
|
|
@@ -361,17 +1045,48 @@ const bindEvents = () => {
|
|
|
361
1045
|
if (state.pointer.dragNode) {
|
|
362
1046
|
state.pointer.dragNode.x = point.x
|
|
363
1047
|
state.pointer.dragNode.y = point.y
|
|
1048
|
+
markRenderDirty()
|
|
364
1049
|
return
|
|
365
1050
|
}
|
|
366
1051
|
state.transform.x += dx
|
|
367
1052
|
state.transform.y += dy
|
|
1053
|
+
markRenderDirty()
|
|
368
1054
|
})
|
|
369
1055
|
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)
|
|
1056
|
+
if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
|
|
1057
|
+
if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
|
|
372
1058
|
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
373
1059
|
canvas.releasePointerCapture(event.pointerId)
|
|
374
1060
|
})
|
|
1061
|
+
canvas.addEventListener('pointercancel', () => {
|
|
1062
|
+
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
1063
|
+
})
|
|
1064
|
+
canvas.addEventListener('pointerenter', event => {
|
|
1065
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
1066
|
+
})
|
|
1067
|
+
canvas.addEventListener('pointerleave', event => {
|
|
1068
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
|
|
1069
|
+
})
|
|
1070
|
+
window.addEventListener('keydown', event => {
|
|
1071
|
+
if (event.key === '+' || event.key === '=') {
|
|
1072
|
+
event.preventDefault()
|
|
1073
|
+
const rect = canvas.getBoundingClientRect()
|
|
1074
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.25)
|
|
1075
|
+
return
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
if (event.key === '-' || event.key === '_') {
|
|
1079
|
+
event.preventDefault()
|
|
1080
|
+
const rect = canvas.getBoundingClientRect()
|
|
1081
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.8)
|
|
1082
|
+
return
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (event.key === '0') {
|
|
1086
|
+
event.preventDefault()
|
|
1087
|
+
resetView()
|
|
1088
|
+
}
|
|
1089
|
+
})
|
|
375
1090
|
}
|
|
376
1091
|
|
|
377
1092
|
const loadAgents = async () => {
|
|
@@ -386,9 +1101,10 @@ const loadAgents = async () => {
|
|
|
386
1101
|
|
|
387
1102
|
state.agentId = selected
|
|
388
1103
|
if (signature !== state.agentsSignature) {
|
|
1104
|
+
const formatAgentLabel = (agent) => agent.id
|
|
389
1105
|
elements.agent.innerHTML = agents.length
|
|
390
|
-
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent
|
|
391
|
-
: '<option value="shared">shared
|
|
1106
|
+
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
|
|
1107
|
+
: '<option value="shared">shared</option>'
|
|
392
1108
|
state.agentsSignature = signature
|
|
393
1109
|
}
|
|
394
1110
|
elements.agent.value = selected
|
|
@@ -417,14 +1133,29 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
417
1133
|
state.graph = graph
|
|
418
1134
|
state.nodes = layout.nodes
|
|
419
1135
|
state.edges = layout.edges
|
|
1136
|
+
state.nodeDegrees = state.edges.reduce((degrees, edge) => {
|
|
1137
|
+
degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
|
|
1138
|
+
if (edge.target) {
|
|
1139
|
+
degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
|
|
1140
|
+
}
|
|
1141
|
+
return degrees
|
|
1142
|
+
}, new Map())
|
|
1143
|
+
state.nodeDetails = new Map()
|
|
1144
|
+
resetContentFilter()
|
|
1145
|
+
recomputeVisibility()
|
|
1146
|
+
scheduleContentFilterSync()
|
|
420
1147
|
const tags = new Set(graph.nodes.flatMap(node => node.tags))
|
|
421
|
-
|
|
1148
|
+
setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
|
|
422
1149
|
elements.nodeCount.textContent = graph.nodes.length
|
|
423
1150
|
elements.edgeCount.textContent = graph.edges.length
|
|
424
1151
|
elements.tagCount.textContent = tags.size
|
|
425
1152
|
resize()
|
|
426
1153
|
if (options.reset) resetView()
|
|
427
|
-
|
|
1154
|
+
const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
|
|
1155
|
+
selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
|
|
1156
|
+
if (!selectedNode && elements.contentDialog.open) {
|
|
1157
|
+
elements.contentDialog.close()
|
|
1158
|
+
}
|
|
428
1159
|
}
|
|
429
1160
|
|
|
430
1161
|
bindEvents()
|
|
@@ -441,10 +1172,7 @@ const refreshGraphLoop = () => {
|
|
|
441
1172
|
return
|
|
442
1173
|
}
|
|
443
1174
|
|
|
444
|
-
loadGraph().catch(
|
|
445
|
-
elements.stats.textContent = 'Failed to refresh graph'
|
|
446
|
-
console.error(error)
|
|
447
|
-
})
|
|
1175
|
+
loadGraph().catch(handleGraphRefreshError)
|
|
448
1176
|
|
|
449
1177
|
tickCounter += 1
|
|
450
1178
|
if (tickCounter % 3 === 0) {
|
|
@@ -461,7 +1189,6 @@ loadAgents()
|
|
|
461
1189
|
setInterval(refreshGraphLoop, pollIntervalMs)
|
|
462
1190
|
})
|
|
463
1191
|
.catch(error => {
|
|
464
|
-
elements.stats.textContent = 'Failed to load graph'
|
|
465
1192
|
console.error(error)
|
|
466
1193
|
})
|
|
467
1194
|
|
|
@@ -470,9 +1197,6 @@ document.addEventListener('visibilitychange', () => {
|
|
|
470
1197
|
return
|
|
471
1198
|
}
|
|
472
1199
|
|
|
473
|
-
loadGraph({ reset: true }).catch(
|
|
474
|
-
elements.stats.textContent = 'Failed to refresh graph'
|
|
475
|
-
console.error(error)
|
|
476
|
-
})
|
|
1200
|
+
loadGraph({ reset: true }).catch(handleGraphRefreshError)
|
|
477
1201
|
})
|
|
478
1202
|
`;
|