Optimize geohash globe rendering and gestures

This commit is contained in:
a1denvalu3 2026-07-30 14:01:02 +02:00
parent b692ec7b44
commit 2a43265e12
5 changed files with 324 additions and 115 deletions

View File

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

View File

@ -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<Double, Double, Int>? = 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() {

View File

@ -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<Long>()
val panVec = ArrayDeque<Offset>()
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<DiscPt>(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<LandData.City>,
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

View File

@ -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<Ring>? = 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
}
}

View File

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