Bring back a gentle zoom-in to the globe's opening rotation

The opening rotation had lost the zoom-in it used to have. It now
starts at 72% of the whole-globe size and grows into it while turning
to the location, as one 1.4 s eased move, so the selection stays at
level 1 and the entire globe stays in view.

Pinching during an interrupted intro continues from the current size
instead of snapping up to the minimum, and never pinches further out.
This commit is contained in:
callebtc 2026-10-27 15:32:10 +01:00
parent a3d7c14276
commit e5d232004e
2 changed files with 70 additions and 3 deletions

View File

@ -37,7 +37,8 @@ class GlobeState(
private set
var centerLon by mutableDoubleStateOf(if (startZoomedOut) GlobeMath.normalizeLon(targetLon - 70.0) else targetLon)
private set
var zoom by mutableFloatStateOf(if (startZoomedOut) GlobeMath.MIN_ZOOM else 1f)
// The opening rotation starts a little smaller than the whole-globe view and grows into it.
var zoom by mutableFloatStateOf(if (startZoomedOut) INTRO_START_ZOOM else 1f)
private set
var precision by mutableIntStateOf(initialPrecision.coerceIn(1, GlobeMath.MAX_PRECISION))
private set
@ -93,7 +94,9 @@ class GlobeState(
/** Continuous zoom from pinch gestures; precision follows automatically. */
fun zoomBy(factor: Float) {
if (factor == 1f) return
zoom = (zoom * factor).coerceIn(GlobeMath.MIN_ZOOM, GlobeMath.MAX_ZOOM)
// An interrupted intro can leave the globe below the minimum; continue from there rather
// than snapping up, while never pinching further out than the current size.
zoom = (zoom * factor).coerceIn(minOf(GlobeMath.MIN_ZOOM, zoom), GlobeMath.MAX_ZOOM)
syncPrecisionFromZoom()
syncSelection()
}
@ -192,7 +195,10 @@ class GlobeState(
animateTo(centerLat, centerLon, targetZoom, p, durationMs = 450)
}
/** Cinematic intro: spin and zoom from a far view into the target location. */
/**
* Cinematic intro: turn to the target while gently zooming from [INTRO_START_ZOOM] into the
* frame for [targetPrecision] (the whole globe at level 1).
*/
private fun playIntro(targetLat: Double, targetLon: Double, targetPrecision: Int) {
if (baseRadiusPx <= 0f) {
// viewport not ready yet; retry once attached to layout via caller
@ -255,6 +261,11 @@ class GlobeState(
// clamping tighter would make syncSelection() encode the wrong cell.
const val MIN_LAT = -89.0
const val MAX_LAT = 89.0
/**
* Where the opening rotation's gentle zoom-in starts. Within [FLIGHT_ZOOM_RATIO] of the
* whole-globe view, so the intro rotates and zooms together on the glide path.
*/
const val INTRO_START_ZOOM = 0.72f
/** Zoom changes beyond this factor fly along [GlobeFlight]; smaller ones glide. */
const val FLIGHT_ZOOM_RATIO = 1.5f
const val FLIGHT_MS_PER_UNIT = 230.0

View File

@ -0,0 +1,56 @@
package com.bitchat.android.ui.globe
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class GlobeStateTest {
private fun introState() = GlobeState(
targetLat = 57.0,
targetLon = 10.0,
initialPrecision = 1,
startZoomedOut = true
).apply { setViewport(baseRadiusPx = 475f, screenMinPx = 1080f) }
@Test
fun intro_startsSlightlySmallerThanTheWholeGlobeAtLevelOne() {
val state = introState()
assertTrue(state.zoom < GlobeMath.MIN_ZOOM)
assertTrue(state.zoom > 0.5f)
assertEquals(1, state.precision)
assertEquals(1, state.selectedGeohash.length)
}
@Test
fun wholeGlobeFrame_isLevelOne() {
// The intro lands on the level-1 frame, which is the whole-globe view.
assertEquals(
GlobeMath.MIN_ZOOM,
GlobeMath.zoomForPrecision(1, baseRadiusPx = 475f, screenMinPx = 1080f)
)
}
@Test
fun pinchingAnInterruptedIntro_continuesWithoutSnapping() {
val state = introState()
val start = state.zoom
state.zoomBy(1.1f)
assertEquals(start * 1.1f, state.zoom, 1e-4f)
state.zoomBy(0.5f)
assertEquals(start * 1.1f, state.zoom, 1e-4f)
}
@Test
fun pinchingOut_stopsAtTheWholeGlobe() {
val state = GlobeState(57.0, 10.0, initialPrecision = 1, startZoomedOut = false)
.apply { setViewport(475f, 1080f) }
state.zoomBy(0.5f)
assertEquals(GlobeMath.MIN_ZOOM, state.zoom)
}
}