From a3d7c142760b78bca3a2ad8ecfe49ce7d024e4f5 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:04:29 +0100 Subject: [PATCH] Smooth deep globe flights and open the picker at level 1 Flying into a fine geohash was fast, rough and jittery: - animateTo panned linearly while zooming exponentially over a fixed duration, so near the end the remaining fraction of a degree became a huge on-screen jump (the target was still ~130 screens away at 99% progress). Large zoom changes now follow GlobeFlight, the van Wijk and Nuij smooth pan-and-zoom path used by d3-zoom and Mapbox flyTo, along a great circle, with duration scaled to the distance. - The target precision was forced for the whole flight, so a fine cell was re-selected nearly every frame, recomposing the card each time. Precision now follows the zoom and lands on the target at the end. - Every selection change fired a haptic tick, buzzing through any programmatic flight. Ticks now fire only for user-driven changes. - The view center was a Float; at deep zoom one Float step of latitude spans several pixels. It is now a Double. Opening the picker on a selected geohash no longer dives into it: it plays the same whole-globe rotation as the no-selection case, turning to face that location at level 1 so the world's activity stays in view. --- .../android/ui/GeohashPickerActivity.kt | 14 +-- .../bitchat/android/ui/globe/GlobeFlight.kt | 103 ++++++++++++++++++ .../bitchat/android/ui/globe/GlobeState.kt | 84 ++++++++++---- .../com/bitchat/android/ui/globe/GlobeView.kt | 14 ++- .../android/ui/globe/GlobeFlightTest.kt | 95 ++++++++++++++++ 5 files changed, 277 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/globe/GlobeFlight.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeFlightTest.kt diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt index 62254ef37..3674a7161 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -114,12 +114,10 @@ class GeohashPickerActivity : OrientationAwareActivity() { super.onCreate(savedInstanceState) val initialGeohash = intent.getStringExtra(EXTRA_INITIAL_GEOHASH)?.trim()?.lowercase() - var geohashToFocus: String? = null var initLat = 20.0 var initLon = 10.0 if (!initialGeohash.isNullOrEmpty()) { - geohashToFocus = initialGeohash try { val (lat, lon) = Geohash.decodeToCenter(initialGeohash) initLat = lat @@ -132,7 +130,6 @@ class GeohashPickerActivity : OrientationAwareActivity() { if (!channels.isNullOrEmpty()) { val coarsestChannel = channels.minByOrNull { it.geohash.length } if (coarsestChannel != null) { - geohashToFocus = coarsestChannel.geohash try { val (lat, lon) = Geohash.decodeToCenter(coarsestChannel.geohash) initLat = lat @@ -142,8 +139,9 @@ class GeohashPickerActivity : OrientationAwareActivity() { } } - // With nothing to focus, settle on the whole globe so the world's activity is in view. - val initialPrecision = (geohashToFocus?.length ?: 1).coerceIn(1, 12) + // Always open on the whole globe, turning to face the selected location (if any) rather + // than diving into it, so the world's activity stays in view. + val initialPrecision = 1 val targetLat = initLat val targetLon = initLon @@ -185,7 +183,7 @@ class GeohashPickerActivity : OrientationAwareActivity() { else globeState.centerLat to globeState.centerLon }.collect { center -> if (center != null) { - activityMonitor.focusOn(center.first.toDouble(), center.second.toDouble()) + activityMonitor.focusOn(center.first, center.second) } } } @@ -316,8 +314,8 @@ class GeohashPickerActivity : OrientationAwareActivity() { onWholeGlobe = { cluster = null globeState.animateTo( - globeState.centerLat.toDouble(), - globeState.centerLon.toDouble(), + globeState.centerLat, + globeState.centerLon, GlobeMath.MIN_ZOOM, 1, durationMs = 700 diff --git a/app/src/main/java/com/bitchat/android/ui/globe/GlobeFlight.kt b/app/src/main/java/com/bitchat/android/ui/globe/GlobeFlight.kt new file mode 100644 index 000000000..159db82be --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeFlight.kt @@ -0,0 +1,103 @@ +package com.bitchat.android.ui.globe + +import kotlin.math.abs +import kotlin.math.acos +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.cosh +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.sin +import kotlin.math.sinh +import kotlin.math.sqrt +import kotlin.math.tanh + +/** + * Smooth pan-and-zoom path from van Wijk and Nuij, "Smooth and efficient zooming and panning" + * (the curve behind d3-zoom and Mapbox flyTo). It keeps on-screen motion even when the zoom + * changes by orders of magnitude: it pans while zoomed out and zooms while nearly on target, + * instead of panning linearly while zooming exponentially, which races at the end. + * + * [distance] is the arc between the start and end centers in radians; widths are the angular + * span visible across the screen (screen size over globe radius) at each end. + */ +internal class GlobeFlight( + private val distance: Double, + private val startWidth: Double, + private val endWidth: Double, + private val rho: Double = RHO +) { + private val panning = distance > EPSILON + private val r0: Double + private val length: Double + + init { + if (!panning) { + r0 = 0.0 + length = ln(endWidth / startWidth) / rho + } else { + val rho2 = rho * rho + val rho4 = rho2 * rho2 + val d2 = distance * distance + val b0 = (endWidth * endWidth - startWidth * startWidth + rho4 * d2) / (2 * startWidth * rho2 * distance) + val b1 = (endWidth * endWidth - startWidth * startWidth - rho4 * d2) / (2 * endWidth * rho2 * distance) + r0 = ln(sqrt(b0 * b0 + 1) - b0) + val r1 = ln(sqrt(b1 * b1 + 1) - b1) + length = (r1 - r0) / rho + } + } + + /** Path length in the curve's own units; longer flights should take longer. */ + val pathLength: Double get() = abs(length) + + /** At progress [t] in 0..1: the fraction of [distance] covered and the visible width. */ + fun at(t: Double): Pair { + if (t >= 1.0) return 1.0 to endWidth + val s = t * length + if (!panning) return t to startWidth * exp(rho * s) + val coshR0 = cosh(r0) + val u = startWidth / (rho * rho * distance) * (coshR0 * tanh(rho * s + r0) - sinh(r0)) + val w = startWidth * coshR0 / cosh(rho * s + r0) + return (u / distance).coerceIn(0.0, 1.0) to w + } + + companion object { + /** d3's default: how strongly the path zooms out to travel. */ + const val RHO = 1.414 + private const val EPSILON = 1e-9 + + /** Great-circle angle between two points, radians. */ + fun arc(lat0: Double, lon0: Double, lat1: Double, lon1: Double): Double { + val a = unit(lat0, lon0) + val b = unit(lat1, lon1) + val dot = (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]).coerceIn(-1.0, 1.0) + return acos(dot) + } + + /** Point a [fraction] of the way along the great circle; (lat, lon) in degrees. */ + fun slerp(lat0: Double, lon0: Double, lat1: Double, lon1: Double, fraction: Double): Pair { + val a = unit(lat0, lon0) + val b = unit(lat1, lon1) + val omega = acos((a[0] * b[0] + a[1] * b[1] + a[2] * b[2]).coerceIn(-1.0, 1.0)) + val sinOmega = sin(omega) + if (sinOmega < 1e-6) { + // Same point or exact antipodes: no unique great circle, so interpolate degrees. + val dLon = GlobeMath.normalizeLon(lon1 - lon0) + return (lat0 + (lat1 - lat0) * fraction) to GlobeMath.normalizeLon(lon0 + dLon * fraction) + } + val wa = sin((1 - fraction) * omega) / sinOmega + val wb = sin(fraction * omega) / sinOmega + val x = wa * a[0] + wb * b[0] + val y = wa * a[1] + wb * b[1] + val z = wa * a[2] + wb * b[2] + return Math.toDegrees(asin(z.coerceIn(-1.0, 1.0))) to Math.toDegrees(atan2(y, x)) + } + + private fun unit(latDeg: Double, lonDeg: Double): DoubleArray { + val lat = Math.toRadians(latDeg) + val lon = Math.toRadians(lonDeg) + return doubleArrayOf(cos(lat) * cos(lon), cos(lat) * sin(lon), sin(lat)) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt b/app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt index 6c62d0ef7..89efc689b 100644 --- a/app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.runtime.getValue import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -30,9 +31,11 @@ class GlobeState( initialPrecision: Int, startZoomedOut: Boolean ) { - var centerLat by mutableFloatStateOf(if (startZoomedOut) (targetLat * 0.4).toFloat() else targetLat.toFloat()) + // Double, not Float: at deep zoom one Float step of latitude spans several pixels, which made + // panning and fly-ins visibly step. + var centerLat by mutableDoubleStateOf(if (startZoomedOut) targetLat * 0.4 else targetLat) private set - var centerLon by mutableFloatStateOf(if (startZoomedOut) GlobeMath.normalizeLon(targetLon - 70.0).toFloat() else targetLon.toFloat()) + 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) private set @@ -82,8 +85,8 @@ class GlobeState( val r = globeRadiusPx if (r <= 0f) return val degPerPx = 180.0 / (Math.PI * r) - centerLon = GlobeMath.normalizeLon(centerLon - dxPx * degPerPx).toFloat() - centerLat = (centerLat + dyPx * degPerPx).toFloat().coerceIn(MIN_LAT, MAX_LAT) + centerLon = GlobeMath.normalizeLon(centerLon - dxPx * degPerPx) + centerLat = (centerLat + dyPx * degPerPx).coerceIn(MIN_LAT, MAX_LAT) syncSelection() } @@ -100,7 +103,14 @@ class GlobeState( precision = GlobeMath.autoPrecision(globeRadiusPx, screenMinPx) } - /** Animate the view to a lat/lon, optionally to a zoom and precision. */ + /** + * Animate the view to a lat/lon, optionally to a zoom and precision. + * + * A large zoom change flies along [GlobeFlight] so on-screen motion stays even, and takes + * longer the further it travels; [durationMs] is then a minimum. Precision follows the zoom + * on the way and lands on [targetPrecision] at the end, so fine cells are not re-selected on + * every frame while the globe is still far out. + */ fun animateTo( lat: Double, lon: Double, @@ -112,27 +122,53 @@ class GlobeState( animJob?.cancel() val startLat = centerLat val startLon = centerLon - val dLon = GlobeMath.normalizeLon(lon - startLon) + val endLat = lat.coerceIn(MIN_LAT, MAX_LAT) + val endLon = GlobeMath.normalizeLon(lon) val startZoom = zoom val endZoom = (targetZoom ?: zoom).coerceIn(GlobeMath.MIN_ZOOM, GlobeMath.MAX_ZOOM) + val zoomRatio = endZoom / startZoom + val flight = if (baseRadiusPx > 0f && (zoomRatio > FLIGHT_ZOOM_RATIO || zoomRatio < 1f / FLIGHT_ZOOM_RATIO)) { + GlobeFlight( + distance = GlobeFlight.arc(startLat, startLon, endLat, endLon), + startWidth = visibleWidth(startZoom), + endWidth = visibleWidth(endZoom) + ) + } else null + val duration = if (flight == null) durationMs else + (flight.pathLength * FLIGHT_MS_PER_UNIT).toInt().coerceIn(durationMs, MAX_FLIGHT_MS) + val dLon = GlobeMath.normalizeLon(endLon - startLon) val generation = ++animationGeneration isAnimating = true animJob = s.launch { try { val anim = Animatable(0f) - anim.animateTo(1f, tween(durationMs, easing = FastOutSlowInEasing)) { - val t = value - centerLat = (startLat + (lat.toFloat() - startLat) * t).coerceIn(MIN_LAT, MAX_LAT) - centerLon = GlobeMath.normalizeLon(startLon + dLon * t).toFloat() - // exponential interpolation feels natural for zoom - zoom = startZoom * (endZoom / startZoom).pow(t) - if (targetPrecision != null) { - precision = targetPrecision.coerceIn(1, GlobeMath.MAX_PRECISION) + anim.animateTo(1f, tween(duration, easing = FastOutSlowInEasing)) { + val t = value.toDouble() + if (flight != null) { + val (fraction, width) = flight.at(t) + val (fLat, fLon) = GlobeFlight.slerp(startLat, startLon, endLat, endLon, fraction) + centerLat = fLat.coerceIn(MIN_LAT, MAX_LAT) + centerLon = fLon + zoom = zoomForWidth(width) } else { - syncPrecisionFromZoom() + centerLat = (startLat + (endLat - startLat) * t).coerceIn(MIN_LAT, MAX_LAT) + centerLon = GlobeMath.normalizeLon(startLon + dLon * t) + // Exponential interpolation feels natural for small zoom changes. + zoom = startZoom * zoomRatio.pow(value) } + syncPrecisionFromZoom() syncSelection() } + // Land exactly on the target once the motion completes. + centerLat = endLat + centerLon = endLon + zoom = endZoom + if (targetPrecision != null) { + precision = targetPrecision.coerceIn(1, GlobeMath.MAX_PRECISION) + } else { + syncPrecisionFromZoom() + } + syncSelection() } finally { if (animationGeneration == generation) { isAnimating = false @@ -142,12 +178,18 @@ class GlobeState( } } + /** Angular span across the screen's short side at [zoom], radians. */ + private fun visibleWidth(zoom: Float): Double = screenMinPx.toDouble() / (baseRadiusPx * zoom) + + private fun zoomForWidth(width: Double): Float = + (screenMinPx / (width * baseRadiusPx)).toFloat().coerceIn(GlobeMath.MIN_ZOOM, GlobeMath.MAX_ZOOM) + /** Button-driven precision change: adjusts precision and animates zoom to frame it. */ fun animatePrecision(newPrecision: Int) { val p = newPrecision.coerceIn(1, GlobeMath.MAX_PRECISION) if (p == precision || baseRadiusPx <= 0f) return val targetZoom = GlobeMath.zoomForPrecision(p, baseRadiusPx, screenMinPx) - animateTo(centerLat.toDouble(), centerLon.toDouble(), targetZoom, p, durationMs = 450) + animateTo(centerLat, centerLon, targetZoom, p, durationMs = 450) } /** Cinematic intro: spin and zoom from a far view into the target location. */ @@ -211,8 +253,12 @@ class GlobeState( private companion object { // Close to the projection limit so polar geohash cells remain selectable; // clamping tighter would make syncSelection() encode the wrong cell. - const val MIN_LAT = -89f - const val MAX_LAT = 89f + const val MIN_LAT = -89.0 + const val MAX_LAT = 89.0 + /** 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 + const val MAX_FLIGHT_MS = 2_200 const val MIN_FLING_PX_PER_SECOND = 90f const val MAX_FLING_PX_PER_SECOND = 3_200f const val MAX_FRAME_DELTA_SECONDS = 1f / 30f @@ -222,7 +268,7 @@ class GlobeState( private fun syncSelection() { if (baseRadiusPx <= 0f) return - val gh = Geohash.encode(centerLat.toDouble(), centerLon.toDouble(), precision) + val gh = Geohash.encode(centerLat, centerLon, precision) if (gh != selectedGeohash) selectedGeohash = gh } } diff --git a/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt b/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt index 3dbe84079..ac36c165c 100644 --- a/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt @@ -298,6 +298,8 @@ fun GlobeView( snapshotFlow { state.selectedGeohash } .drop(1) .collect { + // Tick for cells the user drags across, not for every cell a fly-in passes. + if (state.isAnimating) return@collect @Suppress("DEPRECATION") view.performHapticFeedback( HapticFeedbackConstants.KEYBOARD_TAP, @@ -416,8 +418,8 @@ fun GlobeView( val latLon = GlobeMath.unproject( ((downPos.x - cx) / r).toDouble(), ((downPos.y - cy) / r).toDouble(), - state.centerLat.toDouble(), - state.centerLon.toDouble() + state.centerLat, + state.centerLon ) if (latLon != null) { val now = upTime @@ -446,8 +448,8 @@ fun GlobeView( val baseR = state.baseRadiusPx if (baseR <= 0f) return@Canvas val r = state.globeRadiusPx - val cLat = state.centerLat.toDouble() - val cLon = state.centerLon.toDouble() + val cLat = state.centerLat + val cLon = state.centerLon val preparedProjector = GlobeMath.PreparedProjector(cLat, cLon) // Atmosphere glow @@ -591,8 +593,8 @@ fun GlobeView( layoutGlobeMarkers( channels = activity, projector = GlobeMath.PreparedProjector( - state.centerLat.toDouble(), - state.centerLon.toDouble() + state.centerLat, + state.centerLon ), cx = size.width / 2f, cy = size.height / 2f, diff --git a/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeFlightTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeFlightTest.kt new file mode 100644 index 000000000..f032680c9 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeFlightTest.kt @@ -0,0 +1,95 @@ +package com.bitchat.android.ui.globe + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.abs +import kotlin.math.pow + +class GlobeFlightTest { + + // A dive from the whole globe into a city-block cell about 57 degrees away. + private val distance = 1.0 + private val globeWidth = 2.27 + private val blockWidth = 7e-5 + + /** How far the target sits from screen center, in screen widths. */ + private fun targetOffset(fraction: Double, width: Double) = (1 - fraction) * distance / width + + @Test + fun flight_startsAndEndsExactly() { + val flight = GlobeFlight(distance, globeWidth, blockWidth) + + val (startFraction, startWidth) = flight.at(0.0) + assertEquals(0.0, startFraction, 1e-9) + assertEquals(globeWidth, startWidth, 1e-9) + assertEquals(1.0 to blockWidth, flight.at(1.0)) + } + + @Test + fun deepFlight_bringsTheTargetInSteadilyWithoutRacing() { + val flight = GlobeFlight(distance, globeWidth, blockWidth) + var previous = Double.MAX_VALUE + var largestStep = 0.0 + val steps = 400 + for (i in 0..steps) { + val (fraction, width) = flight.at(i.toDouble() / steps) + val offset = targetOffset(fraction, width) + assertTrue("target must never move away", offset <= previous + 1e-9) + if (i > 0) largestStep = maxOf(largestStep, previous - offset) + previous = offset + } + assertTrue("largest jump was $largestStep screens", largestStep < 0.05) + } + + @Test + fun linearPanWithExponentialZoom_racedAtTheEnd() { + // The old path: just before landing the target was still many screens away. + val t = 0.99 + val width = globeWidth * (blockWidth / globeWidth).pow(t) + assertTrue(targetOffset(t, width) > 50.0) + } + + @Test + fun zoomOnly_isExponentialInWidth() { + val flight = GlobeFlight(0.0, 1.0, 0.01) + + val (fraction, width) = flight.at(0.5) + assertEquals(0.5, fraction, 1e-9) + assertEquals(0.1, width, 1e-6) + } + + @Test + fun longerFlights_takeLonger() { + assertTrue( + GlobeFlight(distance, globeWidth, blockWidth).pathLength > + GlobeFlight(distance, globeWidth, 0.1).pathLength + ) + } + + @Test + fun slerp_followsTheShortWayAcrossTheAntimeridian() { + val (lat, lon) = GlobeFlight.slerp(0.0, 170.0, 0.0, -170.0, 0.5) + + assertEquals(0.0, lat, 1e-9) + assertEquals(180.0, abs(lon), 1e-6) + } + + @Test + fun slerp_hitsBothEndpoints() { + val (lat0, lon0) = GlobeFlight.slerp(10.0, 20.0, -30.0, 100.0, 0.0) + val (lat1, lon1) = GlobeFlight.slerp(10.0, 20.0, -30.0, 100.0, 1.0) + + assertEquals(10.0, lat0, 1e-9) + assertEquals(20.0, lon0, 1e-9) + assertEquals(-30.0, lat1, 1e-9) + assertEquals(100.0, lon1, 1e-9) + } + + @Test + fun arc_measuresGreatCircleAngles() { + assertEquals(Math.PI / 2, GlobeFlight.arc(0.0, 0.0, 0.0, 90.0), 1e-9) + assertEquals(Math.PI / 2, GlobeFlight.arc(0.0, 0.0, 90.0, 0.0), 1e-9) + assertEquals(0.0, GlobeFlight.arc(45.0, 45.0, 45.0, 45.0), 1e-6) + } +}