@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.
@@ -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 = 1800
6
- 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
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.05,
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: zoomRange.min, max: 0.24 }
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 nodes = graph.nodes.map(node => ({
268
- ...node,
269
- x: Number.isFinite(node.x) ? node.x : 0,
270
- y: Number.isFinite(node.y) ? node.y : 0,
271
- vx: Number.isFinite(node.vx) ? node.vx : 0,
272
- vy: Number.isFinite(node.vy) ? node.vy : 0
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 = 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
+ })
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 = new Set(nodeIds)
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
- 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) {
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
- if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
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 = state.visibleEdges.filter((edge) => ids.has(edge.source) && edge.target && ids.has(edge.target))
977
+ state.renderEdges = collectVisibleEdgesForNodes(ids)
498
978
  return
499
979
  }
500
980
 
501
- const viewport = worldViewportBounds()
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 < state.visibleNodes.length; index += 1) {
506
- const node = state.visibleNodes[index]
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 centerX = (viewport.minX + viewport.maxX) / 2
525
- const centerY = (viewport.minY + viewport.maxY) / 2
526
- const closest = [...state.visibleNodes]
527
- .sort((left, right) => {
528
- const leftDistance = (left.x - centerX) ** 2 + (left.y - centerY) ** 2
529
- const rightDistance = (right.x - centerX) ** 2 + (right.y - centerY) ** 2
530
- return leftDistance - rightDistance
531
- })
532
- .slice(0, Math.min(renderNodeBudget, 180))
533
- const closestIds = new Set(closest.map((node) => node.id))
534
-
535
- state.renderNodes = closest
536
- state.renderEdges = state.visibleEdges.filter(
537
- (edge) => closestIds.has(edge.source) && edge.target && closestIds.has(edge.target)
538
- )
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 = state.visibleEdges.filter((edge) => nodeIds.has(edge.source) && edge.target && nodeIds.has(edge.target))
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 sanitizeGraphState = () => {
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 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
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 = !(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)
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.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 => {
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.graph.edges
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.graph.edges
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
- const { outgoing, incoming } = linkedNodes(node)
725
- elements.contentTitle.textContent = node.title
726
- elements.contentPath.textContent = node.path
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
- elements.contentOutgoing.innerHTML = list(outgoing)
731
- elements.contentIncoming.innerHTML = list(incoming)
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
- 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
+ }
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 currentExists = agents.some(agent => agent.id === state.agentId)
1525
+ const preferredAgent = state.agentId || initialAgentFromUrl
1526
+ const currentExists = agents.some(agent => agent.id === preferredAgent)
935
1527
  const selected = currentExists
936
- ? state.agentId
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(graph.nodes.flatMap(node => node.tags))
986
- setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
987
- elements.nodeCount.textContent = graph.nodes.length
988
- 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
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()