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 ce450ded..6c20e39b 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -15,6 +15,9 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.* @@ -34,6 +37,8 @@ import com.bitchat.android.geohash.Geohash import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.ui.globe.GlobeColors +import com.bitchat.android.ui.globe.GlobeRenderQuality +import com.bitchat.android.ui.globe.GlobeRenderQualityPreference import com.bitchat.android.ui.globe.GlobeState import com.bitchat.android.ui.globe.GlobeView import com.bitchat.android.ui.globe.LandData @@ -90,13 +95,18 @@ class GeohashPickerActivity : OrientationAwareActivity() { BitchatTheme { val context = LocalContext.current val scope = rememberCoroutineScope() + val initialRenderQuality = remember(context) { + GlobeRenderQualityPreference.load(context) + } + var renderQuality by remember { mutableStateOf(initialRenderQuality) } val globeState = remember { GlobeState( targetLat = targetLat, targetLon = targetLon, initialPrecision = initialPrecision, - startZoomedOut = true + startZoomedOut = true, + initialRenderQuality = initialRenderQuality ).apply { introTarget = Triple(targetLat, targetLon, initialPrecision) } @@ -183,15 +193,62 @@ class GeohashPickerActivity : OrientationAwareActivity() { tonalElevation = 3.dp, shadowElevation = 6.dp ) { - Text( - text = stringResource(R.string.pan_zoom_instruction), - fontSize = 12.sp, - textAlign = TextAlign.Center, - fontFamily = BitchatFontFamily, - color = MaterialTheme.colorScheme.onSurface, + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .padding(horizontal = 14.dp, vertical = 10.dp) - ) + ) { + Text( + text = stringResource(R.string.pan_zoom_instruction), + fontSize = 12.sp, + textAlign = TextAlign.Center, + fontFamily = BitchatFontFamily, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(8.dp)) + val qualities = GlobeRenderQuality.entries + SingleChoiceSegmentedButtonRow( + modifier = Modifier.fillMaxWidth() + ) { + qualities.forEachIndexed { index, quality -> + val selected = renderQuality == quality + SegmentedButton( + selected = selected, + onClick = { + globeState.setRenderQuality(quality) + renderQuality = quality + GlobeRenderQualityPreference.save(context, quality) + }, + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = qualities.size + ), + icon = {}, + label = { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + if (selected) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterStart) + .size(18.dp) + ) + } + Text( + text = stringResource(quality.labelResource), + fontSize = 11.sp, + fontFamily = BitchatFontFamily + ) + } + } + ) + } + } + } } // Floating bottom controls @@ -290,6 +347,13 @@ class GeohashPickerActivity : OrientationAwareActivity() { } } + private val GlobeRenderQuality.labelResource: Int + get() = when (this) { + GlobeRenderQuality.FAST -> R.string.globe_render_quality_fast + GlobeRenderQuality.MEDIUM -> R.string.globe_render_quality_medium + GlobeRenderQuality.HIGH -> R.string.globe_render_quality_high + } + private fun levelForLength(length: Int): GeohashChannelLevel { return when (length) { in 0..2 -> GeohashChannelLevel.REGION 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/GlobeRenderQuality.kt b/app/src/main/java/com/bitchat/android/ui/globe/GlobeRenderQuality.kt new file mode 100644 index 00000000..7a2ba578 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeRenderQuality.kt @@ -0,0 +1,33 @@ +package com.bitchat.android.ui.globe + +import android.content.Context + +enum class GlobeRenderQuality { + FAST, + MEDIUM, + HIGH; + + companion object { + fun fromStoredValue(value: String?): GlobeRenderQuality = + entries.firstOrNull { it.name == value } ?: MEDIUM + } +} + +object GlobeRenderQualityPreference { + private const val PREFERENCES_NAME = "bitchat_settings" + private const val KEY_RENDER_QUALITY = "geohash_globe_render_quality" + + fun load(context: Context): GlobeRenderQuality { + val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + return GlobeRenderQuality.fromStoredValue( + preferences.getString(KEY_RENDER_QUALITY, GlobeRenderQuality.MEDIUM.name) + ) + } + + fun save(context: Context, quality: GlobeRenderQuality) { + context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_RENDER_QUALITY, quality.name) + .apply() + } +} 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..302434d8 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,11 +23,13 @@ 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, initialPrecision: Int, - startZoomedOut: Boolean + startZoomedOut: Boolean, + initialRenderQuality: GlobeRenderQuality = GlobeRenderQuality.MEDIUM ) { var centerLat by mutableFloatStateOf(if (startZoomedOut) (targetLat * 0.4).toFloat() else targetLat.toFloat()) private set @@ -38,12 +43,17 @@ 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) + internal var renderQuality = initialRenderQuality + private set 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 @@ -61,6 +71,14 @@ class GlobeState( this.scope = scope } + /** + * Rendering quality only affects moving frames. This is deliberately not snapshot state: + * changing it must not invalidate the expensive stationary globe beneath the selector. + */ + fun setRenderQuality(quality: GlobeRenderQuality) { + renderQuality = quality + } + fun setViewport(baseRadiusPx: Float, screenMinPx: Float) { if (baseRadiusPx <= 0f || screenMinPx <= 0f) return this.baseRadiusPx = baseRadiusPx @@ -108,20 +126,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 +171,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..0a09ae0a 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 @@ -21,21 +22,27 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.drawscope.DrawScope 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 @@ -59,6 +66,54 @@ data class GlobeColors( private class Star(val x: Float, val y: Float, val radius: Float, val alpha: Float) +internal data class GlobeFrameDetail( + val graticuleStepDegrees: Double, + val landPointStride: Int, + val showBorders: Boolean, + val cityMaxRank: Int?, + val showCityLabels: Boolean, + val showGeohashGrid: Boolean, + val showNeighborCells: Boolean +) + +internal fun globeFrameDetail( + quality: GlobeRenderQuality, + isMoving: Boolean +): GlobeFrameDetail { + if (!isMoving || quality == GlobeRenderQuality.HIGH) { + return GlobeFrameDetail( + graticuleStepDegrees = 4.0, + landPointStride = 1, + showBorders = true, + cityMaxRank = null, + showCityLabels = true, + showGeohashGrid = true, + showNeighborCells = true + ) + } + return when (quality) { + GlobeRenderQuality.FAST -> GlobeFrameDetail( + graticuleStepDegrees = 10.0, + landPointStride = 2, + showBorders = false, + cityMaxRank = -1, + showCityLabels = false, + showGeohashGrid = false, + showNeighborCells = false + ) + GlobeRenderQuality.MEDIUM -> GlobeFrameDetail( + graticuleStepDegrees = 8.0, + landPointStride = 2, + showBorders = true, + cityMaxRank = 1, + showCityLabels = false, + showGeohashGrid = true, + showNeighborCells = false + ) + GlobeRenderQuality.HIGH -> error("Handled above") + } +} + @Composable fun GlobeView( state: GlobeState, @@ -114,21 +169,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 +213,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 +281,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 +295,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 +325,42 @@ fun GlobeView( val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f) - // Graticule - drawGraticule(cx, cy, r, cLat, cLon, colors.graticule, clip) + val frameDetail = globeFrameDetail(state.renderQuality, state.isInMotion) - // Landmasses + // Graticule + drawGraticule( + cx, cy, r, cLat, cLon, colors.graticule, clip, + step = frameDetail.graticuleStepDegrees + ) + + // Fill every landmass first. Coastlines are drawn in a separate final pass so a + // large continent fill can never cover an island outline drawn earlier. + val coastlineRuns = ArrayList>>>(land.size) for (ring in land) { - drawLandRing(ring, scratch, cx, cy, r, cLat, cLon, colors, clip) + val runs = drawLandFill( + ring = ring, + scratch = scratch, + projector = preparedProjector, + cx = cx, + cy = cy, + r = r, + colors = colors, + clip = clip, + pointStride = if (ring.size >= 64) frameDetail.landPointStride else 1, + centerLat = cLat, + centerLon = cLon + ) + if (runs != null) coastlineRuns.add(runs) + } + for (runs in coastlineRuns) { + strokeRuns(runs, cx, cy, r, colors.coastline, 1.4f, clip) } - // Country borders - for (line in borders) { - drawBorderLine(line, borderScratch, cx, cy, r, cLat, cLon, colors, clip) + // Country borders are restored when interaction settles. + if (frameDetail.showBorders) { + for (line in borders) { + drawBorderLine(line, borderScratch, preparedProjector, cx, cy, r, colors, clip) + } } // Sphere shading: dark limb + night side for 3D depth @@ -297,45 +388,64 @@ 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) - } - - // Labels - if (state.selectedGeohash.isNotEmpty()) { - drawGeohashLabels( - state, cx, cy, r, cLat, cLon, colors, - labelPaint, haloPaint, labelTypeface, labelTypefaceBold, - labelTextSize, labelTextSizeSmall + // Cities are detail-only; omitting them while moving keeps touch latency predictable. + val cityMaxRank = frameDetail.cityMaxRank + if (cityMaxRank == null || cityMaxRank >= 0) { + drawCities( + cities, state, preparedProjector, cx, cy, r, colors, + labelPaint, haloPaint, labelTypeface, labelTextSizeSmall, density.density, + maxRankOverride = cityMaxRank, + showLabels = frameDetail.showCityLabels ) } - // 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)) + // Detailed cells and labels settle into place after the gesture ends. + if (frameDetail.showGeohashGrid && state.selectedGeohash.isNotEmpty()) { + drawGeohashGrid( + state, cx, cy, r, cLat, cLon, colors, clip, + includeNeighbors = frameDetail.showNeighborCells + ) + drawGeohashLabels( + state, cx, cy, r, cLat, cLon, colors, + labelPaint, haloPaint, labelTypeface, labelTypefaceBold, + labelTextSize, labelTextSizeSmall, + includeNeighbors = frameDetail.showNeighborCells + ) + } + + } + + // 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) @@ -376,7 +486,7 @@ private fun DrawScope.drawGraticule( drawPath(path, color, style = Stroke(width = 1f)) } -private data class DiscPt(val x: Float, val y: Float, val front: Boolean) +internal data class DiscPt(val x: Float, val y: Float, val front: Boolean) private fun limbPoint(behind: DiscPt, front: DiscPt): Pair { var lo = 0f; var hi = 1f @@ -395,7 +505,10 @@ private fun limbPoint(behind: DiscPt, front: DiscPt): Pair { * padded with the horizon intersection point so they can be closed along the horizon. * Pass [closed] = false for open polylines (border lines). */ -private fun buildFrontRuns(pts: List, closed: Boolean = true): List>> { +internal fun buildFrontRuns( + pts: List, + closed: Boolean = true +): List>> { if (pts.isEmpty()) return emptyList() val n = pts.size val runs = mutableListOf>>() @@ -432,6 +545,12 @@ private fun buildFrontRuns(pts: List, closed: Boolean = true): List, cx: Float, cy: Float, r: Float, color: Color, - clip: ClipRect + clip: ClipRect, + invertFill: Boolean = false, + preparedPolygon: List>? = null ) { if (pts.none { it.front }) return - val poly = buildFillPolygon(pts, cx, cy, r) + val poly = preparedPolygon ?: buildFillPolygon(pts, cx, cy, r) if (poly.size < 3) return val clipped = clipPolygon(poly, clip) if (clipped.size < 3) return val path = Path() + if (invertFill) { + // Orthographic projection can choose the wrong side of the horizon closure for + // very large rings. Even-odd filling with the globe disc flips that one ring. + path.fillType = PathFillType.EvenOdd + path.addOval(Rect(cx - r, cy - r, cx + r, cy + r)) + } path.moveTo(clipped[0].first, clipped[0].second) for (k in 1 until clipped.size) { path.lineTo(clipped[k].first, clipped[k].second) @@ -580,6 +707,96 @@ private fun DrawScope.fillPolygonClipped( drawPath(path, color) } +internal fun projectedFillNeedsInversion( + polygon: List>, + ring: LandData.Ring, + cx: Float, + cy: Float, + r: Float, + centerLat: Double, + centerLon: Double +): Boolean { + // A single center-point check is ambiguous for a large polygon and caused an + // occasional whole-disc fill. Compare several visible points with the original + // geographic ring, and invert only when the opposite fill wins clearly. + val samples = arrayOf( + 0f to 0f, + -0.5f to 0f, + 0.5f to 0f, + 0f to -0.5f, + 0f to 0.5f, + -0.35f to -0.35f, + 0.35f to -0.35f, + -0.35f to 0.35f, + 0.35f to 0.35f + ) + var normalErrors = 0 + var invertedErrors = 0 + for ((nx, ny) in samples) { + val location = GlobeMath.unproject( + x = nx.toDouble(), + y = ny.toDouble(), + centerLatDeg = centerLat, + centerLonDeg = centerLon + ) ?: continue + val geographicInside = ringContainsLocation(ring, location.first, location.second) + val projectedInside = polygonContains(polygon, cx + nx * r, cy + ny * r) + if (projectedInside != geographicInside) normalErrors++ + if (!projectedInside != geographicInside) invertedErrors++ + } + return invertedErrors < normalErrors +} + +internal fun polygonContains( + polygon: List>, + x: Float, + y: Float +): Boolean { + if (polygon.size < 3) return false + var inside = false + var previous = polygon.last() + for (current in polygon) { + val crossesRay = (current.second > y) != (previous.second > y) + if (crossesRay) { + val intersectionX = (previous.first - current.first) * + (y - current.second) / (previous.second - current.second) + + current.first + if (x < intersectionX) inside = !inside + } + previous = current + } + return inside +} + +internal fun ringContainsLocation( + ring: LandData.Ring, + latitude: Double, + longitude: Double +): Boolean { + if (ring.size < 3) return false + + fun relativeLongitude(value: Float): Double = + GlobeMath.normalizeLon(value.toDouble() - longitude) + + var inside = false + var previousIndex = ring.size - 1 + for (index in 0 until ring.size) { + val currentLat = ring.coords[index * 2].toDouble() + val currentLon = relativeLongitude(ring.coords[index * 2 + 1]) + val previousLat = ring.coords[previousIndex * 2].toDouble() + val previousLon = relativeLongitude(ring.coords[previousIndex * 2 + 1]) + val crossesRay = (currentLat > latitude) != (previousLat > latitude) + if (crossesRay) { + val intersectionLon = (previousLon - currentLon) * + (latitude - currentLat) / (previousLat - currentLat) + + currentLon + if (0.0 < intersectionLon) inside = !inside + } + previousIndex = index + } + return inside +} + private fun DrawScope.strokeRuns( runs: List>>, cx: Float, cy: Float, r: Float, @@ -606,8 +823,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 +835,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,18 +853,21 @@ 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, typeface: Typeface?, textSize: Float, - density: Float + density: Float, + maxRankOverride: Int?, + showLabels: Boolean ) { if (cities.isEmpty()) return + val projection = FloatArray(3) val zoom = state.zoom - val maxRank = when { + val maxRank = maxRankOverride ?: when { zoom < 2f -> 1 zoom < 8f -> 3 zoom < 40f -> 4 @@ -655,20 +876,21 @@ 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) else colors.label.copy(alpha = alpha * 0.85f) drawCircle(dotColor, radius = dotRadius, center = Offset(sx, sy)) - if (zoom >= 6f || (important && zoom >= 2.5f)) { + if (showLabels && (zoom >= 6f || (important && zoom >= 2.5f))) { labelPaint.textSize = textSize labelPaint.typeface = typeface labelPaint.textAlign = Paint.Align.LEFT @@ -694,30 +916,34 @@ private fun DrawScope.drawCities( haloPaint.textAlign = Paint.Align.CENTER } -private fun DrawScope.drawLandRing( +private fun DrawScope.drawLandFill( ring: LandData.Ring, scratch: FloatArray, + projector: GlobeMath.PreparedProjector, cx: Float, cy: Float, r: Float, - cLat: Double, cLon: Double, colors: GlobeColors, - clip: ClipRect -) { - val n = ring.size - if (n < 3 || n * 3 > scratch.size) return + clip: ClipRect, + pointStride: Int, + centerLat: Double, + centerLon: Double +): List>>? { + val n = ((ring.size - 1) / pointStride) + 1 + if (n < 3 || n * 3 > scratch.size) return null var anyFront = false + var anyBack = 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 + } else { + anyBack = true + } i++ } - if (!anyFront) return + if (!anyFront) return null val pts = ArrayList(n) i = 0 @@ -726,8 +952,26 @@ private fun DrawScope.drawLandRing( i++ } val runs = buildFrontRuns(pts) - fillPolygonClipped(pts, cx, cy, r, colors.land, clip) - strokeRuns(runs, cx, cy, r, colors.coastline, 1.4f, clip) + val polygon = buildFillPolygon(pts, cx, cy, r) + fillPolygonClipped( + pts = pts, + cx = cx, + cy = cy, + r = r, + color = colors.land, + clip = clip, + invertFill = anyBack && projectedFillNeedsInversion( + polygon = polygon, + ring = ring, + cx = cx, + cy = cy, + r = r, + centerLat = centerLat, + centerLon = centerLon + ), + preparedPolygon = polygon + ) + return runs } private fun DrawScope.drawGeohashGrid( @@ -735,11 +979,12 @@ private fun DrawScope.drawGeohashGrid( cx: Float, cy: Float, r: Float, cLat: Double, cLon: Double, colors: GlobeColors, - clip: ClipRect + clip: ClipRect, + includeNeighbors: Boolean ) { val selected = state.selectedGeohash val cells = linkedSetOf(selected) - cells.addAll(Geohash.neighborsSamePrecision(selected)) + if (includeNeighbors) cells.addAll(Geohash.neighborsSamePrecision(selected)) for (cell in cells) { val isSelected = cell == selected @@ -782,11 +1027,15 @@ private fun DrawScope.drawGeohashGrid( val runs = buildFrontRuns(discPts) if (isSelected) { - fillPolygonClipped(discPts, cx, cy, r, colors.accent.copy(alpha = 0.20f), clip) + fillPolygonClipped( + discPts, cx, cy, r, colors.accent.copy(alpha = 0.20f), clip + ) strokeRuns(runs, cx, cy, r, colors.accent.copy(alpha = 0.35f), 7f, clip) strokeRuns(runs, cx, cy, r, colors.accent, 3.2f, clip) } else { - fillPolygonClipped(discPts, cx, cy, r, colors.grid.copy(alpha = 0.05f), clip) + fillPolygonClipped( + discPts, cx, cy, r, colors.grid.copy(alpha = 0.05f), clip + ) strokeRuns(runs, cx, cy, r, colors.grid, 1.6f, clip) } } @@ -802,11 +1051,12 @@ private fun DrawScope.drawGeohashLabels( labelTypeface: Typeface?, labelTypefaceBold: Typeface?, selectedSize: Float, - neighborSize: Float + neighborSize: Float, + includeNeighbors: Boolean ) { val selected = state.selectedGeohash val cells = linkedSetOf(selected) - cells.addAll(Geohash.neighborsSamePrecision(selected)) + if (includeNeighbors) cells.addAll(Geohash.neighborsSamePrecision(selected)) val canvas = drawContext.canvas.nativeCanvas for (cell in cells) { 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/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 88c495c1..52388349 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -540,6 +540,9 @@ Nobody around… (you) Drag to spin · Pinch to zoom · Tap to focus + Fast + Medium + High Select Type a message… @%1$s diff --git a/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeGeometryTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeGeometryTest.kt new file mode 100644 index 00000000..122e1235 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeGeometryTest.kt @@ -0,0 +1,62 @@ +package com.bitchat.android.ui.globe + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GlobeGeometryTest { + + private val square = listOf( + DiscPt(-0.5f, -0.5f, front = true), + DiscPt(0.5f, -0.5f, front = true), + DiscPt(0.5f, 0.5f, front = true), + DiscPt(-0.5f, 0.5f, front = true) + ) + + @Test + fun closedFullyVisibleRing_connectsLastPointToFirst() { + val run = buildFrontRuns(square, closed = true).single() + + assertEquals(square.size + 1, run.size) + assertEquals(run.first(), run.last()) + } + + @Test + fun openFullyVisibleLine_doesNotConnectLastPointToFirst() { + val run = buildFrontRuns(square, closed = false).single() + + assertEquals(square.size, run.size) + assertNotEquals(run.first(), run.last()) + } + + @Test + fun polygonContains_distinguishesInsideAndOutsidePoints() { + val polygon = listOf( + -10f to -10f, + 10f to -10f, + 10f to 10f, + -10f to 10f + ) + + assertTrue(polygonContains(polygon, 0f, 0f)) + assertFalse(polygonContains(polygon, 20f, 0f)) + } + + @Test + fun ringContainsLocation_distinguishesInsideAndOutsideCoordinates() { + val ring = LandData.Ring( + coords = floatArrayOf( + -10f, -10f, + -10f, 10f, + 10f, 10f, + 10f, -10f + ), + size = 4 + ) + + assertTrue(ringContainsLocation(ring, latitude = 0.0, longitude = 0.0)) + assertFalse(ringContainsLocation(ring, latitude = 20.0, longitude = 0.0)) + } +} 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) + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeRenderQualityTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeRenderQualityTest.kt new file mode 100644 index 00000000..1581e38f --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeRenderQualityTest.kt @@ -0,0 +1,78 @@ +package com.bitchat.android.ui.globe + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GlobeRenderQualityTest { + + @Test + fun invalidStoredValue_defaultsToMedium() { + assertEquals(GlobeRenderQuality.MEDIUM, GlobeRenderQuality.fromStoredValue(null)) + assertEquals(GlobeRenderQuality.MEDIUM, GlobeRenderQuality.fromStoredValue("UNKNOWN")) + } + + @Test + fun globeState_usesAndUpdatesRenderQualityWithoutReplacingState() { + val state = GlobeState( + targetLat = 0.0, + targetLon = 0.0, + initialPrecision = 2, + startZoomedOut = false, + initialRenderQuality = GlobeRenderQuality.FAST + ) + + assertEquals(GlobeRenderQuality.FAST, state.renderQuality) + state.setRenderQuality(GlobeRenderQuality.HIGH) + assertEquals(GlobeRenderQuality.HIGH, state.renderQuality) + } + + @Test + fun stationaryFrame_alwaysUsesFullDetail() { + GlobeRenderQuality.entries.forEach { quality -> + val detail = globeFrameDetail(quality, isMoving = false) + + assertEquals(1, detail.landPointStride) + assertTrue(detail.showBorders) + assertNull(detail.cityMaxRank) + assertTrue(detail.showCityLabels) + assertTrue(detail.showGeohashGrid) + assertTrue(detail.showNeighborCells) + } + } + + @Test + fun movingFastFrame_usesMinimumDetail() { + val detail = globeFrameDetail(GlobeRenderQuality.FAST, isMoving = true) + + assertEquals(2, detail.landPointStride) + assertFalse(detail.showBorders) + assertEquals(-1, detail.cityMaxRank) + assertFalse(detail.showGeohashGrid) + } + + @Test + fun movingMediumFrame_preservesOrientationAndSelection() { + val detail = globeFrameDetail(GlobeRenderQuality.MEDIUM, isMoving = true) + + assertEquals(2, detail.landPointStride) + assertTrue(detail.showBorders) + assertEquals(1, detail.cityMaxRank) + assertFalse(detail.showCityLabels) + assertTrue(detail.showGeohashGrid) + assertFalse(detail.showNeighborCells) + } + + @Test + fun movingHighFrame_usesFullDetail() { + val detail = globeFrameDetail(GlobeRenderQuality.HIGH, isMoving = true) + + assertEquals(1, detail.landPointStride) + assertTrue(detail.showBorders) + assertNull(detail.cityMaxRank) + assertTrue(detail.showCityLabels) + assertTrue(detail.showNeighborCells) + } +}