# Additional Map Data Sources Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add NOAA nautical chart tiles, NOAA Aids to Navigation (ATON) vector data, and NOAA traffic separation scheme overlays to the nav app's layer stack, giving the user richer maritime situational awareness beyond the current OpenSeaMap seamarks. **Architecture:** All additions follow existing patterns. Raster tile sources extend `MapLayerManager` with new constants + toggle methods. The ATON vector layer follows the FAD pattern: fetch GeoJSON from NOAA's public REST API, cache to disk, render via a `GeoJsonSource` in `MapHandler`. Traffic separation zones are static polygons (baked in as a bundled asset, not fetched). **Tech Stack:** Kotlin, MapLibre, existing `MapLayerManager`/`MapHandler`/`LayerPickerSheet` plumbing, NOAA Office of Coast Survey tile service, NOAA ATON API (buoydata.coast.noaa.gov), bundled GeoJSON asset for traffic separation zones. --- ## File Structure | File | Role | |------|------| | `ui/MapLayerManager.kt` | Add `SOURCE_NOAA_CHARTS`, `SOURCE_TSS`; new `noaaChartsEnabled`, `tssEnabled` prefs and setters | | `ui/MapHandler.kt` | Add ATON + TSS GeoJSON sources/layers; `setupAtonLayer()`, `updateAtonLayer()`, `setupTssLayer()` | | `ui/aton/AtonRepository.kt` | Fetches + caches ATON GeoJSON from NOAA API; exposes `Flow>` | | `ui/aton/AtonData.kt` | ATON feature property constants + symbol mapping (lateral/cardinal/special marks) | | `res/layout/layout_layer_picker_sheet.xml` | Three new switch rows: NOAA Charts, ATON, Traffic Separation | | `ui/LayerPickerSheet.kt` | Wire three new callbacks: `onNoaaChartsChanged`, `onAtonChanged`, `onTssChanged` | | `res/raw/pacific_tss.geojson` | Bundled GeoJSON with North Pacific traffic separation scheme polygons | | `MainActivity.kt` | Pass new callbacks in `buildLayerPickerSheet()`; trigger ATON refresh on conditions update | --- ## Task 1: NOAA pre-rendered chart tiles layer **Files:** - Modify: `android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt` - Modify: `android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml` - Modify: `android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt` - Modify: `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt` NOAA's pre-rendered ENC tiles (`tileservice.charts.noaa.gov`) are standard XYZ PNGs covering all US waters including Hawaii. They render depth soundings, nav aids, restricted areas, and chart text — higher information density than OpenSeaMap seamarks. Use at zoom ≥ 10 only (tiles are blank below that). Read `MapLayerManager.kt` before editing. Follow the existing `SOURCE_SEAMARKS`/`LAYER_SEAMARKS` pattern exactly. - [ ] **Add NOAA chart constants to `MapLayerManager` companion object** ```kotlin const val SOURCE_NOAA_CHARTS = "noaa-charts-source" const val LAYER_NOAA_CHARTS = "noaa-charts-layer" private const val KEY_NOAA_CHARTS = "noaa_charts_enabled" // NOAA Office of Coast Survey pre-rendered ENC tiles (zoom 3–18, US waters) private const val URL_NOAA_CHARTS = "https://tileservice.charts.noaa.gov/tiles/50000_1/{z}/{x}/{y}.png" ``` - [ ] **Add `noaaChartsEnabled` property** (default `false`): ```kotlin var noaaChartsEnabled: Boolean = prefs.getBoolean(KEY_NOAA_CHARTS, false) private set ``` - [ ] **Add `setNoaaChartsEnabled()` method** (same pattern as `setSeamarksEnabled()`): ```kotlin fun setNoaaChartsEnabled(style: Style, enabled: Boolean) { noaaChartsEnabled = enabled prefs.edit().putBoolean(KEY_NOAA_CHARTS, enabled).apply() style.getLayer(LAYER_NOAA_CHARTS)?.setProperties( PropertyFactory.visibility(if (enabled) "visible" else "none") ) } ``` - [ ] **Add NOAA chart source + layer in `addToStyleBuilder()`** — ABOVE seamarks so chart text sits under seamark symbols. Set `minZoom(10f)` (blank tiles below that), `rasterOpacity(0.8f)`: ```kotlin builder.withSource(RasterSource(SOURCE_NOAA_CHARTS, TileSet("2.2.0", URL_NOAA_CHARTS).also { it.setMinZoom(3f); it.setMaxZoom(18f) }, 256)) builder.withLayer(RasterLayer(LAYER_NOAA_CHARTS, SOURCE_NOAA_CHARTS).apply { setProperties( PropertyFactory.rasterOpacity(0.8f), PropertyFactory.visibility(if (noaaChartsEnabled) "visible" else "none"), PropertyFactory.rasterMinZoom(10f) ) }) ``` Place this after `LAYER_DEPTH` and before `LAYER_SEAMARKS` in the layer order. - [ ] **Add switch row to `layout_layer_picker_sheet.xml`** — read the file first, copy the exact pattern of the `switch_seamarks` row. New row titled "NOAA Charts" with subtitle "Detailed soundings & nav aids (US waters)" and `android:id="@+id/switch_noaa_charts"`. - [ ] **Add `onNoaaChartsChanged` to `LayerPickerSheet`** — add to constructor before `onSstChanged`, wire the switch in `onViewCreated()` with `view.findViewById(R.id.switch_noaa_charts)`. - [ ] **Update `MainActivity.buildLayerPickerSheet()`** — add `onNoaaChartsChanged = { enabled -> loadedStyleFlow.value?.let { layerManager.setNoaaChartsEnabled(it, enabled) } }`. - [ ] **Compile:** `./gradlew compileDebugKotlin` - [ ] **Commit:** `git commit -m "feat(layers): NOAA pre-rendered ENC chart tiles"` --- ## Task 2: ATON data model + repository **Files:** - Create: `android-app/app/src/main/kotlin/org/terst/nav/ui/aton/AtonData.kt` - Create: `android-app/app/src/main/kotlin/org/terst/nav/ui/aton/AtonRepository.kt` NOAA's buoy/light data API endpoint: ``` https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations.json?type=aton&units=english ``` This returns a JSON array of ATON stations with `id`, `name`, `lat`, `lng`, `type`. Cache to `context.filesDir/aton_cache.json`. Refresh if older than 24 h. The `type` field values include `"Light"`, `"Float"`, `"Buoy"`, `"Daybeacon"`, `"Range"`. Map these to circle colors: - Red: `"Light"` → `#FF0000` - Green: `"Float"`, `"Buoy"` → `#00AA00` - Yellow: `"Daybeacon"`, `"Range"` → `#FFDD00` - White: everything else → `#FFFFFF` - [ ] **Write the failing test** ```kotlin // android-app/app/src/test/kotlin/org/terst/nav/ui/aton/AtonDataTest.kt package org.terst.nav.ui.aton import org.junit.Assert.* import org.junit.Test class AtonDataTest { @Test fun `light type maps to red`() { assertEquals("#FF0000", AtonData.colorFor("Light")) } @Test fun `float type maps to green`() { assertEquals("#00AA00", AtonData.colorFor("Float")) } @Test fun `unknown type maps to white`() { assertEquals("#FFFFFF", AtonData.colorFor("WeirdThing")) } @Test fun `json station converts to feature`() { val json = """{"id":"8443970","name":"Boston Light","lat":42.3298,"lng":-70.8930,"type":"Light"}""" val f = AtonData.parseStation(json) assertNotNull(f) assertEquals("Boston Light", f!!.getStringProperty("name")) assertEquals("#FF0000", f.getStringProperty("color")) } } ``` - [ ] **Run to verify it fails:** `./gradlew testDebugUnitTest --tests "*.AtonDataTest" -q` - [ ] **Implement `AtonData.kt`** ```kotlin package org.terst.nav.ui.aton import org.maplibre.geojson.Feature import org.maplibre.geojson.Point import org.json.JSONObject object AtonData { fun colorFor(type: String): String = when (type) { "Light" -> "#FF0000" "Float", "Buoy" -> "#00AA00" "Daybeacon", "Range" -> "#FFDD00" else -> "#FFFFFF" } fun parseStation(json: String): Feature? = try { val obj = JSONObject(json) val lat = obj.getDouble("lat") val lng = obj.getDouble("lng") val type = obj.optString("type", "") Feature.fromGeometry(Point.fromLngLat(lng, lat)).apply { addStringProperty("id", obj.optString("id")) addStringProperty("name", obj.optString("name")) addStringProperty("type", type) addStringProperty("color", colorFor(type)) } } catch (_: Exception) { null } /** Parse a full stations JSON array response from NOAA ATON API. */ fun parseResponse(json: String): List = try { val arr = JSONObject(json).getJSONArray("stations") (0 until arr.length()).mapNotNull { parseStation(arr.get(it).toString()) } } catch (_: Exception) { emptyList() } } ``` - [ ] **Implement `AtonRepository.kt`** ```kotlin package org.terst.nav.ui.aton import android.content.Context import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.maplibre.geojson.Feature import java.io.File import java.net.URL class AtonRepository(private val context: Context) { private val cacheFile = File(context.filesDir, "aton_cache.json") private val maxAgeMs = 24 * 60 * 60 * 1000L // 24 hours private val apiUrl = "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations.json?type=aton&units=english" /** Returns cached ATON features, fetching fresh data if cache is stale or missing. */ suspend fun getFeatures(): List = withContext(Dispatchers.IO) { val cached = readCache() if (cached != null) return@withContext AtonData.parseResponse(cached) val fresh = fetchFromApi() ?: return@withContext emptyList() cacheFile.writeText(fresh) AtonData.parseResponse(fresh) } private fun readCache(): String? { if (!cacheFile.exists()) return null if (System.currentTimeMillis() - cacheFile.lastModified() > maxAgeMs) return null return cacheFile.readText() } private fun fetchFromApi(): String? = try { URL(apiUrl).openStream().bufferedReader().readText() } catch (_: Exception) { null } } ``` - [ ] **Run tests:** `./gradlew testDebugUnitTest --tests "*.AtonDataTest" -q` → 4 PASSED - [ ] **Commit:** `git commit -m "feat(aton): AtonData model + AtonRepository with 24h cache"` --- ## Task 3: ATON vector layer in MapHandler + MainActivity wiring **Files:** - Modify: `android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt` - Modify: `android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt` - Modify: `android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml` - Modify: `android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt` - Modify: `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt` Read `MapHandler.kt` before editing — follow the FAD layer pattern (`setupFadLayer`, `updateFadLayer`, `setFadLayerVisible`). - [ ] **Add ATON constants + source field to `MapHandler`**: ```kotlin private val ATON_SOURCE_ID = "aton-source" private val ATON_LAYER_ID = "aton-layer" private var atonSource: GeoJsonSource? = null ``` - [ ] **Add `setupAtonLayer(style: Style, visible: Boolean)` to `MapHandler`**: ```kotlin fun setupAtonLayer(style: Style, visible: Boolean) { val src = GeoJsonSource(ATON_SOURCE_ID) atonSource = src style.addSource(src) style.addLayer(CircleLayer(ATON_LAYER_ID, ATON_SOURCE_ID).apply { setProperties( PropertyFactory.circleColor(Expression.get("color")), PropertyFactory.circleRadius(4f), PropertyFactory.circleStrokeColor("rgba(0,0,0,0.6)"), PropertyFactory.circleStrokeWidth(1f), PropertyFactory.visibility(if (visible) "visible" else "none") ) }) // Label layer: name at zoom 12+ style.addLayer(SymbolLayer("${ATON_LAYER_ID}-labels", ATON_SOURCE_ID).apply { setProperties( PropertyFactory.textField(Expression.get("name")), PropertyFactory.textSize(9f), PropertyFactory.textOffset(arrayOf(0f, -1.2f)), PropertyFactory.textColor("rgba(255,255,255,1)"), PropertyFactory.textHaloColor("rgba(0,0,0,0.7)"), PropertyFactory.textHaloWidth(1f), PropertyFactory.textAllowOverlap(false), PropertyFactory.visibility(if (visible) "visible" else "none"), PropertyFactory.textOpacity( Expression.step(Expression.zoom(), Expression.literal(0f), Expression.stop(12.0, 1f)) ) ) }) } ``` - [ ] **Add `updateAtonLayer(features: List)` and `setAtonLayerVisible(style, visible)` to `MapHandler`**: ```kotlin fun updateAtonLayer(features: List) { atonSource?.setGeoJson(FeatureCollection.fromFeatures(features)) } fun setAtonLayerVisible(style: Style, visible: Boolean) { val v = if (visible) "visible" else "none" style.getLayer(ATON_LAYER_ID)?.setProperties(PropertyFactory.visibility(v)) style.getLayer("${ATON_LAYER_ID}-labels")?.setProperties(PropertyFactory.visibility(v)) } ``` - [ ] **Add `atonEnabled` to `MapLayerManager`** (default `false`; same pattern as `fadsEnabled`): ```kotlin const val SOURCE_ATON = "aton-source" // informational only; source managed by MapHandler private const val KEY_ATON = "aton_enabled" var atonEnabled: Boolean = prefs.getBoolean(KEY_ATON, false) private set fun setAtonEnabled(enabled: Boolean) { atonEnabled = enabled prefs.edit().putBoolean(KEY_ATON, enabled).apply() } ``` - [ ] **Add ATON switch row to `layout_layer_picker_sheet.xml`** — read the file first, copy existing row pattern. Title: "NOAA ATON", subtitle: "Buoys, lights, daybeacons". `android:id="@+id/switch_aton"`. - [ ] **Add `onAtonChanged` callback to `LayerPickerSheet`**, wire the switch. - [ ] **In `MainActivity`**: - Add `private val atonRepository by lazy { AtonRepository(this) }` field - Call `mapHandler?.setupAtonLayer(style, layerManager.atonEnabled)` in style-load callback (after `setupFadLayer`) - In `buildLayerPickerSheet()`, add: ```kotlin onAtonChanged = { enabled -> layerManager.setAtonEnabled(enabled) val style = loadedStyleFlow.value ?: return@LayerPickerSheet mapHandler?.setAtonLayerVisible(style, enabled) if (enabled) { lifecycleScope.launch { val features = atonRepository.getFeatures() mapHandler?.updateAtonLayer(features) } } } ``` - Call `atonRepository.getFeatures()` on first GPS fix if `atonEnabled` is true — in `observeDataSources()` near the GPS collect block: ```kotlin if (layerManager.atonEnabled && !atonLoaded) { atonLoaded = true lifecycleScope.launch { mapHandler?.updateAtonLayer(atonRepository.getFeatures()) } } ``` Add `private var atonLoaded = false` field. - [ ] **Compile:** `./gradlew compileDebugKotlin` - [ ] **Commit:** `git commit -m "feat(aton): NOAA ATON vector layer (buoys + lights) with 24h cache"` --- ## Task 4: Traffic Separation Scheme (TSS) overlay **Files:** - Create: `android-app/app/src/main/res/raw/pacific_tss.geojson` - Modify: `android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt` - Modify: `android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt` - Modify: `android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml` - Modify: `android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt` - Modify: `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt` Traffic separation schemes define inbound/outbound shipping lanes and separation zones. Vessels under COLREGS must avoid impeding traffic in TSS lanes. For Hawaiian waters, the relevant schemes are the approaches to Honolulu and Kawaihae. Source: NOAA / IHO. The GeoJSON asset is bundled (no network required, static data). It contains `Polygon` features with a `"type"` property: `"lane_inbound"`, `"lane_outbound"`, `"separation_zone"`. - [ ] **Create `res/raw/pacific_tss.geojson`** — minimal GeoJSON with the two Hawaii TSS schemes: ```json { "type": "FeatureCollection", "features": [ { "type": "Feature", "properties": { "name": "Honolulu Inbound", "type": "lane_inbound" }, "geometry": { "type": "Polygon", "coordinates": [[ [-157.95, 21.30], [-157.90, 21.28], [-157.87, 21.32], [-157.92, 21.35], [-157.95, 21.30] ]] } }, { "type": "Feature", "properties": { "name": "Honolulu Outbound", "type": "lane_outbound" }, "geometry": { "type": "Polygon", "coordinates": [[ [-157.90, 21.25], [-157.85, 21.23], [-157.82, 21.27], [-157.87, 21.29], [-157.90, 21.25] ]] } }, { "type": "Feature", "properties": { "name": "Honolulu Separation Zone", "type": "separation_zone" }, "geometry": { "type": "Polygon", "coordinates": [[ [-157.90, 21.27], [-157.88, 21.26], [-157.87, 21.28], [-157.89, 21.29], [-157.90, 21.27] ]] } } ] } ``` **Note:** These coordinates are approximate placeholders based on publicly available NOAA chart data. Before shipping, verify against the official IHO/NOAA TSS publication for the actual polygon boundaries. If exact data is unavailable, this layer can be omitted from the initial release and added when verified coordinates are confirmed. - [ ] **Add TSS constants + source to `MapLayerManager` companion object**: ```kotlin const val LAYER_TSS_LANES = "tss-lanes-layer" const val LAYER_TSS_SEP = "tss-sep-layer" private const val KEY_TSS = "tss_enabled" ``` - [ ] **Add `tssEnabled` property and `setTssEnabled()` method** to `MapLayerManager`. - [ ] **Add `setupTssLayer(style: Style, context: Context, visible: Boolean)` to `MapHandler`**: ```kotlin fun setupTssLayer(style: Style, context: Context, visible: Boolean) { val json = context.resources.openRawResource(R.raw.pacific_tss) .bufferedReader().readText() val src = GeoJsonSource("tss-source", FeatureCollection.fromJson(json)) style.addSource(src) val v = if (visible) "visible" else "none" // Lanes: semi-transparent green (inbound) / red (outbound) style.addLayer(FillLayer("tss-lanes-layer", "tss-source").apply { setFilter(Expression.neq(Expression.get("type"), Expression.literal("separation_zone"))) setProperties( PropertyFactory.fillColor( Expression.switchCase( Expression.eq(Expression.get("type"), Expression.literal("lane_inbound")), Expression.literal("rgba(0,180,0,0.12)"), Expression.literal("rgba(200,0,0,0.12)") ) ), PropertyFactory.fillOutlineColor( Expression.switchCase( Expression.eq(Expression.get("type"), Expression.literal("lane_inbound")), Expression.literal("rgba(0,180,0,0.5)"), Expression.literal("rgba(200,0,0,0.5)") ) ), PropertyFactory.visibility(v) ) }) // Separation zone: yellow hatch-style (solid semi-transparent yellow) style.addLayer(FillLayer("tss-sep-layer", "tss-source").apply { setFilter(Expression.eq(Expression.get("type"), Expression.literal("separation_zone"))) setProperties( PropertyFactory.fillColor("rgba(255,200,0,0.25)"), PropertyFactory.fillOutlineColor("rgba(255,200,0,0.7)"), PropertyFactory.visibility(v) ) }) } ``` Check `MapHandler` imports — you'll need `FillLayer` added to imports. - [ ] **Add `setTssLayerVisible(style, visible)` to `MapHandler`**: ```kotlin fun setTssLayerVisible(style: Style, visible: Boolean) { val v = if (visible) "visible" else "none" style.getLayer("tss-lanes-layer")?.setProperties(PropertyFactory.visibility(v)) style.getLayer("tss-sep-layer")?.setProperties(PropertyFactory.visibility(v)) } ``` - [ ] **Add TSS switch row to `layout_layer_picker_sheet.xml`** — title "Traffic Separation", subtitle "Shipping lane zones (COLREGS)". `android:id="@+id/switch_tss"`. - [ ] **Add `onTssChanged` callback to `LayerPickerSheet`**. - [ ] **Wire in `MainActivity.buildLayerPickerSheet()`** — call `setupTssLayer(style, this, ...)` in style-load callback. Pass `onTssChanged` lambda. - [ ] **Compile:** `./gradlew compileDebugKotlin` - [ ] **Commit:** `git commit -m "feat(layers): traffic separation scheme overlay (TSS/COLREGS lanes)"` --- ## Task 5: Update `.agent/code-map.md` - [ ] **Add new layer sources to the `MapLayerManager` entry** in the `ui/` table. - [ ] **Add `ui/aton/` subsection** with `AtonData.kt` and `AtonRepository.kt`. - [ ] **Add ATON test** to the testing guide table. - [ ] **Commit:** `git commit -m "docs(agent): update code-map for ATON + NOAA charts + TSS layers"` --- ## Testing ```bash ./gradlew testDebugUnitTest --tests "*.AtonDataTest" -q # 4 tests ./gradlew compileDebugKotlin # compile all ``` Manual test: 1. Open layer picker → enable "NOAA Charts" → chart renders at zoom ≥ 10 2. Enable "NOAA ATON" → colored dots appear at buoy/light positions; wait ~5s for API response 3. Enable "Traffic Separation" → colored lane polygons appear in Hawaiian waters 4. Disable each → layers hide correctly --- ## Notes **NOAA chart tile scale:** `50000_1` in the URL is the chart scale code for 1:50,000. NOAA provides multiple scales: `20000_1` (1:20,000, detailed), `80000_1` (1:80,000, coastal overview). A future enhancement could auto-select scale based on zoom level, but single-scale is sufficient for V1. **ATON API coverage:** The NOAA ATON API covers all US waters. For international waters (e.g. non-US Pacific islands), the IHO S-125 standard is the eventual authoritative source but is not yet widely available as a public API. **TSS coordinates:** The bundled `pacific_tss.geojson` contains approximate coordinates as placeholders. If the user wants precise TSS boundaries, they should be verified against NOAA chart publication No. 1 or the `nais.gdacs.org` database and updated before shipping.