@andespindola/brainlink 0.1.0-beta.40 → 0.1.0-beta.42
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/CHANGELOG.md +7 -0
- package/README.md +10 -0
- package/dist/application/frontend/client-js.js +667 -68
- package/dist/application/frontend/client-worker-js.js +66 -0
- package/dist/application/index-vault.js +137 -21
- package/dist/application/server/routes.js +36 -1
- package/dist/application/start-server.js +75 -4
- package/dist/cli/commands/write-commands.js +41 -12
- package/dist/infrastructure/file-index.js +30 -0
- package/dist/infrastructure/file-system-vault.js +15 -0
- package/dist/infrastructure/index-state.js +50 -0
- package/package.json +1 -1
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
export const createClientJs = () => `const canvas = document.getElementById('graph')
|
|
2
2
|
const ctx = canvas.getContext('2d')
|
|
3
3
|
const largeGraphNodeThreshold = 4000
|
|
4
|
+
const massiveGraphNodeThreshold = 20000
|
|
4
5
|
const largeGraphEdgeRenderLimit = 16000
|
|
5
|
-
const renderNodeBudget =
|
|
6
|
-
const
|
|
6
|
+
const renderNodeBudget = 900
|
|
7
|
+
const renderEdgeBudget = 2400
|
|
8
|
+
const clusterActivationNodeThreshold = 600
|
|
9
|
+
const clusterZoomThreshold = 0.18
|
|
10
|
+
const clusterCellPixelSize = 64
|
|
11
|
+
const minNodePixelRadius = 2.3
|
|
7
12
|
const viewportPaddingPx = 280
|
|
8
13
|
const worldCoordinateLimit = 5_000_000
|
|
9
14
|
const transformCoordinateLimit = 20_000_000
|
|
15
|
+
const hoverHitTestIntervalMs = 64
|
|
16
|
+
const overviewClusterMaxCount = 1400
|
|
10
17
|
const state = {
|
|
11
18
|
graph: { nodes: [], edges: [] },
|
|
12
19
|
nodes: [],
|
|
@@ -15,6 +22,7 @@ const state = {
|
|
|
15
22
|
visibleEdges: [],
|
|
16
23
|
renderNodes: [],
|
|
17
24
|
renderEdges: [],
|
|
25
|
+
renderClusters: [],
|
|
18
26
|
nodeDegrees: new Map(),
|
|
19
27
|
selected: null,
|
|
20
28
|
hovered: null,
|
|
@@ -28,9 +36,18 @@ const state = {
|
|
|
28
36
|
cursor: { x: 0, y: 0, inCanvas: false },
|
|
29
37
|
graphSignature: '',
|
|
30
38
|
graphStatus: '',
|
|
39
|
+
graphTotals: { nodes: 0, edges: 0 },
|
|
31
40
|
last: performance.now(),
|
|
32
41
|
offscreenFrameCount: 0,
|
|
33
|
-
recoveringViewport: false
|
|
42
|
+
recoveringViewport: false,
|
|
43
|
+
renderVisibilityDirty: true,
|
|
44
|
+
lastViewportKey: '',
|
|
45
|
+
visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
|
|
46
|
+
visibleEdgeByNode: new Map(),
|
|
47
|
+
overviewClusters: [],
|
|
48
|
+
filterWorker: null,
|
|
49
|
+
filterReady: false,
|
|
50
|
+
lastHoverHitAt: 0
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
const byId = id => document.getElementById(id)
|
|
@@ -61,10 +78,20 @@ const elements = {
|
|
|
61
78
|
}
|
|
62
79
|
|
|
63
80
|
const zoomRange = {
|
|
64
|
-
min: 0.
|
|
81
|
+
min: 0.0002,
|
|
65
82
|
max: 4.5
|
|
66
83
|
}
|
|
67
84
|
|
|
85
|
+
const initialAgentFromUrl = (() => {
|
|
86
|
+
try {
|
|
87
|
+
const raw = new URL(window.location.href).searchParams.get('agent')
|
|
88
|
+
const value = raw?.trim() ?? ''
|
|
89
|
+
return value.length > 0 ? value : ''
|
|
90
|
+
} catch {
|
|
91
|
+
return ''
|
|
92
|
+
}
|
|
93
|
+
})()
|
|
94
|
+
|
|
68
95
|
const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
|
|
69
96
|
|
|
70
97
|
const setGraphStatus = text => {
|
|
@@ -88,6 +115,67 @@ const graphTheme = {
|
|
|
88
115
|
label: '#edf2f7'
|
|
89
116
|
}
|
|
90
117
|
|
|
118
|
+
const initFilterWorker = () => {
|
|
119
|
+
if (typeof Worker === 'undefined') {
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const worker = new Worker('/app-worker.js')
|
|
124
|
+
worker.onmessage = event => {
|
|
125
|
+
const payload = event.data
|
|
126
|
+
if (!payload || typeof payload !== 'object') return
|
|
127
|
+
|
|
128
|
+
if (payload.type === 'ready') {
|
|
129
|
+
state.filterReady = true
|
|
130
|
+
if (state.nodes.length > 0) {
|
|
131
|
+
worker.postMessage({
|
|
132
|
+
type: 'load-nodes',
|
|
133
|
+
nodes: state.nodes.map(node => ({
|
|
134
|
+
id: node.id,
|
|
135
|
+
title: node.title,
|
|
136
|
+
path: node.path || '',
|
|
137
|
+
tags: Array.isArray(node.tags) ? node.tags : []
|
|
138
|
+
}))
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (payload.type === 'filter-result') {
|
|
145
|
+
const token = payload.token
|
|
146
|
+
if (token !== state.contentFilter.token) {
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ids = Array.isArray(payload.ids) ? payload.ids.filter(id => typeof id === 'string') : []
|
|
151
|
+
state.contentFilter.query = normalizeQuery(state.query)
|
|
152
|
+
state.contentFilter.ids = new Set(ids)
|
|
153
|
+
recomputeVisibility()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
state.filterWorker = worker
|
|
157
|
+
} catch {
|
|
158
|
+
state.filterWorker = null
|
|
159
|
+
state.filterReady = false
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const pushNodesToFilterWorker = () => {
|
|
164
|
+
if (!state.filterWorker || !state.filterReady) {
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
state.filterWorker.postMessage({
|
|
169
|
+
type: 'load-nodes',
|
|
170
|
+
nodes: state.nodes.map(node => ({
|
|
171
|
+
id: node.id,
|
|
172
|
+
title: node.title,
|
|
173
|
+
path: node.path || '',
|
|
174
|
+
tags: Array.isArray(node.tags) ? node.tags : []
|
|
175
|
+
}))
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
91
179
|
const resize = () => {
|
|
92
180
|
const rect = canvas.getBoundingClientRect()
|
|
93
181
|
const width = Math.max(rect.width, 320)
|
|
@@ -96,6 +184,7 @@ const resize = () => {
|
|
|
96
184
|
canvas.width = Math.floor(width * ratio)
|
|
97
185
|
canvas.height = Math.floor(height * ratio)
|
|
98
186
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
187
|
+
markRenderDirty()
|
|
99
188
|
}
|
|
100
189
|
|
|
101
190
|
const normalizeQuery = value => value.trim().toLowerCase()
|
|
@@ -105,7 +194,7 @@ const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map
|
|
|
105
194
|
const localFilteredNodes = query =>
|
|
106
195
|
state.nodes.filter(node =>
|
|
107
196
|
node.title.toLowerCase().includes(query) ||
|
|
108
|
-
node.path.toLowerCase().includes(query) ||
|
|
197
|
+
(node.path || '').toLowerCase().includes(query) ||
|
|
109
198
|
node.tags.some(tag => tag.toLowerCase().includes(query))
|
|
110
199
|
)
|
|
111
200
|
|
|
@@ -168,13 +257,278 @@ const recomputeVisibility = () => {
|
|
|
168
257
|
|
|
169
258
|
state.visibleNodes = nodes
|
|
170
259
|
state.visibleEdges = limitedEdges
|
|
260
|
+
state.visibleNodeSpatial = createSpatialIndex(nodes)
|
|
261
|
+
state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
|
|
262
|
+
state.overviewClusters = nodes.length > massiveGraphNodeThreshold ? buildOverviewClusters(nodes) : []
|
|
263
|
+
markRenderDirty()
|
|
171
264
|
}
|
|
172
265
|
|
|
173
266
|
const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
|
|
267
|
+
const markRenderDirty = () => {
|
|
268
|
+
state.renderVisibilityDirty = true
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const createSpatialIndex = nodes => {
|
|
272
|
+
if (nodes.length === 0) {
|
|
273
|
+
return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const bounds = graphBounds(nodes)
|
|
277
|
+
if (!bounds) {
|
|
278
|
+
return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const targetNodesPerCell = 18
|
|
282
|
+
const approximateCellArea = Math.max((bounds.width * bounds.height) / Math.max(nodes.length / targetNodesPerCell, 1), 1)
|
|
283
|
+
const cellSize = Math.max(90, Math.min(2200, Math.sqrt(approximateCellArea)))
|
|
284
|
+
const buckets = new Map()
|
|
285
|
+
|
|
286
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
287
|
+
const node = nodes[index]
|
|
288
|
+
const cellX = Math.floor((node.x - bounds.minX) / cellSize)
|
|
289
|
+
const cellY = Math.floor((node.y - bounds.minY) / cellSize)
|
|
290
|
+
const key = cellX + ':' + cellY
|
|
291
|
+
const bucket = buckets.get(key)
|
|
292
|
+
if (bucket) {
|
|
293
|
+
bucket.push(node)
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
buckets.set(key, [node])
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
cellSize,
|
|
301
|
+
minX: bounds.minX,
|
|
302
|
+
minY: bounds.minY,
|
|
303
|
+
maxX: bounds.maxX,
|
|
304
|
+
maxY: bounds.maxY,
|
|
305
|
+
buckets
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const viewportNodesFromSpatialIndex = viewport => {
|
|
310
|
+
if (state.visibleNodes.length <= 2500) {
|
|
311
|
+
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const spatial = state.visibleNodeSpatial
|
|
315
|
+
if (!spatial || spatial.buckets.size === 0) {
|
|
316
|
+
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const minCellX = Math.floor((viewport.minX - spatial.minX) / spatial.cellSize)
|
|
320
|
+
const maxCellX = Math.floor((viewport.maxX - spatial.minX) / spatial.cellSize)
|
|
321
|
+
const minCellY = Math.floor((viewport.minY - spatial.minY) / spatial.cellSize)
|
|
322
|
+
const maxCellY = Math.floor((viewport.maxY - spatial.minY) / spatial.cellSize)
|
|
323
|
+
const nodes = []
|
|
324
|
+
|
|
325
|
+
for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
|
|
326
|
+
for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
|
|
327
|
+
const bucket = spatial.buckets.get(cellX + ':' + cellY)
|
|
328
|
+
if (!bucket) continue
|
|
329
|
+
|
|
330
|
+
for (let index = 0; index < bucket.length; index += 1) {
|
|
331
|
+
const node = bucket[index]
|
|
332
|
+
if (isNodeInViewport(node, viewport)) {
|
|
333
|
+
nodes.push(node)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return nodes
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const createVisibleEdgeLookup = edges => {
|
|
343
|
+
const lookup = new Map()
|
|
344
|
+
|
|
345
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
346
|
+
const edge = edges[index]
|
|
347
|
+
if (!edge.target) continue
|
|
348
|
+
|
|
349
|
+
const sourceList = lookup.get(edge.source)
|
|
350
|
+
if (sourceList) {
|
|
351
|
+
sourceList.push(edge)
|
|
352
|
+
} else {
|
|
353
|
+
lookup.set(edge.source, [edge])
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const targetList = lookup.get(edge.target)
|
|
357
|
+
if (targetList) {
|
|
358
|
+
targetList.push(edge)
|
|
359
|
+
} else {
|
|
360
|
+
lookup.set(edge.target, [edge])
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return lookup
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const buildOverviewClusters = nodes => {
|
|
368
|
+
if (nodes.length === 0) {
|
|
369
|
+
return []
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const bounds = graphBounds(nodes)
|
|
373
|
+
if (!bounds) {
|
|
374
|
+
return []
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const longest = Math.max(bounds.width, bounds.height, 1)
|
|
378
|
+
const cellSize = Math.max(longest / 56, 900)
|
|
379
|
+
const buckets = new Map()
|
|
380
|
+
|
|
381
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
382
|
+
const node = nodes[index]
|
|
383
|
+
const keyX = Math.floor((node.x - bounds.minX) / cellSize)
|
|
384
|
+
const keyY = Math.floor((node.y - bounds.minY) / cellSize)
|
|
385
|
+
const key = keyX + ':' + keyY
|
|
386
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
387
|
+
const current = buckets.get(key)
|
|
388
|
+
if (current) {
|
|
389
|
+
current.count += 1
|
|
390
|
+
current.sumX += node.x
|
|
391
|
+
current.sumY += node.y
|
|
392
|
+
if (degree > current.degree) {
|
|
393
|
+
current.representative = node
|
|
394
|
+
current.degree = degree
|
|
395
|
+
}
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
buckets.set(key, {
|
|
400
|
+
id: key,
|
|
401
|
+
count: 1,
|
|
402
|
+
sumX: node.x,
|
|
403
|
+
sumY: node.y,
|
|
404
|
+
representative: node,
|
|
405
|
+
degree
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return Array.from(buckets.values())
|
|
410
|
+
.sort((left, right) => right.count - left.count)
|
|
411
|
+
.slice(0, overviewClusterMaxCount)
|
|
412
|
+
.map((cluster) => ({
|
|
413
|
+
id: cluster.id,
|
|
414
|
+
x: cluster.sumX / Math.max(cluster.count, 1),
|
|
415
|
+
y: cluster.sumY / Math.max(cluster.count, 1),
|
|
416
|
+
count: cluster.count,
|
|
417
|
+
representative: cluster.representative
|
|
418
|
+
}))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const filterOverviewClustersByViewport = viewport =>
|
|
422
|
+
state.overviewClusters.filter((cluster) =>
|
|
423
|
+
cluster.x >= viewport.minX &&
|
|
424
|
+
cluster.x <= viewport.maxX &&
|
|
425
|
+
cluster.y >= viewport.minY &&
|
|
426
|
+
cluster.y <= viewport.maxY
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
const edgeBudgetForCurrentFrame = () => {
|
|
430
|
+
const zoom = state.transform.scale
|
|
431
|
+
if (zoom < 0.12) return 380
|
|
432
|
+
if (zoom < 0.18) return 700
|
|
433
|
+
if (zoom < 0.28) return 1100
|
|
434
|
+
if (zoom < 0.45) return 1600
|
|
435
|
+
if (zoom < 0.7) return 2100
|
|
436
|
+
return renderEdgeBudget
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const clusterBudgetForScale = (scale) => {
|
|
440
|
+
if (scale < 0.008) return 90
|
|
441
|
+
if (scale < 0.014) return 150
|
|
442
|
+
if (scale < 0.022) return 240
|
|
443
|
+
if (scale < 0.035) return 360
|
|
444
|
+
return 520
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const nodeBudgetForScale = (scale) => {
|
|
448
|
+
if (scale < 0.035) return 220
|
|
449
|
+
if (scale < 0.06) return 360
|
|
450
|
+
if (scale < 0.09) return 520
|
|
451
|
+
if (scale < 0.14) return 720
|
|
452
|
+
return renderNodeBudget
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const collectVisibleEdgesForNodes = nodeIds => {
|
|
456
|
+
if (nodeIds.size === 0) {
|
|
457
|
+
return []
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const seen = new Set()
|
|
461
|
+
const collected = []
|
|
462
|
+
const limit = edgeBudgetForCurrentFrame()
|
|
463
|
+
|
|
464
|
+
nodeIds.forEach(nodeId => {
|
|
465
|
+
const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
466
|
+
for (let index = 0; index < candidateEdges.length; index += 1) {
|
|
467
|
+
const edge = candidateEdges[index]
|
|
468
|
+
if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
|
469
|
+
continue
|
|
470
|
+
}
|
|
471
|
+
const key = edge.source < edge.target
|
|
472
|
+
? edge.source + '|' + edge.target + '|' + edge.targetTitle
|
|
473
|
+
: edge.target + '|' + edge.source + '|' + edge.targetTitle
|
|
474
|
+
if (seen.has(key)) continue
|
|
475
|
+
|
|
476
|
+
seen.add(key)
|
|
477
|
+
collected.push(edge)
|
|
478
|
+
if (collected.length >= limit) {
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
return collected
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const fallbackViewportNodes = () => {
|
|
488
|
+
const nodes = []
|
|
489
|
+
const maxNodes = Math.min(renderNodeBudget, 220)
|
|
490
|
+
const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
|
|
491
|
+
|
|
492
|
+
for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
|
|
493
|
+
nodes.push(state.visibleNodes[index])
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
497
|
+
nodes.push(state.selected)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return nodes
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const sampleVisibleNodes = (limit = renderNodeBudget) => {
|
|
504
|
+
if (state.visibleNodes.length === 0 || limit <= 0) {
|
|
505
|
+
return []
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const nodes = []
|
|
509
|
+
const maxNodes = Math.min(Math.max(limit, 1), state.visibleNodes.length)
|
|
510
|
+
const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
|
|
511
|
+
|
|
512
|
+
for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
|
|
513
|
+
nodes.push(state.visibleNodes[index])
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
517
|
+
nodes.push(state.selected)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return nodes
|
|
521
|
+
}
|
|
174
522
|
|
|
175
523
|
const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
|
|
176
524
|
const isFiniteNumber = value => Number.isFinite(value)
|
|
177
525
|
const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
|
|
526
|
+
const clampTransformCoordinate = value => {
|
|
527
|
+
if (!isFiniteNumber(value)) return 0
|
|
528
|
+
if (value > transformCoordinateLimit) return transformCoordinateLimit
|
|
529
|
+
if (value < -transformCoordinateLimit) return -transformCoordinateLimit
|
|
530
|
+
return value
|
|
531
|
+
}
|
|
178
532
|
|
|
179
533
|
const graphBounds = nodes => {
|
|
180
534
|
if (nodes.length === 0) return null
|
|
@@ -220,7 +574,7 @@ const autoFitScaleRangeByNodeCount = nodeCount => {
|
|
|
220
574
|
if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
|
|
221
575
|
if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
|
|
222
576
|
if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
|
|
223
|
-
return { min:
|
|
577
|
+
return { min: 0.0008, max: 0.24 }
|
|
224
578
|
}
|
|
225
579
|
|
|
226
580
|
const fitView = (options = { useFiltered: true }) => {
|
|
@@ -232,6 +586,9 @@ const fitView = (options = { useFiltered: true }) => {
|
|
|
232
586
|
|
|
233
587
|
if (!bounds) {
|
|
234
588
|
state.transform = { x: width / 2, y: height / 2, scale: 1 }
|
|
589
|
+
state.offscreenFrameCount = 0
|
|
590
|
+
state.recoveringViewport = false
|
|
591
|
+
markRenderDirty()
|
|
235
592
|
return
|
|
236
593
|
}
|
|
237
594
|
|
|
@@ -255,24 +612,62 @@ const fitView = (options = { useFiltered: true }) => {
|
|
|
255
612
|
const centerY = (bounds.minY + bounds.maxY) / 2
|
|
256
613
|
|
|
257
614
|
state.transform = {
|
|
258
|
-
x: width / 2 - centerX * scale,
|
|
259
|
-
y: height / 2 - centerY * scale,
|
|
260
|
-
scale
|
|
615
|
+
x: clampTransformCoordinate(width / 2 - centerX * scale),
|
|
616
|
+
y: clampTransformCoordinate(height / 2 - centerY * scale),
|
|
617
|
+
scale: clampScale(scale)
|
|
261
618
|
}
|
|
619
|
+
state.offscreenFrameCount = 0
|
|
620
|
+
state.recoveringViewport = false
|
|
621
|
+
markRenderDirty()
|
|
262
622
|
}
|
|
263
623
|
|
|
264
624
|
const resetView = () => fitView({ useFiltered: false })
|
|
265
625
|
|
|
266
626
|
const createLayout = graph => {
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
627
|
+
const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
|
|
628
|
+
const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
|
|
629
|
+
const nodes = nodeRows.map(node => {
|
|
630
|
+
if (Array.isArray(node)) {
|
|
631
|
+
const [id, title, x, y, group, segment] = node
|
|
632
|
+
return {
|
|
633
|
+
id: typeof id === 'string' ? id : '',
|
|
634
|
+
title: typeof title === 'string' ? title : 'Untitled',
|
|
635
|
+
path: '',
|
|
636
|
+
tags: [],
|
|
637
|
+
group: typeof group === 'string' ? group : 'root',
|
|
638
|
+
segment: typeof segment === 'string' ? segment : 'root',
|
|
639
|
+
x: Number.isFinite(x) ? x : 0,
|
|
640
|
+
y: Number.isFinite(y) ? y : 0,
|
|
641
|
+
vx: 0,
|
|
642
|
+
vy: 0
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return {
|
|
647
|
+
...node,
|
|
648
|
+
path: typeof node.path === 'string' ? node.path : '',
|
|
649
|
+
tags: Array.isArray(node.tags) ? node.tags : [],
|
|
650
|
+
x: Number.isFinite(node.x) ? node.x : 0,
|
|
651
|
+
y: Number.isFinite(node.y) ? node.y : 0,
|
|
652
|
+
vx: Number.isFinite(node.vx) ? node.vx : 0,
|
|
653
|
+
vy: Number.isFinite(node.vy) ? node.vy : 0
|
|
654
|
+
}
|
|
655
|
+
})
|
|
274
656
|
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
275
|
-
const edges =
|
|
657
|
+
const edges = edgeRows
|
|
658
|
+
.map(edge => {
|
|
659
|
+
if (Array.isArray(edge)) {
|
|
660
|
+
const [source, target, weight, priority] = edge
|
|
661
|
+
return {
|
|
662
|
+
source: typeof source === 'string' ? source : '',
|
|
663
|
+
target: typeof target === 'string' ? target : null,
|
|
664
|
+
targetTitle: '',
|
|
665
|
+
weight: Number.isFinite(weight) ? weight : 1,
|
|
666
|
+
priority: typeof priority === 'string' ? priority : 'normal'
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return edge
|
|
670
|
+
})
|
|
276
671
|
.filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
|
|
277
672
|
.map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
|
|
278
673
|
return { nodes, edges }
|
|
@@ -327,7 +722,8 @@ const syncContentFilter = async (query, token) => {
|
|
|
327
722
|
}
|
|
328
723
|
|
|
329
724
|
state.contentFilter.query = query
|
|
330
|
-
state.contentFilter.ids
|
|
725
|
+
const merged = new Set([...(state.contentFilter.ids instanceof Set ? state.contentFilter.ids : []), ...nodeIds])
|
|
726
|
+
state.contentFilter.ids = merged
|
|
331
727
|
recomputeVisibility()
|
|
332
728
|
}
|
|
333
729
|
|
|
@@ -348,6 +744,14 @@ const scheduleContentFilterSync = () => {
|
|
|
348
744
|
ids: state.contentFilter.ids,
|
|
349
745
|
token,
|
|
350
746
|
timer: setTimeout(() => {
|
|
747
|
+
if (state.filterWorker && state.filterReady) {
|
|
748
|
+
state.filterWorker.postMessage({
|
|
749
|
+
type: 'filter',
|
|
750
|
+
query,
|
|
751
|
+
token,
|
|
752
|
+
limit: Math.max(state.nodes.length, 1)
|
|
753
|
+
})
|
|
754
|
+
}
|
|
351
755
|
syncContentFilter(query, token).catch(() => {})
|
|
352
756
|
}, 180)
|
|
353
757
|
}
|
|
@@ -356,7 +760,11 @@ const scheduleContentFilterSync = () => {
|
|
|
356
760
|
const tick = delta => {
|
|
357
761
|
const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
|
|
358
762
|
const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
|
|
359
|
-
|
|
763
|
+
const shouldRunPhysics =
|
|
764
|
+
state.nodes.length <= 8000 &&
|
|
765
|
+
nodes.length <= 320 &&
|
|
766
|
+
state.transform.scale >= 0.08
|
|
767
|
+
if (!shouldRunPhysics) {
|
|
360
768
|
return
|
|
361
769
|
}
|
|
362
770
|
const strength = Math.min(delta / 16, 2)
|
|
@@ -429,7 +837,11 @@ const worldPoint = event => {
|
|
|
429
837
|
}
|
|
430
838
|
|
|
431
839
|
const hitNode = point => {
|
|
432
|
-
|
|
840
|
+
computeRenderVisibility()
|
|
841
|
+
if (state.renderClusters.length > 0) {
|
|
842
|
+
return null
|
|
843
|
+
}
|
|
844
|
+
if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.9) {
|
|
433
845
|
return null
|
|
434
846
|
}
|
|
435
847
|
|
|
@@ -490,23 +902,145 @@ const viewportNodeStride = () => {
|
|
|
490
902
|
return 8
|
|
491
903
|
}
|
|
492
904
|
|
|
905
|
+
const shouldRenderClusters = viewportNodes =>
|
|
906
|
+
state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
|
|
907
|
+
|
|
908
|
+
const clusterViewportNodes = viewportNodes => {
|
|
909
|
+
if (!shouldRenderClusters(viewportNodes)) {
|
|
910
|
+
return []
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
|
|
914
|
+
const buckets = new Map()
|
|
915
|
+
|
|
916
|
+
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
917
|
+
const node = viewportNodes[index]
|
|
918
|
+
const keyX = Math.floor(node.x / worldCellSize)
|
|
919
|
+
const keyY = Math.floor(node.y / worldCellSize)
|
|
920
|
+
const key = keyX + ':' + keyY
|
|
921
|
+
const current = buckets.get(key)
|
|
922
|
+
if (current) {
|
|
923
|
+
current.count += 1
|
|
924
|
+
current.sumX += node.x
|
|
925
|
+
current.sumY += node.y
|
|
926
|
+
if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
|
|
927
|
+
current.representative = node
|
|
928
|
+
current.degree = state.nodeDegrees.get(node.id) ?? 0
|
|
929
|
+
}
|
|
930
|
+
continue
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
buckets.set(key, {
|
|
934
|
+
id: key,
|
|
935
|
+
count: 1,
|
|
936
|
+
sumX: node.x,
|
|
937
|
+
sumY: node.y,
|
|
938
|
+
representative: node,
|
|
939
|
+
degree: state.nodeDegrees.get(node.id) ?? 0
|
|
940
|
+
})
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return Array.from(buckets.values())
|
|
944
|
+
.sort((left, right) => right.count - left.count)
|
|
945
|
+
.slice(0, Math.min(renderNodeBudget, 900))
|
|
946
|
+
.map((cluster) => ({
|
|
947
|
+
id: cluster.id,
|
|
948
|
+
x: cluster.sumX / Math.max(cluster.count, 1),
|
|
949
|
+
y: cluster.sumY / Math.max(cluster.count, 1),
|
|
950
|
+
count: cluster.count,
|
|
951
|
+
representative: cluster.representative
|
|
952
|
+
}))
|
|
953
|
+
}
|
|
954
|
+
|
|
493
955
|
const computeRenderVisibility = () => {
|
|
956
|
+
if (!hasValidTransform()) {
|
|
957
|
+
fitView({ useFiltered: true })
|
|
958
|
+
}
|
|
959
|
+
const viewport = worldViewportBounds()
|
|
960
|
+
const viewportKey =
|
|
961
|
+
Math.round(viewport.minX * 10) + ':' +
|
|
962
|
+
Math.round(viewport.maxX * 10) + ':' +
|
|
963
|
+
Math.round(viewport.minY * 10) + ':' +
|
|
964
|
+
Math.round(viewport.maxY * 10) + ':' +
|
|
965
|
+
Math.round(state.transform.scale * 1000)
|
|
966
|
+
|
|
967
|
+
if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
|
|
968
|
+
return
|
|
969
|
+
}
|
|
970
|
+
state.lastViewportKey = viewportKey
|
|
971
|
+
state.renderVisibilityDirty = false
|
|
972
|
+
|
|
494
973
|
if (state.visibleNodes.length <= 2000) {
|
|
495
974
|
state.renderNodes = state.visibleNodes
|
|
975
|
+
state.renderClusters = []
|
|
496
976
|
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
497
|
-
state.renderEdges =
|
|
977
|
+
state.renderEdges = collectVisibleEdgesForNodes(ids)
|
|
498
978
|
return
|
|
499
979
|
}
|
|
500
980
|
|
|
501
|
-
|
|
981
|
+
if (state.visibleNodes.length > massiveGraphNodeThreshold && state.transform.scale <= 0.035) {
|
|
982
|
+
const viewportClusters = filterOverviewClustersByViewport(viewport)
|
|
983
|
+
const clusters = viewportClusters.length > 0
|
|
984
|
+
? viewportClusters
|
|
985
|
+
: state.overviewClusters.slice(0, Math.min(220, state.overviewClusters.length))
|
|
986
|
+
const clusterLimit = clusterBudgetForScale(state.transform.scale)
|
|
987
|
+
const limitedClusters = clusters.slice(0, Math.min(clusterLimit, clusters.length))
|
|
988
|
+
if (limitedClusters.length > 0) {
|
|
989
|
+
state.renderClusters = limitedClusters
|
|
990
|
+
state.renderNodes = limitedClusters.map((cluster) => cluster.representative)
|
|
991
|
+
state.renderEdges = []
|
|
992
|
+
return
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (state.visibleNodes.length > massiveGraphNodeThreshold && state.transform.scale <= 0.06) {
|
|
997
|
+
const viewportClusters = filterOverviewClustersByViewport(viewport)
|
|
998
|
+
const clusters = viewportClusters.length > 0
|
|
999
|
+
? viewportClusters
|
|
1000
|
+
: state.overviewClusters.slice(0, Math.min(400, state.overviewClusters.length))
|
|
1001
|
+
const clusterLimit = clusterBudgetForScale(state.transform.scale)
|
|
1002
|
+
const limitedClusters = clusters.slice(0, Math.min(clusterLimit, clusters.length))
|
|
1003
|
+
if (limitedClusters.length > 0) {
|
|
1004
|
+
state.renderClusters = limitedClusters
|
|
1005
|
+
state.renderNodes = limitedClusters.map((cluster) => cluster.representative)
|
|
1006
|
+
state.renderEdges = []
|
|
1007
|
+
return
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
if (state.visibleNodes.length > massiveGraphNodeThreshold) {
|
|
1012
|
+
const sampleLimit = nodeBudgetForScale(state.transform.scale)
|
|
1013
|
+
const sampled = sampleVisibleNodes(Math.min(sampleLimit, renderNodeBudget))
|
|
1014
|
+
const sampledIds = new Set(sampled.map((node) => node.id))
|
|
1015
|
+
state.renderClusters = []
|
|
1016
|
+
state.renderNodes = sampled
|
|
1017
|
+
state.renderEdges = state.transform.scale >= 0.12 ? collectVisibleEdgesForNodes(sampledIds) : []
|
|
1018
|
+
return
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (state.transform.scale <= 0.0015) {
|
|
1022
|
+
const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
|
|
1023
|
+
const sampledIds = new Set(sampled.map((node) => node.id))
|
|
1024
|
+
state.renderClusters = []
|
|
1025
|
+
state.renderNodes = sampled
|
|
1026
|
+
state.renderEdges = collectVisibleEdgesForNodes(sampledIds)
|
|
1027
|
+
return
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
1031
|
+
const clusters = clusterViewportNodes(viewportNodes)
|
|
1032
|
+
if (clusters.length > 0) {
|
|
1033
|
+
state.renderClusters = clusters
|
|
1034
|
+
state.renderNodes = clusters.map(cluster => cluster.representative)
|
|
1035
|
+
state.renderEdges = []
|
|
1036
|
+
return
|
|
1037
|
+
}
|
|
1038
|
+
state.renderClusters = []
|
|
502
1039
|
const stride = viewportNodeStride()
|
|
503
1040
|
const picked = []
|
|
504
1041
|
|
|
505
|
-
for (let index = 0; index <
|
|
506
|
-
const node =
|
|
507
|
-
if (!isNodeInViewport(node, viewport)) {
|
|
508
|
-
continue
|
|
509
|
-
}
|
|
1042
|
+
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
1043
|
+
const node = viewportNodes[index]
|
|
510
1044
|
|
|
511
1045
|
const isPriority =
|
|
512
1046
|
node.id === state.selected?.id ||
|
|
@@ -521,29 +1055,27 @@ const computeRenderVisibility = () => {
|
|
|
521
1055
|
? picked.slice(0, renderNodeBudget)
|
|
522
1056
|
: picked
|
|
523
1057
|
if (nodes.length === 0 && state.visibleNodes.length > 0) {
|
|
524
|
-
const
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
)
|
|
1058
|
+
const fallbackNodes = fallbackViewportNodes()
|
|
1059
|
+
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
1060
|
+
state.renderNodes = fallbackNodes
|
|
1061
|
+
state.renderClusters = []
|
|
1062
|
+
state.renderEdges = collectVisibleEdgesForNodes(fallbackIds)
|
|
539
1063
|
return
|
|
540
1064
|
}
|
|
541
1065
|
|
|
542
1066
|
const nodeIds = new Set(nodes.map((node) => node.id))
|
|
543
|
-
const edges =
|
|
1067
|
+
const edges = collectVisibleEdgesForNodes(nodeIds)
|
|
544
1068
|
|
|
545
1069
|
state.renderNodes = nodes
|
|
546
1070
|
state.renderEdges = edges
|
|
1071
|
+
|
|
1072
|
+
if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
|
|
1073
|
+
const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
|
|
1074
|
+
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
1075
|
+
state.renderClusters = []
|
|
1076
|
+
state.renderNodes = fallbackNodes
|
|
1077
|
+
state.renderEdges = collectVisibleEdgesForNodes(fallbackIds)
|
|
1078
|
+
}
|
|
547
1079
|
}
|
|
548
1080
|
|
|
549
1081
|
const isNodeVisibleOnScreen = (node, width, height) => {
|
|
@@ -574,16 +1106,29 @@ const sanitizeNodePosition = node => {
|
|
|
574
1106
|
if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
|
|
575
1107
|
}
|
|
576
1108
|
|
|
577
|
-
const
|
|
1109
|
+
const sanitizeAllNodePositions = () => {
|
|
578
1110
|
state.nodes.forEach(sanitizeNodePosition)
|
|
579
1111
|
state.visibleNodes.forEach(sanitizeNodePosition)
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const sanitizeGraphState = () => {
|
|
580
1115
|
state.renderNodes.forEach(sanitizeNodePosition)
|
|
581
1116
|
}
|
|
582
1117
|
|
|
583
1118
|
const render = now => {
|
|
584
1119
|
const delta = now - state.last
|
|
585
1120
|
state.last = now
|
|
586
|
-
const
|
|
1121
|
+
const backgroundFrameIntervalMs =
|
|
1122
|
+
state.nodes.length > massiveGraphNodeThreshold
|
|
1123
|
+
? (state.transform.scale < 0.035 ? 130 : state.transform.scale < 0.08 ? 110 : 86)
|
|
1124
|
+
: state.nodes.length > largeGraphNodeThreshold
|
|
1125
|
+
? 64
|
|
1126
|
+
: 16
|
|
1127
|
+
const isInteracting =
|
|
1128
|
+
state.pointer.down ||
|
|
1129
|
+
state.renderVisibilityDirty ||
|
|
1130
|
+
state.recoveringViewport
|
|
1131
|
+
const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
|
|
587
1132
|
if (delta < minFrameIntervalMs) {
|
|
588
1133
|
requestAnimationFrame(render)
|
|
589
1134
|
return
|
|
@@ -624,7 +1169,9 @@ const render = now => {
|
|
|
624
1169
|
} else {
|
|
625
1170
|
state.offscreenFrameCount = 0
|
|
626
1171
|
}
|
|
627
|
-
const drawEdges =
|
|
1172
|
+
const drawEdges =
|
|
1173
|
+
state.renderClusters.length === 0 &&
|
|
1174
|
+
!(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
|
|
628
1175
|
if (drawEdges) {
|
|
629
1176
|
state.renderEdges.forEach(edge => {
|
|
630
1177
|
const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
@@ -637,7 +1184,27 @@ const render = now => {
|
|
|
637
1184
|
})
|
|
638
1185
|
}
|
|
639
1186
|
|
|
640
|
-
state.
|
|
1187
|
+
if (state.renderClusters.length > 0) {
|
|
1188
|
+
const safeScale = Math.max(state.transform.scale, 0.0001)
|
|
1189
|
+
state.renderClusters.forEach(cluster => {
|
|
1190
|
+
const radiusPx = Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
|
|
1191
|
+
const radius = radiusPx / safeScale
|
|
1192
|
+
const haloRadius = (radiusPx + 4) / safeScale
|
|
1193
|
+
ctx.beginPath()
|
|
1194
|
+
ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
|
|
1195
|
+
ctx.fillStyle = graphTheme.nodeHalo
|
|
1196
|
+
ctx.fill()
|
|
1197
|
+
ctx.beginPath()
|
|
1198
|
+
ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
|
|
1199
|
+
ctx.fillStyle = graphTheme.node
|
|
1200
|
+
ctx.fill()
|
|
1201
|
+
ctx.lineWidth = 1.4 / safeScale
|
|
1202
|
+
ctx.strokeStyle = graphTheme.nodeStroke
|
|
1203
|
+
ctx.stroke()
|
|
1204
|
+
// Keep cluster markers minimal and faster to draw on large graphs.
|
|
1205
|
+
})
|
|
1206
|
+
} else {
|
|
1207
|
+
state.renderNodes.forEach(node => {
|
|
641
1208
|
const radius = nodeRadius(node)
|
|
642
1209
|
const isSelected = state.selected?.id === node.id
|
|
643
1210
|
const isHovered = state.hovered?.id === node.id
|
|
@@ -664,10 +1231,11 @@ const render = now => {
|
|
|
664
1231
|
ctx.textBaseline = 'top'
|
|
665
1232
|
ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
|
|
666
1233
|
}
|
|
667
|
-
|
|
1234
|
+
})
|
|
1235
|
+
}
|
|
668
1236
|
|
|
669
1237
|
ctx.restore()
|
|
670
|
-
if (state.renderNodes.length === 0) {
|
|
1238
|
+
if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
|
|
671
1239
|
ctx.fillStyle = '#99a5b5'
|
|
672
1240
|
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
673
1241
|
ctx.textAlign = 'center'
|
|
@@ -687,11 +1255,11 @@ const linkedNodes = node => {
|
|
|
687
1255
|
weight: edge.weight,
|
|
688
1256
|
priority: edge.priority
|
|
689
1257
|
} : null
|
|
690
|
-
const outgoing = state.
|
|
1258
|
+
const outgoing = state.edges
|
|
691
1259
|
.filter(edge => edge.source === node.id)
|
|
692
|
-
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' }, edge))
|
|
1260
|
+
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
|
|
693
1261
|
.filter(Boolean)
|
|
694
|
-
const incoming = state.
|
|
1262
|
+
const incoming = state.edges
|
|
695
1263
|
.filter(edge => edge.target === node.id)
|
|
696
1264
|
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
697
1265
|
.filter(Boolean)
|
|
@@ -721,14 +1289,14 @@ const fetchNodeDetails = async node => {
|
|
|
721
1289
|
|
|
722
1290
|
const openContentDialog = async node => {
|
|
723
1291
|
if (!node) return
|
|
724
|
-
|
|
725
|
-
elements.
|
|
726
|
-
elements.
|
|
727
|
-
elements.contentTags.innerHTML = node.tags.length
|
|
1292
|
+
elements.contentTitle.textContent = node.title || 'Loading...'
|
|
1293
|
+
elements.contentPath.textContent = node.path || 'Loading...'
|
|
1294
|
+
elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
|
|
728
1295
|
? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
729
1296
|
: '<span>No tags</span>'
|
|
730
|
-
|
|
731
|
-
elements.
|
|
1297
|
+
const initialLinks = linkedNodes(node)
|
|
1298
|
+
elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
|
|
1299
|
+
elements.contentIncoming.innerHTML = list(initialLinks.incoming)
|
|
732
1300
|
elements.contentBody.textContent = 'Loading note content...'
|
|
733
1301
|
if (!elements.contentDialog.open) {
|
|
734
1302
|
elements.contentDialog.showModal()
|
|
@@ -739,6 +1307,11 @@ const openContentDialog = async node => {
|
|
|
739
1307
|
if (state.selected?.id !== node.id) {
|
|
740
1308
|
return
|
|
741
1309
|
}
|
|
1310
|
+
elements.contentTitle.textContent = detailedNode.title
|
|
1311
|
+
elements.contentPath.textContent = detailedNode.path
|
|
1312
|
+
elements.contentTags.innerHTML = detailedNode.tags.length
|
|
1313
|
+
? detailedNode.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
1314
|
+
: '<span>No tags</span>'
|
|
742
1315
|
elements.contentBody.textContent = detailedNode.content
|
|
743
1316
|
} catch {
|
|
744
1317
|
elements.contentBody.textContent = 'Unable to load note content.'
|
|
@@ -764,9 +1337,11 @@ const zoomAtPoint = (screenX, screenY, factor) => {
|
|
|
764
1337
|
if (nextScale === state.transform.scale) return
|
|
765
1338
|
const worldX = (screenX - state.transform.x) / state.transform.scale
|
|
766
1339
|
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
|
|
1340
|
+
state.transform.scale = clampScale(nextScale)
|
|
1341
|
+
state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
|
|
1342
|
+
state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
|
|
1343
|
+
state.offscreenFrameCount = 0
|
|
1344
|
+
markRenderDirty()
|
|
770
1345
|
}
|
|
771
1346
|
|
|
772
1347
|
const wheelZoomFactor = event => {
|
|
@@ -869,12 +1444,23 @@ const bindEvents = () => {
|
|
|
869
1444
|
if (node) {
|
|
870
1445
|
node.x = point.x
|
|
871
1446
|
node.y = point.y
|
|
1447
|
+
markRenderDirty()
|
|
872
1448
|
}
|
|
873
1449
|
canvas.setPointerCapture(event.pointerId)
|
|
874
1450
|
})
|
|
875
1451
|
canvas.addEventListener('pointermove', event => {
|
|
876
1452
|
const point = worldPoint(event)
|
|
877
|
-
|
|
1453
|
+
const now = performance.now()
|
|
1454
|
+
const canHoverHitTest =
|
|
1455
|
+
!(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.12)
|
|
1456
|
+
const shouldHitTest = canHoverHitTest &&
|
|
1457
|
+
(state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
|
|
1458
|
+
if (shouldHitTest) {
|
|
1459
|
+
state.hovered = hitNode(point)
|
|
1460
|
+
state.lastHoverHitAt = now
|
|
1461
|
+
} else if (!canHoverHitTest) {
|
|
1462
|
+
state.hovered = null
|
|
1463
|
+
}
|
|
878
1464
|
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
879
1465
|
if (!state.pointer.down) return
|
|
880
1466
|
const dx = event.clientX - state.pointer.x
|
|
@@ -885,10 +1471,15 @@ const bindEvents = () => {
|
|
|
885
1471
|
if (state.pointer.dragNode) {
|
|
886
1472
|
state.pointer.dragNode.x = point.x
|
|
887
1473
|
state.pointer.dragNode.y = point.y
|
|
1474
|
+
markRenderDirty()
|
|
888
1475
|
return
|
|
889
1476
|
}
|
|
890
1477
|
state.transform.x += dx
|
|
891
1478
|
state.transform.y += dy
|
|
1479
|
+
state.transform.x = clampTransformCoordinate(state.transform.x)
|
|
1480
|
+
state.transform.y = clampTransformCoordinate(state.transform.y)
|
|
1481
|
+
state.offscreenFrameCount = 0
|
|
1482
|
+
markRenderDirty()
|
|
892
1483
|
})
|
|
893
1484
|
canvas.addEventListener('pointerup', event => {
|
|
894
1485
|
if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
|
|
@@ -931,9 +1522,10 @@ const loadAgents = async () => {
|
|
|
931
1522
|
const response = await fetch('/api/agents')
|
|
932
1523
|
const payload = await response.json()
|
|
933
1524
|
const agents = Array.isArray(payload.agents) ? payload.agents : []
|
|
934
|
-
const
|
|
1525
|
+
const preferredAgent = state.agentId || initialAgentFromUrl
|
|
1526
|
+
const currentExists = agents.some(agent => agent.id === preferredAgent)
|
|
935
1527
|
const selected = currentExists
|
|
936
|
-
?
|
|
1528
|
+
? preferredAgent
|
|
937
1529
|
: (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
|
|
938
1530
|
const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
|
|
939
1531
|
|
|
@@ -963,6 +1555,10 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
963
1555
|
|
|
964
1556
|
const payload = await response.json()
|
|
965
1557
|
const graph = payload?.layout ?? payload
|
|
1558
|
+
state.graphTotals = {
|
|
1559
|
+
nodes: Number.isFinite(payload?.totals?.nodes) ? payload.totals.nodes : (Array.isArray(graph.nodes) ? graph.nodes.length : 0),
|
|
1560
|
+
edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
|
|
1561
|
+
}
|
|
966
1562
|
const signature = payload?.signature ?? graphSignature(graph)
|
|
967
1563
|
if (!options.reset && signature === state.graphSignature) return
|
|
968
1564
|
const selectedId = state.selected?.id
|
|
@@ -979,13 +1575,15 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
979
1575
|
return degrees
|
|
980
1576
|
}, new Map())
|
|
981
1577
|
state.nodeDetails = new Map()
|
|
1578
|
+
pushNodesToFilterWorker()
|
|
982
1579
|
resetContentFilter()
|
|
1580
|
+
sanitizeAllNodePositions()
|
|
983
1581
|
recomputeVisibility()
|
|
984
1582
|
scheduleContentFilterSync()
|
|
985
|
-
const tags = new Set(
|
|
986
|
-
setGraphStatus(state.agentId + ' · ' +
|
|
987
|
-
elements.nodeCount.textContent =
|
|
988
|
-
elements.edgeCount.textContent =
|
|
1583
|
+
const tags = new Set(state.nodes.flatMap(node => node.tags))
|
|
1584
|
+
setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
|
|
1585
|
+
elements.nodeCount.textContent = state.graphTotals.nodes
|
|
1586
|
+
elements.edgeCount.textContent = state.graphTotals.edges
|
|
989
1587
|
elements.tagCount.textContent = tags.size
|
|
990
1588
|
resize()
|
|
991
1589
|
if (options.reset) resetView()
|
|
@@ -997,6 +1595,7 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
997
1595
|
}
|
|
998
1596
|
|
|
999
1597
|
bindEvents()
|
|
1598
|
+
initFilterWorker()
|
|
1000
1599
|
requestAnimationFrame(() => {
|
|
1001
1600
|
resize()
|
|
1002
1601
|
resetView()
|