diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-04-06 16:22:42 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-04-06 16:22:42 +0000 |
| commit | 676314e3b5ad2445e64120c691fd1c2671076ebb (patch) | |
| tree | c98c261baf1d7b957fcdec708bae9d9e11badcb5 /android-app | |
| parent | 49f1a77fd6365a396ab45e3dbc7456bdb3335078 (diff) | |
feat(map): layer manager — satellite/charts/hybrid + wind toggle, long-press picker
MapLayerManager: all raster sources registered at style-build time,
visibility toggled on demand. Persists base preset and wind state to
SharedPreferences. Sources: Google satellite, NOAA RNC charts
(tileservice.charts.noaa.gov), OWM wind, OpenSeaMap seamarks.
LayerPickerSheet: bottom sheet with chip group (Satellite/Charts/Hybrid)
and wind toggle, launched from map long-press.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app')
4 files changed, 303 insertions, 22 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 06c45ca..de1f4dd 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 @@ -30,10 +30,6 @@ import java.util.Locale import org.maplibre.android.MapLibre import org.maplibre.android.maps.MapView import org.maplibre.android.maps.Style -import org.maplibre.android.style.layers.PropertyFactory -import org.maplibre.android.style.layers.RasterLayer -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.safety.SafetyFragment @@ -48,6 +44,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private var instrumentHandler: InstrumentHandler? = null private var mapHandler: MapHandler? = null private val loadedStyleFlow = MutableStateFlow<Style?>(null) + private lateinit var layerManager: MapLayerManager private lateinit var bottomSheetBehavior: BottomSheetBehavior<View> private lateinit var fragmentContainer: FrameLayout @@ -265,9 +262,10 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } private fun setupMap() { + layerManager = MapLayerManager(this) mapView = findViewById(R.id.mapView) if (NavApplication.isTesting) return - + mapView?.onCreate(null) mapView?.getMapAsync { maplibreMap -> mapHandler = MapHandler(maplibreMap) @@ -282,29 +280,28 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } } } - val style = Style.Builder() - .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("wind-source", - TileSet("2.2.0", "https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=ae2a038149aa0900d1bc74160aa2a37e"), 256)) - .withLayer(RasterLayer("wind-layer", "wind-source").apply { - setProperties(PropertyFactory.rasterOpacity(0.6f)) - }) - .withSource(RasterSource("openseamap-source", - TileSet("2.2.0", "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png").also { - it.setMaxZoom(18f) - }, 256)) - .withLayer(RasterLayer("openseamap-layer", "openseamap-source")) - - maplibreMap.setStyle(style) { style -> + + val styleBuilder = Style.Builder() + .fromUri("https://tiles.openfreemap.org/styles/bright") + layerManager.addToStyleBuilder(styleBuilder) + + maplibreMap.setStyle(styleBuilder) { style -> loadedStyleFlow.value = style val anchorBitmap = rasterizeDrawable(R.drawable.ic_anchor) val arrowBitmap = rasterizeDrawable(R.drawable.ic_tidal_arrow) val userBitmap = rasterizeDrawable(R.drawable.ic_ship_arrow) mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap, userBitmap) } + + maplibreMap.addOnMapLongClickListener { _ -> + val currentStyle = loadedStyleFlow.value ?: return@addOnMapLongClickListener true + LayerPickerSheet( + manager = layerManager, + onBaseChanged = { preset -> layerManager.setBasePreset(currentStyle, preset) }, + onWindChanged = { enabled -> layerManager.setWindEnabled(currentStyle, enabled) } + ).show(supportFragmentManager, "layer_picker") + true + } } } 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 new file mode 100644 index 0000000..48dc808 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt @@ -0,0 +1,48 @@ +package org.terst.nav.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.chip.ChipGroup +import com.google.android.material.switchmaterial.SwitchMaterial +import org.terst.nav.R + +class LayerPickerSheet( + private val manager: MapLayerManager, + private val onBaseChanged: (MapBasePreset) -> Unit, + private val onWindChanged: (Boolean) -> Unit +) : BottomSheetDialogFragment() { + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.layout_layer_picker_sheet, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val chipGroup = view.findViewById<ChipGroup>(R.id.chip_group_base) + val windSwitch = view.findViewById<SwitchMaterial>(R.id.switch_wind) + + // Set initial state from manager + val chipId = when (manager.basePreset) { + MapBasePreset.SATELLITE -> R.id.chip_satellite + MapBasePreset.CHARTS -> R.id.chip_charts + MapBasePreset.HYBRID -> R.id.chip_hybrid + } + chipGroup.check(chipId) + windSwitch.isChecked = manager.windEnabled + + chipGroup.setOnCheckedStateChangeListener { _, checkedIds -> + val preset = when (checkedIds.firstOrNull()) { + R.id.chip_satellite -> MapBasePreset.SATELLITE + R.id.chip_charts -> MapBasePreset.CHARTS + R.id.chip_hybrid -> MapBasePreset.HYBRID + else -> return@setOnCheckedStateChangeListener + } + onBaseChanged(preset) + } + + windSwitch.setOnCheckedChangeListener { _, checked -> + onWindChanged(checked) + } + } +} 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 new file mode 100644 index 0000000..cf78ec2 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt @@ -0,0 +1,132 @@ +package org.terst.nav.ui + +import android.content.Context +import org.maplibre.android.maps.Style +import org.maplibre.android.style.layers.PropertyFactory +import org.maplibre.android.style.layers.RasterLayer +import org.maplibre.android.style.sources.RasterSource +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. + * + * All sources are registered at style-build time; this class only toggles + * layer visibility — no runtime source swapping required. + */ +class MapLayerManager(context: Context) { + + private val prefs = context.getSharedPreferences("map_layers", Context.MODE_PRIVATE) + + var basePreset: MapBasePreset = loadBasePreset() + private set + + var windEnabled: Boolean = prefs.getBoolean(KEY_WIND, true) + 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" + + private const val KEY_BASE = "base_preset" + private const val KEY_WIND = "wind_enabled" + + // Tile URLs + private const val URL_SATELLITE = + "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}" + private const val URL_CHARTS = + "https://tileservice.charts.noaa.gov/tiles/50000_1/{z}/{x}/{y}.png" + private const val URL_WIND = + "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" + } + + /** + * 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. + */ + fun addToStyleBuilder(builder: Style.Builder) { + // ── Sources ─────────────────────────────────────────────────────────── + builder.withSource(RasterSource(SOURCE_SATELLITE, + TileSet("2.2.0", URL_SATELLITE), 256)) + + builder.withSource(RasterSource(SOURCE_CHARTS, + TileSet("2.2.0", URL_CHARTS), 256)) + + builder.withSource(RasterSource(SOURCE_WIND, + TileSet("2.2.0", URL_WIND), 256)) + + builder.withSource(RasterSource(SOURCE_SEAMARKS, + TileSet("2.2.0", URL_SEAMARKS).also { it.setMaxZoom(18f) }, 256)) + + // ── Layers (bottom → top within raster stack) ───────────────────────── + builder.withLayer(RasterLayer(LAYER_SATELLITE, SOURCE_SATELLITE).apply { + setProperties(PropertyFactory.rasterOpacity(1f)) + setProperties(PropertyFactory.visibility(visibilityFor(basePreset, LAYER_SATELLITE))) + }) + + builder.withLayer(RasterLayer(LAYER_CHARTS, SOURCE_CHARTS).apply { + setProperties(PropertyFactory.rasterOpacity(opacityFor(basePreset))) + setProperties(PropertyFactory.visibility(visibilityFor(basePreset, LAYER_CHARTS))) + }) + + builder.withLayer(RasterLayer(LAYER_WIND, SOURCE_WIND).apply { + setProperties(PropertyFactory.rasterOpacity(0.6f)) + setProperties(PropertyFactory.visibility(if (windEnabled) "visible" else "none")) + }) + + builder.withLayer(RasterLayer(LAYER_SEAMARKS, SOURCE_SEAMARKS).apply { + setProperties(PropertyFactory.visibility("visible")) + }) + } + + /** Apply a new base preset to a live style. */ + fun setBasePreset(style: Style, preset: MapBasePreset) { + basePreset = preset + prefs.edit().putString(KEY_BASE, preset.name).apply() + + style.getLayer(LAYER_SATELLITE)?.setProperties( + PropertyFactory.visibility(visibilityFor(preset, LAYER_SATELLITE))) + style.getLayer(LAYER_CHARTS)?.let { + it.setProperties(PropertyFactory.visibility(visibilityFor(preset, LAYER_CHARTS))) + (it as? RasterLayer)?.setProperties(PropertyFactory.rasterOpacity(opacityFor(preset))) + } + } + + /** 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) { + LAYER_SATELLITE -> if (preset == MapBasePreset.SATELLITE || preset == MapBasePreset.HYBRID) "visible" else "none" + LAYER_CHARTS -> if (preset == MapBasePreset.CHARTS || preset == MapBasePreset.HYBRID) "visible" else "none" + else -> "visible" + } + + private fun opacityFor(preset: MapBasePreset): Float = + if (preset == MapBasePreset.HYBRID) 0.75f else 1f + + private fun loadBasePreset(): MapBasePreset = + prefs.getString(KEY_BASE, null)?.let { + runCatching { MapBasePreset.valueOf(it) }.getOrNull() + } ?: MapBasePreset.SATELLITE +} 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 new file mode 100644 index 0000000..c424606 --- /dev/null +++ b/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml @@ -0,0 +1,104 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:paddingStart="24dp" + android:paddingEnd="24dp" + android:paddingBottom="32dp" + android:background="?attr/colorSurface"> + + <View + android:layout_width="36dp" + android:layout_height="4dp" + android:layout_gravity="center_horizontal" + android:layout_marginTop="12dp" + android:layout_marginBottom="20dp" + android:background="@color/md_theme_outline" /> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="16dp" + android:text="MAP LAYERS" + android:textSize="11sp" + android:textAllCaps="true" + android:letterSpacing="0.12" + android:fontFamily="sans-serif-light" + android:textColor="@color/instrument_text_secondary" /> + + <!-- Base map selection --> + <com.google.android.material.chip.ChipGroup + android:id="@+id/chip_group_base" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="24dp" + app:singleSelection="true" + app:selectionRequired="true" + xmlns:app="http://schemas.android.com/apk/res-auto"> + + <com.google.android.material.chip.Chip + android:id="@+id/chip_satellite" + style="@style/Widget.Material3.Chip.Filter" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Satellite" /> + + <com.google.android.material.chip.Chip + android:id="@+id/chip_charts" + style="@style/Widget.Material3.Chip.Filter" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Charts" /> + + <com.google.android.material.chip.Chip + android:id="@+id/chip_hybrid" + style="@style/Widget.Material3.Chip.Filter" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Hybrid" /> + + </com.google.android.material.chip.ChipGroup> + + <!-- Wind divider --> + <View + android:layout_width="match_parent" + android:layout_height="1dp" + android:layout_marginBottom="16dp" + android:background="@color/md_theme_surfaceVariant" /> + + <!-- Wind toggle --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical"> + + <LinearLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:orientation="vertical"> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Wind overlay" + android:textSize="15sp" + android:textColor="@color/instrument_text_normal" /> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="OpenWeatherMap wind speed" + android:textSize="12sp" + android:textColor="@color/instrument_text_secondary" /> + </LinearLayout> + + <com.google.android.material.switchmaterial.SwitchMaterial + android:id="@+id/switch_wind" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + + </LinearLayout> + +</LinearLayout> |
