From 0e867ffb8aa287ecaed4e8f58c52a9cfef1da01a Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 4 Apr 2026 02:10:51 +0000 Subject: feat(map): satellite view, windy/chart overlays, and rich track recording - Switch default map view to Satellite - Add Windy (partial alpha) and OpenSeaMap overlays - Add custom user position icon (ship arrow) with heading rotation - Update TrackPoint to support rich instrument/weather metadata - Change track visualization to a dotted red line - Robustify NavApplication.isTesting with Espresso detection Co-Authored-By: Gemini CLI --- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 14 +++++++-- .../main/kotlin/org/terst/nav/NavApplication.kt | 9 ++++++ .../main/kotlin/org/terst/nav/track/TrackPoint.kt | 16 +++++++--- .../main/kotlin/org/terst/nav/ui/MainViewModel.kt | 6 +++- .../src/main/kotlin/org/terst/nav/ui/MapHandler.kt | 36 ++++++++++++++++++++-- 5 files changed, 71 insertions(+), 10 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 35b6ef7..66aa3e0 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 @@ -260,7 +260,15 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } } val style = Style.Builder() - .fromUri("https://tiles.openfreemap.org/styles/liberty") + .fromUri("https://tiles.openfreemap.org/styles/bright") // Base for labels if needed, or use satellite only + .withSource(RasterSource("satellite-source", + TileSet("2.2.0", "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}"), 256)) + .withLayer(RasterLayer("satellite-layer", "satellite-source")) + .withSource(RasterSource("windy-source", + TileSet("2.2.0", "https://tiles.windy.com/tiles/v2.2/gfs/wind/{z}/{x}/{y}.png"), 256)) + .withLayer(RasterLayer("windy-layer", "windy-source").apply { + setProperties(PropertyFactory.rasterOpacity(0.5f)) + }) .withSource(RasterSource("openseamap-source", TileSet("2.2.0", "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png").also { it.setMaxZoom(18f) @@ -271,7 +279,8 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { loadedStyleFlow.value = style val anchorBitmap = rasterizeDrawable(R.drawable.ic_anchor) val arrowBitmap = rasterizeDrawable(R.drawable.ic_tidal_arrow) - mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap) + val userBitmap = rasterizeDrawable(R.drawable.ic_ship_arrow) + mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap, userBitmap) } } } @@ -281,6 +290,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { lifecycleScope.launch { LocationService.locationFlow.collect { gpsData -> mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude) + mapHandler?.updateUserPosition(gpsData.latitude, gpsData.longitude, gpsData.courseOverGround) val sogKnots = gpsData.speedOverGround * 1.94384 val cogDeg = gpsData.courseOverGround viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, sogKnots, cogDeg.toDouble()) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt index 0b507d2..3b8b596 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt @@ -13,6 +13,15 @@ class NavApplication : Application() { companion object { var isTesting: Boolean = false + get() { + if (field) return true + return try { + Class.forName("androidx.test.espresso.Espresso") + true + } catch (e: ClassNotFoundException) { + false + } + } } override fun onCreate() { diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt index d803c8c..ed38e5e 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt @@ -5,8 +5,16 @@ data class TrackPoint( val lon: Double, val sogKnots: Double, val cogDeg: Double, - val windSpeedKnots: Double, - val windAngleDeg: Double, - val isTrueWind: Boolean, - val timestampMs: Long + val headingDeg: Double? = null, + val waterSpeedKnots: Double? = null, + val depthMeters: Double? = null, + val baroHpa: Double? = null, + val windSpeedKnots: Double? = null, + val windAngleDeg: Double? = null, + val isTrueWind: Boolean = false, + val airTempC: Double? = null, + val waveHeightM: Double? = null, + val currentSpeedKts: Double? = null, + val currentDirDeg: Double? = null, + val timestampMs: Long = System.currentTimeMillis() ) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt index 0431f31..a81a76f 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt @@ -69,10 +69,14 @@ class MainViewModel( } fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) { + val conditions = _marineConditions.value val point = TrackPoint( lat = lat, lon = lon, sogKnots = sogKnots, cogDeg = cogDeg, - windSpeedKnots = 0.0, windAngleDeg = 0.0, isTrueWind = false, + airTempC = conditions?.airTemp, + waveHeightM = conditions?.waveHeight, + currentSpeedKts = conditions?.currentSpeed, + currentDirDeg = conditions?.currentDir, timestampMs = System.currentTimeMillis() ) if (trackRepository.addPoint(point)) { diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt index 7c82808..f1feaed 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt @@ -63,18 +63,23 @@ class MapHandler(private val maplibreMap: MapLibreMap) { private val TIDAL_CURRENT_LAYER_ID = "tidal-current-layer" private val TIDAL_ARROW_ICON_ID = "tidal-arrow-icon" + private val USER_POS_SOURCE_ID = "user-pos-source" + private val USER_POS_LAYER_ID = "user-pos-layer" + private val USER_ICON_ID = "user-icon" + private val TRACK_SOURCE_ID = "track-source" private val TRACK_LAYER_ID = "track-line" private var anchorPointSource: GeoJsonSource? = null private var anchorCircleSource: GeoJsonSource? = null private var tidalCurrentSource: GeoJsonSource? = null + private var userPosSource: GeoJsonSource? = null private var trackSource: GeoJsonSource? = null /** - * Initializes map layers for anchor watch and tidal currents. + * Initializes map layers for anchor watch, tidal currents, and user position. */ - fun setupLayers(style: Style, anchorBitmap: Bitmap, arrowBitmap: Bitmap) { + fun setupLayers(style: Style, anchorBitmap: Bitmap, arrowBitmap: Bitmap, userBitmap: Bitmap) { // Anchor layers style.addImage(ANCHOR_ICON_ID, anchorBitmap) anchorPointSource = GeoJsonSource(ANCHOR_POINT_SOURCE_ID) @@ -112,6 +117,29 @@ class MapHandler(private val maplibreMap: MapLibreMap) { PropertyFactory.iconSize(0.8f) ) }) + + // User Position Layer + style.addImage(USER_ICON_ID, userBitmap) + userPosSource = GeoJsonSource(USER_POS_SOURCE_ID) + style.addSource(userPosSource!!) + style.addLayer(SymbolLayer(USER_POS_LAYER_ID, USER_POS_SOURCE_ID).apply { + setProperties( + PropertyFactory.iconImage(USER_ICON_ID), + PropertyFactory.iconRotate(org.maplibre.android.style.expressions.Expression.get("rotation")), + PropertyFactory.iconAllowOverlap(true), + PropertyFactory.iconIgnorePlacement(true), + PropertyFactory.iconSize(1.0f) + ) + }) + } + + /** + * Updates the user's position and orientation on the map. + */ + fun updateUserPosition(lat: Double, lon: Double, headingDeg: Float) { + userPosSource?.setGeoJson(Feature.fromGeometry(Point.fromLngLat(lon, lat)).apply { + addNumberProperty("rotation", headingDeg) + }) } /** @@ -180,7 +208,9 @@ class MapHandler(private val maplibreMap: MapLibreMap) { style.addLayer(LineLayer(TRACK_LAYER_ID, TRACK_SOURCE_ID).apply { setProperties( PropertyFactory.lineColor("#E53935"), - PropertyFactory.lineWidth(3f) + PropertyFactory.lineWidth(4f), + PropertyFactory.lineDasharray(arrayOf(1f, 2f)), + PropertyFactory.lineCap("round") ) }) } -- cgit v1.2.3