From f881a1f91f4e9f832ab987fce2b5220e100cee68 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 12 May 2026 13:13:55 -1000 Subject: Add saved tracks browser, tack detection, and map zoom-to-bounds - TackDetector: sliding-window COG algorithm (60-140deg delta = tack/jibe) with stability check to prevent mixed-window false positives; 13 tests green - TackEvent, SavedTrack: new models wrapping points + summary + detected tacks - TrackRepository.getSavedTracks(): returns List with lazy load - MapHandler: zoomToTrackBounds (LatLngBounds fit with padding), panToPoint (log step-through), updateTackLayer (yellow circle markers) - MainViewModel: savedTracks + selectedTrack StateFlows, panToPosition SharedFlow - SavedTracksFragment: RecyclerView list with date/distance/duration/tack count - TrackDetailSheet: bottom sheet with stats + event log (tacks, speed changes, start/end); tapping an entry pans the map to that position - MainActivity: zoom map on track complete, wire Saved Tracks button in instrument sheet, observe panToPosition to drive map, show tack markers when a saved track is selected Co-Authored-By: Claude Sonnet 4.6 --- .../kotlin/org/terst/nav/track/TackDetector.kt | 50 ++++++++++++++++++++++ .../main/kotlin/org/terst/nav/track/TackEvent.kt | 9 ++++ 2 files changed, 59 insertions(+) create mode 100644 test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt create mode 100644 test-runner/src/main/kotlin/org/terst/nav/track/TackEvent.kt (limited to 'test-runner/src/main/kotlin/org') 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): List { + if (points.size < 2 * HALF_WIN + 1) return emptyList() + + val results = mutableListOf() + 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 +) -- cgit v1.2.3