@andespindola/brainlink 0.1.0-beta.41 → 0.1.0-beta.43
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 +2 -0
- package/CHANGELOG.md +6 -0
- package/README.md +26 -0
- package/dist/application/frontend/client-js.js +484 -47
- package/dist/application/frontend/client-worker-js.js +66 -0
- package/dist/application/index-vault.js +110 -4
- package/dist/application/server/routes.js +36 -1
- package/dist/application/start-server.js +75 -4
- package/dist/application/watch-vault.js +23 -2
- package/dist/cli/commands/write-commands.js +140 -13
- package/dist/infrastructure/search-packs.js +16 -1
- package/docs/AGENT_USAGE.md +18 -0
- package/package.json +1 -1
|
@@ -1,13 +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 renderEdgeBudget =
|
|
7
|
-
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
|
|
8
12
|
const viewportPaddingPx = 280
|
|
9
13
|
const worldCoordinateLimit = 5_000_000
|
|
10
14
|
const transformCoordinateLimit = 20_000_000
|
|
15
|
+
const hoverHitTestIntervalMs = 64
|
|
16
|
+
const overviewClusterMaxCount = 1400
|
|
11
17
|
const state = {
|
|
12
18
|
graph: { nodes: [], edges: [] },
|
|
13
19
|
nodes: [],
|
|
@@ -16,6 +22,7 @@ const state = {
|
|
|
16
22
|
visibleEdges: [],
|
|
17
23
|
renderNodes: [],
|
|
18
24
|
renderEdges: [],
|
|
25
|
+
renderClusters: [],
|
|
19
26
|
nodeDegrees: new Map(),
|
|
20
27
|
selected: null,
|
|
21
28
|
hovered: null,
|
|
@@ -29,13 +36,18 @@ const state = {
|
|
|
29
36
|
cursor: { x: 0, y: 0, inCanvas: false },
|
|
30
37
|
graphSignature: '',
|
|
31
38
|
graphStatus: '',
|
|
39
|
+
graphTotals: { nodes: 0, edges: 0 },
|
|
32
40
|
last: performance.now(),
|
|
33
41
|
offscreenFrameCount: 0,
|
|
34
42
|
recoveringViewport: false,
|
|
35
43
|
renderVisibilityDirty: true,
|
|
36
44
|
lastViewportKey: '',
|
|
37
45
|
visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
|
|
38
|
-
visibleEdgeByNode: new Map()
|
|
46
|
+
visibleEdgeByNode: new Map(),
|
|
47
|
+
overviewClusters: [],
|
|
48
|
+
filterWorker: null,
|
|
49
|
+
filterReady: false,
|
|
50
|
+
lastHoverHitAt: 0
|
|
39
51
|
}
|
|
40
52
|
|
|
41
53
|
const byId = id => document.getElementById(id)
|
|
@@ -66,10 +78,20 @@ const elements = {
|
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
const zoomRange = {
|
|
69
|
-
min: 0.
|
|
81
|
+
min: 0.0002,
|
|
70
82
|
max: 4.5
|
|
71
83
|
}
|
|
72
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
|
+
|
|
73
95
|
const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
|
|
74
96
|
|
|
75
97
|
const setGraphStatus = text => {
|
|
@@ -93,6 +115,67 @@ const graphTheme = {
|
|
|
93
115
|
label: '#edf2f7'
|
|
94
116
|
}
|
|
95
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
|
+
|
|
96
179
|
const resize = () => {
|
|
97
180
|
const rect = canvas.getBoundingClientRect()
|
|
98
181
|
const width = Math.max(rect.width, 320)
|
|
@@ -111,7 +194,7 @@ const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map
|
|
|
111
194
|
const localFilteredNodes = query =>
|
|
112
195
|
state.nodes.filter(node =>
|
|
113
196
|
node.title.toLowerCase().includes(query) ||
|
|
114
|
-
node.path.toLowerCase().includes(query) ||
|
|
197
|
+
(node.path || '').toLowerCase().includes(query) ||
|
|
115
198
|
node.tags.some(tag => tag.toLowerCase().includes(query))
|
|
116
199
|
)
|
|
117
200
|
|
|
@@ -176,6 +259,7 @@ const recomputeVisibility = () => {
|
|
|
176
259
|
state.visibleEdges = limitedEdges
|
|
177
260
|
state.visibleNodeSpatial = createSpatialIndex(nodes)
|
|
178
261
|
state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
|
|
262
|
+
state.overviewClusters = nodes.length > massiveGraphNodeThreshold ? buildOverviewClusters(nodes) : []
|
|
179
263
|
markRenderDirty()
|
|
180
264
|
}
|
|
181
265
|
|
|
@@ -280,6 +364,94 @@ const createVisibleEdgeLookup = edges => {
|
|
|
280
364
|
return lookup
|
|
281
365
|
}
|
|
282
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
|
+
|
|
283
455
|
const collectVisibleEdgesForNodes = nodeIds => {
|
|
284
456
|
if (nodeIds.size === 0) {
|
|
285
457
|
return []
|
|
@@ -287,6 +459,7 @@ const collectVisibleEdgesForNodes = nodeIds => {
|
|
|
287
459
|
|
|
288
460
|
const seen = new Set()
|
|
289
461
|
const collected = []
|
|
462
|
+
const limit = edgeBudgetForCurrentFrame()
|
|
290
463
|
|
|
291
464
|
nodeIds.forEach(nodeId => {
|
|
292
465
|
const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
@@ -302,7 +475,7 @@ const collectVisibleEdgesForNodes = nodeIds => {
|
|
|
302
475
|
|
|
303
476
|
seen.add(key)
|
|
304
477
|
collected.push(edge)
|
|
305
|
-
if (collected.length >=
|
|
478
|
+
if (collected.length >= limit) {
|
|
306
479
|
return
|
|
307
480
|
}
|
|
308
481
|
}
|
|
@@ -327,9 +500,35 @@ const fallbackViewportNodes = () => {
|
|
|
327
500
|
return nodes
|
|
328
501
|
}
|
|
329
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
|
+
}
|
|
522
|
+
|
|
330
523
|
const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
|
|
331
524
|
const isFiniteNumber = value => Number.isFinite(value)
|
|
332
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
|
+
}
|
|
333
532
|
|
|
334
533
|
const graphBounds = nodes => {
|
|
335
534
|
if (nodes.length === 0) return null
|
|
@@ -375,7 +574,7 @@ const autoFitScaleRangeByNodeCount = nodeCount => {
|
|
|
375
574
|
if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
|
|
376
575
|
if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
|
|
377
576
|
if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
|
|
378
|
-
return { min:
|
|
577
|
+
return { min: 0.0008, max: 0.24 }
|
|
379
578
|
}
|
|
380
579
|
|
|
381
580
|
const fitView = (options = { useFiltered: true }) => {
|
|
@@ -387,6 +586,8 @@ const fitView = (options = { useFiltered: true }) => {
|
|
|
387
586
|
|
|
388
587
|
if (!bounds) {
|
|
389
588
|
state.transform = { x: width / 2, y: height / 2, scale: 1 }
|
|
589
|
+
state.offscreenFrameCount = 0
|
|
590
|
+
state.recoveringViewport = false
|
|
390
591
|
markRenderDirty()
|
|
391
592
|
return
|
|
392
593
|
}
|
|
@@ -411,25 +612,62 @@ const fitView = (options = { useFiltered: true }) => {
|
|
|
411
612
|
const centerY = (bounds.minY + bounds.maxY) / 2
|
|
412
613
|
|
|
413
614
|
state.transform = {
|
|
414
|
-
x: width / 2 - centerX * scale,
|
|
415
|
-
y: height / 2 - centerY * scale,
|
|
416
|
-
scale
|
|
615
|
+
x: clampTransformCoordinate(width / 2 - centerX * scale),
|
|
616
|
+
y: clampTransformCoordinate(height / 2 - centerY * scale),
|
|
617
|
+
scale: clampScale(scale)
|
|
417
618
|
}
|
|
619
|
+
state.offscreenFrameCount = 0
|
|
620
|
+
state.recoveringViewport = false
|
|
418
621
|
markRenderDirty()
|
|
419
622
|
}
|
|
420
623
|
|
|
421
624
|
const resetView = () => fitView({ useFiltered: false })
|
|
422
625
|
|
|
423
626
|
const createLayout = graph => {
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
+
})
|
|
431
656
|
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
432
|
-
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
|
+
})
|
|
433
671
|
.filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
|
|
434
672
|
.map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
|
|
435
673
|
return { nodes, edges }
|
|
@@ -484,7 +722,8 @@ const syncContentFilter = async (query, token) => {
|
|
|
484
722
|
}
|
|
485
723
|
|
|
486
724
|
state.contentFilter.query = query
|
|
487
|
-
state.contentFilter.ids
|
|
725
|
+
const merged = new Set([...(state.contentFilter.ids instanceof Set ? state.contentFilter.ids : []), ...nodeIds])
|
|
726
|
+
state.contentFilter.ids = merged
|
|
488
727
|
recomputeVisibility()
|
|
489
728
|
}
|
|
490
729
|
|
|
@@ -505,6 +744,14 @@ const scheduleContentFilterSync = () => {
|
|
|
505
744
|
ids: state.contentFilter.ids,
|
|
506
745
|
token,
|
|
507
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
|
+
}
|
|
508
755
|
syncContentFilter(query, token).catch(() => {})
|
|
509
756
|
}, 180)
|
|
510
757
|
}
|
|
@@ -513,7 +760,11 @@ const scheduleContentFilterSync = () => {
|
|
|
513
760
|
const tick = delta => {
|
|
514
761
|
const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
|
|
515
762
|
const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
|
|
516
|
-
|
|
763
|
+
const shouldRunPhysics =
|
|
764
|
+
state.nodes.length <= 8000 &&
|
|
765
|
+
nodes.length <= 320 &&
|
|
766
|
+
state.transform.scale >= 0.08
|
|
767
|
+
if (!shouldRunPhysics) {
|
|
517
768
|
return
|
|
518
769
|
}
|
|
519
770
|
const strength = Math.min(delta / 16, 2)
|
|
@@ -587,7 +838,10 @@ const worldPoint = event => {
|
|
|
587
838
|
|
|
588
839
|
const hitNode = point => {
|
|
589
840
|
computeRenderVisibility()
|
|
590
|
-
if (state.
|
|
841
|
+
if (state.renderClusters.length > 0) {
|
|
842
|
+
return null
|
|
843
|
+
}
|
|
844
|
+
if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.9) {
|
|
591
845
|
return null
|
|
592
846
|
}
|
|
593
847
|
|
|
@@ -648,7 +902,60 @@ const viewportNodeStride = () => {
|
|
|
648
902
|
return 8
|
|
649
903
|
}
|
|
650
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
|
+
|
|
651
955
|
const computeRenderVisibility = () => {
|
|
956
|
+
if (!hasValidTransform()) {
|
|
957
|
+
fitView({ useFiltered: true })
|
|
958
|
+
}
|
|
652
959
|
const viewport = worldViewportBounds()
|
|
653
960
|
const viewportKey =
|
|
654
961
|
Math.round(viewport.minX * 10) + ':' +
|
|
@@ -665,12 +972,70 @@ const computeRenderVisibility = () => {
|
|
|
665
972
|
|
|
666
973
|
if (state.visibleNodes.length <= 2000) {
|
|
667
974
|
state.renderNodes = state.visibleNodes
|
|
975
|
+
state.renderClusters = []
|
|
668
976
|
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
669
977
|
state.renderEdges = collectVisibleEdgesForNodes(ids)
|
|
670
978
|
return
|
|
671
979
|
}
|
|
672
980
|
|
|
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
|
+
|
|
673
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 = []
|
|
674
1039
|
const stride = viewportNodeStride()
|
|
675
1040
|
const picked = []
|
|
676
1041
|
|
|
@@ -693,6 +1058,7 @@ const computeRenderVisibility = () => {
|
|
|
693
1058
|
const fallbackNodes = fallbackViewportNodes()
|
|
694
1059
|
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
695
1060
|
state.renderNodes = fallbackNodes
|
|
1061
|
+
state.renderClusters = []
|
|
696
1062
|
state.renderEdges = collectVisibleEdgesForNodes(fallbackIds)
|
|
697
1063
|
return
|
|
698
1064
|
}
|
|
@@ -702,6 +1068,14 @@ const computeRenderVisibility = () => {
|
|
|
702
1068
|
|
|
703
1069
|
state.renderNodes = nodes
|
|
704
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
|
+
}
|
|
705
1079
|
}
|
|
706
1080
|
|
|
707
1081
|
const isNodeVisibleOnScreen = (node, width, height) => {
|
|
@@ -732,16 +1106,29 @@ const sanitizeNodePosition = node => {
|
|
|
732
1106
|
if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
|
|
733
1107
|
}
|
|
734
1108
|
|
|
735
|
-
const
|
|
1109
|
+
const sanitizeAllNodePositions = () => {
|
|
736
1110
|
state.nodes.forEach(sanitizeNodePosition)
|
|
737
1111
|
state.visibleNodes.forEach(sanitizeNodePosition)
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const sanitizeGraphState = () => {
|
|
738
1115
|
state.renderNodes.forEach(sanitizeNodePosition)
|
|
739
1116
|
}
|
|
740
1117
|
|
|
741
1118
|
const render = now => {
|
|
742
1119
|
const delta = now - state.last
|
|
743
1120
|
state.last = now
|
|
744
|
-
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
|
|
745
1132
|
if (delta < minFrameIntervalMs) {
|
|
746
1133
|
requestAnimationFrame(render)
|
|
747
1134
|
return
|
|
@@ -782,7 +1169,9 @@ const render = now => {
|
|
|
782
1169
|
} else {
|
|
783
1170
|
state.offscreenFrameCount = 0
|
|
784
1171
|
}
|
|
785
|
-
const drawEdges =
|
|
1172
|
+
const drawEdges =
|
|
1173
|
+
state.renderClusters.length === 0 &&
|
|
1174
|
+
!(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
|
|
786
1175
|
if (drawEdges) {
|
|
787
1176
|
state.renderEdges.forEach(edge => {
|
|
788
1177
|
const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
@@ -795,7 +1184,27 @@ const render = now => {
|
|
|
795
1184
|
})
|
|
796
1185
|
}
|
|
797
1186
|
|
|
798
|
-
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 => {
|
|
799
1208
|
const radius = nodeRadius(node)
|
|
800
1209
|
const isSelected = state.selected?.id === node.id
|
|
801
1210
|
const isHovered = state.hovered?.id === node.id
|
|
@@ -822,10 +1231,11 @@ const render = now => {
|
|
|
822
1231
|
ctx.textBaseline = 'top'
|
|
823
1232
|
ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
|
|
824
1233
|
}
|
|
825
|
-
|
|
1234
|
+
})
|
|
1235
|
+
}
|
|
826
1236
|
|
|
827
1237
|
ctx.restore()
|
|
828
|
-
if (state.renderNodes.length === 0) {
|
|
1238
|
+
if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
|
|
829
1239
|
ctx.fillStyle = '#99a5b5'
|
|
830
1240
|
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
831
1241
|
ctx.textAlign = 'center'
|
|
@@ -845,11 +1255,11 @@ const linkedNodes = node => {
|
|
|
845
1255
|
weight: edge.weight,
|
|
846
1256
|
priority: edge.priority
|
|
847
1257
|
} : null
|
|
848
|
-
const outgoing = state.
|
|
1258
|
+
const outgoing = state.edges
|
|
849
1259
|
.filter(edge => edge.source === node.id)
|
|
850
|
-
.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))
|
|
851
1261
|
.filter(Boolean)
|
|
852
|
-
const incoming = state.
|
|
1262
|
+
const incoming = state.edges
|
|
853
1263
|
.filter(edge => edge.target === node.id)
|
|
854
1264
|
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
855
1265
|
.filter(Boolean)
|
|
@@ -879,14 +1289,14 @@ const fetchNodeDetails = async node => {
|
|
|
879
1289
|
|
|
880
1290
|
const openContentDialog = async node => {
|
|
881
1291
|
if (!node) return
|
|
882
|
-
|
|
883
|
-
elements.
|
|
884
|
-
elements.
|
|
885
|
-
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
|
|
886
1295
|
? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
887
1296
|
: '<span>No tags</span>'
|
|
888
|
-
|
|
889
|
-
elements.
|
|
1297
|
+
const initialLinks = linkedNodes(node)
|
|
1298
|
+
elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
|
|
1299
|
+
elements.contentIncoming.innerHTML = list(initialLinks.incoming)
|
|
890
1300
|
elements.contentBody.textContent = 'Loading note content...'
|
|
891
1301
|
if (!elements.contentDialog.open) {
|
|
892
1302
|
elements.contentDialog.showModal()
|
|
@@ -897,6 +1307,11 @@ const openContentDialog = async node => {
|
|
|
897
1307
|
if (state.selected?.id !== node.id) {
|
|
898
1308
|
return
|
|
899
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>'
|
|
900
1315
|
elements.contentBody.textContent = detailedNode.content
|
|
901
1316
|
} catch {
|
|
902
1317
|
elements.contentBody.textContent = 'Unable to load note content.'
|
|
@@ -922,9 +1337,10 @@ const zoomAtPoint = (screenX, screenY, factor) => {
|
|
|
922
1337
|
if (nextScale === state.transform.scale) return
|
|
923
1338
|
const worldX = (screenX - state.transform.x) / state.transform.scale
|
|
924
1339
|
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
|
|
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
|
|
928
1344
|
markRenderDirty()
|
|
929
1345
|
}
|
|
930
1346
|
|
|
@@ -1034,7 +1450,17 @@ const bindEvents = () => {
|
|
|
1034
1450
|
})
|
|
1035
1451
|
canvas.addEventListener('pointermove', event => {
|
|
1036
1452
|
const point = worldPoint(event)
|
|
1037
|
-
|
|
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
|
+
}
|
|
1038
1464
|
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
1039
1465
|
if (!state.pointer.down) return
|
|
1040
1466
|
const dx = event.clientX - state.pointer.x
|
|
@@ -1050,6 +1476,9 @@ const bindEvents = () => {
|
|
|
1050
1476
|
}
|
|
1051
1477
|
state.transform.x += dx
|
|
1052
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
|
|
1053
1482
|
markRenderDirty()
|
|
1054
1483
|
})
|
|
1055
1484
|
canvas.addEventListener('pointerup', event => {
|
|
@@ -1093,9 +1522,10 @@ const loadAgents = async () => {
|
|
|
1093
1522
|
const response = await fetch('/api/agents')
|
|
1094
1523
|
const payload = await response.json()
|
|
1095
1524
|
const agents = Array.isArray(payload.agents) ? payload.agents : []
|
|
1096
|
-
const
|
|
1525
|
+
const preferredAgent = state.agentId || initialAgentFromUrl
|
|
1526
|
+
const currentExists = agents.some(agent => agent.id === preferredAgent)
|
|
1097
1527
|
const selected = currentExists
|
|
1098
|
-
?
|
|
1528
|
+
? preferredAgent
|
|
1099
1529
|
: (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
|
|
1100
1530
|
const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
|
|
1101
1531
|
|
|
@@ -1125,6 +1555,10 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
1125
1555
|
|
|
1126
1556
|
const payload = await response.json()
|
|
1127
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
|
+
}
|
|
1128
1562
|
const signature = payload?.signature ?? graphSignature(graph)
|
|
1129
1563
|
if (!options.reset && signature === state.graphSignature) return
|
|
1130
1564
|
const selectedId = state.selected?.id
|
|
@@ -1141,13 +1575,15 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
1141
1575
|
return degrees
|
|
1142
1576
|
}, new Map())
|
|
1143
1577
|
state.nodeDetails = new Map()
|
|
1578
|
+
pushNodesToFilterWorker()
|
|
1144
1579
|
resetContentFilter()
|
|
1580
|
+
sanitizeAllNodePositions()
|
|
1145
1581
|
recomputeVisibility()
|
|
1146
1582
|
scheduleContentFilterSync()
|
|
1147
|
-
const tags = new Set(
|
|
1148
|
-
setGraphStatus(state.agentId + ' · ' +
|
|
1149
|
-
elements.nodeCount.textContent =
|
|
1150
|
-
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
|
|
1151
1587
|
elements.tagCount.textContent = tags.size
|
|
1152
1588
|
resize()
|
|
1153
1589
|
if (options.reset) resetView()
|
|
@@ -1159,6 +1595,7 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
1159
1595
|
}
|
|
1160
1596
|
|
|
1161
1597
|
bindEvents()
|
|
1598
|
+
initFilterWorker()
|
|
1162
1599
|
requestAnimationFrame(() => {
|
|
1163
1600
|
resize()
|
|
1164
1601
|
resetView()
|