Merge pull request #833 from a1denvalu3/optimize/geohash-picker-low-end

Optimize geohash globe rendering and gestures
This commit is contained in:
callebtc 2026-07-30 19:08:38 +02:00 committed by GitHub
commit ae70c02149
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 816 additions and 151 deletions

View File

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

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

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

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

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
@ -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<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 +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<List<MutableList<Pair<Float, Float>>>>(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<Float, Float> {
var lo = 0f; var hi = 1f
@ -395,7 +505,10 @@ private fun limbPoint(behind: DiscPt, front: DiscPt): Pair<Float, Float> {
* 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<DiscPt>, closed: Boolean = true): List<MutableList<Pair<Float, Float>>> {
internal fun buildFrontRuns(
pts: List<DiscPt>,
closed: Boolean = true
): List<MutableList<Pair<Float, Float>>> {
if (pts.isEmpty()) return emptyList()
val n = pts.size
val runs = mutableListOf<MutableList<Pair<Float, Float>>>()
@ -432,6 +545,12 @@ private fun buildFrontRuns(pts: List<DiscPt>, closed: Boolean = true): List<Muta
if (closed && runs.isNotEmpty() && pts[0].front && pts[n - 1].front) {
runs[0] = (run + runs[0]).toMutableList()
} else {
// A fully front-facing ring has no limb transition to terminate its run.
// Close it explicitly; cell boundary sampling intentionally omits the
// duplicated final corner.
if (closed && runs.isEmpty() && pts[0].front && pts[n - 1].front) {
run.add(run.first())
}
runs.add(run)
}
}
@ -564,14 +683,22 @@ private fun DrawScope.fillPolygonClipped(
pts: List<DiscPt>,
cx: Float, cy: Float, r: Float,
color: Color,
clip: ClipRect
clip: ClipRect,
invertFill: Boolean = false,
preparedPolygon: List<Pair<Float, Float>>? = 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<Pair<Float, Float>>,
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<Pair<Float, Float>>,
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<MutableList<Pair<Float, Float>>>,
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<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,18 +853,21 @@ 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,
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<MutableList<Pair<Float, Float>>>? {
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<DiscPt>(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) {

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

@ -540,6 +540,9 @@
<string name="nobody_around">Nobody around…</string>
<string name="you_suffix"> (you)</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="type_a_message_placeholder">Type a message…</string>
<string name="mention_suggestion_at">@%1$s</string>

View File

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

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

View File

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