@andespindola/brainlink 0.1.0-beta.111 → 0.1.0-beta.113

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,7 @@ 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
598
599
  - 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
600
  - keyboard shortcuts: `+` zoom in, `-` zoom out, `0` reset fit
600
601
  - 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.006
17
- const defaultMacroScale = 0.006
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 = 560
46
- const graphDepthPerspective = 760
47
- const graphDepthYaw = 0.2
48
- const graphDepthPitch = 0.12
49
- const graphDepthRadialGain = 0.08
50
- const graphDepthMinScale = 0.58
51
- const graphDepthOpacityFloor = 0.42
52
- const graphDepthEdgeOpacityFloor = 0.24
53
- const graphDepthProjectionNodeThreshold = 120
54
- const graphDepthProjectionNodeCap = 980
55
- const graphDepthProjectionMinScale = 0.08
56
- const graphDepthProjectionMaxScale = 0.95
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
55
+ const graphDepthProjectionNodeThreshold = 40
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,12 @@ 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
68
80
  const physicsDragFrameIntervalMs = 16
69
81
  const physicsIdleFrameIntervalMs = 78
70
82
  const physicsLargeGraphIdleFrameIntervalMs = 108
@@ -123,7 +135,18 @@ const state = {
123
135
  lastHoverHitAt: 0,
124
136
  lastManualZoomAt: 0,
125
137
  lastZoomFocus: { x: 0, y: 0, at: 0 },
126
- macroViewActive: false
138
+ macroViewActive: false,
139
+ ecosystemViewActive: false,
140
+ depthProjectionActive: false,
141
+ zoomTransition: {
142
+ active: false,
143
+ source: 'generic',
144
+ screenX: 0,
145
+ screenY: 0,
146
+ worldX: 0,
147
+ worldY: 0,
148
+ targetScale: 1
149
+ }
127
150
  }
128
151
 
129
152
  const byId = id => document.getElementById(id)
@@ -1593,10 +1616,20 @@ const cursorWorldPoint = () => {
1593
1616
 
1594
1617
  const visibilityScaleBucket = (scale) => {
1595
1618
  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)
1619
+ return Math.round(safeScale * 180_000)
1620
+ }
1621
+
1622
+ const shouldRenderEcosystemClusterView = (nodeCount, scale) => {
1623
+ const baseThreshold = nodeCount > massiveGraphNodeThreshold
1624
+ ? massiveEcosystemClusterScaleThreshold
1625
+ : ecosystemClusterScaleThreshold
1626
+ const enterThreshold = baseThreshold * ecosystemClusterEnterHysteresis
1627
+ const exitThreshold = baseThreshold * ecosystemClusterExitHysteresis
1628
+ const shouldRender = state.ecosystemViewActive
1629
+ ? scale <= exitThreshold
1630
+ : scale <= enterThreshold
1631
+ state.ecosystemViewActive = shouldRender
1632
+ return shouldRender
1600
1633
  }
1601
1634
 
1602
1635
  const shouldRenderMacroGalaxyView = () => {
@@ -2462,8 +2495,8 @@ const currentZoomMax = () => {
2462
2495
  }
2463
2496
 
2464
2497
  const zoomFloorByNodeCount = (nodeCount) => {
2465
- if (nodeCount > massiveGraphNodeThreshold) return 0.0042
2466
- if (nodeCount > largeGraphNodeThreshold) return 0.0021
2498
+ if (nodeCount > massiveGraphNodeThreshold) return 0.018
2499
+ if (nodeCount > largeGraphNodeThreshold) return 0.0032
2467
2500
  if (nodeCount > ecosystemActivationNodeThreshold) return 0.001
2468
2501
  return zoomRange.min
2469
2502
  }
@@ -2483,6 +2516,52 @@ const clampTransformCoordinate = value => {
2483
2516
  return value
2484
2517
  }
2485
2518
 
2519
+ const clearZoomTransition = () => {
2520
+ state.zoomTransition.active = false
2521
+ state.zoomTransition.targetScale = state.transform.scale
2522
+ }
2523
+
2524
+ const zoomTransitionLerp = (delta, source) => {
2525
+ const normalizedDelta = Math.max(0, Math.min(1, delta / 32))
2526
+ const base = zoomAnimationSlowLerp + (zoomAnimationFastLerp - zoomAnimationSlowLerp) * normalizedDelta
2527
+ const sourceBoost = source === 'wheel' ? 1 : 1.25
2528
+ return Math.max(0.08, Math.min(0.45, base * sourceBoost))
2529
+ }
2530
+
2531
+ const applyZoomTransition = (delta) => {
2532
+ if (!state.zoomTransition.active) {
2533
+ return
2534
+ }
2535
+
2536
+ const targetScale = clampScale(state.zoomTransition.targetScale)
2537
+ const scaleDelta = targetScale - state.transform.scale
2538
+ const targetX = clampTransformCoordinate(state.zoomTransition.screenX - state.zoomTransition.worldX * targetScale)
2539
+ const targetY = clampTransformCoordinate(state.zoomTransition.screenY - state.zoomTransition.worldY * targetScale)
2540
+ const scaleSettled = Math.abs(scaleDelta) <= zoomAnimationScaleSnap
2541
+
2542
+ if (scaleSettled) {
2543
+ state.transform.scale = targetScale
2544
+ state.transform.x = targetX
2545
+ state.transform.y = targetY
2546
+ clearZoomTransition()
2547
+ return
2548
+ }
2549
+
2550
+ const lerp = zoomTransitionLerp(delta, state.zoomTransition.source)
2551
+ const nextScale = clampScale(state.transform.scale + scaleDelta * lerp)
2552
+ state.transform.scale = nextScale
2553
+ state.transform.x = clampTransformCoordinate(state.zoomTransition.screenX - state.zoomTransition.worldX * nextScale)
2554
+ state.transform.y = clampTransformCoordinate(state.zoomTransition.screenY - state.zoomTransition.worldY * nextScale)
2555
+ const settledX = Math.abs(targetX - state.transform.x) <= zoomAnimationPositionSnap
2556
+ const settledY = Math.abs(targetY - state.transform.y) <= zoomAnimationPositionSnap
2557
+ if (Math.abs(targetScale - nextScale) <= zoomAnimationScaleSnap && settledX && settledY) {
2558
+ state.transform.scale = targetScale
2559
+ state.transform.x = targetX
2560
+ state.transform.y = targetY
2561
+ clearZoomTransition()
2562
+ }
2563
+ }
2564
+
2486
2565
  const graphBounds = nodes => {
2487
2566
  if (nodes.length === 0) return null
2488
2567
  let minX = Number.POSITIVE_INFINITY
@@ -2515,8 +2594,8 @@ const fitScaleBiasByNodeCount = nodeCount => {
2515
2594
  if (nodeCount <= 180) return 1
2516
2595
  if (nodeCount <= 600) return 0.94
2517
2596
  if (nodeCount <= 2000) return 0.82
2518
- if (nodeCount <= 6000) return 0.68
2519
- return 0.56
2597
+ if (nodeCount <= 6000) return 0.74
2598
+ return 0.72
2520
2599
  }
2521
2600
 
2522
2601
  const autoFitScaleRangeByNodeCount = nodeCount => {
@@ -2526,8 +2605,8 @@ const autoFitScaleRangeByNodeCount = nodeCount => {
2526
2605
  if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
2527
2606
  if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
2528
2607
  if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
2529
- if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
2530
- return { min: 0.0012, max: 0.24 }
2608
+ if (nodeCount <= 6000) return { min: 0.08, max: 0.38 }
2609
+ return { min: 0.0085, max: 0.36 }
2531
2610
  }
2532
2611
 
2533
2612
  const macroFaceToFaceScale = (nodeCount, hubDistance) => {
@@ -2537,7 +2616,7 @@ const macroFaceToFaceScale = (nodeCount, hubDistance) => {
2537
2616
 
2538
2617
  const rect = canvas.getBoundingClientRect()
2539
2618
  const viewportReference = Math.max(320, Math.min(rect.width, rect.height))
2540
- const share = nodeCount > massiveGraphNodeThreshold ? 0.082 : 0.068
2619
+ const share = nodeCount > massiveGraphNodeThreshold ? 0.2 : 0.17
2541
2620
  const targetPx = Math.max(24, viewportReference * share)
2542
2621
  return targetPx / hubDistance
2543
2622
  }
@@ -2576,7 +2655,7 @@ const macroEcosystemFaceScale = (nodeCount) => {
2576
2655
 
2577
2656
  const rect = canvas.getBoundingClientRect()
2578
2657
  const viewportReference = Math.max(320, Math.min(rect.width, rect.height))
2579
- const targetShare = nodeCount > massiveGraphNodeThreshold ? 0.114 : 0.096
2658
+ const targetShare = nodeCount > massiveGraphNodeThreshold ? 0.28 : 0.24
2580
2659
  const targetPx = Math.max(30, viewportReference * targetShare)
2581
2660
  return targetPx / nearestDistance
2582
2661
  }
@@ -2589,6 +2668,7 @@ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: t
2589
2668
 
2590
2669
  if (!bounds) {
2591
2670
  state.transform = { x: width / 2, y: height / 2, scale: 1 }
2671
+ clearZoomTransition()
2592
2672
  state.offscreenFrameCount = 0
2593
2673
  state.recoveringViewport = false
2594
2674
  markRenderDirty()
@@ -2638,6 +2718,7 @@ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: t
2638
2718
  y: clampTransformCoordinate(height / 2 - centerY * resolvedScale),
2639
2719
  scale: clampScale(resolvedScale)
2640
2720
  }
2721
+ clearZoomTransition()
2641
2722
  state.offscreenFrameCount = 0
2642
2723
  state.recoveringViewport = false
2643
2724
  markRenderDirty()
@@ -2662,6 +2743,7 @@ const focusPrimaryHub = () => {
2662
2743
  y: clampTransformCoordinate(height / 2 - hub.y * targetScale),
2663
2744
  scale: targetScale
2664
2745
  }
2746
+ clearZoomTransition()
2665
2747
  state.offscreenFrameCount = 0
2666
2748
  markRenderDirty()
2667
2749
  }
@@ -3068,13 +3150,25 @@ const clusterOpacity = cluster =>
3068
3150
  const clusterDepth = cluster => Number.isFinite(cluster.depth) ? cluster.depth : ecosystemDepthNear
3069
3151
  const clusterDepthScale = cluster => Number.isFinite(cluster.depthScale) ? cluster.depthScale : 1
3070
3152
 
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
3153
+ const shouldProjectRenderNodesInDepth = () => {
3154
+ const withinNodeCountWindow =
3155
+ state.renderClusters.length === 0 &&
3156
+ state.renderNodes.length >= graphDepthProjectionNodeThreshold &&
3157
+ state.renderNodes.length <= graphDepthProjectionNodeCap &&
3158
+ !state.pointer.down
3159
+
3160
+ if (!withinNodeCountWindow) {
3161
+ state.depthProjectionActive = false
3162
+ return false
3163
+ }
3164
+
3165
+ const scale = state.transform.scale
3166
+ const shouldProject = state.depthProjectionActive
3167
+ ? scale >= graphDepthProjectionExitMinScale && scale <= graphDepthProjectionExitMaxScale
3168
+ : scale >= graphDepthProjectionEnterMinScale && scale <= graphDepthProjectionEnterMaxScale
3169
+ state.depthProjectionActive = shouldProject
3170
+ return shouldProject
3171
+ }
3078
3172
 
3079
3173
  const nodeProjectionAnchor = () => {
3080
3174
  const hub = state.primaryHub
@@ -3312,12 +3406,9 @@ const computeRenderVisibility = () => {
3312
3406
  return
3313
3407
  }
3314
3408
 
3315
- const ecosystemScaleThreshold = state.visibleNodes.length > massiveGraphNodeThreshold
3316
- ? massiveEcosystemClusterScaleThreshold
3317
- : ecosystemClusterScaleThreshold
3318
3409
  if (
3319
3410
  state.ecosystemExpansionLevels.length > 0 &&
3320
- state.transform.scale <= ecosystemScaleThreshold &&
3411
+ shouldRenderEcosystemClusterView(state.visibleNodes.length, state.transform.scale) &&
3321
3412
  state.ecosystemClusters.length > 0
3322
3413
  ) {
3323
3414
  const clusters = selectHierarchicalEcosystemClusters(viewport)
@@ -3331,6 +3422,7 @@ const computeRenderVisibility = () => {
3331
3422
  state.renderEdges = []
3332
3423
  return
3333
3424
  }
3425
+ state.ecosystemViewActive = false
3334
3426
 
3335
3427
  if (state.visibleNodes.length <= 2000) {
3336
3428
  state.renderNodes = state.visibleNodes
@@ -3509,7 +3601,8 @@ const render = now => {
3509
3601
  const isInteracting =
3510
3602
  state.pointer.down ||
3511
3603
  state.renderVisibilityDirty ||
3512
- state.recoveringViewport
3604
+ state.recoveringViewport ||
3605
+ state.zoomTransition.active
3513
3606
  const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
3514
3607
  if (delta < minFrameIntervalMs) {
3515
3608
  requestAnimationFrame(render)
@@ -3523,6 +3616,7 @@ const render = now => {
3523
3616
  if (!hasValidTransform()) {
3524
3617
  resetView()
3525
3618
  }
3619
+ applyZoomTransition(delta)
3526
3620
  ctx.clearRect(0, 0, width, height)
3527
3621
  webGlRenderer?.clear(width, height)
3528
3622
  if (state.nodes.length === 0) {
@@ -3752,12 +3846,23 @@ const selectNodeById = id => {
3752
3846
 
3753
3847
  const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
3754
3848
  state.lastManualZoomAt = performance.now()
3755
- const effectiveFactor = factor
3756
- const nextScale = clampScale(state.transform.scale * effectiveFactor)
3757
- if (nextScale === state.transform.scale) {
3849
+ const baseScale = state.zoomTransition.active
3850
+ ? state.zoomTransition.targetScale
3851
+ : state.transform.scale
3852
+ const boundedFactor = source === 'wheel'
3853
+ ? Math.max(wheelZoomInputFloorCap, Math.min(wheelZoomInputCeilCap, factor))
3854
+ : factor
3855
+ const nextScale = clampScale(baseScale * boundedFactor)
3856
+ if (nextScale === baseScale && !state.zoomTransition.active) {
3758
3857
  return
3759
3858
  }
3760
- const worldPointAtCursor = screenToWorldPoint(screenX, screenY)
3859
+ const worldPointAtCursor =
3860
+ state.zoomTransition.active &&
3861
+ state.zoomTransition.source === source &&
3862
+ state.zoomTransition.screenX === screenX &&
3863
+ state.zoomTransition.screenY === screenY
3864
+ ? { x: state.zoomTransition.worldX, y: state.zoomTransition.worldY }
3865
+ : screenToWorldPoint(screenX, screenY)
3761
3866
  const worldX = worldPointAtCursor.x
3762
3867
  const worldY = worldPointAtCursor.y
3763
3868
  state.lastZoomFocus = {
@@ -3765,9 +3870,15 @@ const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
3765
3870
  y: worldY,
3766
3871
  at: performance.now()
3767
3872
  }
3768
- state.transform.scale = clampScale(nextScale)
3769
- state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
3770
- state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
3873
+ state.zoomTransition = {
3874
+ active: true,
3875
+ source,
3876
+ screenX,
3877
+ screenY,
3878
+ worldX,
3879
+ worldY,
3880
+ targetScale: nextScale
3881
+ }
3771
3882
  state.offscreenFrameCount = 0
3772
3883
  markRenderDirty()
3773
3884
  }
@@ -3786,13 +3897,22 @@ const wheelZoomFactor = event => {
3786
3897
  state.transform.scale <= massiveEcosystemClusterScaleThreshold
3787
3898
  const sensitivityMultiplier = isMassiveEcosystemZoom ? 0.48 : 1
3788
3899
  const capMultiplier = isMassiveEcosystemZoom ? 0.34 : 1
3789
- const sensitivity = wheelZoomExponent * (isModifierZoom ? wheelZoomModifierBoost : 1) * sensitivityMultiplier
3790
- const exponentCap = wheelZoomExponentCap * capMultiplier
3900
+ const isZoomOut = normalizedDelta > 0
3901
+ const currentScale = state.transform.scale
3902
+ const zoomOutDamping = isZoomOut
3903
+ ? (currentScale <= 0.03 ? 0.38 : currentScale <= 0.08 ? 0.52 : 0.68)
3904
+ : 1
3905
+ const sensitivity = wheelZoomExponent * (isModifierZoom ? wheelZoomModifierBoost : 1) * sensitivityMultiplier * zoomOutDamping
3906
+ const exponentCap = wheelZoomExponentCap * capMultiplier * (isZoomOut ? 0.74 : 1)
3791
3907
  const exponent = Math.max(
3792
3908
  -exponentCap,
3793
3909
  Math.min(exponentCap, -normalizedDelta * sensitivity)
3794
3910
  )
3795
- return Math.exp(exponent)
3911
+ const factor = Math.exp(exponent)
3912
+ if (factor > 1) {
3913
+ return Math.min(factor, wheelZoomInputCeilCap)
3914
+ }
3915
+ return Math.max(factor, wheelZoomInputFloorCap)
3796
3916
  }
3797
3917
 
3798
3918
  const handleWheelZoom = event => {
@@ -3876,6 +3996,7 @@ const bindEvents = () => {
3876
3996
  zoomAtPoint(cursorX, cursorY, 1.055)
3877
3997
  })
3878
3998
  canvas.addEventListener('pointerdown', event => {
3999
+ clearZoomTransition()
3879
4000
  const point = worldPoint(event)
3880
4001
  const node = hitNode(point)
3881
4002
  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.111",
3
+ "version": "0.1.0-beta.113",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",