summaryrefslogtreecommitdiff
path: root/android-app/app/src/main
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-06-04 04:23:51 +0000
committerClaude <noreply@anthropic.com>2026-06-04 04:23:51 +0000
commit06d0c40b9efde77594c7e9a336b7ffb8ad7708c6 (patch)
treee783747b70e303555c22c0af78a2ffa89a80a6f1 /android-app/app/src/main
parent7a51bb3ca3b8929a52165be0b729cc77dd87912d (diff)
parentfff4641bb27e151339bba4969e0b232f02fbdb08 (diff)
Merge claude/wind-particles-movement-eNrTx: spatial wind particles
https://claude.ai/code/session_017TwkSjhzhmTcHKmVG5MWUa
Diffstat (limited to 'android-app/app/src/main')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt1
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt148
2 files changed, 94 insertions, 55 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
index b66c20b..0a37eaf 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
@@ -784,6 +784,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
lifecycleScope.launch {
viewModel.windGrid.collect { arrows ->
mapHandler?.updateWindGridLayer(arrows)
+ particleWindView?.setWindGrid(arrows)
}
}
lifecycleScope.launch {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt
index 746efb5..706d719 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt
@@ -8,24 +8,24 @@ import android.util.AttributeSet
import android.view.View
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapLibreMap
+import org.terst.nav.data.model.WindArrow
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
+import kotlin.math.sqrt
import kotlin.random.Random
/**
- * Transparent canvas overlay that renders ~300 animated wind particles.
+ * Transparent canvas overlay that renders animated wind particles using spatially-varying
+ * wind data bilinearly interpolated from a 4×5 grid covering the visible viewport.
*
- * Particle positions are tracked in lat/lon (FloatArray — no per-frame allocation).
- * Each frame: project lat/lon → screen XY via MapLibreMap.projection, draw a short
- * tail segment, then advance the position by the wind vector.
+ * Particles are invisible until setWindGrid() is called with live data.
*
- * Speed is scaled to the visible viewport so the animation looks consistent at any
- * zoom level: a particle at reference wind (10 kt) crosses ~100% of the screen in
- * MAX_AGE seconds.
+ * Screen projection uses a one-time 3-point affine transform per frame instead of
+ * one JNI call per particle, keeping frame time low during map movement.
*
- * Longitude arithmetic is done in a west-relative [0, lonSpan] coordinate space
- * so that viewports crossing the antimeridian (±180°) work correctly.
+ * Longitude arithmetic uses a west-relative [0, lonSpan] coordinate space so that
+ * viewports crossing the antimeridian (±180°) work correctly.
*/
class ParticleWindView @JvmOverloads constructor(
context: Context,
@@ -34,20 +34,26 @@ class ParticleWindView @JvmOverloads constructor(
private var map: MapLibreMap? = null
- /** Direction the wind is coming FROM, degrees true (meteorological convention). */
- private var windDirFromDeg = 0.0
- private var windSpeedKt = 0.0
-
- private val N = 300
- private var activeN = 150 // wind-speed-dependent; updated in setWind()
+ private val N = 600
+ private var activeN = 300
private val particleLat = FloatArray(N)
private val particleLon = FloatArray(N)
private val particleAge = FloatArray(N)
- private val MAX_AGE = 25f // seconds before forced respawn
- private var lastLatRange = 0f // for zoom-out scatter detection
+ private val MAX_AGE = 25f
+ private var lastLatRange = 0f
+
+ // Wind grid: 4 cols (lon) × 5 rows (lat) = 20 points
+ private val GRID_COLS = 4
+ private val GRID_ROWS = 5
+ private val windGridU = FloatArray(GRID_COLS * GRID_ROWS) // eastward component, knots
+ private val windGridV = FloatArray(GRID_COLS * GRID_ROWS) // northward component, knots
+ private var gridLatMin = 0f; private var gridLatMax = 0f
+ private var gridLonMin = 0f; private var gridLonMax = 0f
+ private var gridLatStep = 1f; private var gridLonStep = 1f
+ private var hasWindGrid = false
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- strokeWidth = 3.5f
+ strokeWidth = 2.5f
strokeCap = Paint.Cap.ROUND
style = Paint.Style.STROKE
}
@@ -62,17 +68,34 @@ class ParticleWindView @JvmOverloads constructor(
scattered = false
}
+ /** Scales active particle count with local wind speed. */
fun setWind(dirFromDeg: Double, speedKt: Double) {
- windDirFromDeg = dirFromDeg
- windSpeedKt = speedKt
- // Scale particle count: 60 at calm → 300 at 25+ kt
- activeN = (60 + (speedKt.coerceIn(0.0, 25.0) / 25.0 * 240)).toInt()
+ activeN = (150 + (speedKt.coerceIn(0.0, 25.0) / 25.0 * 450)).toInt()
+ }
+
+ fun setWindGrid(arrows: List<WindArrow>) {
+ if (arrows.size < 2) return
+ val sorted = arrows.sortedWith(compareBy({ it.lat }, { it.lon }))
+ gridLatMin = sorted.minOf { it.lat }.toFloat()
+ gridLatMax = sorted.maxOf { it.lat }.toFloat()
+ gridLonMin = sorted.minOf { it.lon }.toFloat()
+ gridLonMax = sorted.maxOf { it.lon }.toFloat()
+ gridLatStep = if (GRID_ROWS > 1) (gridLatMax - gridLatMin) / (GRID_ROWS - 1) else 1f
+ gridLonStep = if (GRID_COLS > 1) (gridLonMax - gridLonMin) / (GRID_COLS - 1) else 1f
+ sorted.forEachIndexed { i, arrow ->
+ if (i >= windGridU.size) return@forEachIndexed
+ val tRad = Math.toRadians((arrow.directionDeg + 180.0) % 360.0)
+ windGridU[i] = (sin(tRad) * arrow.speedKt).toFloat()
+ windGridV[i] = (cos(tRad) * arrow.speedKt).toFloat()
+ }
+ hasWindGrid = true
}
// ── Rendering ────────────────────────────────────────────────────────────
override fun onDraw(canvas: Canvas) {
val m = map ?: run { postInvalidateOnAnimation(); return }
+ if (!hasWindGrid) { postInvalidateOnAnimation(); return }
if (!scattered) scatter(m)
@@ -91,44 +114,41 @@ class ParticleWindView @JvmOverloads constructor(
val latNorth = maxOf(nL.latitude, nR.latitude, fL.latitude, fR.latitude).toFloat()
val latRange = latNorth - latSouth
- // If the user zoomed out significantly, immediately redistribute particles
- // across the new viewport rather than waiting for them to drift to the edges.
if (lastLatRange > 0f && latRange > lastLatRange * 1.8f) scatter(m)
lastLatRange = latRange
- // West edge = left screen side; east edge = right screen side.
- // Using screen-ordered corners handles antimeridian crossing correctly.
val lonWest = minOf(nL.longitude, fL.longitude).toFloat()
val lonEast = maxOf(nR.longitude, fR.longitude).toFloat()
- // Span wraps around antimeridian when lonEast < lonWest (e.g. Pacific viewport)
val lonSpan = if (lonEast >= lonWest) lonEast - lonWest else lonEast - lonWest + 360f
- // Speed scale: at 10kt a particle crosses 100% of viewport in MAX_AGE seconds.
+ // Speed scale: at 10 kt a particle crosses ~100% of the viewport in MAX_AGE seconds.
val speedScale = latRange * 0.1f / MAX_AGE
- // Geographic travel direction: opposite of the FROM direction.
- val travelRad = Math.toRadians((windDirFromDeg + 180.0) % 360.0)
- val cosTravel = cos(travelRad).toFloat()
- val sinTravel = sin(travelRad).toFloat()
-
- val dlat = cosTravel * windSpeedKt.toFloat() * speedScale * dt
- val dlon = sinTravel * windSpeedKt.toFloat() * speedScale * dt
-
- // Screen-space tail direction (accounts for map bearing).
- val screenRad = travelRad - Math.toRadians(m.cameraPosition.bearing)
- val tailDx = sin(screenRad).toFloat() * TAIL_PX
- val tailDy = (-cos(screenRad)).toFloat() * TAIL_PX
+ // Affine transform from lat/lon → screen pixels, computed once per frame (3 JNI calls).
+ // This handles any map bearing correctly without per-particle JNI overhead.
+ val origin = m.projection.toScreenLocation(LatLng(latSouth.toDouble(), lonWest.toDouble()))
+ val latRef = m.projection.toScreenLocation(LatLng(latSouth + 1.0, lonWest.toDouble()))
+ val lonRef = m.projection.toScreenLocation(LatLng(latSouth.toDouble(), lonWest + 1.0))
+ val dxDlat = (latRef.x - origin.x).toFloat()
+ val dyDlat = (latRef.y - origin.y).toFloat()
+ val dxDlon = (lonRef.x - origin.x).toFloat()
+ val dyDlon = (lonRef.y - origin.y).toFloat()
+ val originX = origin.x.toFloat()
+ val originY = origin.y.toFloat()
+
+ val bearingRad = Math.toRadians(m.cameraPosition.bearing)
+ val sinB = sin(bearingRad).toFloat()
+ val cosB = cos(bearingRad).toFloat()
for (i in 0 until activeN) {
- particleLat[i] += dlat
- particleLon[i] += dlon
- // Wrap longitude into [-180, 180] after movement
+ val (u, v) = windAt(particleLat[i], particleLon[i])
+
+ particleLat[i] += v * speedScale * dt
+ particleLon[i] += u * speedScale * dt
if (particleLon[i] > 180f) particleLon[i] -= 360f
else if (particleLon[i] < -180f) particleLon[i] += 360f
particleAge[i] += dt
- // Normalize particle longitude relative to lonWest into [0, 360)
- // so the antimeridian-spanning bounds check works correctly.
val normLon = ((particleLon[i] - lonWest + 360f) % 360f)
val needsRespawn = particleAge[i] > MAX_AGE
|| particleLat[i] < latSouth || particleLat[i] > latNorth
@@ -139,22 +159,25 @@ class ParticleWindView @JvmOverloads constructor(
var newLon = lonWest + Random.nextFloat() * lonSpan
if (newLon > 180f) newLon -= 360f
particleLon[i] = newLon
- // Start fresh so each particle gets a full lifetime to drift across the viewport.
- // A tiny stagger (0–2 s) desynchronises bursts of simultaneous respawns.
particleAge[i] = Random.nextFloat() * 2f
continue
}
- val pt = m.projection.toScreenLocation(
- LatLng(particleLat[i].toDouble(), particleLon[i].toDouble())
- )
+ // Screen position via affine transform (no JNI per particle)
+ val screenX = originX + (particleLat[i] - latSouth) * dxDlat + normLon * dxDlon
+ val screenY = originY + (particleLat[i] - latSouth) * dyDlat + normLon * dyDlon
+
+ // Per-particle tail direction in screen space, accounting for map bearing
+ val tx = u * cosB - v * sinB
+ val ty = -u * sinB - v * cosB
+ val len = sqrt(tx * tx + ty * ty).coerceAtLeast(0.001f)
+ val tailDx = tx / len * TAIL_PX
+ val tailDy = ty / len * TAIL_PX
- // Sine curve: smooth fade-in from birth, peak at mid-life, smooth fade-out.
- // No bright birth flash — particles ease in and ease out.
- val alpha = (sin(PI * particleAge[i] / MAX_AGE) * 200).toInt().coerceIn(15, 200)
+ val alpha = (sin(PI * particleAge[i] / MAX_AGE) * 110).toInt().coerceIn(8, 110)
paint.color = Color.argb(alpha, 30, 100, 255)
- canvas.drawLine(pt.x - tailDx, pt.y - tailDy, pt.x, pt.y, paint)
+ canvas.drawLine(screenX - tailDx, screenY - tailDy, screenX, screenY, paint)
}
postInvalidateOnAnimation()
@@ -170,6 +193,21 @@ class ParticleWindView @JvmOverloads constructor(
// ── Helpers ──────────────────────────────────────────────────────────────
+ private fun windAt(lat: Float, lon: Float): Pair<Float, Float> {
+ val rowF = ((lat - gridLatMin) / gridLatStep).coerceIn(0f, GRID_ROWS - 1.001f)
+ val colF = ((lon - gridLonMin) / gridLonStep).coerceIn(0f, GRID_COLS - 1.001f)
+ val r0 = rowF.toInt(); val r1 = (r0 + 1).coerceAtMost(GRID_ROWS - 1)
+ val c0 = colF.toInt(); val c1 = (c0 + 1).coerceAtMost(GRID_COLS - 1)
+ val fy = rowF - r0; val fx = colF - c0
+ fun idx(r: Int, c: Int) = r * GRID_COLS + c
+ fun lerp(a: Float, b: Float, t: Float) = a + t * (b - a)
+ val u = lerp(lerp(windGridU[idx(r0, c0)], windGridU[idx(r0, c1)], fx),
+ lerp(windGridU[idx(r1, c0)], windGridU[idx(r1, c1)], fx), fy)
+ val v = lerp(lerp(windGridV[idx(r0, c0)], windGridV[idx(r0, c1)], fx),
+ lerp(windGridV[idx(r1, c0)], windGridV[idx(r1, c1)], fx), fy)
+ return u to v
+ }
+
private fun scatter(m: MapLibreMap) {
val r = m.projection.visibleRegion
val nL = r.nearLeft ?: return
@@ -189,7 +227,7 @@ class ParticleWindView @JvmOverloads constructor(
var newLon = lonWest + Random.nextFloat() * lonSpan
if (newLon > 180f) newLon -= 360f
particleLon[i] = newLon
- particleAge[i] = Random.nextFloat() * MAX_AGE // stagger so no mass respawn
+ particleAge[i] = Random.nextFloat() * MAX_AGE
}
scattered = true
}