Created
March 27, 2026 19:46
-
-
Save gshaw/bebafc908b2429bdb85fe20ec9484026 to your computer and use it in GitHub Desktop.
Revisions
-
gshaw created this gist
Mar 27, 2026 .There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,248 @@ # Clean-Slate Design: Route & Navigation State ## What the app actually needs The app has two modes for a route: **editing** and **navigating**. The current design splits this across `CurrentRouteState` (route definition + editing) and `NavigateToState` (live navigation engine), with `NavigationInfo` as a per-tick geometry snapshot. The complexity comes from duplicated fields, manual sync, and identity data mixed into geometry structs. --- ## Current pain points 1. **`nextWaypointID` exists on both states**, kept in sync by 5+ write sites 2. **`NavigationInfo` mixes geometry with identity** — `nextWaypoint`, `currentCoordinate`, `currentPositionIndex` are all available elsewhere 3. **3 views independently recompute `currentPositionIndex`** with identical `firstIndex(where:)` logic 4. **Views fork on `isNavigating`** to pick between `NavigationInfo.nextWaypoint` and `CurrentRouteState.nextWaypoint` even though they're the same value 5. **Steer-line caching bookkeeping** (`routedSteerLine`, `isLoadingSteerLine`, target/origin coordinates) lives on `NavigateToState` but is only used by one operation --- ## Proposed clean-slate design ### `RouteState` (replaces `CurrentRouteState`) The single source of truth for the route definition and the user's position within it. Persisted to disk. ```swift class RouteState: ObservableObject { // Route definition var points: [DrawingPoint] = [] { didSet { waypoints = BuildWaypoints.call(routePoints: points) isSaved = false objectWillChange.send() } } @Published var routeMode: RouteMode = .auto @Published var draft = Measurement(value: 2, unit: UnitLength.meters) @Published var segments: [TrackSegment] = [] @Published var unnavigableSegments: [TrackSegment] = [] @Published var navigableLine: LineString? @Published var isLoadingSegments = false // Route metadata var drawingID: UUID? var name: String? var notes = "" @Published var isSaved = true // User's position within the route (single source of truth) @Published var nextWaypointID: UUID? // THE one and only copy @Published var selectedPointID: UUID? @Published var isDragging = false // Derived (not persisted) private(set) var waypoints: [Waypoint] = [] // Computed conveniences var nextWaypoint: Waypoint? { waypoints.first { $0.id == nextWaypointID } } var nextWaypointIndex: Int? { // NEW: replaces 3 duplicated view computations guard let nextWaypointID else { return nil } return waypoints.firstIndex { $0.id == nextWaypointID } } var selectedWaypoint: Waypoint? { waypoints.first { $0.id == selectedPointID } } var isRouteEmpty: Bool { waypoints.isEmpty } var isRouteReady: Bool { waypoints.hasAtLeastTwo } } ``` **What changed from `CurrentRouteState`:** - Renamed to `RouteState` (it IS the route, "current" is implied) - Added `nextWaypointIndex` computed property (eliminates duplication in 3 views) - Everything else stays the same — this is already well-structured ### `NavigationState` (replaces `NavigateToState`) The live navigation engine. Transient — nothing persisted. Only meaningful when `phase == .start`. ```swift final class NavigationState: ObservableObject { enum Phase { case start case pause case stop } @Published var phase = Phase.stop @Published var geometry: NavigationGeometry? // was `currentInfo: NavigationInfo?` // Steer line async state (only used by UpdateSteerLineRoute operation) @Published var routedSteerLine: LineString? @Published var isLoadingSteerLine = false var routedSteerTargetCoordinate: Coordinate? var routedSteerOriginCoordinate: Coordinate? // Arrival tracking var arrivalConfirmationCount = 0 var lastNotifiedWaypointID: UUID? var isNavigating: Bool { phase == .start } // REMOVED: nextWaypointID (use routeState.nextWaypointID instead) } ``` **What changed from `NavigateToState`:** - Renamed to `NavigationState` (clearer) - **Removed `nextWaypointID`** — `RouteState.nextWaypointID` is the single source of truth - Renamed `currentInfo` → `geometry` to clarify what it holds ### `NavigationGeometry` (replaces `NavigationInfo`) A per-tick geometry snapshot. Pure computed values — no identity, no lookups. ```swift struct NavigationGeometry { // Track geometry let trackCoordinate: Coordinate // closest point on route to boat let steerCoordinate: Coordinate // intercept point for smooth course // Precomputed lines for map rendering let completedLine: LineString? // start → trackCoordinate let crossTrackErrorLine: LineString? // boat → trackCoordinate (straight) let crossTrackErrorDirection: CrossTrackErrorDirection? let steerLine: LineString? // boat → steerCoordinate (smooth curve) let toEndLine: LineString? // steerLine + (steerCoordinate → end) let toNextLine: LineString? // steerLine + (steerCoordinate → next waypoint) // Precomputed dashboard values let courseToSteerInDegrees: Double let distanceToEndInMeters: Double let routeCrossTrackDistance: Double // REMOVED: nextWaypoint (use routeState.nextWaypoint) // REMOVED: currentCoordinate (use locationState.currentPosition) // REMOVED: currentPositionIndex (use routeState.nextWaypointIndex) } ``` **What changed from `NavigationInfo`:** - Renamed to `NavigationGeometry` (says what it is) - **Removed `nextWaypoint`** — available as `routeState.nextWaypoint` - **Removed `currentCoordinate`** — available from `locationState`/`locationPuckState` - **Removed `currentPositionIndex`** — available as `routeState.nextWaypointIndex` - What remains is purely geometry: coordinates, lines, distances, bearings --- ## How operations simplify ### Before: `SetNextWaypoint` (had to write two places) ```swift // Before navState.nextWaypointID = nextWaypointID navState.arrivalConfirmationCount = 0 appState.currentRouteState.nextWaypointID = nextWaypointID appState.currentRouteState.selectedPointID = nextWaypointID ``` ### After: `SetNextWaypoint` (single write) ```swift // After appState.routeState.nextWaypointID = nextWaypointID appState.routeState.selectedPointID = nextWaypointID appState.navigationState.arrivalConfirmationCount = 0 ``` ### Before: `UpdateNavigation` start phase ```swift // Before navState.nextWaypointID = determineNextWaypointID(appState: appState) appState.currentRouteState.nextWaypointID = navState.nextWaypointID // sync! ``` ### After: just set it once ```swift // After appState.routeState.nextWaypointID = determineNextWaypointID(appState: appState) ``` ### Before: `BuildNavigationInfo` ```swift // Before — reads navState.nextWaypointID, bundles nextWaypoint into the struct let nextWaypoint = waypoints.first(where: { $0.id == navState.nextWaypointID }) ?? waypoints.first // ... later ... return NavigationInfo(nextWaypoint: nextWaypoint, currentCoordinate: coord, ...) ``` ### After: `BuildNavigationGeometry` ```swift // After — reads routeState.nextWaypointID, doesn't bundle identity into the struct let nextWaypoint = appState.routeState.nextWaypoint ?? waypoints.first // uses nextWaypoint.coordinate for geometry calculations, but doesn't store it in the result return NavigationGeometry(trackCoordinate: ..., steerCoordinate: ..., ...) ``` --- ## How views simplify ### Before: dashboard waypoint label (forked on isNavigating) ```swift private var waypointLabel: String? { if isNavigating { return navigateToState.currentInfo?.nextWaypoint.pinLabel } return currentRouteState.nextWaypoint?.pinLabel } ``` ### After: always one source ```swift private var waypointLabel: String? { routeState.nextWaypoint?.pinLabel } ``` ### Before: 3 views duplicating currentPositionIndex ```swift // CurrentRouteBottomSheet, MiniMapContent, WaypointsTableSection — all identical private var currentPositionIndex: Int? { guard let nextID = currentRouteState.nextWaypointID else { return nil } return currentRouteState.waypoints.firstIndex(where: { $0.id == nextID }) } ``` ### After: computed property on RouteState ```swift // Views just use: routeState.nextWaypointIndex ``` --- ## Migration path These changes can be done incrementally without breaking anything: | Step | What | Risk | |---|---|---| | 1 | Add `nextWaypointIndex` to `CurrentRouteState`, use it in 3 views | None | | 2 | Remove `NavigateToState.nextWaypointID`, read from `currentRouteState` everywhere | Low | | 3 | Remove `nextWaypoint`, `currentCoordinate`, `currentPositionIndex` from `NavigationInfo` | Medium | | 4 | Rename types (`RouteState`, `NavigationState`, `NavigationGeometry`) | Low (mechanical) | Each step is independently shippable and testable.