diff options
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" |
