@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.
@@ -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 = 1800
6
- const renderEdgeBudget = 5200
7
- const minNodePixelRadius = 1.8
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.05,
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 >= renderEdgeBudget) {
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: zoomRange.min, max: 0.24 }
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 nodes = graph.nodes.map(node => ({
425
- ...node,
426
- x: Number.isFinite(node.x) ? node.x : 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
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 = graph.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 = new Set(nodeIds)
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
- if (nodes.length > 1200) {
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.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
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 sanitizeGraphState = () => {
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 minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 48 : 16
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 = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
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.renderNodes.forEach(node => {
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.graph.edges
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.graph.edges
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
- const { outgoing, incoming } = linkedNodes(node)
883
- elements.contentTitle.textContent = node.title
884
- elements.contentPath.textContent = node.path
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
- elements.contentOutgoing.innerHTML = list(outgoing)
889
- elements.contentIncoming.innerHTML = list(incoming)
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
- state.hovered = hitNode(point)
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 currentExists = agents.some(agent => agent.id === state.agentId)
1525
+ const preferredAgent = state.agentId || initialAgentFromUrl
1526
+ const currentExists = agents.some(agent => agent.id === preferredAgent)
1097
1527
  const selected = currentExists
1098
- ? state.agentId
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(graph.nodes.flatMap(node => node.tags))
1148
- setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
1149
- elements.nodeCount.textContent = graph.nodes.length
1150
- elements.edgeCount.textContent = graph.edges.length
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()