Optimize geohash globe rendering and gestures

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

View File

@ -12,6 +12,33 @@ object GlobeMath {
data class Projection(val x: Float, val y: Float, val cosC: Float) 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). * 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 * Returns x/y in units of globe radius (screen y down). [Projection.cosC] is negative

View File

@ -4,6 +4,7 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -12,7 +13,9 @@ import com.bitchat.android.geohash.Geohash
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.runtime.withFrameNanos
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.pow import kotlin.math.pow
/** /**
@ -20,6 +23,7 @@ import kotlin.math.pow
* and the current selection. All mutation funnels through this class so rendering, * and the current selection. All mutation funnels through this class so rendering,
* gestures and buttons stay in sync. * gestures and buttons stay in sync.
*/ */
@Stable
class GlobeState( class GlobeState(
targetLat: Double, targetLat: Double,
targetLon: Double, targetLon: Double,
@ -38,12 +42,15 @@ class GlobeState(
private set private set
var isInteracting by mutableStateOf(false) var isInteracting by mutableStateOf(false)
internal set internal set
var isAnimating by mutableStateOf(false)
private set
internal var baseRadiusPx by mutableFloatStateOf(0f) internal var baseRadiusPx by mutableFloatStateOf(0f)
internal var screenMinPx by mutableFloatStateOf(0f) internal var screenMinPx by mutableFloatStateOf(0f)
private var scope: CoroutineScope? = null private var scope: CoroutineScope? = null
private var animJob: Job? = null private var animJob: Job? = null
private var animationGeneration = 0
/** Pending cinematic intro target (lat, lon, precision); consumed when played. */ /** Pending cinematic intro target (lat, lon, precision); consumed when played. */
var introTarget: Triple<Double, Double, Int>? = null var introTarget: Triple<Double, Double, Int>? = null
@ -108,20 +115,29 @@ class GlobeState(
val dLon = GlobeMath.normalizeLon(lon - startLon) val dLon = GlobeMath.normalizeLon(lon - startLon)
val startZoom = zoom val startZoom = zoom
val endZoom = (targetZoom ?: zoom).coerceIn(GlobeMath.MIN_ZOOM, GlobeMath.MAX_ZOOM) val endZoom = (targetZoom ?: zoom).coerceIn(GlobeMath.MIN_ZOOM, GlobeMath.MAX_ZOOM)
val generation = ++animationGeneration
isAnimating = true
animJob = s.launch { animJob = s.launch {
val anim = Animatable(0f) try {
anim.animateTo(1f, tween(durationMs, easing = FastOutSlowInEasing)) { val anim = Animatable(0f)
val t = value anim.animateTo(1f, tween(durationMs, easing = FastOutSlowInEasing)) {
centerLat = (startLat + (lat.toFloat() - startLat) * t).coerceIn(MIN_LAT, MAX_LAT) val t = value
centerLon = GlobeMath.normalizeLon(startLon + dLon * t).toFloat() centerLat = (startLat + (lat.toFloat() - startLat) * t).coerceIn(MIN_LAT, MAX_LAT)
// exponential interpolation feels natural for zoom centerLon = GlobeMath.normalizeLon(startLon + dLon * t).toFloat()
zoom = startZoom * (endZoom / startZoom).pow(t) // exponential interpolation feels natural for zoom
if (targetPrecision != null) { zoom = startZoom * (endZoom / startZoom).pow(t)
precision = targetPrecision.coerceIn(1, GlobeMath.MAX_PRECISION) if (targetPrecision != null) {
} else { precision = targetPrecision.coerceIn(1, GlobeMath.MAX_PRECISION)
syncPrecisionFromZoom() } else {
syncPrecisionFromZoom()
}
syncSelection()
}
} finally {
if (animationGeneration == generation) {
isAnimating = false
animJob = null
} }
syncSelection()
} }
} }
} }
@ -144,37 +160,64 @@ class GlobeState(
animateTo(targetLat, targetLon, targetZoom, targetPrecision, durationMs = 1400) 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) { fun fling(velocityX: Float, velocityY: Float) {
val s = scope ?: return 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() animJob?.cancel()
val generation = ++animationGeneration
isAnimating = true
animJob = s.launch { animJob = s.launch {
var vx = velocityX try {
var vy = velocityY var vx = initialVx
var lastTime = System.nanoTime() var vy = initialVy
while (abs(vx) > 0.02f || abs(vy) > 0.02f) { var lastFrameNanos = withFrameNanos { it }
val now = System.nanoTime() while (abs(vx) >= MIN_FLING_PX_PER_SECOND || abs(vy) >= MIN_FLING_PX_PER_SECOND) {
val dtMs = ((now - lastTime) / 1_000_000f).coerceAtMost(50f) val frameNanos = withFrameNanos { it }
lastTime = now val dtSeconds = ((frameNanos - lastFrameNanos) / 1_000_000_000f)
rotateBy(vx * dtMs, vy * dtMs) .coerceIn(0f, MAX_FRAME_DELTA_SECONDS)
val decay = 0.94f.pow(dtMs / 16f) lastFrameNanos = frameNanos
vx *= decay val maxStep = globeRadiusPx * MAX_FLING_RADIUS_FRACTION_PER_FRAME
vy *= decay rotateBy(
kotlinx.coroutines.delay(16) (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() { fun cancelAnimations() {
animationGeneration++
animJob?.cancel() animJob?.cancel()
animJob = null
isAnimating = false
} }
val isInMotion: Boolean get() = isInteracting || isAnimating
private companion object { private companion object {
// Close to the projection limit so polar geohash cells remain selectable; // Close to the projection limit so polar geohash cells remain selectable;
// clamping tighter would make syncSelection() encode the wrong cell. // clamping tighter would make syncSelection() encode the wrong cell.
const val MIN_LAT = -89f const val MIN_LAT = -89f
const val MAX_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() { 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.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan import androidx.compose.foundation.gestures.calculatePan
@ -29,13 +30,17 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged 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.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.bitchat.android.geohash.Geohash import com.bitchat.android.geohash.Geohash
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.min import kotlin.math.min
@ -114,21 +119,37 @@ fun GlobeView(
} }
LaunchedEffect(state) { LaunchedEffect(state) {
snapshotFlow { state.selectedGeohash } snapshotFlow { state.selectedGeohash to state.isInMotion }
.distinctUntilChanged()
.drop(1) .drop(1)
.collect { .collectLatest { (geohash, inMotion) ->
view.performHapticFeedback( if (geohash.isNotEmpty() && !inMotion) {
HapticFeedbackConstants.KEYBOARD_TAP, delay(SETTLED_HAPTIC_DELAY_MS)
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING if (!state.isInMotion && state.selectedGeohash == geohash) {
) view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}
}
} }
} }
val labelTextSize = with(density) { 12.5.sp.toPx() } val labelTextSize = with(density) { 12.5.sp.toPx() }
val labelTextSizeSmall = with(density) { 10.sp.toPx() } val labelTextSizeSmall = with(density) { 10.sp.toPx() }
Canvas( Box(modifier = modifier) {
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 -> .onSizeChanged { size: IntSize ->
val minDim = min(size.width, size.height).toFloat() val minDim = min(size.width, size.height).toFloat()
state.setViewport(minDim * 0.44f, minDim) state.setViewport(minDim * 0.44f, minDim)
@ -142,40 +163,45 @@ fun GlobeView(
state.isInteracting = true state.isInteracting = true
val downTime = SystemClock.uptimeMillis() val downTime = SystemClock.uptimeMillis()
val downPos = down.position val downPos = down.position
var maxPointers = 1
var moved = Offset.Zero var moved = Offset.Zero
val panTimes = ArrayDeque<Long>() var dragStarted = false
val panVec = ArrayDeque<Offset>() var hadMultiplePointers = false
val velocityTracker = VelocityTracker()
velocityTracker.addPosition(down.uptimeMillis, down.position)
while (true) { while (true) {
val event = awaitPointerEvent() val event = awaitPointerEvent()
val pressed = event.changes.filter { it.pressed } val pressed = event.changes.filter { it.pressed }
if (pressed.isEmpty()) break if (pressed.isEmpty()) break
maxPointers = maxOf(maxPointers, pressed.size) if (pressed.size > 1) {
hadMultiplePointers = true
velocityTracker.resetTracking()
}
val pan = event.calculatePan() val pan = event.calculatePan()
val zoomChange = event.calculateZoom() val zoomChange = event.calculateZoom()
if (pan != Offset.Zero) { if (pan != Offset.Zero) {
state.rotateBy(pan.x, pan.y)
moved += pan moved += pan
val now = SystemClock.uptimeMillis() if (!dragStarted && moved.getDistance() >= viewConfiguration.touchSlop) {
panTimes.addLast(now) dragStarted = true
panVec.addLast(pan)
while (panTimes.isNotEmpty() && now - panTimes.first() > 120) {
panTimes.removeFirst()
panVec.removeFirst()
} }
if (dragStarted) state.rotateBy(pan.x, pan.y)
} }
if (zoomChange != 1f) { if (zoomChange != 1f) {
state.zoomBy(zoomChange) 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() } event.changes.forEach { if (it.positionChanged()) it.consume() }
} }
state.isInteracting = false state.isInteracting = false
val upTime = SystemClock.uptimeMillis() val upTime = SystemClock.uptimeMillis()
val isTap = maxPointers == 1 && val isTap = !hadMultiplePointers &&
!dragStarted &&
upTime - downTime < 400 && upTime - downTime < 400 &&
moved.getDistance() < viewConfiguration.touchSlop moved.getDistance() < viewConfiguration.touchSlop
@ -205,15 +231,13 @@ fun GlobeView(
} }
} }
} }
} else if (panVec.isNotEmpty()) { } else if (dragStarted && !hadMultiplePointers) {
var sx = 0f; var sy = 0f val velocity = velocityTracker.calculateVelocity()
panVec.forEach { sx += it.x; sy += it.y } state.fling(velocity.x, velocity.y)
val windowMs = (SystemClock.uptimeMillis() - panTimes.first()).coerceAtLeast(1)
state.fling(sx / windowMs, sy / windowMs)
} }
} }
} }
) { ) {
val cx = size.width / 2f val cx = size.width / 2f
val cy = size.height / 2f val cy = size.height / 2f
val baseR = state.baseRadiusPx val baseR = state.baseRadiusPx
@ -221,15 +245,7 @@ fun GlobeView(
val r = state.globeRadiusPx val r = state.globeRadiusPx
val cLat = state.centerLat.toDouble() val cLat = state.centerLat.toDouble()
val cLon = state.centerLon.toDouble() val cLon = state.centerLon.toDouble()
val preparedProjector = GlobeMath.PreparedProjector(cLat, cLon)
// 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)
)
}
// Atmosphere glow // Atmosphere glow
drawCircle( drawCircle(
@ -259,17 +275,34 @@ fun GlobeView(
val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f) val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f)
val lowDetail = state.isInMotion
// Graticule // Graticule
drawGraticule(cx, cy, r, cLat, cLon, colors.graticule, clip) drawGraticule(
cx, cy, r, cLat, cLon, colors.graticule, clip,
step = if (lowDetail) 10.0 else 4.0
)
// Landmasses // Landmasses
for (ring in land) { for (ring in land) {
drawLandRing(ring, scratch, cx, cy, r, cLat, cLon, colors, clip) drawLandRing(
ring = ring,
scratch = scratch,
projector = preparedProjector,
cx = cx,
cy = cy,
r = r,
colors = colors,
clip = clip,
pointStride = if (lowDetail && ring.size >= 64) 2 else 1
)
} }
// Country borders // Country borders are restored when interaction settles.
for (line in borders) { if (!lowDetail) {
drawBorderLine(line, borderScratch, cx, cy, r, cLat, cLon, colors, clip) for (line in borders) {
drawBorderLine(line, borderScratch, preparedProjector, cx, cy, r, colors, clip)
}
} }
// Sphere shading: dark limb + night side for 3D depth // Sphere shading: dark limb + night side for 3D depth
@ -297,19 +330,17 @@ fun GlobeView(
center = Offset(cx, cy) center = Offset(cx, cy)
) )
// Cities (dots + names) over the shaded sphere // Cities are detail-only; omitting them while moving keeps touch latency predictable.
drawCities( if (!lowDetail) {
cities, state, cx, cy, r, cLat, cLon, colors, drawCities(
labelPaint, haloPaint, labelTypeface, labelTextSizeSmall, density.density cities, state, preparedProjector, cx, cy, r, colors,
) labelPaint, haloPaint, labelTypeface, labelTextSizeSmall, density.density
)
// Geohash cells
if (state.selectedGeohash.isNotEmpty()) {
drawGeohashGrid(state, cx, cy, r, cLat, cLon, colors, clip)
} }
// Labels // Detailed cells and labels settle into place after the gesture ends.
if (state.selectedGeohash.isNotEmpty()) { if (!lowDetail && state.selectedGeohash.isNotEmpty()) {
drawGeohashGrid(state, cx, cy, r, cLat, cLon, colors, clip)
drawGeohashLabels( drawGeohashLabels(
state, cx, cy, r, cLat, cLon, colors, state, cx, cy, r, cLat, cLon, colors,
labelPaint, haloPaint, labelTypeface, labelTypefaceBold, labelPaint, haloPaint, labelTypeface, labelTypefaceBold,
@ -317,25 +348,39 @@ fun GlobeView(
) )
} }
// Center crosshair }
val crossAlpha = if (state.isInteracting) 0.9f else pulse
val crossColor = colors.accent.copy(alpha = crossAlpha) // Keep this animation isolated so its pulse does not redraw the globe geometry.
val gap = 5 * density.density Canvas(modifier = Modifier.matchParentSize()) {
val len = 9 * density.density val cx = size.width / 2f
val strokeW = 1.6f * density.density val cy = size.height / 2f
drawLine(crossColor, Offset(cx - gap - len, cy), Offset(cx - gap, cy), strokeW) val crossAlpha = if (state.isInMotion) 0.9f else pulse
drawLine(crossColor, Offset(cx + gap, cy), Offset(cx + gap + len, cy), strokeW) val crossColor = colors.accent.copy(alpha = crossAlpha)
drawLine(crossColor, Offset(cx, cy - gap - len), Offset(cx, cy - gap), strokeW) val gap = 5 * density.density
drawLine(crossColor, Offset(cx, cy + gap), Offset(cx, cy + gap + len), strokeW) val len = 9 * density.density
drawCircle(crossColor, radius = 1.8f * density.density, center = Offset(cx, cy)) 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( 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 path = Path()
val step = 4.0
fun strokeSegment(x0: Float, y0: Float, x1: Float, y1: Float) { fun strokeSegment(x0: Float, y0: Float, x1: Float, y1: Float) {
val seg = clipSegment(x0, y0, x1, y1, clip) ?: return val seg = clipSegment(x0, y0, x1, y1, clip) ?: return
path.moveTo(seg.first.first, seg.first.second) path.moveTo(seg.first.first, seg.first.second)
@ -606,8 +651,8 @@ private fun DrawScope.strokeRuns(
private fun DrawScope.drawBorderLine( private fun DrawScope.drawBorderLine(
line: LandData.Ring, line: LandData.Ring,
scratch: FloatArray, scratch: FloatArray,
projector: GlobeMath.PreparedProjector,
cx: Float, cy: Float, r: Float, cx: Float, cy: Float, r: Float,
cLat: Double, cLon: Double,
colors: GlobeColors, colors: GlobeColors,
clip: ClipRect clip: ClipRect
) { ) {
@ -618,12 +663,13 @@ private fun DrawScope.drawBorderLine(
val pts = ArrayList<DiscPt>(n) val pts = ArrayList<DiscPt>(n)
var i = 0 var i = 0
while (i < n) { while (i < n) {
val lat = line.coords[i * 2].toDouble() projector.project(line.projectionTerms, i * 4, scratch, i * 3)
val lon = line.coords[i * 2 + 1].toDouble() val x = scratch[i * 3]
val p = GlobeMath.projectRaw(lat, lon, cLat, cLon) val y = scratch[i * 3 + 1]
val front = p.cosC > 0.005f val cosC = scratch[i * 3 + 2]
pts.add(DiscPt(p.x, p.y, front)) val front = cosC > 0.005f
if (p.cosC >= 0f) anyFront = true pts.add(DiscPt(x, y, front))
if (cosC >= 0f) anyFront = true
i++ i++
} }
if (!anyFront) return if (!anyFront) return
@ -635,8 +681,8 @@ private fun DrawScope.drawBorderLine(
private fun DrawScope.drawCities( private fun DrawScope.drawCities(
cities: List<LandData.City>, cities: List<LandData.City>,
state: GlobeState, state: GlobeState,
projector: GlobeMath.PreparedProjector,
cx: Float, cy: Float, r: Float, cx: Float, cy: Float, r: Float,
cLat: Double, cLon: Double,
colors: GlobeColors, colors: GlobeColors,
labelPaint: Paint, labelPaint: Paint,
haloPaint: Paint, haloPaint: Paint,
@ -645,6 +691,7 @@ private fun DrawScope.drawCities(
density: Float density: Float
) { ) {
if (cities.isEmpty()) return if (cities.isEmpty()) return
val projection = FloatArray(3)
val zoom = state.zoom val zoom = state.zoom
val maxRank = when { val maxRank = when {
zoom < 2f -> 1 zoom < 2f -> 1
@ -655,13 +702,14 @@ private fun DrawScope.drawCities(
val canvas = drawContext.canvas.nativeCanvas val canvas = drawContext.canvas.nativeCanvas
for (city in cities) { for (city in cities) {
if (city.rank > maxRank) continue if (city.rank > maxRank) continue
val p = GlobeMath.project(city.lat.toDouble(), city.lon.toDouble(), cLat, cLon) ?: continue projector.project(city.projectionTerms, 0, projection, 0)
if (p.cosC < 0.03f) continue val cosC = projection[2]
val sx = cx + p.x * r if (cosC < 0.03f) continue
val sy = cy + p.y * r 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 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 important = city.capital || city.megacity
val dotRadius = (if (important) 2.6f else 1.8f) * density val dotRadius = (if (important) 2.6f else 1.8f) * density
val dotColor = if (city.capital) colors.accent.copy(alpha = alpha) val dotColor = if (city.capital) colors.accent.copy(alpha = alpha)
@ -697,24 +745,21 @@ private fun DrawScope.drawCities(
private fun DrawScope.drawLandRing( private fun DrawScope.drawLandRing(
ring: LandData.Ring, ring: LandData.Ring,
scratch: FloatArray, scratch: FloatArray,
projector: GlobeMath.PreparedProjector,
cx: Float, cy: Float, r: Float, cx: Float, cy: Float, r: Float,
cLat: Double, cLon: Double,
colors: GlobeColors, colors: GlobeColors,
clip: ClipRect clip: ClipRect,
pointStride: Int
) { ) {
val n = ring.size val n = ((ring.size - 1) / pointStride) + 1
if (n < 3 || n * 3 > scratch.size) return if (n < 3 || n * 3 > scratch.size) return
var anyFront = false var anyFront = false
var i = 0 var i = 0
while (i < n) { while (i < n) {
val lat = ring.coords[i * 2].toDouble() val sourceIndex = (i * pointStride).coerceAtMost(ring.size - 1)
val lon = ring.coords[i * 2 + 1].toDouble() projector.project(ring.projectionTerms, sourceIndex * 4, scratch, i * 3)
val p = GlobeMath.projectRaw(lat, lon, cLat, cLon) if (scratch[i * 3 + 2] >= 0f) anyFront = true
scratch[i * 3] = p.x
scratch[i * 3 + 1] = p.y
scratch[i * 3 + 2] = p.cosC
if (p.cosC >= 0f) anyFront = true
i++ i++
} }
if (!anyFront) return if (!anyFront) return

View File

@ -2,6 +2,8 @@ package com.bitchat.android.ui.globe
import android.content.Context import android.content.Context
import org.json.JSONObject import org.json.JSONObject
import kotlin.math.cos
import kotlin.math.sin
/** /**
* Loads the bundled Natural Earth 110m land polygons (public domain) from assets * Loads the bundled Natural Earth 110m land polygons (public domain) from assets
@ -9,9 +11,28 @@ import org.json.JSONObject
*/ */
object LandData { 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 @Volatile
private var cached: List<Ring>? = null private var cached: List<Ring>? = null
@ -127,4 +148,17 @@ object LandData {
out.add(Ring(coords, n)) out.add(Ring(coords, n))
} }
} }
private fun prepareProjectionTerms(coords: FloatArray, size: Int): FloatArray {
val result = FloatArray(size * 4)
for (index in 0 until size) {
val latRadians = Math.toRadians(coords[index * 2].toDouble())
val lonRadians = Math.toRadians(coords[index * 2 + 1].toDouble())
result[index * 4] = sin(latRadians).toFloat()
result[index * 4 + 1] = cos(latRadians).toFloat()
result[index * 4 + 2] = sin(lonRadians).toFloat()
result[index * 4 + 3] = cos(lonRadians).toFloat()
}
return result
}
} }

View File

@ -0,0 +1,60 @@
package com.bitchat.android.ui.globe
import kotlin.math.cos
import kotlin.math.sin
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class GlobeMathTest {
@Test
fun preparedProjector_matchesReferenceProjection() {
val centers = listOf(
0.0 to 0.0,
45.25 to 12.5,
-72.0 to 179.5
)
val points = listOf(
0.0 to 0.0,
51.5074 to -0.1278,
-33.8688 to 151.2093,
89.0 to -179.9
)
for ((centerLat, centerLon) in centers) {
val projector = GlobeMath.PreparedProjector(centerLat, centerLon)
for ((lat, lon) in points) {
val latRadians = Math.toRadians(lat)
val lonRadians = Math.toRadians(lon)
val terms = floatArrayOf(
sin(latRadians).toFloat(),
cos(latRadians).toFloat(),
sin(lonRadians).toFloat(),
cos(lonRadians).toFloat()
)
val actual = FloatArray(3)
projector.project(terms, 0, actual, 0)
val expected = GlobeMath.projectRaw(lat, lon, centerLat, centerLon)
assertEquals(expected.x, actual[0], 0.000_002f)
assertEquals(expected.y, actual[1], 0.000_002f)
assertEquals(expected.cosC, actual[2], 0.000_002f)
}
}
}
@Test
fun zoomForPrecision_staysWithinInteractiveBounds() {
for (precision in 1..GlobeMath.MAX_PRECISION) {
val zoom = GlobeMath.zoomForPrecision(
precision = precision,
baseRadiusPx = 400f,
screenMinPx = 900f
)
assertTrue(zoom >= GlobeMath.MIN_ZOOM)
assertTrue(zoom <= GlobeMath.MAX_ZOOM)
}
}
}