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.
This commit is contained in:
callebtc 2026-10-27 15:04:29 +01:00
parent 12703a405b
commit a3d7c14276
5 changed files with 277 additions and 33 deletions

View File

@ -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

View File

@ -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<Double, Double> {
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<Double, Double> {
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))
}
}
}

View File

@ -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
}
}

View File

@ -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,

View File

@ -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)
}
}