Skip to content
C.W.K.
Stream
Lesson 04 of 04 · published

The Maneuver Lives at the End

~14 min · swift, mapkit, navigation, conventions, map-camera

Level 0Lost
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

A Convention Nobody Writes Down

Every routing provider hands back a list of steps, and every step has two obvious fields: an instruction and a distance. What the documentation rarely says is how the two relate. Is "turn right onto X" the thing you do at the start of the step's distance, or at its end? Both readings are self-consistent. Only one is true for a given provider, and if you guess wrong every turn is announced at the wrong place.

Pathfinder did not guess; it measured. On its first day (2026-10-01) it asked Apple's Korean routing through the Maps Server API, which serves the same data MapKit does, and read the steps against the geometry. The step "장항IC에서 … 왼쪽 차선 유지" carries the 1.5 km that end at 장항IC, and the final "목적지에 도착" carries the last 109 m. Then MapKit itself, run in the simulator with a drive replayed along Apple's own route geometry, confirmed it: "백마로로 우회전" was shown 150 m before the actual turn onto 백마로. The step's 150 m is the road leading up to the turn.

Read that step the tempting way, maneuver at the start, and Pippa says "우회전해" a full 150 m early, at an intersection that is not the one. On the interchange step the error would be 1.5 km.

Pin It Once: maneuverAlong

The architecture doc turns the measurement into invariant 3: a step's instruction is the maneuver at the END of the step. The code makes the invariant impossible to forget by giving NavStep a field that already holds the answer: maneuverAlong, the distance along the whole route where the maneuver happens. The measured example lives in that type's doc comment, so anyone editing it meets the evidence first.

The engine never looks at a step's distance to decide when to speak. It reads maneuverAlong - progress and nothing else. That means every provider must translate its own convention into this one number, and none of that translation leaks into guidance:

  • Apple (AppleMapsProvider.navRoute): take the last point of each step's polyline, project it onto the whole route inside a window that starts where the previous step ended, accept the match if it lies within 60 m of the line, never move backwards, and clamp to the route's length. If the projection fails, fall back to the running total.
  • Kakao has two conventions, and cwkPippa translates both before the phone sees them. A driving guide sits on the first vertex of the road it names (measured as 0.0 m on every guide of a real route). A walking step describes its stretch, so the maneuver at its start is read from Kakao's words and, where they are silent, from the line's own turn.
  • Google guides itself: its trips never run through this engine, and its maneuvers arrive through its own feed.

The test fixture obeys the same rule: "East 3 km, left turn, north 2 km, arrive" carries the left turn at 3,000 m and the arrival at 5,000 m, and the first step, a departure placeholder with no words, is never spoken.

The Map Faces Where Dad Faces

The same discipline (decide once, as a value in the kit, test it on a Mac) also governs where the map looks. Dad's ruling was blunt: 3D alone is confusing. So the map is flat by default, with 3D one button away; it is heading-up; and Dad sits low on the screen, the way Kakao Navi does it.

FollowCamera is a pure value that answers two questions. Which way should the map face? Driving: the GPS course while moving at 2.5 m/s or more, otherwise the road's direction on the route at the current progress, otherwise the previous heading. Walking: course at a brisk 1.8 m/s, otherwise the compass, because a person turns on the spot and the phone knows. Never north by default; the old code fell back to north and spun the map at every red light. Where should the camera sit? Walking at 350 m; driving at speed × 40 m, clamped between 600 and 1,800 m so the highway shows more road ahead. The centre is pushed ahead of Dad by 0.12 × that distance in 2D (0.05 in the tilted view), and since the simulator showed the screen's height spanning about 0.55 × the camera distance, that puts him about 22 % below centre. Pitch is 0 flat and 55° in 3D.

While the drive is on route, Dad's marker sits on the snapshot's matched point, the fix snapped onto the line, so the arrow the map follows is the same position the voice is reasoning about.

When a convention can be read two ways, measure it once and store the answer, not the convention. A field that already holds the position cannot be misread by the next person who touches it; a comment that explains which end to use can.

Code

AppleMapsProvider.swift: pinning each step's END on the whole route (verbatim excerpt)·swift
    static func navRoute(from route: MKRoute, origin: Coordinate, destination: Place) -> NavRoute {
      let polyline = Polyline(Self.coordinates(of: route.polyline))
      var steps: [NavStep] = []
      var along = 0.0
      for step in route.steps {
        // A step's instruction is the maneuver at the END of its path (see
        // `NavStep`); pin that end on the whole route, never moving backwards.
        let points = Self.coordinates(of: step.polyline)
        var maneuverAlong = along + step.distance
        if let end = points.last {
          let window = along...(along + step.distance * 1.5 + 200)
          if let match = polyline.project(end, within: window, preferAlong: along + step.distance),
            match.lateral < 60
          {
            maneuverAlong = max(along, match.distanceAlong)
          }
        }
        maneuverAlong = min(maneuverAlong, polyline.length)
        steps.append(
          NavStep(
            instruction: step.instructions, distance: step.distance, maneuverAlong: maneuverAlong,
            notice: step.notice))
        along = maneuverAlong
      }
A minimal sketch: the same steps read at the end and at the start (compiled and run with swiftc)·swift
// A minimal sketch: two readings of the same provider steps. Each step is
// (instruction, metres driven on that step). The numbers echo the measured
// shape (1.5 km to an interchange, a short step to a turn, 109 m to arrive)
// but the route is made up.
let steps: [(instruction: String, distance: Double)] = [
  ("", 0),                               // the departure placeholder
  ("keep left at the interchange", 1500),
  ("turn right", 150),
  ("arrive", 109),
]

// Reading A (Pathfinder's NavStep.maneuverAlong): the maneuver happens at
// the END of the step's path, so its position is the running total.
// Reading B (the tempting one): the maneuver happens where the step starts.
var along = 0.0
var atEnd: [Double] = [], atStart: [Double] = []
for step in steps {
  atStart.append(along)
  along += step.distance
  atEnd.append(along)
}

for (i, step) in steps.enumerated() where !step.instruction.isEmpty {
  let early = atEnd[i] - atStart[i]
  print("\(step.instruction): end-reading \(Int(atEnd[i])) m, start-reading \(Int(atStart[i])) m (\(Int(early)) m early)")
}

// The car has just done the keep-left: 1500 m along.
let progress = 1500.0
let i = 2
print("at \(Int(progress)) m, '\(steps[i].instruction)' is \(Int(atEnd[i] - progress)) m ahead (A) or \(Int(atStart[i] - progress)) m ahead (B)")
// keep left at the interchange: end-reading 1500 m, start-reading 0 m (1500 m early)
// turn right: end-reading 1650 m, start-reading 1500 m (150 m early)
// arrive: end-reading 1759 m, start-reading 1650 m (109 m early)
// at 1500 m, 'turn right' is 150 m ahead (A) or 0 m ahead (B)
FollowCamera.swift: which way the map faces (verbatim)·swift
  /// The heading the map should face.
  ///
  /// GPS course means something only while moving; stopped at a light it is
  /// noise or nothing, and the old code fell back to north, spinning the map.
  /// Driving: course when moving, else the road's direction on the route,
  /// else where the map already faced. Walking: course at a brisk pace, else
  /// the compass (a person turns on the spot; the phone knows), else the
  /// route, else the previous heading.
  public static func heading(
    mode: TravelMode, course: Double?, speed: Double?, compass: Double?, routeBearing: Double?, previous: Double?
  ) -> Double? {
    let moving = (speed ?? 0) >= (mode == .walking ? 1.8 : 2.5)
    if moving, let course { return Geo.normalized(course) }
    let order: [Double?] =
      mode == .walking ? [compass, routeBearing, previous] : [routeBearing, previous, compass]
    return order.compactMap { $0 }.first.map(Geo.normalized)
  }

External links

Exercise

Take a routing API you can call (MapKit's MKDirections, a public OSRM server, any directions API) and request a route with one obvious turn. For each step, compare where its polyline starts and ends with where the turn actually is, and decide which end the instruction describes. Write a translation function that turns that provider's steps into a list of (instruction, maneuverAlong) pairs, and a test that fails if someone swaps the convention.
Hint
Use a public landmark or a made-up point as the origin, never your home. The clearest evidence is the last step: if it is something like 'arrive' and carries a non-zero distance, the instruction describes the end of that distance.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.