@andespindola/brainlink 0.1.0-beta.116 → 0.1.0-beta.118

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.
@@ -6,7 +6,10 @@ const massiveGraphNodeThreshold = 20000
6
6
  const largeGraphEdgeRenderLimit = 120000
7
7
  const renderNodeBudget = 900
8
8
  const zoomedMassiveRenderNodeBudget = 2200
9
- const renderEdgeBudget = 2400
9
+ const massiveOverviewRenderNodeBudget = 1800
10
+ const massiveOverviewScaleThreshold = 0.065
11
+ const massiveSegmentedScaleThreshold = 0.45
12
+ const massiveSegmentRepresentativeBudget = 760
10
13
  const massiveAutoFitMacroScale = 0.018
11
14
  const minNodePixelRadius = 2.3
12
15
  const viewportPaddingPx = 280
@@ -17,7 +20,6 @@ const zoomRecoveryGuardMs = 4200
17
20
  const meshEdgeScaleThreshold = 0.09
18
21
  const meshEdgeMinBudget = 140
19
22
  const meshEdgeMaxBudget = 1400
20
- const layeredCoreScaleThreshold = 0.55
21
23
  const dragNeighborhoodMaxAffected = 180
22
24
  const dragSettleRounds = 3
23
25
  const wheelZoomExponent = 0.0009
@@ -42,7 +44,6 @@ const state = {
42
44
  visibleEdges: [],
43
45
  renderNodes: [],
44
46
  renderEdges: [],
45
- renderNodeDepthProjectionById: new Map(),
46
47
  nodeDegrees: new Map(),
47
48
  selected: null,
48
49
  hovered: null,
@@ -73,7 +74,6 @@ const state = {
73
74
  lastHoverHitAt: 0,
74
75
  lastManualZoomAt: 0,
75
76
  lastZoomFocus: { x: 0, y: 0, at: 0 },
76
- depthProjectionActive: false,
77
77
  zoomTransition: {
78
78
  active: false,
79
79
  source: 'generic',
@@ -635,12 +635,6 @@ const createVisibleEdgeLookup = edges => {
635
635
  return lookup
636
636
  }
637
637
 
638
- const isClusterInViewport = (cluster, viewport) =>
639
- cluster.x >= viewport.minX &&
640
- cluster.x <= viewport.maxX &&
641
- cluster.y >= viewport.minY &&
642
- cluster.y <= viewport.maxY
643
-
644
638
  const edgeBudgetForCurrentFrame = () => {
645
639
  const zoom = state.transform.scale
646
640
  if (zoom < 0.12) return 380
@@ -652,112 +646,24 @@ const edgeBudgetForCurrentFrame = () => {
652
646
  return 7600
653
647
  }
654
648
 
655
- const clusterBudgetForScale = (scale) => {
656
- if (scale < 0.008) return 90
657
- if (scale < 0.014) return 150
658
- if (scale < 0.022) return 240
659
- if (scale < 0.035) return 360
660
- return 520
661
- }
662
-
663
649
  const nodeBudgetForScale = (scale) => {
664
- if (scale < 0.035) return 220
665
- if (scale < 0.06) return 360
666
- if (scale < 0.09) return 520
667
- if (scale < 0.14) return 720
668
650
  if (state.visibleNodes.length > massiveGraphNodeThreshold) {
651
+ if (scale < massiveOverviewScaleThreshold) return massiveOverviewRenderNodeBudget
652
+ if (scale < 0.09) return 1600
653
+ if (scale < 0.14) return 1800
669
654
  if (scale < 0.28) return renderNodeBudget
670
655
  if (scale < 0.45) return 1100
671
656
  if (scale < 0.7) return 1400
672
657
  if (scale < 1.05) return 1800
673
658
  return zoomedMassiveRenderNodeBudget
674
659
  }
660
+ if (scale < 0.035) return 220
661
+ if (scale < 0.06) return 360
662
+ if (scale < 0.09) return 520
663
+ if (scale < 0.14) return 720
675
664
  return renderNodeBudget
676
665
  }
677
666
 
678
- const layerFocusForScale = (scale) => {
679
- const normalized = Math.max(0, Math.min(1, (scale - 0.06) / 0.94))
680
- const shellCenter = Math.max(0.08, 0.96 - normalized * 0.86)
681
- const shellWidth = Math.max(0.24, 0.46 - normalized * 0.16)
682
- const coreRadius = Math.max(0.06, 0.1 + normalized * 0.22)
683
- const coreRatio = Math.max(0.2, Math.min(0.72, 0.24 + normalized * 0.48))
684
-
685
- return { shellCenter, shellWidth, coreRadius, coreRatio }
686
- }
687
-
688
- const selectLayeredNodesForScale = (sourceNodes, targetCount) => {
689
- const hub = state.primaryHub
690
- if (!hub || sourceNodes.length <= 1200 || state.visibleNodes.length <= massiveGraphNodeThreshold) {
691
- return sourceNodes
692
- }
693
-
694
- let maxDistance = 0
695
- const distances = sourceNodes.map((node) => {
696
- const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
697
- if (distance > maxDistance) {
698
- maxDistance = distance
699
- }
700
- return { node, distance }
701
- })
702
-
703
- if (maxDistance <= 0.001) {
704
- return sourceNodes
705
- }
706
-
707
- const focus = layerFocusForScale(state.transform.scale)
708
- const normalizedRows = distances.map((item) => ({
709
- ...item,
710
- normalized: item.distance / maxDistance
711
- }))
712
- const desired = Math.max(260, Math.min(sourceNodes.length, targetCount * 2))
713
- const coreTarget = Math.max(36, Math.min(desired - 8, Math.floor(desired * focus.coreRatio)))
714
- const shellTarget = Math.max(12, desired - coreTarget)
715
- const shellHalf = focus.shellWidth / 2
716
-
717
- const coreNodes = normalizedRows
718
- .filter((item) => item.normalized <= focus.coreRadius)
719
- .sort((left, right) => {
720
- const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
721
- const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
722
- if (leftScore !== rightScore) return rightScore - leftScore
723
- return left.node.id.localeCompare(right.node.id)
724
- })
725
- .slice(0, coreTarget)
726
- .map((item) => item.node)
727
-
728
- const shellNodes = normalizedRows
729
- .sort((left, right) => {
730
- const leftDelta = Math.abs(left.normalized - focus.shellCenter)
731
- const rightDelta = Math.abs(right.normalized - focus.shellCenter)
732
- const leftInside = leftDelta <= shellHalf ? 0 : 1
733
- const rightInside = rightDelta <= shellHalf ? 0 : 1
734
- if (leftInside !== rightInside) return leftInside - rightInside
735
- if (leftDelta !== rightDelta) return leftDelta - rightDelta
736
- const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
737
- const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
738
- if (leftScore !== rightScore) return rightScore - leftScore
739
- return left.node.id.localeCompare(right.node.id)
740
- })
741
- .slice(0, shellTarget)
742
- .map((item) => item.node)
743
-
744
- const merged = []
745
- const ids = new Set()
746
- const pushUnique = (node) => {
747
- if (!node || ids.has(node.id)) return
748
- ids.add(node.id)
749
- merged.push(node)
750
- }
751
-
752
- if (state.transform.scale >= layeredCoreScaleThreshold) {
753
- pushUnique(hub)
754
- }
755
- for (let index = 0; index < coreNodes.length; index += 1) pushUnique(coreNodes[index])
756
- for (let index = 0; index < shellNodes.length; index += 1) pushUnique(shellNodes[index])
757
-
758
- return merged.length > 0 ? merged : sourceNodes
759
- }
760
-
761
667
  const viewportCenterWorldPoint = () => {
762
668
  const viewport = worldViewportBounds()
763
669
  return {
@@ -857,31 +763,6 @@ const selectStableSampleNodes = (sourceNodes, limit) => {
857
763
  .slice(0, limit)
858
764
  }
859
765
 
860
- const selectAccessBridgeNodes = (sourceNodes, limit) => {
861
- if (limit <= 0 || sourceNodes.length === 0) {
862
- return []
863
- }
864
-
865
- const now = performance.now()
866
- const cursorPoint = cursorWorldPoint()
867
- const recentZoomFocus =
868
- now - state.lastZoomFocus.at <= 1200
869
- ? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
870
- : null
871
- const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
872
- return [...sourceNodes]
873
- .sort((left, right) => {
874
- const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
875
- const rightDistance = Math.hypot(right.x - anchor.x, right.y - anchor.y)
876
- if (leftDistance !== rightDistance) return leftDistance - rightDistance
877
- const leftDegree = state.nodeDegrees.get(left.id) ?? 0
878
- const rightDegree = state.nodeDegrees.get(right.id) ?? 0
879
- if (leftDegree !== rightDegree) return rightDegree - leftDegree
880
- return left.id.localeCompare(right.id)
881
- })
882
- .slice(0, limit)
883
- }
884
-
885
766
  const edgeIdentityKey = edge => {
886
767
  if (!edge.target) return ''
887
768
  const pair = edge.source < edge.target
@@ -1002,8 +883,8 @@ const edgeWidthFor = (edge, selectedEdge) => {
1002
883
  const drawGraphEdge = (edge) => {
1003
884
  const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
1004
885
  ctx.beginPath()
1005
- ctx.moveTo(nodeRenderX(edge.sourceNode), nodeRenderY(edge.sourceNode))
1006
- ctx.lineTo(nodeRenderX(edge.targetNode), nodeRenderY(edge.targetNode))
886
+ ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
887
+ ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
1007
888
  ctx.strokeStyle = edgeStrokeFor(edge, selectedEdge)
1008
889
  ctx.lineWidth = edgeWidthFor(edge, selectedEdge)
1009
890
  ctx.stroke()
@@ -1017,8 +898,8 @@ const drawEdgeBatch = (edges, options) => {
1017
898
  ctx.beginPath()
1018
899
  for (let index = 0; index < edges.length; index += 1) {
1019
900
  const edge = edges[index]
1020
- ctx.moveTo(nodeRenderX(edge.sourceNode), nodeRenderY(edge.sourceNode))
1021
- ctx.lineTo(nodeRenderX(edge.targetNode), nodeRenderY(edge.targetNode))
901
+ ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
902
+ ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
1022
903
  }
1023
904
  ctx.strokeStyle = options.strokeStyle
1024
905
  ctx.lineWidth = options.lineWidth
@@ -1073,13 +954,11 @@ const shouldDrawNodeLabels = (node, isSelected, isHovered) =>
1073
954
  (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
1074
955
 
1075
956
  const drawSingleNode = (node, options = { drawLabel: true }) => {
1076
- const radius = nodeRadius(node) * nodeRenderScale(node)
1077
- const x = nodeRenderX(node)
1078
- const y = nodeRenderY(node)
1079
- const opacity = nodeRenderOpacity(node)
957
+ const radius = nodeRadius(node)
958
+ const x = node.x
959
+ const y = node.y
1080
960
  const isSelected = state.selected?.id === node.id
1081
961
  const isHovered = state.hovered?.id === node.id
1082
- ctx.globalAlpha = opacity
1083
962
  ctx.beginPath()
1084
963
  ctx.arc(x, y, radius + (isSelected ? 7 : isHovered ? 4 : 0), 0, Math.PI * 2)
1085
964
  ctx.fillStyle = isSelected || isHovered ? graphTheme.nodeHaloActive : graphTheme.nodeHalo
@@ -1093,7 +972,7 @@ const drawSingleNode = (node, options = { drawLabel: true }) => {
1093
972
  ctx.stroke()
1094
973
 
1095
974
  if (options.drawLabel && shouldDrawNodeLabels(node, isSelected, isHovered)) {
1096
- ctx.globalAlpha = Math.max(0.58, opacity)
975
+ ctx.globalAlpha = 1
1097
976
  ctx.fillStyle = graphTheme.label
1098
977
  ctx.font = '12px Inter, system-ui, sans-serif'
1099
978
  ctx.textAlign = 'center'
@@ -1112,10 +991,10 @@ const drawNodeBatch = (nodes) => {
1112
991
  if (drawHalos) {
1113
992
  for (let index = 0; index < nodes.length; index += 1) {
1114
993
  const node = nodes[index]
1115
- const radius = nodeRadius(node) * nodeRenderScale(node)
1116
- const x = nodeRenderX(node)
1117
- const y = nodeRenderY(node)
1118
- ctx.globalAlpha = Math.max(0.2, nodeRenderOpacity(node) * 0.5)
994
+ const radius = nodeRadius(node)
995
+ const x = node.x
996
+ const y = node.y
997
+ ctx.globalAlpha = 0.5
1119
998
  ctx.beginPath()
1120
999
  ctx.arc(x, y, radius + 3, 0, Math.PI * 2)
1121
1000
  ctx.fillStyle = graphTheme.nodeHalo
@@ -1126,11 +1005,10 @@ const drawNodeBatch = (nodes) => {
1126
1005
 
1127
1006
  for (let index = 0; index < nodes.length; index += 1) {
1128
1007
  const node = nodes[index]
1129
- const radius = nodeRadius(node) * nodeRenderScale(node)
1130
- const x = nodeRenderX(node)
1131
- const y = nodeRenderY(node)
1132
- const opacity = nodeRenderOpacity(node)
1133
- ctx.globalAlpha = opacity
1008
+ const radius = nodeRadius(node)
1009
+ const x = node.x
1010
+ const y = node.y
1011
+ ctx.globalAlpha = 1
1134
1012
  ctx.beginPath()
1135
1013
  ctx.arc(x, y, radius, 0, Math.PI * 2)
1136
1014
  ctx.fillStyle = graphTheme.node
@@ -1167,10 +1045,10 @@ const drawGraphNodes = () => {
1167
1045
  ctx.textBaseline = 'top'
1168
1046
  for (let index = 0; index < regularNodes.length; index += 1) {
1169
1047
  const node = regularNodes[index]
1170
- const x = nodeRenderX(node)
1171
- const y = nodeRenderY(node)
1172
- const radius = nodeRadius(node) * nodeRenderScale(node)
1173
- ctx.globalAlpha = Math.max(0.6, nodeRenderOpacity(node))
1048
+ const x = node.x
1049
+ const y = node.y
1050
+ const radius = nodeRadius(node)
1051
+ ctx.globalAlpha = 1
1174
1052
  ctx.fillText(node.title.slice(0, 34), x, y + radius + 8)
1175
1053
  }
1176
1054
  ctx.globalAlpha = 1
@@ -1224,10 +1102,10 @@ const drawGraphLabels = nodes => {
1224
1102
  ctx.textBaseline = 'top'
1225
1103
  for (let index = 0; index < nodes.length; index += 1) {
1226
1104
  const node = nodes[index]
1227
- const x = nodeRenderX(node)
1228
- const y = nodeRenderY(node)
1229
- const radius = nodeRadius(node) * nodeRenderScale(node)
1230
- ctx.globalAlpha = Math.max(0.6, nodeRenderOpacity(node))
1105
+ const x = node.x
1106
+ const y = node.y
1107
+ const radius = nodeRadius(node)
1108
+ ctx.globalAlpha = 1
1231
1109
  ctx.fillText(node.title.slice(0, 34), x, y + radius + 8)
1232
1110
  }
1233
1111
  ctx.globalAlpha = 1
@@ -1435,6 +1313,82 @@ const sampleVisibleNodes = (limit = renderNodeBudget, sourceNodes = state.visibl
1435
1313
  return nodes
1436
1314
  }
1437
1315
 
1316
+ const sampleMassiveOverviewNodes = (limit) => {
1317
+ const sampled = sampleVisibleNodes(limit, state.visibleNodes)
1318
+ return ensureHubNodesInRenderedSet(sampled)
1319
+ }
1320
+
1321
+ const representativeNodeFromBucket = bucket => {
1322
+ if (!bucket || bucket.length === 0) {
1323
+ return null
1324
+ }
1325
+
1326
+ let representative = bucket[0]
1327
+ let representativeDegree = state.nodeDegrees.get(representative.id) ?? 0
1328
+
1329
+ for (let index = 1; index < bucket.length; index += 1) {
1330
+ const candidate = bucket[index]
1331
+ const candidateDegree = state.nodeDegrees.get(candidate.id) ?? 0
1332
+ if (candidateDegree <= representativeDegree) {
1333
+ continue
1334
+ }
1335
+ representative = candidate
1336
+ representativeDegree = candidateDegree
1337
+ }
1338
+
1339
+ return representative
1340
+ }
1341
+
1342
+ const sampleMassiveSegmentRepresentatives = (limit) => {
1343
+ const spatial = state.visibleNodeSpatial
1344
+ if (!spatial || spatial.buckets.size === 0 || limit <= 0) {
1345
+ return []
1346
+ }
1347
+
1348
+ const keys = [...spatial.buckets.keys()].sort()
1349
+ const maxNodes = Math.min(limit, keys.length)
1350
+ const step = Math.max(1, Math.ceil(keys.length / maxNodes))
1351
+ const representatives = []
1352
+
1353
+ for (let index = 0; index < keys.length && representatives.length < maxNodes; index += step) {
1354
+ const representative = representativeNodeFromBucket(spatial.buckets.get(keys[index]))
1355
+ if (representative) {
1356
+ representatives.push(representative)
1357
+ }
1358
+ }
1359
+
1360
+ return representatives
1361
+ }
1362
+
1363
+ const massiveSegmentRepresentativeLimit = (scale, limit) => {
1364
+ if (scale >= massiveSegmentedScaleThreshold) {
1365
+ return 0
1366
+ }
1367
+ if (scale < 0.09) {
1368
+ return Math.min(massiveSegmentRepresentativeBudget, Math.floor(limit * 0.5))
1369
+ }
1370
+ if (scale < 0.18) {
1371
+ return Math.min(620, Math.floor(limit * 0.42))
1372
+ }
1373
+ if (scale < 0.28) {
1374
+ return Math.min(460, Math.floor(limit * 0.34))
1375
+ }
1376
+ return Math.min(260, Math.floor(limit * 0.22))
1377
+ }
1378
+
1379
+ const sampleMassiveSegmentedNodes = (limit, viewport) => {
1380
+ const representativeLimit = massiveSegmentRepresentativeLimit(state.transform.scale, limit)
1381
+ const representatives = sampleMassiveSegmentRepresentatives(representativeLimit)
1382
+ const localLimit = Math.max(1, limit - representatives.length)
1383
+ const localMargin = Math.max(520, Math.min(5200, 780 / Math.max(state.transform.scale, 0.0001)))
1384
+ const localViewport = expandViewportBounds(viewport, localMargin)
1385
+ const localViewportNodes = viewportNodesFromSpatialIndex(localViewport)
1386
+ const localSource = localViewportNodes.length > 0 ? localViewportNodes : state.visibleNodes
1387
+ const localNodes = selectStableSampleNodes(localSource, localLimit)
1388
+
1389
+ return mergeUniqueNodes(representatives, localNodes, limit)
1390
+ }
1391
+
1438
1392
  const enrichSampleWithNeighbors = (nodes) => {
1439
1393
  if (nodes.length === 0) {
1440
1394
  return {
@@ -1724,7 +1678,7 @@ const autoFitScaleRangeByNodeCount = nodeCount => {
1724
1678
  return { min: 0.0085, max: 0.36 }
1725
1679
  }
1726
1680
 
1727
- const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
1681
+ const fitView = (options = { useFiltered: true, preferHubCenter: true }) => {
1728
1682
  const rect = canvas.getBoundingClientRect()
1729
1683
  const width = Math.max(rect.width, 320)
1730
1684
  const height = Math.max(rect.height, 320)
@@ -1777,12 +1731,12 @@ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: t
1777
1731
  markRenderDirty()
1778
1732
  }
1779
1733
 
1780
- const resetView = () => fitView({ useFiltered: false, macro: false, preferHubCenter: false })
1734
+ const resetView = () => fitView({ useFiltered: false, preferHubCenter: false })
1781
1735
 
1782
1736
  const focusPrimaryHub = () => {
1783
1737
  const hub = state.primaryHub
1784
1738
  if (!hub) {
1785
- fitView({ useFiltered: true, macro: false, preferHubCenter: true })
1739
+ fitView({ useFiltered: true, preferHubCenter: true })
1786
1740
  return
1787
1741
  }
1788
1742
 
@@ -2161,9 +2115,9 @@ const hitNode = point => {
2161
2115
  const nodes = state.renderNodes
2162
2116
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
2163
2117
  const node = nodes[index]
2164
- const radius = nodeRadius(node) * nodeRenderScale(node)
2165
- const x = nodeRenderX(node)
2166
- const y = nodeRenderY(node)
2118
+ const radius = nodeRadius(node)
2119
+ const x = node.x
2120
+ const y = node.y
2167
2121
  if (Math.hypot(point.x - x, point.y - y) <= radius + 5) return node
2168
2122
  }
2169
2123
  return null
@@ -2176,15 +2130,6 @@ const baseNodeRadius = node => {
2176
2130
 
2177
2131
  const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
2178
2132
 
2179
- const refreshRenderNodeDepthProjection = () => {
2180
- state.renderNodeDepthProjectionById = new Map()
2181
- }
2182
-
2183
- const projectedNode = node => state.renderNodeDepthProjectionById.get(node.id) ?? null
2184
- const nodeRenderX = node => projectedNode(node)?.x ?? node.x
2185
- const nodeRenderY = node => projectedNode(node)?.y ?? node.y
2186
- const nodeRenderScale = node => projectedNode(node)?.scale ?? 1
2187
- const nodeRenderOpacity = node => projectedNode(node)?.opacity ?? 1
2188
2133
  const worldViewportBounds = () => {
2189
2134
  const width = Math.max(state.viewport.width, 320)
2190
2135
  const height = Math.max(state.viewport.height, 320)
@@ -2264,9 +2209,26 @@ const computeRenderVisibility = () => {
2264
2209
  }
2265
2210
 
2266
2211
  if (state.visibleNodes.length > massiveGraphNodeThreshold) {
2267
- const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2268
- const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
2269
2212
  const sampleLimit = nodeBudgetForScale(state.transform.scale)
2213
+ if (state.transform.scale < massiveOverviewScaleThreshold) {
2214
+ const overviewNodes = sampleMassiveOverviewNodes(sampleLimit)
2215
+ const overviewIds = new Set(overviewNodes.map((node) => node.id))
2216
+ state.renderNodes = overviewNodes
2217
+ state.renderEdges = withMeshEdges(overviewNodes, collectVisibleEdgesForNodes(overviewIds))
2218
+ return
2219
+ }
2220
+
2221
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2222
+ const segmentedNodes =
2223
+ state.transform.scale < massiveSegmentedScaleThreshold
2224
+ ? sampleMassiveSegmentedNodes(sampleLimit, viewport)
2225
+ : []
2226
+ const sourceNodes =
2227
+ segmentedNodes.length > 0
2228
+ ? segmentedNodes
2229
+ : viewportNodes.length > 0
2230
+ ? viewportNodes
2231
+ : sampleMassiveOverviewNodes(sampleLimit)
2270
2232
  const carryMargin = Math.max(240, Math.min(1200, 340 / Math.max(state.transform.scale, 0.0001)))
2271
2233
  const carryViewport = expandViewportBounds(viewport, carryMargin)
2272
2234
  const carryOverLimit = Math.max(180, Math.min(sampleLimit, Math.floor(sampleLimit * 0.5)))
@@ -2441,10 +2403,11 @@ const render = now => {
2441
2403
 
2442
2404
  computeRenderVisibility()
2443
2405
  tick(delta, now)
2444
- refreshRenderNodeDepthProjection()
2445
2406
  const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
2446
2407
  const manualZoomGuardActive = now - state.lastManualZoomAt < zoomRecoveryGuardMs
2447
- const allowViewportAutoRecovery = state.nodes.length <= massiveGraphNodeThreshold
2408
+ const allowViewportAutoRecovery =
2409
+ state.nodes.length <= massiveGraphNodeThreshold ||
2410
+ state.transform.scale >= massiveOverviewScaleThreshold
2448
2411
  if (allowViewportAutoRecovery && !hasVisibleNodeOnScreen && state.renderNodes.length > 0 && !manualZoomGuardActive) {
2449
2412
  state.offscreenFrameCount += 1
2450
2413
  if (state.offscreenFrameCount >= 22 && !state.recoveringViewport) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.116",
3
+ "version": "0.1.0-beta.118",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",