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.

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 identitynextWaypoint, 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.

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.

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 nextWaypointIDRouteState.nextWaypointID is the single source of truth
  • Renamed currentInfogeometry to clarify what it holds

NavigationGeometry (replaces NavigationInfo)

A per-tick geometry snapshot. Pure computed values — no identity, no lookups.

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)

// Before
navState.nextWaypointID = nextWaypointID
navState.arrivalConfirmationCount = 0
appState.currentRouteState.nextWaypointID = nextWaypointID
appState.currentRouteState.selectedPointID = nextWaypointID

After: SetNextWaypoint (single write)

// After
appState.routeState.nextWaypointID = nextWaypointID
appState.routeState.selectedPointID = nextWaypointID
appState.navigationState.arrivalConfirmationCount = 0

Before: UpdateNavigation start phase

// Before
navState.nextWaypointID = determineNextWaypointID(appState: appState)
appState.currentRouteState.nextWaypointID = navState.nextWaypointID  // sync!

After: just set it once

// After
appState.routeState.nextWaypointID = determineNextWaypointID(appState: appState)

Before: BuildNavigationInfo

// 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

// 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)

private var waypointLabel: String? {
    if isNavigating {
        return navigateToState.currentInfo?.nextWaypoint.pinLabel
    }
    return currentRouteState.nextWaypoint?.pinLabel
}

After: always one source

private var waypointLabel: String? {
    routeState.nextWaypoint?.pinLabel
}

Before: 3 views duplicating currentPositionIndex

// 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

// 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment