diff options
Diffstat (limited to 'android-app/app/src/main/kotlin')
| -rw-r--r-- | android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt | 1 | ||||
| -rw-r--r-- | android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt | 148 |
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 } |
