summaryrefslogtreecommitdiff
path: root/android-app
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-04-08 23:55:05 +0000
committerClaude <noreply@anthropic.com>2026-04-08 23:55:05 +0000
commitf3a85cd44f9b4b469f0c8448d64740125c15ea20 (patch)
treeb2d62850e7b526a4ac9667653f3f7e1a577164be /android-app
parentc4adce2e05fc1f9199f98ddb46c1231739a52137 (diff)
parent5e82a3afb108b53180f6e9bf1089cce4fa448cc4 (diff)
Merge branch 'claude/particle-wind-simulation-IMVxT' — Level 2 particle simulation
Diffstat (limited to 'android-app')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt9
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt156
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml7
3 files changed, 171 insertions, 1 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 17fa52f..7046b23 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
@@ -37,6 +37,7 @@ import org.maplibre.android.style.sources.RasterSource
import org.maplibre.android.style.sources.TileSet
import org.terst.nav.ui.*
import org.terst.nav.ui.doc.DocFragment
+import org.terst.nav.ui.map.ParticleWindView
import org.terst.nav.ui.safety.SafetyFragment
import org.terst.nav.ui.voicelog.VoiceLogFragment
import java.util.*
@@ -51,6 +52,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private val loadedStyleFlow = MutableStateFlow<Style?>(null)
private var weatherLoaded = false
private var mapLibreMapRef: org.maplibre.android.maps.MapLibreMap? = null
+ private var particleWindView: ParticleWindView? = null
private lateinit var bottomSheetBehavior: BottomSheetBehavior<View>
private lateinit var fragmentContainer: FrameLayout
@@ -231,10 +233,12 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private fun setupMap() {
mapView = findViewById(R.id.mapView)
+ particleWindView = findViewById(R.id.particle_wind_view)
mapView?.onCreate(null)
mapView?.getMapAsync { maplibreMap ->
mapLibreMapRef = maplibreMap
mapHandler = MapHandler(maplibreMap)
+ particleWindView?.attachMap(maplibreMap)
val style = Style.Builder()
.fromUri("https://tiles.openfreemap.org/styles/liberty")
.withSource(RasterSource("openseamap-source",
@@ -277,7 +281,10 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
lifecycleScope.launch {
viewModel.windArrow.collect { arrow ->
- if (arrow != null) mapHandler?.updateWindLayer(arrow)
+ if (arrow != null) {
+ mapHandler?.updateWindLayer(arrow)
+ particleWindView?.setWind(arrow.directionDeg, arrow.speedKt)
+ }
}
}
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
new file mode 100644
index 0000000..0169fc1
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt
@@ -0,0 +1,156 @@
+package org.terst.nav.ui.map
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.util.AttributeSet
+import android.view.View
+import org.maplibre.android.geometry.LatLng
+import org.maplibre.android.maps.MapLibreMap
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.random.Random
+
+/**
+ * Transparent canvas overlay that renders ~300 animated wind particles.
+ *
+ * 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.
+ *
+ * Speed is scaled to the visible viewport so the animation looks consistent at any
+ * zoom level: a particle at reference wind (10 kt) crosses ~30% of the screen in
+ * MAX_AGE seconds.
+ */
+class ParticleWindView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null
+) : View(context, attrs) {
+
+ 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 val particleLat = FloatArray(N)
+ private val particleLon = FloatArray(N)
+ private val particleAge = FloatArray(N)
+ private val MAX_AGE = 8f // seconds before forced respawn
+
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ strokeWidth = 2.5f
+ strokeCap = Paint.Cap.ROUND
+ style = Paint.Style.STROKE
+ }
+
+ private var lastFrameNs = 0L
+ private var scattered = false
+
+ // ── Public API ──────────────────────────────────────────────────────────
+
+ fun attachMap(mapLibreMap: MapLibreMap) {
+ map = mapLibreMap
+ scattered = false
+ }
+
+ fun setWind(dirFromDeg: Double, speedKt: Double) {
+ windDirFromDeg = dirFromDeg
+ windSpeedKt = speedKt
+ }
+
+ // ── Rendering ────────────────────────────────────────────────────────────
+
+ override fun onDraw(canvas: Canvas) {
+ val m = map ?: run { postInvalidateOnAnimation(); return }
+
+ if (!scattered) scatter(m)
+
+ val now = System.nanoTime()
+ val dt = if (lastFrameNs == 0L) 0.016f
+ else ((now - lastFrameNs) / 1_000_000_000f).coerceAtMost(0.05f)
+ lastFrameNs = now
+
+ val bounds = m.projection.visibleRegion.latLngBounds
+ val latSouth = bounds.latSouth.toFloat()
+ val latNorth = bounds.latNorth.toFloat()
+ val lonWest = bounds.lonWest.toFloat()
+ val lonEast = bounds.lonEast.toFloat()
+ val latRange = latNorth - latSouth
+ val lonRange = lonEast - lonWest
+
+ // Speed scale: at 10kt a particle crosses 30% of viewport in MAX_AGE seconds.
+ val speedScale = latRange * 0.03f / 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
+
+ for (i in 0 until N) {
+ particleLat[i] += dlat
+ particleLon[i] += dlon
+ particleAge[i] += dt
+
+ val needsRespawn = particleAge[i] > MAX_AGE
+ || particleLat[i] < latSouth || particleLat[i] > latNorth
+ || particleLon[i] < lonWest || particleLon[i] > lonEast
+
+ if (needsRespawn) {
+ particleLat[i] = latSouth + Random.nextFloat() * latRange
+ particleLon[i] = lonWest + Random.nextFloat() * lonRange
+ particleAge[i] = 0f
+ continue
+ }
+
+ val pt = m.projection.toScreenLocation(
+ LatLng(particleLat[i].toDouble(), particleLon[i].toDouble())
+ )
+
+ val alpha = ((1f - particleAge[i] / MAX_AGE) * 200).toInt().coerceIn(30, 200)
+ paint.color = Color.argb(alpha, 120, 200, 255)
+
+ canvas.drawLine(pt.x - tailDx, pt.y - tailDy, pt.x, pt.y, paint)
+ }
+
+ postInvalidateOnAnimation()
+ }
+
+ override fun onVisibilityChanged(changedView: View, visibility: Int) {
+ super.onVisibilityChanged(changedView, visibility)
+ if (visibility == VISIBLE) {
+ lastFrameNs = 0L
+ postInvalidateOnAnimation()
+ }
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ private fun scatter(m: MapLibreMap) {
+ val bounds = m.projection.visibleRegion.latLngBounds
+ val latSouth = bounds.latSouth.toFloat()
+ val lonWest = bounds.lonWest.toFloat()
+ val latRange = (bounds.latNorth - latSouth).toFloat()
+ val lonRange = (bounds.lonEast - lonWest).toFloat()
+ for (i in 0 until N) {
+ particleLat[i] = latSouth + Random.nextFloat() * latRange
+ particleLon[i] = lonWest + Random.nextFloat() * lonRange
+ particleAge[i] = Random.nextFloat() * MAX_AGE // stagger so no mass respawn
+ }
+ scattered = true
+ }
+
+ companion object {
+ private const val TAIL_PX = 14f
+ }
+}
diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml
index 68abc60..feeb43d 100644
--- a/android-app/app/src/main/res/layout/activity_main.xml
+++ b/android-app/app/src/main/res/layout/activity_main.xml
@@ -17,6 +17,13 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />
+ <org.terst.nav.ui.map.ParticleWindView
+ android:id="@+id/particle_wind_view"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:clickable="false"
+ android:focusable="false" />
+
<!-- Overlay Fragment Container (for Log, Safety, Help) -->
<FrameLayout
android:id="@+id/fragment_container"