From 36efb5a8b9502d0def41c0cdc17ed894744c8562 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 30 Jun 2026 17:03:31 +0000 Subject: feat: add ocean layers — depth contours, seamarks toggle, FADs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MapLayerManager: depth contour tiles (OpenSeaMap), seamarks made toggleable (was always visible), FAD pref; layer order is now satellite → charts → wind → depth → seamarks - MapHandler: FAD GeoJSON layer (orange circles + zoom-10 labels), setupFadLayer / setFadLayerVisible / updateFadLayer helpers - LayerPickerSheet: wire seamarks, depth, FAD switches; fix chip binding direction bug (DepthUnit.METERS side was swapped) - layout_layer_picker_sheet: add OCEAN LAYERS section with three toggles (seamarks/nav aids, depth contours, FADs) - MainActivity: call setupFadLayer on style load; wire all three ocean layer callbacks in buildLayerPickerSheet Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 18 ++- .../kotlin/org/terst/nav/ui/LayerPickerSheet.kt | 18 +++ .../src/main/kotlin/org/terst/nav/ui/MapHandler.kt | 63 +++++++++++ .../kotlin/org/terst/nav/ui/MapLayerManager.kt | 100 ++++++++++++----- .../main/res/layout/layout_layer_picker_sheet.xml | 122 +++++++++++++++++++++ 5 files changed, 287 insertions(+), 34 deletions(-) (limited to 'android-app/app/src') 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 476fb0d..ea45c36 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 @@ -388,9 +388,9 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private fun buildLayerPickerSheet(): LayerPickerSheet { val currentStyle = loadedStyleFlow.value return LayerPickerSheet( - manager = layerManager, - onBaseChanged = { preset -> currentStyle?.let { layerManager.setBasePreset(it, preset) } }, - onWindChanged = { enabled -> currentStyle?.let { layerManager.setWindEnabled(it, enabled) } }, + manager = layerManager, + onBaseChanged = { preset -> currentStyle?.let { layerManager.setBasePreset(it, preset) } }, + onWindChanged = { enabled -> currentStyle?.let { layerManager.setWindEnabled(it, enabled) } }, onParticlesChanged = { enabled -> layerManager.setParticlesEnabled(enabled) particleWindView?.visibility = if (enabled) View.VISIBLE else View.GONE @@ -401,8 +401,15 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { val past = if (enabled) viewModel.pastTracks.value else emptyList() mapHandler?.updateTrackLayer(style, viewModel.trackPoints.value, past) }, - unitPrefs = unitPrefs, - onUnitsChanged = { refreshUnits() } + onSeamarksChanged = { enabled -> currentStyle?.let { layerManager.setSeamarksEnabled(it, enabled) } }, + onDepthChanged = { enabled -> currentStyle?.let { layerManager.setDepthEnabled(it, enabled) } }, + onFadsChanged = { enabled -> + layerManager.setFadsEnabled(enabled) + val style = loadedStyleFlow.value ?: return@LayerPickerSheet + mapHandler?.setFadLayerVisible(style, enabled) + }, + unitPrefs = unitPrefs, + onUnitsChanged = { refreshUnits() } ) } @@ -657,6 +664,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { val windArrowBitmap = rasterizeDrawable(R.drawable.ic_wind_arrow) mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap, userBitmap) mapHandler?.setupWindLayer(style, windArrowBitmap) + mapHandler?.setupFadLayer(style, layerManager.fadsEnabled) // Eagerly load conditions at style-ready time so the HUD is never blank // on startup — the camera-idle may fire before GPS fixes, this is the backstop. maplibreMap.cameraPosition.target?.let { center -> diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt index bd84961..a9dcca8 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt @@ -20,6 +20,9 @@ class LayerPickerSheet( private val onWindChanged: (Boolean) -> Unit, private val onParticlesChanged: (Boolean) -> Unit, private val onPastTracksChanged: (Boolean) -> Unit, + private val onSeamarksChanged: (Boolean) -> Unit, + private val onDepthChanged: (Boolean) -> Unit, + private val onFadsChanged: (Boolean) -> Unit, private val unitPrefs: UnitPrefs, private val onUnitsChanged: () -> Unit ) : BottomSheetDialogFragment() { @@ -60,6 +63,21 @@ class LayerPickerSheet( pastTracksSwitch.isChecked = manager.pastTracksEnabled pastTracksSwitch.setOnCheckedChangeListener { _, checked -> onPastTracksChanged(checked) } + // ── Seamarks switch ─────────────────────────────────────────────────── + val seamarksSwitch = view.findViewById(R.id.switch_seamarks) + seamarksSwitch.isChecked = manager.seamarksEnabled + seamarksSwitch.setOnCheckedChangeListener { _, checked -> onSeamarksChanged(checked) } + + // ── Depth contours switch ───────────────────────────────────────────── + val depthSwitch = view.findViewById(R.id.switch_depth) + depthSwitch.isChecked = manager.depthEnabled + depthSwitch.setOnCheckedChangeListener { _, checked -> onDepthChanged(checked) } + + // ── FADs switch ─────────────────────────────────────────────────────── + val fadsSwitch = view.findViewById(R.id.switch_fads) + fadsSwitch.isChecked = manager.fadsEnabled + fadsSwitch.setOnCheckedChangeListener { _, checked -> onFadsChanged(checked) } + // ── Temperature chips ───────────────────────────────────────────────── val chipTemp = view.findViewById(R.id.chip_group_temp) chipTemp.check(when (unitPrefs.tempUnit) { 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 a3c1eac..99c8ffc 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 @@ -90,6 +90,12 @@ class MapHandler(private val maplibreMap: MapLibreMap) { private val TACK_SOURCE_ID = "tack-source" private val TACK_LAYER_ID = "tack-layer" + private val FAD_SOURCE_ID = "fad-source" + private val FAD_LAYER_ID = "fad-layer" + private val FAD_LABEL_LAYER_ID = "fad-label-layer" + + private var fadSource: GeoJsonSource? = null + private var anchorPointSource: GeoJsonSource? = null private var anchorCircleSource: GeoJsonSource? = null private var tidalCurrentSource: GeoJsonSource? = null @@ -421,6 +427,63 @@ class MapHandler(private val maplibreMap: MapLibreMap) { tackSource?.setGeoJson(FeatureCollection.fromFeatures(features)) } + /** + * Initialises the FAD (Fish Aggregating Device) layer. + * Renders FADs as orange circles with ID labels at zoom 10+. + * Call once after style loads, then call [updateFadLayer] to populate. + */ + fun setupFadLayer(style: Style, visible: Boolean) { + fadSource = GeoJsonSource(FAD_SOURCE_ID) + style.addSource(fadSource!!) + + style.addLayer(CircleLayer(FAD_LAYER_ID, FAD_SOURCE_ID).apply { + setProperties( + PropertyFactory.circleColor("rgba(255, 140, 0, 0.85)"), + PropertyFactory.circleRadius(6f), + PropertyFactory.circleStrokeColor("rgba(200, 100, 0, 1)"), + PropertyFactory.circleStrokeWidth(1.5f), + PropertyFactory.visibility(if (visible) "visible" else "none") + ) + }) + + style.addLayer(SymbolLayer(FAD_LABEL_LAYER_ID, FAD_SOURCE_ID).apply { + setProperties( + PropertyFactory.textField(Expression.get("id")), + PropertyFactory.textSize(10f), + PropertyFactory.textOffset(arrayOf(0f, -1.4f)), + PropertyFactory.textColor("rgba(255, 255, 255, 1)"), + PropertyFactory.textHaloColor("rgba(0, 0, 0, 0.8)"), + PropertyFactory.textHaloWidth(1.2f), + PropertyFactory.textAllowOverlap(false), + PropertyFactory.visibility(if (visible) "visible" else "none"), + // Only show labels at zoom 10+ to avoid clutter + PropertyFactory.textOpacity( + Expression.step( + Expression.zoom(), + Expression.literal(0f), + Expression.stop(10.0, 1f) + ) + ) + ) + }) + } + + /** Shows or hides the FAD layer (and its label layer) on a live style. */ + fun setFadLayerVisible(style: Style, visible: Boolean) { + val v = if (visible) "visible" else "none" + style.getLayer(FAD_LAYER_ID)?.setProperties(PropertyFactory.visibility(v)) + style.getLayer(FAD_LABEL_LAYER_ID)?.setProperties(PropertyFactory.visibility(v)) + } + + /** + * Replaces FAD markers from a list of [Feature] points. + * Each feature must have a string property "id" (e.g. "K-15") and + * optionally "name" (e.g. "Kona 15") and "depth_m" (number). + */ + fun updateFadLayer(features: List) { + fadSource?.setGeoJson(FeatureCollection.fromFeatures(features)) + } + /** Clears all tack markers. */ fun clearTackLayer() { tackSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList())) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt index 1c46248..33072c4 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt @@ -10,10 +10,13 @@ import org.maplibre.android.style.sources.TileSet enum class MapBasePreset { SATELLITE, CHARTS, HYBRID } /** - * Manages the raster layer stack: base imagery (satellite / NOAA charts / hybrid) - * and the wind overlay. Persists selections across sessions. + * Manages the raster layer stack: base imagery (satellite / NOAA charts / hybrid), + * wind overlay, OpenSeaMap seamarks, and depth contours. * - * All sources are registered at style-build time; this class only toggles + * FADs are a vector (GeoJSON) layer managed by MapHandler — this class only + * stores the `fadsEnabled` preference so LayerPickerSheet and MainActivity can read it. + * + * All raster sources are registered at style-build time; this class toggles * layer visibility — no runtime source swapping required. */ class MapLayerManager(context: Context) { @@ -23,32 +26,41 @@ class MapLayerManager(context: Context) { var basePreset: MapBasePreset = loadBasePreset() private set - var windEnabled: Boolean = prefs.getBoolean(KEY_WIND, true) + var windEnabled: Boolean = prefs.getBoolean(KEY_WIND, true) private set - - var particlesEnabled: Boolean = prefs.getBoolean(KEY_PARTICLES, true) + var particlesEnabled: Boolean = prefs.getBoolean(KEY_PARTICLES, true) private set - var pastTracksEnabled: Boolean = prefs.getBoolean(KEY_PAST_TRACKS, true) private set + var seamarksEnabled: Boolean = prefs.getBoolean(KEY_SEAMARKS, true) + private set + var depthEnabled: Boolean = prefs.getBoolean(KEY_DEPTH, false) + private set + var fadsEnabled: Boolean = prefs.getBoolean(KEY_FADS, false) + private set // ── Source / Layer IDs ──────────────────────────────────────────────────── companion object { - const val SOURCE_SATELLITE = "satellite-source" - const val SOURCE_CHARTS = "charts-source" - const val SOURCE_WIND = "wind-source" - const val SOURCE_SEAMARKS = "openseamap-source" - - const val LAYER_SATELLITE = "satellite-layer" - const val LAYER_CHARTS = "charts-layer" - const val LAYER_WIND = "wind-layer" - const val LAYER_SEAMARKS = "openseamap-layer" + const val SOURCE_SATELLITE = "satellite-source" + const val SOURCE_CHARTS = "charts-source" + const val SOURCE_WIND = "wind-source" + const val SOURCE_SEAMARKS = "openseamap-source" + const val SOURCE_DEPTH = "osm-depth-source" + + const val LAYER_SATELLITE = "satellite-layer" + const val LAYER_CHARTS = "charts-layer" + const val LAYER_WIND = "wind-layer" + const val LAYER_SEAMARKS = "openseamap-layer" + const val LAYER_DEPTH = "osm-depth-layer" private const val KEY_BASE = "base_preset" private const val KEY_WIND = "wind_enabled" private const val KEY_PARTICLES = "particles_enabled" private const val KEY_PAST_TRACKS = "past_tracks_enabled" + private const val KEY_SEAMARKS = "seamarks_enabled" + private const val KEY_DEPTH = "depth_enabled" + private const val KEY_FADS = "fads_enabled" // Tile URLs private const val URL_SATELLITE = @@ -59,12 +71,15 @@ class MapLayerManager(context: Context) { "https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=ae2a038149aa0900d1bc74160aa2a37e" private const val URL_SEAMARKS = "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png" + // Depth soundings + contours from OpenSeaMap (zoom 3–18, coverage varies by region) + private const val URL_DEPTH = + "https://tiles.openseamap.org/depth/{z}/{x}/{y}.png" } /** - * Registers all raster sources and layers into the [Style.Builder]. - * Call this before [Style.Builder.build]. Layers are added in the correct - * order; MapHandler's vector layers go on top after style loads. + * Registers all raster sources and layers into [Style.Builder]. + * Layer order (bottom → top): satellite → charts → wind → depth → seamarks. + * MapHandler's vector layers go on top after the style loads. */ fun addToStyleBuilder(builder: Style.Builder) { // ── Sources ─────────────────────────────────────────────────────────── @@ -80,6 +95,9 @@ class MapLayerManager(context: Context) { builder.withSource(RasterSource(SOURCE_SEAMARKS, TileSet("2.2.0", URL_SEAMARKS).also { it.setMaxZoom(18f) }, 256)) + builder.withSource(RasterSource(SOURCE_DEPTH, + TileSet("2.2.0", URL_DEPTH).also { it.setMinZoom(3f); it.setMaxZoom(18f) }, 256)) + // ── Layers (bottom → top within raster stack) ───────────────────────── builder.withLayer(RasterLayer(LAYER_SATELLITE, SOURCE_SATELLITE).apply { setProperties(PropertyFactory.rasterOpacity(1f)) @@ -96,8 +114,14 @@ class MapLayerManager(context: Context) { setProperties(PropertyFactory.visibility(if (windEnabled) "visible" else "none")) }) + // Depth contours sit below seamarks so soundings don't obscure nav aid symbols + builder.withLayer(RasterLayer(LAYER_DEPTH, SOURCE_DEPTH).apply { + setProperties(PropertyFactory.rasterOpacity(0.85f)) + setProperties(PropertyFactory.visibility(if (depthEnabled) "visible" else "none")) + }) + builder.withLayer(RasterLayer(LAYER_SEAMARKS, SOURCE_SEAMARKS).apply { - setProperties(PropertyFactory.visibility("visible")) + setProperties(PropertyFactory.visibility(if (seamarksEnabled) "visible" else "none")) }) } @@ -114,7 +138,33 @@ class MapLayerManager(context: Context) { } } - /** Toggle wind particles (Canvas overlay — no style interaction needed). */ + fun setWindEnabled(style: Style, enabled: Boolean) { + windEnabled = enabled + prefs.edit().putBoolean(KEY_WIND, enabled).apply() + style.getLayer(LAYER_WIND)?.setProperties( + PropertyFactory.visibility(if (enabled) "visible" else "none")) + } + + fun setSeamarksEnabled(style: Style, enabled: Boolean) { + seamarksEnabled = enabled + prefs.edit().putBoolean(KEY_SEAMARKS, enabled).apply() + style.getLayer(LAYER_SEAMARKS)?.setProperties( + PropertyFactory.visibility(if (enabled) "visible" else "none")) + } + + fun setDepthEnabled(style: Style, enabled: Boolean) { + depthEnabled = enabled + prefs.edit().putBoolean(KEY_DEPTH, enabled).apply() + style.getLayer(LAYER_DEPTH)?.setProperties( + PropertyFactory.visibility(if (enabled) "visible" else "none")) + } + + /** FADs are a GeoJSON vector layer in MapHandler; this just persists the pref. */ + fun setFadsEnabled(enabled: Boolean) { + fadsEnabled = enabled + prefs.edit().putBoolean(KEY_FADS, enabled).apply() + } + fun setParticlesEnabled(enabled: Boolean) { particlesEnabled = enabled prefs.edit().putBoolean(KEY_PARTICLES, enabled).apply() @@ -125,14 +175,6 @@ class MapLayerManager(context: Context) { prefs.edit().putBoolean(KEY_PAST_TRACKS, enabled).apply() } - /** Toggle wind overlay on a live style. */ - fun setWindEnabled(style: Style, enabled: Boolean) { - windEnabled = enabled - prefs.edit().putBoolean(KEY_WIND, enabled).apply() - style.getLayer(LAYER_WIND)?.setProperties( - PropertyFactory.visibility(if (enabled) "visible" else "none")) - } - // ── Helpers ─────────────────────────────────────────────────────────────── private fun visibilityFor(preset: MapBasePreset, layerId: String): String = when (layerId) { diff --git a/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml b/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml index 2e6f982..065b720 100644 --- a/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml +++ b/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml @@ -176,6 +176,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +