mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
Merge pull request #833 from a1denvalu3/optimize/geohash-picker-low-end
Optimize geohash globe rendering and gestures
This commit is contained in:
commit
ae70c02149
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user