@andespindola/brainlink 0.1.0-beta.112 → 0.1.0-beta.114

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -595,6 +595,8 @@ The graph UI shows:
595
595
  - realtime refresh while `--watch` is enabled
596
596
  - graph controls for zoom in, zoom out, fit visible nodes and reset-to-fit-all
597
597
  - wheel zoom (including `cmd+scroll` and `ctrl+scroll`) anchored to cursor position for faster navigation in large graphs
598
+ - continuous target-scale interpolation for wheel/button zoom to avoid abrupt LOD jumps while keeping cursor-anchored focus
599
+ - bloom-like navigation profile: wheel zoom now biases focus toward hovered/selected/hub nodes and applies subtle camera-anchor parallax so depth remains stable while zooming and panning
598
600
  - zoom-out floor for large and massive graphs, plus reset macro floor tied to hub-neighbor distance and first-level cluster spacing, with a tighter initial camera so the first graph appears as a closer cell instead of a distant mesh
599
601
  - keyboard shortcuts: `+` zoom in, `-` zoom out, `0` reset fit
600
602
  - double-click on canvas zooms in at cursor position
@@ -13,8 +13,8 @@ const macroGalaxyZoomThreshold = 0.012
13
13
  const macroGalaxyEnterHysteresis = 0.86
14
14
  const macroGalaxyExitHysteresis = 1.24
15
15
  const galaxyDiscoveryEnabled = false
16
- const massiveAutoFitMacroScale = 0.013
17
- const defaultMacroScale = 0.013
16
+ const massiveAutoFitMacroScale = 0.018
17
+ const defaultMacroScale = 0.018
18
18
  const clusterCellPixelSize = 64
19
19
  const minNodePixelRadius = 2.3
20
20
  const viewportPaddingPx = 280
@@ -28,32 +28,38 @@ const ecosystemHubEdgeLimit = 120
28
28
  const ecosystemSiblingEdgeLimit = 180
29
29
  const ecosystemClusterScaleThreshold = 0.78
30
30
  const massiveEcosystemClusterScaleThreshold = 4.2
31
+ const ecosystemClusterEnterHysteresis = 0.94
32
+ const ecosystemClusterExitHysteresis = 1.1
31
33
  const ecosystemSubgraphScaleThreshold = 0.18
32
34
  const ecosystemMicroScaleThreshold = 0.08
33
35
  const ecosystemFocusedParentLimit = 2
34
36
  const ecosystemDepthNear = 80
35
37
  const ecosystemDepthFar = 2600
36
- const ecosystemDepthPerspective = 620
37
- const ecosystemDepthTiltY = 0.24
38
- const ecosystemDepthYaw = 0.22
39
- const ecosystemDepthPitch = 0.16
40
- const ecosystemDepthRadialGain = 0.09
38
+ const ecosystemDepthPerspective = 560
39
+ const ecosystemDepthTiltY = 0.3
40
+ const ecosystemDepthYaw = 0.3
41
+ const ecosystemDepthPitch = 0.24
42
+ const ecosystemDepthRadialGain = 0.13
41
43
  const ecosystemDepthOrbitalMaxOffset = 160
42
- const ecosystemDepthMinScale = 0.24
43
- const ecosystemDepthOpacityFloor = 0.2
44
+ const ecosystemDepthMinScale = 0.2
45
+ const ecosystemDepthOpacityFloor = 0.16
44
46
  const graphDepthNear = 40
45
- const graphDepthFar = 1180
46
- const graphDepthPerspective = 520
47
- const graphDepthYaw = 0.34
48
- const graphDepthPitch = 0.24
49
- const graphDepthRadialGain = 0.18
50
- const graphDepthMinScale = 0.42
51
- const graphDepthOpacityFloor = 0.28
52
- const graphDepthEdgeOpacityFloor = 0.16
47
+ const graphDepthFar = 1320
48
+ const graphDepthPerspective = 430
49
+ const graphDepthYaw = 0.42
50
+ const graphDepthPitch = 0.3
51
+ const graphDepthRadialGain = 0.24
52
+ const graphDepthMinScale = 0.34
53
+ const graphDepthOpacityFloor = 0.22
54
+ const graphDepthEdgeOpacityFloor = 0.12
53
55
  const graphDepthProjectionNodeThreshold = 40
54
- const graphDepthProjectionNodeCap = 1200
55
- const graphDepthProjectionMinScale = 0.045
56
- const graphDepthProjectionMaxScale = 1.45
56
+ const graphDepthProjectionNodeCap = 2600
57
+ const graphDepthProjectionMinScale = 0.03
58
+ const graphDepthProjectionMaxScale = 1.7
59
+ const graphDepthProjectionEnterMinScale = graphDepthProjectionMinScale * 1.08
60
+ const graphDepthProjectionExitMinScale = graphDepthProjectionMinScale * 0.88
61
+ const graphDepthProjectionEnterMaxScale = graphDepthProjectionMaxScale * 0.92
62
+ const graphDepthProjectionExitMaxScale = graphDepthProjectionMaxScale * 1.16
57
63
  const zoomRecoveryGuardMs = 4200
58
64
  const zoomCapTargetViewportShare = 0.72
59
65
  const meshEdgeScaleThreshold = 0.09
@@ -65,6 +71,17 @@ const dragSettleRounds = 3
65
71
  const wheelZoomExponent = 0.0009
66
72
  const wheelZoomExponentCap = 0.035
67
73
  const wheelZoomModifierBoost = 1.08
74
+ const wheelZoomInputFloorCap = 0.976
75
+ const wheelZoomInputCeilCap = 1.024
76
+ const zoomAnimationSlowLerp = 0.18
77
+ const zoomAnimationFastLerp = 0.36
78
+ const zoomAnimationScaleSnap = 0.00008
79
+ const zoomAnimationPositionSnap = 0.14
80
+ const bloomZoomFocusMaxScreenDistance = 260
81
+ const bloomZoomFocusFarStrength = 0.66
82
+ const bloomZoomFocusNearStrength = 0.34
83
+ const bloomCameraParallaxNearStrength = 0.2
84
+ const bloomCameraParallaxFarStrength = 0.06
68
85
  const physicsDragFrameIntervalMs = 16
69
86
  const physicsIdleFrameIntervalMs = 78
70
87
  const physicsLargeGraphIdleFrameIntervalMs = 108
@@ -123,7 +140,18 @@ const state = {
123
140
  lastHoverHitAt: 0,
124
141
  lastManualZoomAt: 0,
125
142
  lastZoomFocus: { x: 0, y: 0, at: 0 },
126
- macroViewActive: false
143
+ macroViewActive: false,
144
+ ecosystemViewActive: false,
145
+ depthProjectionActive: false,
146
+ zoomTransition: {
147
+ active: false,
148
+ source: 'generic',
149
+ screenX: 0,
150
+ screenY: 0,
151
+ worldX: 0,
152
+ worldY: 0,
153
+ targetScale: 1
154
+ }
127
155
  }
128
156
 
129
157
  const byId = id => document.getElementById(id)
@@ -1247,19 +1275,20 @@ const projectEcosystemPoint = (x, y, depth, anchor) => {
1247
1275
 
1248
1276
  const applyEcosystemDepthProjection = (clusters, edges, anchor) => {
1249
1277
  const levelIndexMap = ecosystemLevelIndexBySize()
1278
+ const effectiveAnchor = applyBloomCameraParallax(anchor)
1250
1279
  const projectedClusters = []
1251
1280
  const clusterById = new Map()
1252
1281
 
1253
1282
  for (let index = 0; index < clusters.length; index += 1) {
1254
1283
  const cluster = clusters[index]
1255
1284
  const baseDepth = ecosystemDepthForCluster(cluster, levelIndexMap)
1256
- const radialDistance = Math.hypot(cluster.x - anchor.x, cluster.y - anchor.y)
1285
+ const radialDistance = Math.hypot(cluster.x - effectiveAnchor.x, cluster.y - effectiveAnchor.y)
1257
1286
  const radialOffset = cluster.isHub ? 0 : Math.min(320, radialDistance * ecosystemDepthRadialGain)
1258
1287
  const orbitalOffset = cluster.isHub
1259
1288
  ? 0
1260
- : Math.sin(Math.atan2(cluster.y - anchor.y, cluster.x - anchor.x) * 2.2) * ecosystemDepthOrbitalMaxOffset
1289
+ : Math.sin(Math.atan2(cluster.y - effectiveAnchor.y, cluster.x - effectiveAnchor.x) * 2.2) * ecosystemDepthOrbitalMaxOffset
1261
1290
  const depth = Math.max(0, baseDepth + radialOffset + orbitalOffset)
1262
- const projected = projectEcosystemPoint(cluster.x, cluster.y, depth, anchor)
1291
+ const projected = projectEcosystemPoint(cluster.x, cluster.y, depth, effectiveAnchor)
1263
1292
  const baseOpacity = Number.isFinite(cluster.lodOpacity) ? cluster.lodOpacity : 1
1264
1293
  const depthScale = ecosystemDepthMinScale + (1 - ecosystemDepthMinScale) * projected.factor
1265
1294
  const depthOpacity = Math.max(
@@ -1591,12 +1620,107 @@ const cursorWorldPoint = () => {
1591
1620
  return screenToWorldPoint(screenX, screenY)
1592
1621
  }
1593
1622
 
1623
+ const bloomZoomFocusStrength = (scale) => {
1624
+ if (scale <= 0.08) return bloomZoomFocusFarStrength
1625
+ if (scale <= 0.2) return 0.58
1626
+ if (scale <= 0.45) return 0.5
1627
+ if (scale <= 0.9) return 0.42
1628
+ return bloomZoomFocusNearStrength
1629
+ }
1630
+
1631
+ const bloomCameraParallaxStrength = (scale) => {
1632
+ if (scale <= 0.08) return bloomCameraParallaxNearStrength
1633
+ if (scale <= 0.2) return 0.16
1634
+ if (scale <= 0.45) return 0.12
1635
+ return bloomCameraParallaxFarStrength
1636
+ }
1637
+
1638
+ const screenPointForWorld = (worldX, worldY) => ({
1639
+ x: worldX * state.transform.scale + state.transform.x,
1640
+ y: worldY * state.transform.scale + state.transform.y
1641
+ })
1642
+
1643
+ const bloomZoomFocusCandidate = (screenX, screenY) => {
1644
+ const candidates = [state.hovered, state.selected, state.primaryHub].filter(Boolean)
1645
+ if (candidates.length === 0) {
1646
+ return null
1647
+ }
1648
+
1649
+ let bestNode = null
1650
+ let bestDistance = Number.POSITIVE_INFINITY
1651
+ for (let index = 0; index < candidates.length; index += 1) {
1652
+ const node = candidates[index]
1653
+ const point = screenPointForWorld(nodeRenderX(node), nodeRenderY(node))
1654
+ const distance = Math.hypot(point.x - screenX, point.y - screenY)
1655
+ if (distance < bestDistance) {
1656
+ bestDistance = distance
1657
+ bestNode = node
1658
+ }
1659
+ }
1660
+
1661
+ if (!bestNode) {
1662
+ return null
1663
+ }
1664
+
1665
+ const scale = state.transform.scale
1666
+ const allowDistance = scale <= 0.16 ? bloomZoomFocusMaxScreenDistance * 1.7 : bloomZoomFocusMaxScreenDistance
1667
+ if (bestDistance > allowDistance) {
1668
+ return null
1669
+ }
1670
+
1671
+ return {
1672
+ x: nodeRenderX(bestNode),
1673
+ y: nodeRenderY(bestNode)
1674
+ }
1675
+ }
1676
+
1677
+ const resolveZoomAnchorWorldPoint = (screenX, screenY, source) => {
1678
+ const cursorPoint = screenToWorldPoint(screenX, screenY)
1679
+ if (source !== 'wheel') {
1680
+ return cursorPoint
1681
+ }
1682
+
1683
+ const focusCandidate = bloomZoomFocusCandidate(screenX, screenY)
1684
+ if (!focusCandidate) {
1685
+ return cursorPoint
1686
+ }
1687
+
1688
+ const strength = bloomZoomFocusStrength(state.transform.scale)
1689
+ return {
1690
+ x: cursorPoint.x + (focusCandidate.x - cursorPoint.x) * strength,
1691
+ y: cursorPoint.y + (focusCandidate.y - cursorPoint.y) * strength
1692
+ }
1693
+ }
1694
+
1695
+ const applyBloomCameraParallax = (anchor) => {
1696
+ const cursorPoint = cursorWorldPoint()
1697
+ if (!cursorPoint) {
1698
+ return anchor
1699
+ }
1700
+
1701
+ const strength = bloomCameraParallaxStrength(state.transform.scale)
1702
+ return {
1703
+ x: anchor.x + (cursorPoint.x - anchor.x) * strength,
1704
+ y: anchor.y + (cursorPoint.y - anchor.y) * strength
1705
+ }
1706
+ }
1707
+
1594
1708
  const visibilityScaleBucket = (scale) => {
1595
1709
  const safeScale = Math.max(zoomRange.min, scale)
1596
- if (safeScale < 0.01) return Math.round(safeScale * 300_000)
1597
- if (safeScale < 0.05) return Math.round(safeScale * 120_000)
1598
- if (safeScale < 0.2) return Math.round(safeScale * 40_000)
1599
- return Math.round(safeScale * 8_000)
1710
+ return Math.round(safeScale * 180_000)
1711
+ }
1712
+
1713
+ const shouldRenderEcosystemClusterView = (nodeCount, scale) => {
1714
+ const baseThreshold = nodeCount > massiveGraphNodeThreshold
1715
+ ? massiveEcosystemClusterScaleThreshold
1716
+ : ecosystemClusterScaleThreshold
1717
+ const enterThreshold = baseThreshold * ecosystemClusterEnterHysteresis
1718
+ const exitThreshold = baseThreshold * ecosystemClusterExitHysteresis
1719
+ const shouldRender = state.ecosystemViewActive
1720
+ ? scale <= exitThreshold
1721
+ : scale <= enterThreshold
1722
+ state.ecosystemViewActive = shouldRender
1723
+ return shouldRender
1600
1724
  }
1601
1725
 
1602
1726
  const shouldRenderMacroGalaxyView = () => {
@@ -2462,7 +2586,7 @@ const currentZoomMax = () => {
2462
2586
  }
2463
2587
 
2464
2588
  const zoomFloorByNodeCount = (nodeCount) => {
2465
- if (nodeCount > massiveGraphNodeThreshold) return 0.0085
2589
+ if (nodeCount > massiveGraphNodeThreshold) return 0.018
2466
2590
  if (nodeCount > largeGraphNodeThreshold) return 0.0032
2467
2591
  if (nodeCount > ecosystemActivationNodeThreshold) return 0.001
2468
2592
  return zoomRange.min
@@ -2483,6 +2607,52 @@ const clampTransformCoordinate = value => {
2483
2607
  return value
2484
2608
  }
2485
2609
 
2610
+ const clearZoomTransition = () => {
2611
+ state.zoomTransition.active = false
2612
+ state.zoomTransition.targetScale = state.transform.scale
2613
+ }
2614
+
2615
+ const zoomTransitionLerp = (delta, source) => {
2616
+ const normalizedDelta = Math.max(0, Math.min(1, delta / 32))
2617
+ const base = zoomAnimationSlowLerp + (zoomAnimationFastLerp - zoomAnimationSlowLerp) * normalizedDelta
2618
+ const sourceBoost = source === 'wheel' ? 1 : 1.25
2619
+ return Math.max(0.08, Math.min(0.45, base * sourceBoost))
2620
+ }
2621
+
2622
+ const applyZoomTransition = (delta) => {
2623
+ if (!state.zoomTransition.active) {
2624
+ return
2625
+ }
2626
+
2627
+ const targetScale = clampScale(state.zoomTransition.targetScale)
2628
+ const scaleDelta = targetScale - state.transform.scale
2629
+ const targetX = clampTransformCoordinate(state.zoomTransition.screenX - state.zoomTransition.worldX * targetScale)
2630
+ const targetY = clampTransformCoordinate(state.zoomTransition.screenY - state.zoomTransition.worldY * targetScale)
2631
+ const scaleSettled = Math.abs(scaleDelta) <= zoomAnimationScaleSnap
2632
+
2633
+ if (scaleSettled) {
2634
+ state.transform.scale = targetScale
2635
+ state.transform.x = targetX
2636
+ state.transform.y = targetY
2637
+ clearZoomTransition()
2638
+ return
2639
+ }
2640
+
2641
+ const lerp = zoomTransitionLerp(delta, state.zoomTransition.source)
2642
+ const nextScale = clampScale(state.transform.scale + scaleDelta * lerp)
2643
+ state.transform.scale = nextScale
2644
+ state.transform.x = clampTransformCoordinate(state.zoomTransition.screenX - state.zoomTransition.worldX * nextScale)
2645
+ state.transform.y = clampTransformCoordinate(state.zoomTransition.screenY - state.zoomTransition.worldY * nextScale)
2646
+ const settledX = Math.abs(targetX - state.transform.x) <= zoomAnimationPositionSnap
2647
+ const settledY = Math.abs(targetY - state.transform.y) <= zoomAnimationPositionSnap
2648
+ if (Math.abs(targetScale - nextScale) <= zoomAnimationScaleSnap && settledX && settledY) {
2649
+ state.transform.scale = targetScale
2650
+ state.transform.x = targetX
2651
+ state.transform.y = targetY
2652
+ clearZoomTransition()
2653
+ }
2654
+ }
2655
+
2486
2656
  const graphBounds = nodes => {
2487
2657
  if (nodes.length === 0) return null
2488
2658
  let minX = Number.POSITIVE_INFINITY
@@ -2537,7 +2707,7 @@ const macroFaceToFaceScale = (nodeCount, hubDistance) => {
2537
2707
 
2538
2708
  const rect = canvas.getBoundingClientRect()
2539
2709
  const viewportReference = Math.max(320, Math.min(rect.width, rect.height))
2540
- const share = nodeCount > massiveGraphNodeThreshold ? 0.14 : 0.12
2710
+ const share = nodeCount > massiveGraphNodeThreshold ? 0.2 : 0.17
2541
2711
  const targetPx = Math.max(24, viewportReference * share)
2542
2712
  return targetPx / hubDistance
2543
2713
  }
@@ -2576,7 +2746,7 @@ const macroEcosystemFaceScale = (nodeCount) => {
2576
2746
 
2577
2747
  const rect = canvas.getBoundingClientRect()
2578
2748
  const viewportReference = Math.max(320, Math.min(rect.width, rect.height))
2579
- const targetShare = nodeCount > massiveGraphNodeThreshold ? 0.2 : 0.16
2749
+ const targetShare = nodeCount > massiveGraphNodeThreshold ? 0.28 : 0.24
2580
2750
  const targetPx = Math.max(30, viewportReference * targetShare)
2581
2751
  return targetPx / nearestDistance
2582
2752
  }
@@ -2589,6 +2759,7 @@ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: t
2589
2759
 
2590
2760
  if (!bounds) {
2591
2761
  state.transform = { x: width / 2, y: height / 2, scale: 1 }
2762
+ clearZoomTransition()
2592
2763
  state.offscreenFrameCount = 0
2593
2764
  state.recoveringViewport = false
2594
2765
  markRenderDirty()
@@ -2638,6 +2809,7 @@ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: t
2638
2809
  y: clampTransformCoordinate(height / 2 - centerY * resolvedScale),
2639
2810
  scale: clampScale(resolvedScale)
2640
2811
  }
2812
+ clearZoomTransition()
2641
2813
  state.offscreenFrameCount = 0
2642
2814
  state.recoveringViewport = false
2643
2815
  markRenderDirty()
@@ -2662,6 +2834,7 @@ const focusPrimaryHub = () => {
2662
2834
  y: clampTransformCoordinate(height / 2 - hub.y * targetScale),
2663
2835
  scale: targetScale
2664
2836
  }
2837
+ clearZoomTransition()
2665
2838
  state.offscreenFrameCount = 0
2666
2839
  markRenderDirty()
2667
2840
  }
@@ -3068,13 +3241,25 @@ const clusterOpacity = cluster =>
3068
3241
  const clusterDepth = cluster => Number.isFinite(cluster.depth) ? cluster.depth : ecosystemDepthNear
3069
3242
  const clusterDepthScale = cluster => Number.isFinite(cluster.depthScale) ? cluster.depthScale : 1
3070
3243
 
3071
- const shouldProjectRenderNodesInDepth = () =>
3072
- state.renderClusters.length === 0 &&
3073
- state.renderNodes.length >= graphDepthProjectionNodeThreshold &&
3074
- state.renderNodes.length <= graphDepthProjectionNodeCap &&
3075
- state.transform.scale >= graphDepthProjectionMinScale &&
3076
- state.transform.scale <= graphDepthProjectionMaxScale &&
3077
- !state.pointer.down
3244
+ const shouldProjectRenderNodesInDepth = () => {
3245
+ const withinNodeCountWindow =
3246
+ state.renderClusters.length === 0 &&
3247
+ state.renderNodes.length >= graphDepthProjectionNodeThreshold &&
3248
+ state.renderNodes.length <= graphDepthProjectionNodeCap &&
3249
+ !state.pointer.down
3250
+
3251
+ if (!withinNodeCountWindow) {
3252
+ state.depthProjectionActive = false
3253
+ return false
3254
+ }
3255
+
3256
+ const scale = state.transform.scale
3257
+ const shouldProject = state.depthProjectionActive
3258
+ ? scale >= graphDepthProjectionExitMinScale && scale <= graphDepthProjectionExitMaxScale
3259
+ : scale >= graphDepthProjectionEnterMinScale && scale <= graphDepthProjectionEnterMaxScale
3260
+ state.depthProjectionActive = shouldProject
3261
+ return shouldProject
3262
+ }
3078
3263
 
3079
3264
  const nodeProjectionAnchor = () => {
3080
3265
  const hub = state.primaryHub
@@ -3114,7 +3299,7 @@ const refreshRenderNodeDepthProjection = () => {
3114
3299
  return
3115
3300
  }
3116
3301
 
3117
- const anchor = nodeProjectionAnchor()
3302
+ const anchor = applyBloomCameraParallax(nodeProjectionAnchor())
3118
3303
  let maxDistance = 1
3119
3304
  for (let index = 0; index < state.renderNodes.length; index += 1) {
3120
3305
  const node = state.renderNodes[index]
@@ -3312,12 +3497,9 @@ const computeRenderVisibility = () => {
3312
3497
  return
3313
3498
  }
3314
3499
 
3315
- const ecosystemScaleThreshold = state.visibleNodes.length > massiveGraphNodeThreshold
3316
- ? massiveEcosystemClusterScaleThreshold
3317
- : ecosystemClusterScaleThreshold
3318
3500
  if (
3319
3501
  state.ecosystemExpansionLevels.length > 0 &&
3320
- state.transform.scale <= ecosystemScaleThreshold &&
3502
+ shouldRenderEcosystemClusterView(state.visibleNodes.length, state.transform.scale) &&
3321
3503
  state.ecosystemClusters.length > 0
3322
3504
  ) {
3323
3505
  const clusters = selectHierarchicalEcosystemClusters(viewport)
@@ -3331,6 +3513,7 @@ const computeRenderVisibility = () => {
3331
3513
  state.renderEdges = []
3332
3514
  return
3333
3515
  }
3516
+ state.ecosystemViewActive = false
3334
3517
 
3335
3518
  if (state.visibleNodes.length <= 2000) {
3336
3519
  state.renderNodes = state.visibleNodes
@@ -3509,7 +3692,8 @@ const render = now => {
3509
3692
  const isInteracting =
3510
3693
  state.pointer.down ||
3511
3694
  state.renderVisibilityDirty ||
3512
- state.recoveringViewport
3695
+ state.recoveringViewport ||
3696
+ state.zoomTransition.active
3513
3697
  const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
3514
3698
  if (delta < minFrameIntervalMs) {
3515
3699
  requestAnimationFrame(render)
@@ -3523,6 +3707,7 @@ const render = now => {
3523
3707
  if (!hasValidTransform()) {
3524
3708
  resetView()
3525
3709
  }
3710
+ applyZoomTransition(delta)
3526
3711
  ctx.clearRect(0, 0, width, height)
3527
3712
  webGlRenderer?.clear(width, height)
3528
3713
  if (state.nodes.length === 0) {
@@ -3752,12 +3937,23 @@ const selectNodeById = id => {
3752
3937
 
3753
3938
  const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
3754
3939
  state.lastManualZoomAt = performance.now()
3755
- const effectiveFactor = factor
3756
- const nextScale = clampScale(state.transform.scale * effectiveFactor)
3757
- if (nextScale === state.transform.scale) {
3940
+ const baseScale = state.zoomTransition.active
3941
+ ? state.zoomTransition.targetScale
3942
+ : state.transform.scale
3943
+ const boundedFactor = source === 'wheel'
3944
+ ? Math.max(wheelZoomInputFloorCap, Math.min(wheelZoomInputCeilCap, factor))
3945
+ : factor
3946
+ const nextScale = clampScale(baseScale * boundedFactor)
3947
+ if (nextScale === baseScale && !state.zoomTransition.active) {
3758
3948
  return
3759
3949
  }
3760
- const worldPointAtCursor = screenToWorldPoint(screenX, screenY)
3950
+ const worldPointAtCursor =
3951
+ state.zoomTransition.active &&
3952
+ state.zoomTransition.source === source &&
3953
+ state.zoomTransition.screenX === screenX &&
3954
+ state.zoomTransition.screenY === screenY
3955
+ ? { x: state.zoomTransition.worldX, y: state.zoomTransition.worldY }
3956
+ : resolveZoomAnchorWorldPoint(screenX, screenY, source)
3761
3957
  const worldX = worldPointAtCursor.x
3762
3958
  const worldY = worldPointAtCursor.y
3763
3959
  state.lastZoomFocus = {
@@ -3765,9 +3961,15 @@ const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
3765
3961
  y: worldY,
3766
3962
  at: performance.now()
3767
3963
  }
3768
- state.transform.scale = clampScale(nextScale)
3769
- state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
3770
- state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
3964
+ state.zoomTransition = {
3965
+ active: true,
3966
+ source,
3967
+ screenX,
3968
+ screenY,
3969
+ worldX,
3970
+ worldY,
3971
+ targetScale: nextScale
3972
+ }
3771
3973
  state.offscreenFrameCount = 0
3772
3974
  markRenderDirty()
3773
3975
  }
@@ -3797,7 +3999,11 @@ const wheelZoomFactor = event => {
3797
3999
  -exponentCap,
3798
4000
  Math.min(exponentCap, -normalizedDelta * sensitivity)
3799
4001
  )
3800
- return Math.exp(exponent)
4002
+ const factor = Math.exp(exponent)
4003
+ if (factor > 1) {
4004
+ return Math.min(factor, wheelZoomInputCeilCap)
4005
+ }
4006
+ return Math.max(factor, wheelZoomInputFloorCap)
3801
4007
  }
3802
4008
 
3803
4009
  const handleWheelZoom = event => {
@@ -3881,6 +4087,7 @@ const bindEvents = () => {
3881
4087
  zoomAtPoint(cursorX, cursorY, 1.055)
3882
4088
  })
3883
4089
  canvas.addEventListener('pointerdown', event => {
4090
+ clearZoomTransition()
3884
4091
  const point = worldPoint(event)
3885
4092
  const node = hitNode(point)
3886
4093
  state.pointer = { x: event.clientX, y: event.clientY, down: true, dragNode: node, moved: false }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.112",
3
+ "version": "0.1.0-beta.114",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",