Skip to content

Instantly share code, notes, and snippets.

@gshaw
Created March 27, 2026 19:46
Show Gist options
  • Select an option

  • Save gshaw/bebafc908b2429bdb85fe20ec9484026 to your computer and use it in GitHub Desktop.

Select an option

Save gshaw/bebafc908b2429bdb85fe20ec9484026 to your computer and use it in GitHub Desktop.

Revisions

  1. gshaw created this gist Mar 27, 2026.
    248 changes: 248 additions & 0 deletions NavigationState-CleanSlate.md
    Original 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.