Merge pull request #836 from permissionlesstech/codex/restore-globe-picker-ux

Restore globe picker UX and teleport selection
This commit is contained in:
callebtc 2026-07-30 23:40:12 +02:00 committed by GitHub
commit 13f15ca1ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 212 additions and 242 deletions

View File

@ -15,9 +15,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -37,8 +34,6 @@ import com.bitchat.android.geohash.Geohash
import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.ui.globe.GlobeColors 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.GlobeState
import com.bitchat.android.ui.globe.GlobeView import com.bitchat.android.ui.globe.GlobeView
import com.bitchat.android.ui.globe.LandData import com.bitchat.android.ui.globe.LandData
@ -95,18 +90,13 @@ class GeohashPickerActivity : OrientationAwareActivity() {
BitchatTheme { BitchatTheme {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val initialRenderQuality = remember(context) {
GlobeRenderQualityPreference.load(context)
}
var renderQuality by remember { mutableStateOf(initialRenderQuality) }
val globeState = remember { val globeState = remember {
GlobeState( GlobeState(
targetLat = targetLat, targetLat = targetLat,
targetLon = targetLon, targetLon = targetLon,
initialPrecision = initialPrecision, initialPrecision = initialPrecision,
startZoomedOut = true, startZoomedOut = true
initialRenderQuality = initialRenderQuality
).apply { ).apply {
introTarget = Triple(targetLat, targetLon, initialPrecision) introTarget = Triple(targetLat, targetLon, initialPrecision)
} }
@ -193,62 +183,15 @@ class GeohashPickerActivity : OrientationAwareActivity() {
tonalElevation = 3.dp, tonalElevation = 3.dp,
shadowElevation = 6.dp shadowElevation = 6.dp
) { ) {
Column( Text(
horizontalAlignment = Alignment.CenterHorizontally, text = stringResource(R.string.pan_zoom_instruction),
fontSize = 12.sp,
textAlign = TextAlign.Center,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier modifier = Modifier
.padding(horizontal = 14.dp, vertical = 10.dp) .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 // Floating bottom controls
@ -347,13 +290,6 @@ 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 { private fun levelForLength(length: Int): GeohashChannelLevel {
return when (length) { return when (length) {
in 0..2 -> GeohashChannelLevel.REGION in 0..2 -> GeohashChannelLevel.REGION

View File

@ -137,6 +137,18 @@ fun LocationChannelsSheet(
var customGeohash by remember { mutableStateOf("") } var customGeohash by remember { mutableStateOf("") }
var customError by remember { mutableStateOf<String?>(null) } var customError by remember { mutableStateOf<String?>(null) }
val teleportToGeohash: (String) -> Unit = { value ->
val channel = channelForManualGeohash(value)
if (channel != null) {
customError = null
locationManager.selectManual(channel)
onDismiss()
} else {
customGeohash = value.trim().lowercase().replace("#", "")
customError = context.getString(R.string.invalid_geohash)
}
}
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@ -158,8 +170,7 @@ fun LocationChannelsSheet(
if (result.resultCode == android.app.Activity.RESULT_OK) { if (result.resultCode == android.app.Activity.RESULT_OK) {
val gh = result.data?.getStringExtra(GeohashPickerActivity.EXTRA_RESULT_GEOHASH) val gh = result.data?.getStringExtra(GeohashPickerActivity.EXTRA_RESULT_GEOHASH)
if (!gh.isNullOrBlank()) { if (!gh.isNullOrBlank()) {
customGeohash = gh teleportToGeohash(gh)
customError = null
} }
} }
} }
@ -503,15 +514,7 @@ fun LocationChannelsSheet(
mapPickerLauncher.launch(intent) mapPickerLauncher.launch(intent)
}, },
onTeleport = { onTeleport = {
val normalized = customGeohash.trim().lowercase().replace("#", "") teleportToGeohash(customGeohash)
if (validateGeohash(normalized)) {
val level = levelForLength(normalized.length)
val channel = GeohashChannel(level = level, geohash = normalized)
locationManager.selectManual(channel)
onDismiss()
} else {
customError = context.getString(R.string.invalid_geohash)
}
} }
) )
} }
@ -1173,6 +1176,15 @@ private fun validateGeohash(geohash: String): Boolean {
return geohash.all { it in allowed } return geohash.all { it in allowed }
} }
internal fun channelForManualGeohash(value: String): GeohashChannel? {
val normalized = value.trim().lowercase().replace("#", "")
if (!validateGeohash(normalized)) return null
return GeohashChannel(
level = levelForLength(normalized.length),
geohash = normalized
)
}
private fun levelForLength(length: Int): GeohashChannelLevel { private fun levelForLength(length: Int): GeohashChannelLevel {
return when (length) { return when (length) {
in 0..2 -> GeohashChannelLevel.REGION in 0..2 -> GeohashChannelLevel.REGION

View File

@ -1,33 +0,0 @@
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()
}
}

View File

@ -28,8 +28,7 @@ class GlobeState(
targetLat: Double, targetLat: Double,
targetLon: Double, targetLon: Double,
initialPrecision: Int, initialPrecision: Int,
startZoomedOut: Boolean, startZoomedOut: Boolean
initialRenderQuality: GlobeRenderQuality = GlobeRenderQuality.MEDIUM
) { ) {
var centerLat by mutableFloatStateOf(if (startZoomedOut) (targetLat * 0.4).toFloat() else targetLat.toFloat()) var centerLat by mutableFloatStateOf(if (startZoomedOut) (targetLat * 0.4).toFloat() else targetLat.toFloat())
private set private set
@ -48,8 +47,6 @@ class GlobeState(
internal var baseRadiusPx by mutableFloatStateOf(0f) internal var baseRadiusPx by mutableFloatStateOf(0f)
internal var screenMinPx by mutableFloatStateOf(0f) internal var screenMinPx by mutableFloatStateOf(0f)
internal var renderQuality = initialRenderQuality
private set
private var scope: CoroutineScope? = null private var scope: CoroutineScope? = null
private var animJob: Job? = null private var animJob: Job? = null
@ -71,14 +68,6 @@ class GlobeState(
this.scope = scope 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) { fun setViewport(baseRadiusPx: Float, screenMinPx: Float) {
if (baseRadiusPx <= 0f || screenMinPx <= 0f) return if (baseRadiusPx <= 0f || screenMinPx <= 0f) return
this.baseRadiusPx = baseRadiusPx this.baseRadiusPx = baseRadiusPx

View File

@ -18,8 +18,11 @@ import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
@ -39,10 +42,7 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.bitchat.android.geohash.Geohash import com.bitchat.android.geohash.Geohash
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.min import kotlin.math.min
@ -76,12 +76,15 @@ internal data class GlobeFrameDetail(
val showNeighborCells: Boolean val showNeighborCells: Boolean
) )
internal fun globeFrameDetail( internal enum class GlobeMotionDetail {
quality: GlobeRenderQuality, FULL,
isMoving: Boolean BALANCED,
): GlobeFrameDetail { FAST
if (!isMoving || quality == GlobeRenderQuality.HIGH) { }
return GlobeFrameDetail(
internal fun globeFrameDetail(motionDetail: GlobeMotionDetail): GlobeFrameDetail =
when (motionDetail) {
GlobeMotionDetail.FULL -> GlobeFrameDetail(
graticuleStepDegrees = 4.0, graticuleStepDegrees = 4.0,
landPointStride = 1, landPointStride = 1,
showBorders = true, showBorders = true,
@ -90,18 +93,7 @@ internal fun globeFrameDetail(
showGeohashGrid = true, showGeohashGrid = true,
showNeighborCells = true showNeighborCells = true
) )
} GlobeMotionDetail.BALANCED -> GlobeFrameDetail(
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, graticuleStepDegrees = 8.0,
landPointStride = 2, landPointStride = 2,
showBorders = true, showBorders = true,
@ -110,7 +102,44 @@ internal fun globeFrameDetail(
showGeohashGrid = true, showGeohashGrid = true,
showNeighborCells = false showNeighborCells = false
) )
GlobeRenderQuality.HIGH -> error("Handled above") GlobeMotionDetail.FAST -> GlobeFrameDetail(
graticuleStepDegrees = 10.0,
landPointStride = 2,
showBorders = false,
cityMaxRank = -1,
showCityLabels = false,
showGeohashGrid = false,
showNeighborCells = false
)
}
/**
* Adjusts moving-frame detail from measured frame cadence. Hysteresis keeps the renderer
* from oscillating between levels when timings sit near a boundary.
*/
internal fun nextGlobeMotionDetail(
current: GlobeMotionDetail,
averageFrameMillis: Float
): GlobeMotionDetail {
if (!averageFrameMillis.isFinite()) return GlobeMotionDetail.FAST
return when (current) {
GlobeMotionDetail.FULL -> when {
averageFrameMillis > SEVERELY_SLOW_FRAME_MILLIS -> GlobeMotionDetail.FAST
averageFrameMillis > SLOW_FRAME_MILLIS -> GlobeMotionDetail.BALANCED
else -> GlobeMotionDetail.FULL
}
GlobeMotionDetail.BALANCED -> when {
averageFrameMillis > VERY_SLOW_FRAME_MILLIS -> GlobeMotionDetail.FAST
averageFrameMillis < SMOOTH_FRAME_MILLIS -> GlobeMotionDetail.FULL
else -> GlobeMotionDetail.BALANCED
}
GlobeMotionDetail.FAST -> {
if (averageFrameMillis < RECOVERING_FRAME_MILLIS) {
GlobeMotionDetail.BALANCED
} else {
GlobeMotionDetail.FAST
}
}
} }
} }
@ -169,19 +198,48 @@ fun GlobeView(
} }
LaunchedEffect(state) { LaunchedEffect(state) {
snapshotFlow { state.selectedGeohash to state.isInMotion } snapshotFlow { state.selectedGeohash }
.distinctUntilChanged()
.drop(1) .drop(1)
.collectLatest { (geohash, inMotion) -> .collect {
if (geohash.isNotEmpty() && !inMotion) { @Suppress("DEPRECATION")
delay(SETTLED_HAPTIC_DELAY_MS) view.performHapticFeedback(
if (!state.isInMotion && state.selectedGeohash == geohash) { HapticFeedbackConstants.KEYBOARD_TAP,
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
} )
}
} }
} }
var motionDetail by remember(state) { mutableStateOf(GlobeMotionDetail.BALANCED) }
LaunchedEffect(state, state.isInMotion) {
if (!state.isInMotion) {
motionDetail = GlobeMotionDetail.FULL
return@LaunchedEffect
}
// Balanced is a safe first frame; measured cadence then moves detail up or down.
motionDetail = GlobeMotionDetail.BALANCED
var previousFrameNanos = withFrameNanos { it }
var sampledFrameMillis = 0f
var sampledFrameCount = 0
while (state.isInMotion) {
val frameNanos = withFrameNanos { it }
val frameMillis = ((frameNanos - previousFrameNanos) / 1_000_000f)
.coerceIn(1f, MAX_SAMPLED_FRAME_MILLIS)
previousFrameNanos = frameNanos
sampledFrameMillis += frameMillis
sampledFrameCount++
if (sampledFrameCount >= FRAME_SAMPLE_COUNT) {
motionDetail = nextGlobeMotionDetail(
current = motionDetail,
averageFrameMillis = sampledFrameMillis / sampledFrameCount
)
sampledFrameMillis = 0f
sampledFrameCount = 0
}
}
}
val labelTextSize = with(density) { 12.5.sp.toPx() } val labelTextSize = with(density) { 12.5.sp.toPx() }
val labelTextSizeSmall = with(density) { 10.sp.toPx() } val labelTextSizeSmall = with(density) { 10.sp.toPx() }
@ -325,7 +383,9 @@ fun GlobeView(
val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f) val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f)
val frameDetail = globeFrameDetail(state.renderQuality, state.isInMotion) val frameDetail = globeFrameDetail(
if (state.isInMotion) motionDetail else GlobeMotionDetail.FULL
)
// Graticule // Graticule
drawGraticule( drawGraticule(
@ -433,7 +493,13 @@ fun GlobeView(
} }
} }
private const val SETTLED_HAPTIC_DELAY_MS = 80L private const val FRAME_SAMPLE_COUNT = 8
private const val MAX_SAMPLED_FRAME_MILLIS = 50f
private const val SMOOTH_FRAME_MILLIS = 18.5f
private const val SLOW_FRAME_MILLIS = 22f
private const val RECOVERING_FRAME_MILLIS = 22f
private const val VERY_SLOW_FRAME_MILLIS = 29f
private const val SEVERELY_SLOW_FRAME_MILLIS = 34f
private fun DrawScope.drawGraticule( private fun DrawScope.drawGraticule(
cx: Float, cx: Float,

View File

@ -540,9 +540,6 @@
<string name="nobody_around">Nobody around…</string> <string name="nobody_around">Nobody around…</string>
<string name="you_suffix"> (you)</string> <string name="you_suffix"> (you)</string>
<string name="pan_zoom_instruction">Drag to spin · Pinch to zoom · Tap to focus</string> <string name="pan_zoom_instruction">Drag to spin · Pinch to zoom · Tap to focus</string>
<string name="globe_render_quality_fast">Fast</string>
<string name="globe_render_quality_medium">Medium</string>
<string name="globe_render_quality_high">High</string>
<string name="select">Select</string> <string name="select">Select</string>
<string name="type_a_message_placeholder">Type a message…</string> <string name="type_a_message_placeholder">Type a message…</string>
<string name="mention_suggestion_at">@%1$s</string> <string name="mention_suggestion_at">@%1$s</string>

View File

@ -42,6 +42,22 @@ class LocationChannelsSheetTest {
) )
} }
@Test
fun `globe result resolves to a manual teleport channel`() {
assertEquals(
GeohashChannel(
level = GeohashChannelLevel.CITY,
geohash = "u33dc"
),
channelForManualGeohash(" #U33DC ")
)
}
@Test
fun `invalid globe result cannot create a teleport channel`() {
assertNull(channelForManualGeohash("not-a-geohash"))
}
private fun channel(geohash: String) = GeohashChannel( private fun channel(geohash: String) = GeohashChannel(
level = GeohashChannelLevel.CITY, level = GeohashChannelLevel.CITY,
geohash = geohash geohash = geohash

View File

@ -0,0 +1,65 @@
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 GlobeInteractionPolicyTest {
@Test
fun fullDetail_preservesAllGlobeFeatures() {
val detail = globeFrameDetail(GlobeMotionDetail.FULL)
assertEquals(1, detail.landPointStride)
assertTrue(detail.showBorders)
assertNull(detail.cityMaxRank)
assertTrue(detail.showCityLabels)
assertTrue(detail.showGeohashGrid)
assertTrue(detail.showNeighborCells)
}
@Test
fun balancedDetail_preservesOrientationAndSelection() {
val detail = globeFrameDetail(GlobeMotionDetail.BALANCED)
assertEquals(2, detail.landPointStride)
assertTrue(detail.showBorders)
assertEquals(1, detail.cityMaxRank)
assertFalse(detail.showCityLabels)
assertTrue(detail.showGeohashGrid)
assertFalse(detail.showNeighborCells)
}
@Test
fun fastDetail_usesMinimumMovingFrameWork() {
val detail = globeFrameDetail(GlobeMotionDetail.FAST)
assertEquals(2, detail.landPointStride)
assertFalse(detail.showBorders)
assertEquals(-1, detail.cityMaxRank)
assertFalse(detail.showCityLabels)
assertFalse(detail.showGeohashGrid)
}
@Test
fun adaptiveDetail_degradesOnSlowFramesAndRecoversWithHysteresis() {
assertEquals(
GlobeMotionDetail.BALANCED,
nextGlobeMotionDetail(GlobeMotionDetail.FULL, averageFrameMillis = 24f)
)
assertEquals(
GlobeMotionDetail.FAST,
nextGlobeMotionDetail(GlobeMotionDetail.BALANCED, averageFrameMillis = 32f)
)
assertEquals(
GlobeMotionDetail.BALANCED,
nextGlobeMotionDetail(GlobeMotionDetail.FAST, averageFrameMillis = 17f)
)
assertEquals(
GlobeMotionDetail.FULL,
nextGlobeMotionDetail(GlobeMotionDetail.BALANCED, averageFrameMillis = 17f)
)
}
}

View File

@ -1,78 +0,0 @@
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)
}
}