mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Optimize geohash globe rendering and gestures
This commit is contained in:
parent
b692ec7b44
commit
2a43265e12
@ -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
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user