diff options
22 files changed, 887 insertions, 1 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 39e0696..0df4069 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 @@ -40,6 +40,8 @@ import org.maplibre.android.style.sources.TileSet import org.terst.nav.settings.HardwareSource import org.terst.nav.thermal.ThermalState import org.terst.nav.thermal.thermalFlow +import org.terst.nav.track.SavedTracksFragment +import org.terst.nav.track.TrackDetailSheet import org.terst.nav.ui.* import org.terst.nav.ui.doc.DocFragment import org.terst.nav.ui.map.ParticleWindView @@ -216,11 +218,32 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } lifecycleScope.launch { viewModel.trackSummary.collect { summary -> + // Zoom the map to show the completed track before the summary sheet appears + val justCompleted = viewModel.pastTracks.value.firstOrNull() + if (justCompleted != null) { + val style = loadedStyleFlow.value + if (style != null) mapHandler?.zoomToTrackBounds(justCompleted) + } org.terst.nav.track.TrackSummarySheet .from(summary, viewModel.trackStartMs) .show(supportFragmentManager, "track_summary") } } + + // Pan the map when the user taps a log entry in TrackDetailSheet + lifecycleScope.launch { + viewModel.panToPosition.collect { (lat, lon) -> + mapHandler?.panToPoint(lat, lon) + } + } + + // "Saved Tracks" button in the instrument sheet + findViewById<View>(R.id.btn_saved_tracks).setOnClickListener { + showOverlay(SavedTracksFragment()) + bottomSheetBehavior.isHideable = true + bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN + bottomNav.selectedItemId = R.id.nav_map // keep map tab highlighted + } } /** Updates all static unit label TextViews to match current [unitPrefs]. */ @@ -676,6 +699,19 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { mapHandler?.updateTrackLayer(style, active, visiblePast) } } + + // When a saved track is selected: zoom to it and show tack markers + lifecycleScope.launch { + viewModel.selectedTrack.collect { track -> + val style = loadedStyleFlow.value ?: return@collect + if (track != null) { + mapHandler?.zoomToTrackBounds(track.points) + mapHandler?.updateTackLayer(style, track.tacks) + } else { + mapHandler?.clearTackLayer() + } + } + } } private fun rasterizeDrawable(drawableId: Int): Bitmap { diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTrack.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTrack.kt new file mode 100644 index 0000000..423592d --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTrack.kt @@ -0,0 +1,20 @@ +package org.terst.nav.track + +data class SavedTrack( + val points: List<TrackPoint>, + val startMs: Long, + val summary: TrackSummary, + val tacks: List<TackEvent> +) { + val endMs: Long get() = points.lastOrNull()?.timestampMs ?: startMs +} + +fun List<TrackPoint>.toSavedTrack(): SavedTrack { + require(isNotEmpty()) + return SavedTrack( + points = this, + startMs = first().timestampMs, + summary = summarise(this), + tacks = TackDetector.detectTacks(this) + ) +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt new file mode 100644 index 0000000..b73b034 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt @@ -0,0 +1,101 @@ +package org.terst.nav.track + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.launch +import org.terst.nav.R +import org.terst.nav.ui.MainViewModel +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +class SavedTracksFragment : Fragment() { + + private val viewModel: MainViewModel by activityViewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.fragment_saved_tracks, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view.findViewById<View>(R.id.btn_back).setOnClickListener { + parentFragmentManager.popBackStack() + } + + val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks) + val empty = view.findViewById<TextView>(R.id.tv_empty) + rv.layoutManager = LinearLayoutManager(requireContext()) + rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) + + val adapter = SavedTrackAdapter { track -> + viewModel.selectTrack(track) + parentFragmentManager.popBackStack() + TrackDetailSheet.from(track).show(parentFragmentManager, "track_detail") + } + rv.adapter = adapter + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.savedTracks.collect { tracks -> + adapter.submitList(tracks) + empty.visibility = if (tracks.isEmpty()) View.VISIBLE else View.GONE + rv.visibility = if (tracks.isEmpty()) View.GONE else View.VISIBLE + } + } + } +} + +private val DATE_FMT: DateTimeFormatter = + DateTimeFormatter.ofPattern("EEE dd MMM yyyy · HH:mm", Locale.getDefault()) + .withZone(ZoneId.systemDefault()) + +class SavedTrackAdapter( + private val onSelect: (SavedTrack) -> Unit +) : RecyclerView.Adapter<SavedTrackAdapter.VH>() { + + private var items: List<SavedTrack> = emptyList() + + fun submitList(list: List<SavedTrack>) { + items = list + notifyDataSetChanged() + } + + inner class VH(view: View) : RecyclerView.ViewHolder(view) { + val tvDate = view.findViewById<TextView>(R.id.tv_track_date) + val tvStats = view.findViewById<TextView>(R.id.tv_track_stats) + val tvTacks = view.findViewById<TextView>(R.id.tv_track_tacks) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = + VH(LayoutInflater.from(parent.context).inflate(R.layout.item_saved_track, parent, false)) + + override fun getItemCount() = items.size + + override fun onBindViewHolder(holder: VH, position: Int) { + val track = items[position] + holder.tvDate.text = DATE_FMT.format(Instant.ofEpochMilli(track.startMs)) + + val s = track.summary + val hours = s.durationMs / 3_600_000 + val minutes = (s.durationMs % 3_600_000) / 60_000 + val duration = if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" + holder.tvStats.text = "%.1f nm · %s · avg %.1f kt".format(s.distanceNm, duration, s.avgSogKt) + + if (track.tacks.isNotEmpty()) { + holder.tvTacks.text = "${track.tacks.size} tacks" + holder.tvTacks.visibility = View.VISIBLE + } else { + holder.tvTacks.visibility = View.GONE + } + + holder.itemView.setOnClickListener { onSelect(track) } + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt new file mode 100644 index 0000000..6616acf --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt @@ -0,0 +1,48 @@ +package org.terst.nav.track + +import kotlin.math.abs + +object TackDetector { + private const val HALF_WIN = 2 + private const val MIN_DELTA = 60.0 + private const val MAX_DELTA = 140.0 + private const val SKIP_AFTER = 5 + private const val STABILITY_MAX = 30.0 + + fun detectTacks(points: List<TrackPoint>): List<TackEvent> { + if (points.size < 2 * HALF_WIN + 1) return emptyList() + + val results = mutableListOf<TackEvent>() + var nextCandidate = 0 + + for (i in HALF_WIN until points.size - HALF_WIN) { + if (i < nextCandidate) continue + + val spreadBefore = abs(angleDiff(points[i - 2].cogDeg, points[i - 1].cogDeg)) + val spreadAfter = abs(angleDiff(points[i + 1].cogDeg, points[i + 2].cogDeg)) + if (spreadBefore > STABILITY_MAX || spreadAfter > STABILITY_MAX) continue + + val cogBefore = circularMean(points[i - 2].cogDeg, points[i - 1].cogDeg) + val cogAfter = circularMean(points[i + 1].cogDeg, points[i + 2].cogDeg) + val delta = abs(angleDiff(cogBefore, cogAfter)) + + if (delta in MIN_DELTA..MAX_DELTA) { + results += TackEvent(i, points[i].lat, points[i].lon, cogBefore, cogAfter) + nextCandidate = i + SKIP_AFTER + } + } + return results + } + + internal fun angleDiff(from: Double, to: Double): Double { + var diff = to - from + while (diff > 180) diff -= 360 + while (diff < -180) diff += 360 + return diff + } + + private fun circularMean(a: Double, b: Double): Double { + val half = angleDiff(a, b) / 2.0 + return ((a + half) % 360.0 + 360.0) % 360.0 + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TackEvent.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TackEvent.kt new file mode 100644 index 0000000..7c0bf85 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TackEvent.kt @@ -0,0 +1,9 @@ +package org.terst.nav.track + +data class TackEvent( + val index: Int, + val lat: Double, + val lon: Double, + val cogBefore: Double, + val cogAfter: Double +) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt new file mode 100644 index 0000000..0ec42f7 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt @@ -0,0 +1,160 @@ +package org.terst.nav.track + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.terst.nav.R +import org.terst.nav.ui.MainViewModel +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.math.abs + +/** A notable event extracted from a track for display in the log step-through. */ +data class LogEvent( + val lat: Double, + val lon: Double, + val timestampMs: Long, + val icon: String, + val label: String, + val detail: String +) + +class TrackDetailSheet : BottomSheetDialogFragment() { + + private val viewModel: MainViewModel by activityViewModels() + + companion object { + private const val ARG_TRACK_INDEX = "track_index" + + fun from(track: SavedTrack): TrackDetailSheet { + // We pass the track via the shared ViewModel — the BottomSheet reads selectedTrack. + return TrackDetailSheet() + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.layout_track_detail_sheet, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val track = viewModel.selectedTrack.value ?: run { dismiss(); return } + + val titleFmt = DateTimeFormatter.ofPattern("dd MMM yyyy · HH:mm", Locale.getDefault()) + .withZone(ZoneId.systemDefault()) + view.findViewById<TextView>(R.id.detail_title).text = + "Track · ${titleFmt.format(Instant.ofEpochMilli(track.startMs))}" + + val s = track.summary + view.findViewById<TextView>(R.id.detail_distance).text = "%.1f".format(s.distanceNm) + view.findViewById<TextView>(R.id.detail_max_sog).text = "%.1f".format(s.maxSogKt) + view.findViewById<TextView>(R.id.detail_avg_sog).text = "%.1f".format(s.avgSogKt) + val hours = s.durationMs / 3_600_000 + val minutes = (s.durationMs % 3_600_000) / 60_000 + view.findViewById<TextView>(R.id.detail_duration).text = + if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" + + val events = buildLogEvents(track) + val rv = view.findViewById<RecyclerView>(R.id.rv_log_entries) + rv.layoutManager = LinearLayoutManager(requireContext()) + rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) + rv.adapter = LogEventAdapter(events) { event -> + viewModel.panToTrackPoint(event.lat, event.lon) + } + } + + override fun onDestroyView() { + super.onDestroyView() + viewModel.clearSelectedTrack() + } + + private fun buildLogEvents(track: SavedTrack): List<LogEvent> { + val events = mutableListOf<LogEvent>() + val points = track.points + val tacks = track.tacks + + // Start / end + points.firstOrNull()?.let { p -> + events += LogEvent(p.lat, p.lon, p.timestampMs, "🚩", "Start", formatPointDetail(p)) + } + + // Tacks — detected maneuvers + for (t in tacks) { + val p = points[t.index] + val delta = TackDetector.angleDiff(t.cogBefore, t.cogAfter) + val type = if (abs(delta) > 70) "Tack" else "Jibe" + val dir = if (delta > 0) "→ stbd" else "→ port" + events += LogEvent(p.lat, p.lon, p.timestampMs, "⬡", + "$type $dir", + "%.0f° → %.0f° (Δ%.0f°)".format(t.cogBefore, t.cogAfter, abs(delta))) + } + + // Significant speed changes (> 1.5 kt shift sustained over 2 consecutive points) + for (i in 1 until points.size - 1) { + val prev = points[i - 1].sogKnots + val curr = points[i].sogKnots + val next = points[i + 1].sogKnots + val rise = curr - prev + val fall = curr - next + val p = points[i] + if (rise > 1.5 && rise > fall) { + events += LogEvent(p.lat, p.lon, p.timestampMs, "↑", + "Speed up", "%.1f → %.1f kt".format(prev, curr)) + } else if (fall > 1.5 && fall > rise) { + events += LogEvent(p.lat, p.lon, p.timestampMs, "↓", + "Slow down", "%.1f → %.1f kt".format(curr, next)) + } + } + + points.lastOrNull()?.let { p -> + events += LogEvent(p.lat, p.lon, p.timestampMs, "🏁", "End", formatPointDetail(p)) + } + + return events.sortedBy { it.timestampMs } + } + + private fun formatPointDetail(p: TrackPoint): String { + val parts = mutableListOf("%.1f kt %.0f°".format(p.sogKnots, p.cogDeg)) + p.windSpeedKnots?.let { parts += "W %.0f kt".format(it) } + p.depthMeters?.let { parts += "D %.0f m".format(it) } + return parts.joinToString(" ") + } +} + +private val TIME_FMT: DateTimeFormatter = + DateTimeFormatter.ofPattern("HH:mm:ss", Locale.getDefault()) + .withZone(ZoneId.systemDefault()) + +private class LogEventAdapter( + private val events: List<LogEvent>, + private val onTap: (LogEvent) -> Unit +) : RecyclerView.Adapter<LogEventAdapter.VH>() { + + inner class VH(view: View) : RecyclerView.ViewHolder(view) { + val tvIcon = view.findViewById<TextView>(R.id.tv_log_icon) + val tvTime = view.findViewById<TextView>(R.id.tv_log_time) + val tvLabel = view.findViewById<TextView>(R.id.tv_log_label) + val tvDetail = view.findViewById<TextView>(R.id.tv_log_detail) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = + VH(LayoutInflater.from(parent.context).inflate(R.layout.item_log_entry, parent, false)) + + override fun getItemCount() = events.size + + override fun onBindViewHolder(holder: VH, position: Int) { + val e = events[position] + holder.tvIcon.text = e.icon + holder.tvTime.text = TIME_FMT.format(Instant.ofEpochMilli(e.timestampMs)) + holder.tvLabel.text = e.label + holder.tvDetail.text = e.detail + holder.itemView.setOnClickListener { onTap(e) } + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt index 1b4cf34..7f765a1 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt @@ -100,6 +100,15 @@ class TrackRepository(context: Context) { _pastTracks.toList() } + /** + * Returns all completed tracks as [SavedTrack] wrappers (summary + tacks pre-computed). + * Internally delegates to [getPastTracks]; the first call loads from disk. + */ + suspend fun getSavedTracks(): List<SavedTrack> = + getPastTracks() + .filter { it.isNotEmpty() } + .map { it.toSavedTrack() } + companion object { fun haversineNm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { val r = 3440.065 // Earth radius in nautical miles 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 0dc1705..cc51dd1 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 @@ -14,6 +14,7 @@ import org.terst.nav.ais.CpaThresholds import org.terst.nav.data.api.AisHubApiService import org.terst.nav.thermal.ThermalReading import org.terst.nav.thermal.ThermalState +import org.terst.nav.track.SavedTrack import org.terst.nav.track.TrackPoint import org.terst.nav.track.TrackRepository import org.terst.nav.track.TrackStats @@ -98,7 +99,10 @@ class MainViewModel( init { viewModelScope.launch { runCatching { trackRepository.getPastTracks() } - .onSuccess { _pastTracks.value = it } + .onSuccess { + _pastTracks.value = it + _savedTracks.value = trackRepository.getSavedTracks() + } .onFailure { e -> NavLogger.e("track", "init: failed to load past tracks: ${e.javaClass.simpleName}: ${e.message}") } } } @@ -129,6 +133,16 @@ class MainViewModel( private val _trackSummary = MutableSharedFlow<TrackSummary>(replay = 0) val trackSummary: SharedFlow<TrackSummary> = _trackSummary.asSharedFlow() + private val _savedTracks = MutableStateFlow<List<SavedTrack>>(emptyList()) + val savedTracks: StateFlow<List<SavedTrack>> = _savedTracks.asStateFlow() + + private val _selectedTrack = MutableStateFlow<SavedTrack?>(null) + val selectedTrack: StateFlow<SavedTrack?> = _selectedTrack.asStateFlow() + + /** Emits a lat/lon whenever the user taps a log entry to pan the map there. */ + private val _panToPosition = MutableSharedFlow<Pair<Double, Double>>(replay = 0) + val panToPosition: SharedFlow<Pair<Double, Double>> = _panToPosition.asSharedFlow() + fun startTrack() { trackRepository.startTrack() _trackPoints.value = emptyList() @@ -142,6 +156,7 @@ class MainViewModel( viewModelScope.launch { val summary = trackRepository.stopTrack() _pastTracks.value = trackRepository.getPastTracks() + _savedTracks.value = trackRepository.getSavedTracks() _trackPoints.value = emptyList() _isRecording.value = false _trackStats.value = null @@ -158,6 +173,7 @@ class MainViewModel( points.forEach { trackRepository.addPoint(it) } val summary = trackRepository.stopTrack() _pastTracks.value = trackRepository.getPastTracks() + _savedTracks.value = trackRepository.getSavedTracks() _trackPoints.value = emptyList() _isRecording.value = false _trackStats.value = null @@ -391,6 +407,13 @@ class MainViewModel( } } + fun selectTrack(track: SavedTrack) { _selectedTrack.value = track } + fun clearSelectedTrack() { _selectedTrack.value = null } + + fun panToTrackPoint(lat: Double, lon: Double) { + viewModelScope.launch { _panToPosition.emit(lat to lon) } + } + private inner class GeoThrottle(private val minAgeMs: Long, private val minDistNm: Double) { private var lastLat = Double.NaN private var lastLon = Double.NaN 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 a7d746f..32b3643 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 @@ -4,6 +4,7 @@ import android.graphics.Bitmap import org.maplibre.android.camera.CameraPosition import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng +import org.maplibre.android.geometry.LatLngBounds import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.Style import org.maplibre.android.style.expressions.Expression @@ -23,6 +24,7 @@ import org.maplibre.geojson.Polygon import org.terst.nav.safety.AnchorWatchState import org.terst.nav.TidalCurrentState import org.terst.nav.data.model.WindArrow +import org.terst.nav.track.TackEvent import org.terst.nav.track.TrackPoint import kotlin.math.cos import kotlin.math.sin @@ -81,6 +83,9 @@ class MapHandler(private val maplibreMap: MapLibreMap) { private val WIND_GRID_SOURCE_ID = "wind-grid-source" private val WIND_GRID_LAYER_ID = "wind-grid-arrows" + private val TACK_SOURCE_ID = "tack-source" + private val TACK_LAYER_ID = "tack-layer" + private var anchorPointSource: GeoJsonSource? = null private var anchorCircleSource: GeoJsonSource? = null private var tidalCurrentSource: GeoJsonSource? = null @@ -89,6 +94,7 @@ class MapHandler(private val maplibreMap: MapLibreMap) { private var trackPastSource: GeoJsonSource? = null private var windSource: GeoJsonSource? = null private var windGridSource: GeoJsonSource? = null + private var tackSource: GeoJsonSource? = null /** * Initializes map layers for anchor watch, tidal currents, and user position. @@ -342,6 +348,59 @@ class MapHandler(private val maplibreMap: MapLibreMap) { } } + /** + * Animates the camera to show all [points] with [paddingPx] padding on each edge. + * Disables GPS follow so the view stays put. + */ + fun zoomToTrackBounds(points: List<TrackPoint>, paddingPx: Int = 80) { + if (points.size < 2) return + _isFollowing.value = false + val bounds = LatLngBounds.Builder().apply { + points.forEach { include(LatLng(it.lat, it.lon)) } + }.build() + maplibreMap.easeCamera( + CameraUpdateFactory.newLatLngBounds(bounds, paddingPx), + 600 + ) + } + + /** + * Pans (without zoom change) to a specific point from the log step-through. + */ + fun panToPoint(lat: Double, lon: Double) { + _isFollowing.value = false + maplibreMap.easeCamera( + CameraUpdateFactory.newLatLng(LatLng(lat, lon)), + 300 + ) + } + + /** + * Renders tack/jibe markers as yellow circles on the map. + * Lazily initialises the layer on first call. + */ + fun updateTackLayer(style: Style, tacks: List<TackEvent>) { + if (tackSource == null) { + tackSource = GeoJsonSource(TACK_SOURCE_ID) + style.addSource(tackSource!!) + style.addLayer(CircleLayer(TACK_LAYER_ID, TACK_SOURCE_ID).apply { + setProperties( + PropertyFactory.circleColor("#FDD835"), + PropertyFactory.circleRadius(7f), + PropertyFactory.circleStrokeColor("#F57F17"), + PropertyFactory.circleStrokeWidth(2f) + ) + }) + } + val features = tacks.map { Feature.fromGeometry(Point.fromLngLat(it.lon, it.lat)) } + tackSource?.setGeoJson(FeatureCollection.fromFeatures(features)) + } + + /** Clears all tack markers. */ + fun clearTackLayer() { + tackSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList())) + } + private fun createCirclePolygon(lat: Double, lon: Double, radiusMeters: Double): Polygon { val points = mutableListOf<Point>() val degreesBetweenPoints = 8 diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt deleted file mode 100644 index e69de29..0000000 --- a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt +++ /dev/null diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt deleted file mode 100644 index e69de29..0000000 --- a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt +++ /dev/null diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt deleted file mode 100644 index e69de29..0000000 --- a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt +++ /dev/null diff --git a/android-app/app/src/main/res/drawable/ic_back.xml b/android-app/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..3b56c6e --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="?attr/colorOnSurface" + android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/> +</vector> diff --git a/android-app/app/src/main/res/layout/fragment_saved_tracks.xml b/android-app/app/src/main/res/layout/fragment_saved_tracks.xml new file mode 100644 index 0000000..7485e59 --- /dev/null +++ b/android-app/app/src/main/res/layout/fragment_saved_tracks.xml @@ -0,0 +1,54 @@ +<?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="match_parent" + android:orientation="vertical"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="56dp" + android:orientation="horizontal" + android:gravity="center_vertical" + android:paddingStart="8dp" + android:paddingEnd="16dp" + android:elevation="4dp" + android:background="?attr/colorSurface"> + + <ImageButton + android:id="@+id/btn_back" + android:layout_width="48dp" + android:layout_height="48dp" + android:src="@drawable/ic_back" + android:background="?attr/selectableItemBackgroundBorderless" + android:contentDescription="Back" /> + + <TextView + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Saved Tracks" + android:textSize="18sp" + android:textStyle="bold" + android:layout_marginStart="4dp" /> + + </LinearLayout> + + <TextView + android:id="@+id/tv_empty" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:gravity="center" + android:text="No saved tracks yet.\nRecord a trip to see it here." + android:textAlignment="center" + android:textColor="?attr/colorOnSurfaceVariant" + android:visibility="gone" /> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/rv_saved_tracks" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipToPadding="false" + android:paddingBottom="80dp" /> + +</LinearLayout> diff --git a/android-app/app/src/main/res/layout/item_log_entry.xml b/android-app/app/src/main/res/layout/item_log_entry.xml new file mode 100644 index 0000000..fc53ef1 --- /dev/null +++ b/android-app/app/src/main/res/layout/item_log_entry.xml @@ -0,0 +1,55 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.constraintlayout.widget.ConstraintLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?attr/selectableItemBackground" + android:paddingHorizontal="16dp" + android:paddingVertical="10dp"> + + <TextView + android:id="@+id/tv_log_icon" + android:layout_width="28dp" + android:layout_height="wrap_content" + android:textSize="18sp" + android:gravity="center" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintStart_toStartOf="parent" /> + + <TextView + android:id="@+id/tv_log_time" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="12sp" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginStart="8dp" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintStart_toEndOf="@id/tv_log_icon" /> + + <TextView + android:id="@+id/tv_log_label" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:textSize="14sp" + android:textStyle="bold" + android:textColor="?attr/colorOnSurface" + android:layout_marginStart="8dp" + app:layout_constraintTop_toBottomOf="@id/tv_log_time" + app:layout_constraintStart_toEndOf="@id/tv_log_icon" + app:layout_constraintEnd_toEndOf="parent" /> + + <TextView + android:id="@+id/tv_log_detail" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:textSize="12sp" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginTop="2dp" + app:layout_constraintTop_toBottomOf="@id/tv_log_label" + app:layout_constraintStart_toEndOf="@id/tv_log_icon" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + android:layout_marginStart="8dp" /> + +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/android-app/app/src/main/res/layout/item_saved_track.xml b/android-app/app/src/main/res/layout/item_saved_track.xml new file mode 100644 index 0000000..b0a7e24 --- /dev/null +++ b/android-app/app/src/main/res/layout/item_saved_track.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.constraintlayout.widget.ConstraintLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?attr/selectableItemBackground" + android:padding="16dp"> + + <TextView + android:id="@+id/tv_track_date" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:textSize="15sp" + android:textStyle="bold" + android:textColor="?attr/colorOnSurface" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toStartOf="@id/tv_track_tacks" /> + + <TextView + android:id="@+id/tv_track_tacks" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="13sp" + android:textColor="#FDD835" + android:visibility="gone" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintEnd_toEndOf="parent" /> + + <TextView + android:id="@+id/tv_track_stats" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:textSize="13sp" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginTop="4dp" + app:layout_constraintTop_toBottomOf="@id/tv_track_date" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintBottom_toBottomOf="parent" /> + +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml index 59a92c3..f8dc365 100644 --- a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml +++ b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml @@ -339,4 +339,19 @@ </LinearLayout> + <!-- Saved Tracks button --> + <com.google.android.material.button.MaterialButton + android:id="@+id/btn_saved_tracks" + style="@style/Widget.Material3.Button.OutlinedButton" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="16dp" + android:layout_marginTop="12dp" + android:layout_marginBottom="8dp" + android:text="Saved Tracks" + android:textAllCaps="false" + app:layout_constraintTop_toBottomOf="@id/forecast_row" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" /> + </androidx.constraintlayout.widget.ConstraintLayout> diff --git a/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml b/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml new file mode 100644 index 0000000..9b2eb30 --- /dev/null +++ b/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml @@ -0,0 +1,91 @@ +<?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:paddingBottom="16dp"> + + <!-- drag handle --> + <View + android:layout_width="40dp" + android:layout_height="4dp" + android:layout_gravity="center_horizontal" + android:layout_marginTop="8dp" + android:layout_marginBottom="8dp" + android:background="#BDBDBD" /> + + <TextView + android:id="@+id/detail_title" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingHorizontal="20dp" + android:paddingBottom="12dp" + android:textSize="17sp" + android:textStyle="bold" /> + + <!-- stats row: distance · duration · max · avg --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:paddingHorizontal="12dp" + android:paddingBottom="12dp"> + + <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" + android:layout_weight="1" android:orientation="vertical" android:gravity="center"> + <TextView android:id="@+id/detail_distance" android:layout_width="wrap_content" + android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" /> + <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" + android:text="nm" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" /> + </LinearLayout> + + <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" + android:layout_weight="1" android:orientation="vertical" android:gravity="center"> + <TextView android:id="@+id/detail_duration" android:layout_width="wrap_content" + android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" /> + <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" + android:text="time" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" /> + </LinearLayout> + + <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" + android:layout_weight="1" android:orientation="vertical" android:gravity="center"> + <TextView android:id="@+id/detail_max_sog" android:layout_width="wrap_content" + android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" /> + <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" + android:text="max kt" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" /> + </LinearLayout> + + <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" + android:layout_weight="1" android:orientation="vertical" android:gravity="center"> + <TextView android:id="@+id/detail_avg_sog" android:layout_width="wrap_content" + android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" /> + <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" + android:text="avg kt" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" /> + </LinearLayout> + + </LinearLayout> + + <View android:layout_width="match_parent" android:layout_height="1dp" + android:background="?attr/colorOutlineVariant" android:layout_marginHorizontal="16dp" /> + + <!-- event log --> + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingHorizontal="20dp" + android:paddingTop="12dp" + android:paddingBottom="4dp" + android:text="Event Log" + android:textSize="13sp" + android:textAllCaps="true" + android:letterSpacing="0.08" + android:textColor="?attr/colorOnSurfaceVariant" /> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/rv_log_entries" + android:layout_width="match_parent" + android:layout_height="300dp" + android:nestedScrollingEnabled="true" /> + +</LinearLayout> diff --git a/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt b/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt deleted file mode 100644 index e69de29..0000000 --- a/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt +++ /dev/null diff --git a/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt new file mode 100644 index 0000000..b392f67 --- /dev/null +++ b/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt @@ -0,0 +1,50 @@ +package org.terst.nav.track + +import kotlin.math.abs + +object TackDetector { + private const val HALF_WIN = 2 // look 2 points before and after + private const val MIN_DELTA = 60.0 + private const val MAX_DELTA = 140.0 + private const val SKIP_AFTER = 5 // suppress re-detection for this many indices + private const val STABILITY_MAX = 30.0 // max internal spread of a before/after pair + + fun detectTacks(points: List<TrackPoint>): List<TackEvent> { + if (points.size < 2 * HALF_WIN + 1) return emptyList() + + val results = mutableListOf<TackEvent>() + var nextCandidate = 0 + + for (i in HALF_WIN until points.size - HALF_WIN) { + if (i < nextCandidate) continue + + // Require both "before" and "after" pairs to be internally stable; + // if one spans the transition itself, that window is noise. + val spreadBefore = abs(angleDiff(points[i - 2].cogDeg, points[i - 1].cogDeg)) + val spreadAfter = abs(angleDiff(points[i + 1].cogDeg, points[i + 2].cogDeg)) + if (spreadBefore > STABILITY_MAX || spreadAfter > STABILITY_MAX) continue + + val cogBefore = circularMean(points[i - 2].cogDeg, points[i - 1].cogDeg) + val cogAfter = circularMean(points[i + 1].cogDeg, points[i + 2].cogDeg) + val delta = abs(angleDiff(cogBefore, cogAfter)) + + if (delta in MIN_DELTA..MAX_DELTA) { + results += TackEvent(i, points[i].lat, points[i].lon, cogBefore, cogAfter) + nextCandidate = i + SKIP_AFTER + } + } + return results + } + + internal fun angleDiff(from: Double, to: Double): Double { + var diff = to - from + while (diff > 180) diff -= 360 + while (diff < -180) diff += 360 + return diff + } + + private fun circularMean(a: Double, b: Double): Double { + val half = angleDiff(a, b) / 2.0 + return ((a + half) % 360.0 + 360.0) % 360.0 + } +} diff --git a/test-runner/src/main/kotlin/org/terst/nav/track/TackEvent.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TackEvent.kt new file mode 100644 index 0000000..7c0bf85 --- /dev/null +++ b/test-runner/src/main/kotlin/org/terst/nav/track/TackEvent.kt @@ -0,0 +1,9 @@ +package org.terst.nav.track + +data class TackEvent( + val index: Int, + val lat: Double, + val lon: Double, + val cogBefore: Double, + val cogAfter: Double +) diff --git a/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt b/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt new file mode 100644 index 0000000..44bd667 --- /dev/null +++ b/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt @@ -0,0 +1,95 @@ +package org.terst.nav.track + +import org.junit.Assert.* +import org.junit.Test + +class TackDetectorTest { + + private fun pt(lat: Double, lon: Double, cog: Double, ts: Long) = + TrackPoint(lat, lon, 5.0, cog, 12.0, 0.0, true, ts) + + private fun straightTrack(cog: Double, count: Int = 10): List<TrackPoint> = + (0 until count).map { i -> pt(37.0 + i * 0.01, -122.0, cog, i * 10_000L) } + + private fun tackTrack(cogBefore: Double, cogAfter: Double, each: Int = 6): List<TrackPoint> { + val before = (0 until each).map { i -> pt(37.0 + i * 0.005, -122.0, cogBefore, i * 10_000L) } + val after = (0 until each).map { i -> pt(37.0 + each * 0.005 + i * 0.005, -122.0, cogAfter, (each + i) * 10_000L) } + return before + after + } + + @Test fun `straight course yields no tacks`() { + assertTrue(TackDetector.detectTacks(straightTrack(90.0)).isEmpty()) + } + + @Test fun `straight course on port tack yields no tacks`() { + assertTrue(TackDetector.detectTacks(straightTrack(330.0)).isEmpty()) + } + + @Test fun `detects port to starboard tack`() { + val tacks = TackDetector.detectTacks(tackTrack(330.0, 70.0)) + assertEquals(1, tacks.size) + assertTrue("cogBefore should be near 330", tacks[0].cogBefore in 325.0..335.0) + assertTrue("cogAfter should be near 70", tacks[0].cogAfter in 65.0..75.0) + } + + @Test fun `detects starboard to port tack`() { + val tacks = TackDetector.detectTacks(tackTrack(70.0, 330.0)) + assertEquals(1, tacks.size) + } + + @Test fun `detects jibe on broad reach`() { + // broad reach port → starboard: 150° → 210°, delta = 60° + val tacks = TackDetector.detectTacks(tackTrack(150.0, 250.0)) + assertEquals(1, tacks.size) + } + + @Test fun `handles 0-360 wrap`() { + // 350° → 80°: delta = 90° through north + val tacks = TackDetector.detectTacks(tackTrack(350.0, 80.0)) + assertEquals(1, tacks.size) + } + + @Test fun `gradual course change is not a tack`() { + // COG increases by 5° per point: window delta ≈ 15°, below threshold + val points = (0 until 20).map { i -> pt(37.0 + i * 0.01, -122.0, i * 5.0, i * 10_000L) } + assertTrue(TackDetector.detectTacks(points).isEmpty()) + } + + @Test fun `small heading change below threshold is ignored`() { + // 45° change — not a tack + assertTrue(TackDetector.detectTacks(tackTrack(90.0, 135.0)).isEmpty()) + } + + @Test fun `very large heading change above threshold is ignored`() { + // 160° delta — U-turn, not a normal tack or jibe + assertTrue(TackDetector.detectTacks(tackTrack(90.0, 250.0)).isEmpty()) + } + + @Test fun `detects two tacks in sequence`() { + val leg1 = (0 until 6).map { i -> pt(37.0 + i * 0.01, -122.0, 330.0, i * 10_000L) } + val leg2 = (0 until 6).map { i -> pt(37.06 + i * 0.01, -122.0, 70.0, (6 + i) * 10_000L) } + val leg3 = (0 until 6).map { i -> pt(37.12 + i * 0.01, -122.0, 330.0, (12 + i) * 10_000L) } + val tacks = TackDetector.detectTacks(leg1 + leg2 + leg3) + assertEquals(2, tacks.size) + } + + @Test fun `deduplicates tacks at same transition`() { + val before = (0 until 8).map { i -> pt(37.0, -122.0 + i * 0.0001, 330.0, i * 5_000L) } + val after = (0 until 8).map { i -> pt(37.0, -122.0 + 0.0008 + i * 0.0001, 70.0, (8 + i) * 5_000L) } + val tacks = TackDetector.detectTacks(before + after) + assertEquals(1, tacks.size) + } + + @Test fun `tack event contains correct position`() { + val tacks = TackDetector.detectTacks(tackTrack(330.0, 70.0)) + assertEquals(1, tacks.size) + // Position should be somewhere in the track bounds + assertTrue(tacks[0].lat in 37.0..37.1) + assertTrue(tacks[0].lon in -122.1..-122.0) + } + + @Test fun `too few points returns empty`() { + val points = (0 until 3).map { i -> pt(37.0, -122.0, 90.0, i * 1000L) } + assertTrue(TackDetector.detectTacks(points).isEmpty()) + } +} |
