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

A Map That Faces Forward

~14 min · swift, core-location, compass, maps, walking

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

Where the map looks

After the first drives Dad said what he wanted from the map in one line, written into FollowCamera's doc comment: "무조건 차 또는 걷는 사람이 정면을 바라보는 식으로", always as if the car or the walker is looking straight ahead, with Kakao Navi as the reference. Invariant 10 turns that into rules. The map is flat by default and 3D is one button. It is heading-up. Dad sits low on the screen so the way ahead fills it. And it is never north by default: the old code fell back to north whenever the GPS course went quiet, so the map spun at every red light.

FollowCamera is a pure value in PathfinderKit, so all of this is tested on a Mac. It answers two questions: which way should the map face, and where should the camera sit.

Course or compass

A GPS course is derived from movement. It means something only while you are moving, and at a crawl it is mostly noise. A car stopped at a light has no course; a walker strolling has a poor one. So FollowCamera.heading first asks whether you are moving: at least 2.5 m/s when driving, 1.8 m/s when walking. If you are, and there is a course, the map faces the course.

If not, the two modes part ways. Driving asks the route first (the direction of the road at your progress), then the previous heading, and only then the compass. Walking asks the compass first, then the route, then the previous heading. The doc comment gives the reason in eight words: a person turns on the spot, and the phone knows. A car cannot face sideways on a road. A person at a corner turns ninety degrees without moving a metre, and only the magnetometer sees it.

One number deserves a second look. Wikipedia's figure for preferred walking speed is 1.10 to 1.65 m/s, and the threshold is 1.8. That means an ordinary stroll stays on the compass, and only a brisk pace hands the map to the course. That is the intent: the kit's test is literally named walkingTurnsWithTheCompassUntilThePaceIsBrisk, and its first assertion is a walker at 1.0 m/s with a course of 200° and a compass of 30°, who gets 30°.

Google reached the same conclusion on its own. In the Navigation SDK's GMSNavigationTravelMode, driving bases the user's course on the direction of movement, while walking bases it on the device's compass heading. When Google's map guides a walk, Pathfinder sets session.travelMode to .walking in route(to:), so by Google's own documentation the course it shows comes from the compass too.

Where the compass comes from

The app's LocationFeed starts startUpdatingHeading whenever heading is available, with headingFilter = 3, so a twitch under three degrees is not an update. It drops readings whose headingAccuracy is negative (iOS's way of saying invalid), and takes trueHeading when it is valid, magneticHeading otherwise. Every compass reading calls compassTurned(), which re-places the map only when the last fix is under thirty seconds old and slower than 2.5 m/s. Moving faster than that, the course turns the map and the compass is ignored.

Dad's arrow is the app's own drawing, not the system's blue dot: it rotates by the puck heading minus the map heading, so on a heading-up map it always points up. On a walk it is green and smaller (30 points against 38), and while the trip is on route it sits on the route's matched point.

Where the camera sits

FollowCamera.make sets the distance. Driving zooms out with speed, from 600 m to 1,800 m. Walking stays at 350 m whatever the pace, because a walker needs the next corner and the one after it, not the next interchange. The camera's centre is pushed ahead of Dad along the heading by 0.12 of the distance on a flat map (0.05 in 3D), which puts him about 22% below the centre, a value measured in the simulator to land near Kakao Navi's spot. Pitch is 0 flat and 55° in 3D.

Kakao's map needed one more rule for walking. Its follow camera is animated, and the first version queued each animation behind the last. A walk's compass turns several times a second, so a queue of 0.7-second moves fell further and further behind Dad. Now each move replaces the one in flight. On a walk Kakao also keeps the street-level zoom; only a fast car steps out a level.

The walking map's controls

The walking map shows no traffic and no camera pins. While a walk is guided, the round buttons on the right are the same set a drive gets: Turns ahead (the list of remaining maneuvers), Show the whole route (toggles the overview and back to following), the 3D toggle, and Recenter, which appears only after you pan away. On Apple's map the compass and the scale sit under Pathfinder's top bar: the map runs under the status bar, and on a heading-up map MapKit had put its compass over the battery. The 3D button reads "3D" in every language now, because "Tilt" in Korean, "기울기", did not fit the circle and showed as "기…" (#401).

A walk stays a walk

The heading rules depend on the trip knowing it is a walk, and the one place that knowledge used to leak was the reroute. A walk that left its path once came back as a driving route, and the session went on guiding a pedestrian with car distances and cameras. The reroute now asks the provider with the route's own mode, never the settings default, and refuses an answer from another provider or in another mode. If no route comes, Pippa says "길을 못 찾았어" once, and the session asks again every fifteen seconds while you are still off the route. A Google walk is rerouted by Google's engine inside its walking session.

Pick the sensor that knows. A heading can come from movement, from the road, or from the magnetometer, and each is right only in its own situation. Write the order down per mode, as data, and test it with the numbers a real walker produces.

Code

PathfinderKit, Map/FollowCamera.swift: which way the map faces (verbatim excerpt)·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)
  }
A minimal sketch: the same rule replayed with the kit's own test values (compiled with swiftc and run)·swift
import Foundation

// A minimal sketch: FollowCamera.heading's rule, replayed with the kit's own
// test values (walkingTurnsWithTheCompassUntilThePaceIsBrisk).
enum Mode { case driving, walking }

func normalized(_ b: Double) -> Double { let r = b.truncatingRemainder(dividingBy: 360); return r < 0 ? r + 360 : r }

func heading(mode: Mode, 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 normalized(course) }
  let order: [Double?] = mode == .walking ? [compass, routeBearing, previous] : [routeBearing, previous, compass]
  return order.compactMap { $0 }.first.map(normalized)
}

// Strolling at 1.0 m/s: the GPS course (200) is noise, the compass (30) wins.
print(heading(mode: .walking, course: 200, speed: 1.0, compass: 30, routeBearing: 90, previous: 0)!)   // 30
// A brisk 2.2 m/s: now the course means something.
print(heading(mode: .walking, course: 200, speed: 2.2, compass: 30, routeBearing: 90, previous: 0)!)   // 200
// Standing still with a compass reading of -10: normalized, never north by default.
print(heading(mode: .walking, course: nil, speed: 0, compass: -10, routeBearing: nil, previous: nil)!) // 350
// The same strolling fix in a car: the road's direction, not the compass.
print(heading(mode: .driving, course: 200, speed: 1.0, compass: 30, routeBearing: 90, previous: 0)!)   // 90
// Nothing known at all: nil. The rule never invents north.
print(heading(mode: .walking, course: nil, speed: nil, compass: nil, routeBearing: nil, previous: nil) as Any) // nil
PathfinderMobile, Navigation/NavigationSession.swift: a reroute asks in the trip's own mode (verbatim excerpt)·swift
  private func reroute(from sample: LocationSample) {
    guard rerouteTask == nil, !ended else { return }
    rerouting = true
    let options = deps.routeOptions()
    let provider = deps.provider
    let destination = self.destination
    // The drive's own mode, never the settings default: a walk that left its
    // path once came back as a DRIVING route (code review 2026-10-02), and
    // the session then guided a pedestrian with car distances and cameras.
    let mode = route.mode
    rerouteTask = Task { [weak self] in
      do {
        let fresh = try await provider.routes(
          from: sample.coordinate, heading: sample.course, to: destination,
          options: RouteOptions(
            alternatives: false, avoidTolls: options.avoidTolls,
            avoidHighways: options.avoidHighways, mode: mode))
        guard let self, !Task.isCancelled else { return }
        guard let best = fresh.first else { throw MapsIssue.noRoute }
        guard best.providerID == self.route.providerID, best.mode == mode else {
          throw MapsIssue.mismatchedProvider
        }
        self.adopt(best)
      } catch {
        guard let self, !Task.isCancelled else { return }
        self.rerouteFailed(error)
      }
    }
  }

External links

Exercise

Write your own version of the heading rule for a mode the app does not have: cycling. Decide the moving threshold and the fallback order, then write five test cases in the style of the sketch (a value in, the expected heading out), including one where the rider stands still at a junction and one where nothing is known.
Hint
Google's GMSNavigationTravelMode documentation states which heading source it uses for cycling. Compare your order with theirs, and if you disagree, write down which situation on a bicycle makes you right.

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.