diff options
| -rw-r--r-- | .agent/code-map.md | 48 | ||||
| -rw-r--r-- | .agent/worklog.md | 12 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-06-30-additional-map-sources.md | 514 |
3 files changed, 563 insertions, 11 deletions
diff --git a/.agent/code-map.md b/.agent/code-map.md index 1a36542..e5b28a1 100644 --- a/.agent/code-map.md +++ b/.agent/code-map.md @@ -1,7 +1,7 @@ # Code Map & Context Tips **Purpose:** Bootstrap context for any new agent session. Read this before touching code. -**Last updated:** 2026-06-03 +**Last updated:** 2026-06-30 --- @@ -116,10 +116,10 @@ All source lives under `org.terst.nav`. | `LogbookRepository.kt` | Interface | | `InMemoryLogbookRepository.kt` | Thread-safe impl (`@Synchronized` on save/getAll/reload) | | `LogbookStorage.kt` | JSON persistence to SAF directory | -| `LogEntry.kt` | Log entry data class (type enum, text, photo, position) | +| `LogEntry.kt` | Log entry data class (type enum, text, photo, position, optional `trackId: Long?`) | | `LogbookFormatter.kt` | Formats log for display | | `LogbookPdfExporter.kt` | PDF export | -| `VoiceLogViewModel.kt` | ViewModel for logbook + trip report tab | +| `VoiceLogViewModel.kt` | ViewModel for logbook tab — filters to standalone entries (`trackId == null`) only; `save()` accepts optional `trackId` | | `VoiceLogState.kt` | Sealed state for voice log UI | ### `nmea/` @@ -170,7 +170,7 @@ Data-only classes (no logic): `WindData`, `HeadingData`, `BoatSpeedData`, `Depth | File | Role | |------|------| -| `TrackRepository.kt` | Coordinates GPX + Room storage; source of truth = GPX/SAF | +| `TrackRepository.kt` | Coordinates GPX + Room storage; source of truth = GPX/SAF; **sorted descending by `startMs`** | | `TrackStorage.kt` | SAF primary + MediaStore fallback for GPX files | | `TrackEntity.kt` | Room entity; `toShallowSavedTrack()` for fast list load | | `TrackDao.kt` | Room DAO | @@ -193,7 +193,7 @@ Data-only classes (no logic): `WindData`, `HeadingData`, `BoatSpeedData`, `Depth | `TripReportFragment.kt` | Full trip report view (map + stats + log entries) | | `TripReportGenerator.kt` | Generates report from track + log data | | `TripReportViewModel.kt` | ViewModel for trip report | -| `PreTripReportFragment.kt` | Pre-departure checklist / briefing | +| `PreTripReportFragment.kt` | Pre-departure briefing; auto-generates on open if forecast available; `newAutoInstance()` triggers generation immediately (called when recording starts) | | `PreTripReportGenerator.kt` | Generates pre-trip summary (weather, similar trips, watch items) | | `PreTripReportViewModel.kt` | ViewModel for pre-trip report | | `PreTripModels.kt` | Data classes for pre-trip report | @@ -205,6 +205,15 @@ Data-only classes (no logic): `WindData`, `HeadingData`, `BoatSpeedData`, `Depth |------|------| | `VesselRepository.kt` | Crew + vessel data; in-memory + persistence | +### `ui/fishing/` + +| File | Role | +|------|------| +| `FishingTarget.kt` | `FishingSpecies` enum, `RigRecommendation`, `FishingTarget` data classes | +| `RigAdvisor.kt` | Pure function: `recommend(tempC, currentKt, windKt) → RigRecommendation` — Ono for warm+fast current, Mahi for moderate, BOTH otherwise | +| `FishingModeManager.kt` | Persists `isActive` pref; `computeTarget()` returns cross-current bearing toward estimated temp break | +| `FishingOverlayView.kt` | Custom `FrameLayout` inflating `view_fishing_overlay.xml`; `bind(FishingTarget)` updates all text views | + ### `ui/` (top level) | File | Role | @@ -212,9 +221,10 @@ Data-only classes (no logic): `WindData`, `HeadingData`, `BoatSpeedData`, `Depth | `MainViewModel.kt` | Top-level app state | | `MobHandler.kt` | MOB activation logic; always-on-top button handler | | `InstrumentHandler.kt` | Routes NMEA/GPS data to instrument display widgets | -| `MapHandler.kt` | MapLibre map setup, overlay rendering, track display | -| `MapLayerManager.kt` | Layer visibility management | -| `LayerPickerSheet.kt` | Bottom sheet: layer toggle UI | +| `MapHandler.kt` | MapLibre map setup, overlay rendering, track display, fishing bearing line | +| `MapLayerManager.kt` | Layer visibility: satellite, charts, wind, SST, depth (GEBCO WMS), seamarks, FADs toggle | +| `LayerPickerSheet.kt` | Bottom sheet: layer toggle UI; constructor takes `onSstChanged` in addition to existing callbacks | +| `FadData.kt` | 18 Hawaii Island DLNR FAD positions in decimal degrees; `toFeatures()` returns GeoJSON for MapHandler | | `NavLogger.kt` | In-app debug log (primary debugging tool — prefer over ADB) | | `LocationPermissionHandler.kt` | Runtime location permission request flow | | `DevLogSheet.kt` | Bottom sheet: shows NavLogger output | @@ -234,7 +244,7 @@ Data-only classes (no logic): `WindData`, `HeadingData`, `BoatSpeedData`, `Depth | File | Role | |------|------| -| `SafetyFragment.kt` | Safety tab: MOB, anchor alarm (depth + rode inputs, radius display) | +| `SafetyFragment.kt` | Safety tab: MOB, CPA alerts, anchor alarm (collapsible), hardware sources (collapsible); Quit button at top of title row | ### `ui/voicelog/` @@ -301,7 +311,10 @@ JVM-only; mirrors a subset of android-app source with no Android SDK dependencie 7. **`VoiceLogFragment`** must pass both `onSelect` and `onReport` to `SavedTrackAdapter` — omitting either causes a CI failure. 8. **MapLibre Android 11.x**: `Expression.get("color")` silently fails for `lineColor` on `LineLayer`. Use `Expression.step(Expression.get("speed"), ...)` to map speed → color literal. 9. **Speed/temp/depth in UI** must use `UnitPrefs` conversions — never raw SI values. -10. **NMEA sentences from instruments are magnetic.** Convert to true using WMM variation from `HarmonicTideCalculator`'s companion magnetic model — not a hardcoded offset. +10. **NMEA sentences from instruments are magnetic.** +11. **Night vision vs system dark mode are separate.** `NavApplication` does NOT set `MODE_NIGHT_NO` — system dark mode is respected. `MainActivity` has a `fab_night_mode` FAB that toggles `Theme.Nav.NightVision` (red-on-black, 0.3× brightness) for dark adaptation at sea. These are independent; do not confuse them. +12. **`LogEntry.trackId`**: entries saved while recording carry `trackId = mainViewModel.trackStartMs`. Ship's log (`VoiceLogFragment`) filters to `trackId == null` only — in-track notes/photos are associated with the track, not the standalone log. +13. **Idle exit timer**: `MainActivity.resetIdleTimer()` schedules a 3-hour coroutine to `exitProcess(0)` if no user interaction. Cancelled on any `onUserInteraction()` call. Skipped entirely while recording (`isRecording == true`). Convert to true using WMM variation from `HarmonicTideCalculator`'s companion magnetic model — not a hardcoded offset. --- @@ -328,6 +341,8 @@ Key test files by domain: | AIS / CPA | `test-runner/.../ais/CpaCalculatorTest.kt` | | NMEA parsing | `test-runner/.../nmea/NmeaParserTest.kt` | | GRIB storage | `test-runner/.../data/storage/GribFileManagerTest.kt` | +| Fishing / rig advisor | `android-app/.../ui/fishing/RigAdvisorTest.kt` (2 tests) | +| Fishing / mode manager | `android-app/.../ui/fishing/FishingModeManagerTest.kt` (1 test) | --- @@ -383,6 +398,19 @@ CI (`android.yml`) triggers only on pushes to `main`. Firebase App Distribution - Report auto-generates on tab open; RETRY button appears only on error. - Track map uses a full-bleed 260dp MapLibre instance embedded in the report fragment. - Pirate mode Easter egg: long-press title in `TripReportFragment`. +- **Pre-trip report** auto-generates when recording starts: `MainActivity` calls `PreTripReportFragment.newAutoInstance()` and shows it as an overlay. Use `newAutoInstance()` for immediate generation; otherwise the fragment waits for user tap. + +### Fishing Mode +- `fab_fishing` (bottom-start mini FAB) toggles `FishingModeManager.isActive`; shows/hides `FishingOverlayView` and SST layer in sync. +- `updateFishingOverlay()` in `MainActivity` is called from `marineConditions.collect` — updates whenever conditions refresh. +- `lastCogDeg: Double` is updated from `gpsData.cog` inside `observeDataSources()` GPS collect. +- FADs (18 Hawaii DLNR buoys) are loaded once at style setup via `FadData.toFeatures()`. They default **visible** (`fadsEnabled = true`). Layer is in MapHandler; visibility pref is in MapLayerManager. +- Depth tiles: GEBCO-equivalent from NOAA CoastWatch ETOPO180 WMS — has full Pacific coverage. OpenSeaMap depth tiles were dropped (essentially empty for Hawaii). + +### LocationService notifications +- While idle: non-persistent notification titled "Sailing Companion". +- While recording: persistent (`setOngoing(true)`) notification titled "Recording track". +- `MainActivity` sends `ACTION_SET_RECORDING` intent to update the notification when `isRecording` state changes. ### GRIB / Weather - GRIB files stored via `GribFileManager`; staleness checked by `GribStalenessChecker`. diff --git a/.agent/worklog.md b/.agent/worklog.md index 342ffa0..1ca8a7c 100644 --- a/.agent/worklog.md +++ b/.agent/worklog.md @@ -2,12 +2,22 @@ ## Backlog -*(all backlog items completed as of 2026-05-27)* +- **Additional map sources** — NOAA chart tiles, ATON vector layer, TSS overlay. Plan: `docs/superpowers/plans/2026-06-30-additional-map-sources.md` (5 tasks). --- ## Recently Completed +### Bug Fixes (pre-fishing-mode release) — 2026-06-30 + +1. **Restore dark mode** — removed `MODE_NIGHT_NO` from `NavApplication`; system dark mode now works. NightVision (red-on-black FAB) is separate and unaffected. +2. **Depth tile source** — swapped empty `OpenSeaMap/depth` URL for NOAA CoastWatch ETOPO180 WMS; full Pacific/Hawaii coverage. +3. **FADs visible by default** — changed `fadsEnabled` default from `false` to `true`. + +### Code Map Update — 2026-06-30 + +Updated `code-map.md` to reflect all changes since 2026-06-03: `ui/fishing/` package, `FadData.kt`, updated `MapLayerManager`/`MapHandler`/`LayerPickerSheet` entries, `SafetyFragment` collapsible sections, `PreTripReportFragment` auto-generate, `TrackRepository` reverse sort, `LogEntry.trackId` field, `VoiceLogViewModel` standalone filtering, new invariants (night vision vs dark mode, idle timer, trackId filter), `LocationService` notification behavior, fishing mode gotchas. + ### Fishing Mode Feature — 2026-06-30 Implemented all 7 tasks for the fishing mode feature: diff --git a/docs/superpowers/plans/2026-06-30-additional-map-sources.md b/docs/superpowers/plans/2026-06-30-additional-map-sources.md new file mode 100644 index 0000000..8b2b71a --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-additional-map-sources.md @@ -0,0 +1,514 @@ +# 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<List<Feature>>` | +| `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<SwitchMaterial>(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<Feature> = 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<Feature> = 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<Feature>)` and `setAtonLayerVisible(style, visible)` to `MapHandler`**: +```kotlin +fun updateAtonLayer(features: List<Feature>) { + 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. |
