From 2a43265e12e56c44ff4bc610600201aba357ece0 Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Thu, 30 Jul 2026 14:01:02 +0200 Subject: [PATCH] Optimize geohash globe rendering and gestures --- .../com/bitchat/android/ui/globe/GlobeMath.kt | 27 +++ .../bitchat/android/ui/globe/GlobeState.kt | 95 +++++--- .../com/bitchat/android/ui/globe/GlobeView.kt | 219 +++++++++++------- .../com/bitchat/android/ui/globe/LandData.kt | 38 ++- .../bitchat/android/ui/globe/GlobeMathTest.kt | 60 +++++ 5 files changed, 324 insertions(+), 115 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeMathTest.kt diff --git a/app/src/main/java/com/bitchat/android/ui/globe/GlobeMath.kt b/app/src/main/java/com/bitchat/android/ui/globe/GlobeMath.kt index 7f79a00f..42f41f8e 100644 --- a/app/src/main/java/com/bitchat/android/ui/globe/GlobeMath.kt +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeMath.kt @@ -12,6 +12,33 @@ object GlobeMath { data class Projection(val x: Float, val y: Float, val cosC: Float) + /** + * Frame-local projector for prepared static geometry. Center terms are calculated once + * and projection results are written to a caller-owned array without allocating objects. + */ + class PreparedProjector(centerLatDeg: Double, centerLonDeg: Double) { + private val sinCenterLat = sin(Math.toRadians(centerLatDeg)).toFloat() + private val cosCenterLat = cos(Math.toRadians(centerLatDeg)).toFloat() + private val sinCenterLon = sin(Math.toRadians(centerLonDeg)).toFloat() + private val cosCenterLon = cos(Math.toRadians(centerLonDeg)).toFloat() + + fun project(terms: FloatArray, termOffset: Int, out: FloatArray, outOffset: Int) { + val sinLat = terms[termOffset] + val cosLat = terms[termOffset + 1] + val sinLon = terms[termOffset + 2] + val cosLon = terms[termOffset + 3] + val sinDeltaLon = sinLon * cosCenterLon - cosLon * sinCenterLon + val cosDeltaLon = cosLon * cosCenterLon + sinLon * sinCenterLon + out[outOffset] = cosLat * sinDeltaLon + out[outOffset + 1] = -( + cosCenterLat * sinLat - + sinCenterLat * cosLat * cosDeltaLon + ) + out[outOffset + 2] = + sinCenterLat * sinLat + cosCenterLat * cosLat * cosDeltaLon + } + } + /** * Projects (lat, lon) onto the view disc of a globe centered at (centerLat, centerLon). * Returns x/y in units of globe radius (screen y down). [Projection.cosC] is negative 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 6908c716..6c62d0ef 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 @@ -4,6 +4,7 @@ import androidx.compose.animation.core.Animatable 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.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -12,7 +13,9 @@ import com.bitchat.android.geohash.Geohash import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import androidx.compose.runtime.withFrameNanos import kotlin.math.abs +import kotlin.math.exp import kotlin.math.pow /** @@ -20,6 +23,7 @@ import kotlin.math.pow * and the current selection. All mutation funnels through this class so rendering, * gestures and buttons stay in sync. */ +@Stable class GlobeState( targetLat: Double, targetLon: Double, @@ -38,12 +42,15 @@ class GlobeState( private set var isInteracting by mutableStateOf(false) internal set + var isAnimating by mutableStateOf(false) + private set internal var baseRadiusPx by mutableFloatStateOf(0f) internal var screenMinPx by mutableFloatStateOf(0f) private var scope: CoroutineScope? = null private var animJob: Job? = null + private var animationGeneration = 0 /** Pending cinematic intro target (lat, lon, precision); consumed when played. */ var introTarget: Triple? = null @@ -108,20 +115,29 @@ class GlobeState( val dLon = GlobeMath.normalizeLon(lon - startLon) val startZoom = zoom val endZoom = (targetZoom ?: zoom).coerceIn(GlobeMath.MIN_ZOOM, GlobeMath.MAX_ZOOM) + val generation = ++animationGeneration + isAnimating = true animJob = s.launch { - 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) - } else { - syncPrecisionFromZoom() + 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) + } else { + syncPrecisionFromZoom() + } + syncSelection() + } + } finally { + if (animationGeneration == generation) { + isAnimating = false + animJob = null } - syncSelection() } } } @@ -144,37 +160,64 @@ class GlobeState( animateTo(targetLat, targetLon, targetZoom, targetPrecision, durationMs = 1400) } - /** Inertial spin after a fling. Velocities are in px/ms. */ + /** Inertial spin after a fling. Velocities are in px/second. */ fun fling(velocityX: Float, velocityY: Float) { val s = scope ?: return - if (abs(velocityX) < 0.05f && abs(velocityY) < 0.05f) return + var initialVx = velocityX.coerceIn(-MAX_FLING_PX_PER_SECOND, MAX_FLING_PX_PER_SECOND) + var initialVy = velocityY.coerceIn(-MAX_FLING_PX_PER_SECOND, MAX_FLING_PX_PER_SECOND) + if (abs(initialVx) < MIN_FLING_PX_PER_SECOND) initialVx = 0f + if (abs(initialVy) < MIN_FLING_PX_PER_SECOND) initialVy = 0f + if (initialVx == 0f && initialVy == 0f) return animJob?.cancel() + val generation = ++animationGeneration + isAnimating = true animJob = s.launch { - var vx = velocityX - var vy = velocityY - var lastTime = System.nanoTime() - while (abs(vx) > 0.02f || abs(vy) > 0.02f) { - val now = System.nanoTime() - val dtMs = ((now - lastTime) / 1_000_000f).coerceAtMost(50f) - lastTime = now - rotateBy(vx * dtMs, vy * dtMs) - val decay = 0.94f.pow(dtMs / 16f) - vx *= decay - vy *= decay - kotlinx.coroutines.delay(16) + try { + var vx = initialVx + var vy = initialVy + var lastFrameNanos = withFrameNanos { it } + while (abs(vx) >= MIN_FLING_PX_PER_SECOND || abs(vy) >= MIN_FLING_PX_PER_SECOND) { + val frameNanos = withFrameNanos { it } + val dtSeconds = ((frameNanos - lastFrameNanos) / 1_000_000_000f) + .coerceIn(0f, MAX_FRAME_DELTA_SECONDS) + lastFrameNanos = frameNanos + val maxStep = globeRadiusPx * MAX_FLING_RADIUS_FRACTION_PER_FRAME + rotateBy( + (vx * dtSeconds).coerceIn(-maxStep, maxStep), + (vy * dtSeconds).coerceIn(-maxStep, maxStep) + ) + val decay = exp(-FLING_FRICTION_PER_SECOND * dtSeconds) + vx *= decay + vy *= decay + } + } finally { + if (animationGeneration == generation) { + isAnimating = false + animJob = null + } } } } fun cancelAnimations() { + animationGeneration++ animJob?.cancel() + animJob = null + isAnimating = false } + val isInMotion: Boolean get() = isInteracting || isAnimating + 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_FLING_PX_PER_SECOND = 90f + const val MAX_FLING_PX_PER_SECOND = 3_200f + const val MAX_FRAME_DELTA_SECONDS = 1f / 30f + const val MAX_FLING_RADIUS_FRACTION_PER_FRAME = 0.12f + const val FLING_FRICTION_PER_SECOND = 4.2f } private fun syncSelection() { 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 8d7f3937..e7717735 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 @@ -10,6 +10,7 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.calculatePan @@ -29,13 +30,17 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.sp import com.bitchat.android.geohash.Geohash +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlin.math.ceil import kotlin.math.min @@ -114,21 +119,37 @@ fun GlobeView( } LaunchedEffect(state) { - snapshotFlow { state.selectedGeohash } + snapshotFlow { state.selectedGeohash to state.isInMotion } + .distinctUntilChanged() .drop(1) - .collect { - view.performHapticFeedback( - HapticFeedbackConstants.KEYBOARD_TAP, - HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING - ) + .collectLatest { (geohash, inMotion) -> + if (geohash.isNotEmpty() && !inMotion) { + delay(SETTLED_HAPTIC_DELAY_MS) + if (!state.isInMotion && state.selectedGeohash == geohash) { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + } + } } } val labelTextSize = with(density) { 12.5.sp.toPx() } val labelTextSizeSmall = with(density) { 10.sp.toPx() } - Canvas( - modifier = modifier + Box(modifier = modifier) { + // Static background lives in its own layer and is not invalidated by globe movement. + Canvas(modifier = Modifier.matchParentSize()) { + stars.forEach { star -> + drawCircle( + color = colors.star.copy(alpha = star.alpha), + radius = star.radius * density.density, + center = Offset(star.x * size.width, star.y * size.height) + ) + } + } + + Canvas( + modifier = Modifier + .matchParentSize() .onSizeChanged { size: IntSize -> val minDim = min(size.width, size.height).toFloat() state.setViewport(minDim * 0.44f, minDim) @@ -142,40 +163,45 @@ fun GlobeView( state.isInteracting = true val downTime = SystemClock.uptimeMillis() val downPos = down.position - var maxPointers = 1 var moved = Offset.Zero - val panTimes = ArrayDeque() - val panVec = ArrayDeque() + var dragStarted = false + var hadMultiplePointers = false + val velocityTracker = VelocityTracker() + velocityTracker.addPosition(down.uptimeMillis, down.position) while (true) { val event = awaitPointerEvent() val pressed = event.changes.filter { it.pressed } if (pressed.isEmpty()) break - maxPointers = maxOf(maxPointers, pressed.size) + if (pressed.size > 1) { + hadMultiplePointers = true + velocityTracker.resetTracking() + } val pan = event.calculatePan() val zoomChange = event.calculateZoom() if (pan != Offset.Zero) { - state.rotateBy(pan.x, pan.y) moved += pan - val now = SystemClock.uptimeMillis() - panTimes.addLast(now) - panVec.addLast(pan) - while (panTimes.isNotEmpty() && now - panTimes.first() > 120) { - panTimes.removeFirst() - panVec.removeFirst() + if (!dragStarted && moved.getDistance() >= viewConfiguration.touchSlop) { + dragStarted = true } + if (dragStarted) state.rotateBy(pan.x, pan.y) } if (zoomChange != 1f) { state.zoomBy(zoomChange) } + if (!hadMultiplePointers && pressed.size == 1) { + val change = pressed[0] + velocityTracker.addPosition(change.uptimeMillis, change.position) + } event.changes.forEach { if (it.positionChanged()) it.consume() } } state.isInteracting = false val upTime = SystemClock.uptimeMillis() - val isTap = maxPointers == 1 && + val isTap = !hadMultiplePointers && + !dragStarted && upTime - downTime < 400 && moved.getDistance() < viewConfiguration.touchSlop @@ -205,15 +231,13 @@ fun GlobeView( } } } - } else if (panVec.isNotEmpty()) { - var sx = 0f; var sy = 0f - panVec.forEach { sx += it.x; sy += it.y } - val windowMs = (SystemClock.uptimeMillis() - panTimes.first()).coerceAtLeast(1) - state.fling(sx / windowMs, sy / windowMs) + } else if (dragStarted && !hadMultiplePointers) { + val velocity = velocityTracker.calculateVelocity() + state.fling(velocity.x, velocity.y) } } } - ) { + ) { val cx = size.width / 2f val cy = size.height / 2f val baseR = state.baseRadiusPx @@ -221,15 +245,7 @@ fun GlobeView( val r = state.globeRadiusPx val cLat = state.centerLat.toDouble() val cLon = state.centerLon.toDouble() - - // Starfield - stars.forEach { s -> - drawCircle( - color = colors.star.copy(alpha = s.alpha), - radius = s.radius * density.density, - center = Offset(s.x * size.width, s.y * size.height) - ) - } + val preparedProjector = GlobeMath.PreparedProjector(cLat, cLon) // Atmosphere glow drawCircle( @@ -259,17 +275,34 @@ fun GlobeView( val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f) + val lowDetail = state.isInMotion + // Graticule - drawGraticule(cx, cy, r, cLat, cLon, colors.graticule, clip) + drawGraticule( + cx, cy, r, cLat, cLon, colors.graticule, clip, + step = if (lowDetail) 10.0 else 4.0 + ) // Landmasses for (ring in land) { - drawLandRing(ring, scratch, cx, cy, r, cLat, cLon, colors, clip) + drawLandRing( + ring = ring, + scratch = scratch, + projector = preparedProjector, + cx = cx, + cy = cy, + r = r, + colors = colors, + clip = clip, + pointStride = if (lowDetail && ring.size >= 64) 2 else 1 + ) } - // Country borders - for (line in borders) { - drawBorderLine(line, borderScratch, cx, cy, r, cLat, cLon, colors, clip) + // Country borders are restored when interaction settles. + if (!lowDetail) { + for (line in borders) { + drawBorderLine(line, borderScratch, preparedProjector, cx, cy, r, colors, clip) + } } // Sphere shading: dark limb + night side for 3D depth @@ -297,19 +330,17 @@ fun GlobeView( center = Offset(cx, cy) ) - // Cities (dots + names) over the shaded sphere - drawCities( - cities, state, cx, cy, r, cLat, cLon, colors, - labelPaint, haloPaint, labelTypeface, labelTextSizeSmall, density.density - ) - - // Geohash cells - if (state.selectedGeohash.isNotEmpty()) { - drawGeohashGrid(state, cx, cy, r, cLat, cLon, colors, clip) + // Cities are detail-only; omitting them while moving keeps touch latency predictable. + if (!lowDetail) { + drawCities( + cities, state, preparedProjector, cx, cy, r, colors, + labelPaint, haloPaint, labelTypeface, labelTextSizeSmall, density.density + ) } - // Labels - if (state.selectedGeohash.isNotEmpty()) { + // Detailed cells and labels settle into place after the gesture ends. + if (!lowDetail && state.selectedGeohash.isNotEmpty()) { + drawGeohashGrid(state, cx, cy, r, cLat, cLon, colors, clip) drawGeohashLabels( state, cx, cy, r, cLat, cLon, colors, labelPaint, haloPaint, labelTypeface, labelTypefaceBold, @@ -317,25 +348,39 @@ fun GlobeView( ) } - // Center crosshair - val crossAlpha = if (state.isInteracting) 0.9f else pulse - val crossColor = colors.accent.copy(alpha = crossAlpha) - val gap = 5 * density.density - val len = 9 * density.density - val strokeW = 1.6f * density.density - drawLine(crossColor, Offset(cx - gap - len, cy), Offset(cx - gap, cy), strokeW) - drawLine(crossColor, Offset(cx + gap, cy), Offset(cx + gap + len, cy), strokeW) - drawLine(crossColor, Offset(cx, cy - gap - len), Offset(cx, cy - gap), strokeW) - drawLine(crossColor, Offset(cx, cy + gap), Offset(cx, cy + gap + len), strokeW) - drawCircle(crossColor, radius = 1.8f * density.density, center = Offset(cx, cy)) + } + + // Keep this animation isolated so its pulse does not redraw the globe geometry. + Canvas(modifier = Modifier.matchParentSize()) { + val cx = size.width / 2f + val cy = size.height / 2f + val crossAlpha = if (state.isInMotion) 0.9f else pulse + val crossColor = colors.accent.copy(alpha = crossAlpha) + val gap = 5 * density.density + val len = 9 * density.density + val strokeW = 1.6f * density.density + drawLine(crossColor, Offset(cx - gap - len, cy), Offset(cx - gap, cy), strokeW) + drawLine(crossColor, Offset(cx + gap, cy), Offset(cx + gap + len, cy), strokeW) + drawLine(crossColor, Offset(cx, cy - gap - len), Offset(cx, cy - gap), strokeW) + drawLine(crossColor, Offset(cx, cy + gap), Offset(cx, cy + gap + len), strokeW) + drawCircle(crossColor, radius = 1.8f * density.density, center = Offset(cx, cy)) + } } } +private const val SETTLED_HAPTIC_DELAY_MS = 80L + private fun DrawScope.drawGraticule( - cx: Float, cy: Float, r: Float, cLat: Double, cLon: Double, color: Color, clip: ClipRect + cx: Float, + cy: Float, + r: Float, + cLat: Double, + cLon: Double, + color: Color, + clip: ClipRect, + step: Double ) { val path = Path() - val step = 4.0 fun strokeSegment(x0: Float, y0: Float, x1: Float, y1: Float) { val seg = clipSegment(x0, y0, x1, y1, clip) ?: return path.moveTo(seg.first.first, seg.first.second) @@ -606,8 +651,8 @@ private fun DrawScope.strokeRuns( private fun DrawScope.drawBorderLine( line: LandData.Ring, scratch: FloatArray, + projector: GlobeMath.PreparedProjector, cx: Float, cy: Float, r: Float, - cLat: Double, cLon: Double, colors: GlobeColors, clip: ClipRect ) { @@ -618,12 +663,13 @@ private fun DrawScope.drawBorderLine( val pts = ArrayList(n) var i = 0 while (i < n) { - val lat = line.coords[i * 2].toDouble() - val lon = line.coords[i * 2 + 1].toDouble() - val p = GlobeMath.projectRaw(lat, lon, cLat, cLon) - val front = p.cosC > 0.005f - pts.add(DiscPt(p.x, p.y, front)) - if (p.cosC >= 0f) anyFront = true + projector.project(line.projectionTerms, i * 4, scratch, i * 3) + val x = scratch[i * 3] + val y = scratch[i * 3 + 1] + val cosC = scratch[i * 3 + 2] + val front = cosC > 0.005f + pts.add(DiscPt(x, y, front)) + if (cosC >= 0f) anyFront = true i++ } if (!anyFront) return @@ -635,8 +681,8 @@ private fun DrawScope.drawBorderLine( private fun DrawScope.drawCities( cities: List, state: GlobeState, + projector: GlobeMath.PreparedProjector, cx: Float, cy: Float, r: Float, - cLat: Double, cLon: Double, colors: GlobeColors, labelPaint: Paint, haloPaint: Paint, @@ -645,6 +691,7 @@ private fun DrawScope.drawCities( density: Float ) { if (cities.isEmpty()) return + val projection = FloatArray(3) val zoom = state.zoom val maxRank = when { zoom < 2f -> 1 @@ -655,13 +702,14 @@ private fun DrawScope.drawCities( val canvas = drawContext.canvas.nativeCanvas for (city in cities) { if (city.rank > maxRank) continue - val p = GlobeMath.project(city.lat.toDouble(), city.lon.toDouble(), cLat, cLon) ?: continue - if (p.cosC < 0.03f) continue - val sx = cx + p.x * r - val sy = cy + p.y * r + projector.project(city.projectionTerms, 0, projection, 0) + val cosC = projection[2] + if (cosC < 0.03f) continue + val sx = cx + projection[0] * r + val sy = cy + projection[1] * r if (sx < -50 || sx > size.width + 50 || sy < -50 || sy > size.height + 50) continue - val alpha = p.cosC.coerceIn(0.25f, 1f) + val alpha = cosC.coerceIn(0.25f, 1f) val important = city.capital || city.megacity val dotRadius = (if (important) 2.6f else 1.8f) * density val dotColor = if (city.capital) colors.accent.copy(alpha = alpha) @@ -697,24 +745,21 @@ private fun DrawScope.drawCities( private fun DrawScope.drawLandRing( ring: LandData.Ring, scratch: FloatArray, + projector: GlobeMath.PreparedProjector, cx: Float, cy: Float, r: Float, - cLat: Double, cLon: Double, colors: GlobeColors, - clip: ClipRect + clip: ClipRect, + pointStride: Int ) { - val n = ring.size + val n = ((ring.size - 1) / pointStride) + 1 if (n < 3 || n * 3 > scratch.size) return var anyFront = false var i = 0 while (i < n) { - val lat = ring.coords[i * 2].toDouble() - val lon = ring.coords[i * 2 + 1].toDouble() - val p = GlobeMath.projectRaw(lat, lon, cLat, cLon) - scratch[i * 3] = p.x - scratch[i * 3 + 1] = p.y - scratch[i * 3 + 2] = p.cosC - if (p.cosC >= 0f) anyFront = true + val sourceIndex = (i * pointStride).coerceAtMost(ring.size - 1) + projector.project(ring.projectionTerms, sourceIndex * 4, scratch, i * 3) + if (scratch[i * 3 + 2] >= 0f) anyFront = true i++ } if (!anyFront) return diff --git a/app/src/main/java/com/bitchat/android/ui/globe/LandData.kt b/app/src/main/java/com/bitchat/android/ui/globe/LandData.kt index 798534fd..7348f88b 100644 --- a/app/src/main/java/com/bitchat/android/ui/globe/LandData.kt +++ b/app/src/main/java/com/bitchat/android/ui/globe/LandData.kt @@ -2,6 +2,8 @@ package com.bitchat.android.ui.globe import android.content.Context import org.json.JSONObject +import kotlin.math.cos +import kotlin.math.sin /** * Loads the bundled Natural Earth 110m land polygons (public domain) from assets @@ -9,9 +11,28 @@ import org.json.JSONObject */ object LandData { - data class Ring(val coords: FloatArray, val size: Int) + data class Ring( + val coords: FloatArray, + val size: Int, + /** + * Per-point sin(latitude), cos(latitude), sin(longitude), cos(longitude). + * Preparing this once removes nearly all trigonometry from animated frames. + */ + val projectionTerms: FloatArray = prepareProjectionTerms(coords, size) + ) - data class City(val name: String, val lat: Float, val lon: Float, val rank: Int, val capital: Boolean, val megacity: Boolean) + data class City( + val name: String, + val lat: Float, + val lon: Float, + val rank: Int, + val capital: Boolean, + val megacity: Boolean, + val projectionTerms: FloatArray = prepareProjectionTerms( + floatArrayOf(lat, lon), + size = 1 + ) + ) @Volatile private var cached: List? = null @@ -127,4 +148,17 @@ object LandData { out.add(Ring(coords, n)) } } + + private fun prepareProjectionTerms(coords: FloatArray, size: Int): FloatArray { + val result = FloatArray(size * 4) + for (index in 0 until size) { + val latRadians = Math.toRadians(coords[index * 2].toDouble()) + val lonRadians = Math.toRadians(coords[index * 2 + 1].toDouble()) + result[index * 4] = sin(latRadians).toFloat() + result[index * 4 + 1] = cos(latRadians).toFloat() + result[index * 4 + 2] = sin(lonRadians).toFloat() + result[index * 4 + 3] = cos(lonRadians).toFloat() + } + return result + } } diff --git a/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeMathTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeMathTest.kt new file mode 100644 index 00000000..0f39572a --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeMathTest.kt @@ -0,0 +1,60 @@ +package com.bitchat.android.ui.globe + +import kotlin.math.cos +import kotlin.math.sin +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GlobeMathTest { + + @Test + fun preparedProjector_matchesReferenceProjection() { + val centers = listOf( + 0.0 to 0.0, + 45.25 to 12.5, + -72.0 to 179.5 + ) + val points = listOf( + 0.0 to 0.0, + 51.5074 to -0.1278, + -33.8688 to 151.2093, + 89.0 to -179.9 + ) + + for ((centerLat, centerLon) in centers) { + val projector = GlobeMath.PreparedProjector(centerLat, centerLon) + for ((lat, lon) in points) { + val latRadians = Math.toRadians(lat) + val lonRadians = Math.toRadians(lon) + val terms = floatArrayOf( + sin(latRadians).toFloat(), + cos(latRadians).toFloat(), + sin(lonRadians).toFloat(), + cos(lonRadians).toFloat() + ) + val actual = FloatArray(3) + projector.project(terms, 0, actual, 0) + val expected = GlobeMath.projectRaw(lat, lon, centerLat, centerLon) + + assertEquals(expected.x, actual[0], 0.000_002f) + assertEquals(expected.y, actual[1], 0.000_002f) + assertEquals(expected.cosC, actual[2], 0.000_002f) + } + } + } + + @Test + fun zoomForPrecision_staysWithinInteractiveBounds() { + for (precision in 1..GlobeMath.MAX_PRECISION) { + val zoom = GlobeMath.zoomForPrecision( + precision = precision, + baseRadiusPx = 400f, + screenMinPx = 900f + ) + + assertTrue(zoom >= GlobeMath.MIN_ZOOM) + assertTrue(zoom <= GlobeMath.MAX_ZOOM) + } + } +}