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

The Walking Numbers

~15 min · swift, navigation, walking, gps, configuration

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

Walking is a mode, not another app

Dad's request was one sentence: "여행가서 도보로도 쓸 거야", I'll use it on foot when I travel. The tempting answer is a second screen, a second engine, a second set of rules somewhere in a WalkingViewController. Pathfinder's architecture says no, in invariant 11: walking is a mode, not another app. TravelMode is a two-case enum, driving and walking, and it rides the values that already flow everywhere. RouteOptions.mode carries it to the provider when a route is asked for; NavRoute.mode carries it back on every route the provider returns. Apple's child turns it into MKDirectionsTransportType.walking; Google's engine turns it into its session's travel mode; Kakao's walk comes through cwkPippa's maps door with mode: walking in the request.

From then on nothing asks the settings what mode Dad is in. Whoever holds the route reads route.mode. NavigationSession builds its engine with GuidanceConfig.forMode(route.mode); FeedGuidedTrip, the child that follows Google's engine, builds its feed with the same call; PromptComposer defaults to it. One value, read at every place it matters.

The numbers, one by one

GuidanceConfig.walking starts from the driving defaults and changes ten of them. Every change has a reason you can say out loud, and most of the reasons are one of three things: a person is slow, GPS error is large next to a person's pace, and a person on a pavement faces wherever they like.

SettingDrivingWalkingWhy it differs
Stage plan (early / prepare / imminent)1000 / 300 / 90 m200 / 50 / 20 mSpeed. At about 1.4 m/s, 200 m is over two minutes of walking and 20 m is about fourteen seconds. A car at 50 km/h covers 300 m in about twenty-two.
Highway plan from19.4 m/s (70 km/h).infinityNo pedestrian reaches it, and no GPS spike should switch a walk to 2 km warnings.
Off-route distance45 m35 mA footpath is narrower than a road, and the wrong street in an old town can be a building's width away.
Off-route minimum duration4 s10 sAt walking pace the fix's error is large next to the distance covered. Three misses in a row still have to happen; they must now also span ten seconds.
Wrong-way angle120°181°A person may face any way on the pavement. No angle between two headings exceeds 180°, so this rule can never fire.
Course trusted from4 m/s1 m/sOnly used by the wrong-way rule, which 181° has switched off.
Arrival distance35 m20 mA walk ends at a door, not at a kerb beside a car park.
Join distance150 m100 mA car starts in a car park; a walker starts in a lobby or a square, usually closer to the path.
Minimum look-ahead500 m200 mThe matching window ahead of progress: a walker cannot have gone far since the last fix.
Chain distance180 m40 m"이어서 …" joins the next maneuver only when it really is right after this one.

Two defaults the walk keeps are worth noticing. offRouteConfirmations stays at three, and accuracyMargin stays at 20 m, so the off-route threshold is still max(35, accuracy + 20). On an open street with a good fix the walk is judged at 35 m; under tall buildings, where a phone may report 30 m of accuracy, it widens to 50 m by itself. GPS.gov puts a smartphone's open-sky accuracy at about 4.9 m and says it worsens near buildings, bridges and trees. A car outruns that error in a second. A walker needs several seconds to move as far as the error is wide.

Ten-metre steps

The driving plan never speaks a distance under 75 m: its shortest stage, imminent, is always said as "잠시 후", soon. A walk's prepare stage sits at 50 m, so SpokenDistance grew a rule for it: below 75 m, round to ten metres, never less than ten. A prepare line opened by a fix at 47 m says "50미터 앞에서"; one opened at 44 m says "40미터". Above 75 m nothing changed, which a test pins (90 m is still "100미터"), and the closed vocabulary the voice prefetches gained 10 to 70 m in tens.

What a walk does not hear or see

A walk hears no camera warnings. NavigationSession hands its camera watcher the bundled camera index only when route.mode == .driving, and passes nil otherwise, so no camera is placed on a walking route and no camera chunk is prefetched for it. A walk shows no traffic: Apple's map style is built with showsTraffic: mode == .driving. CarPlay refuses a walk outright ("CarPlay is for driving"). And the guided location segment changes its activity type: .fitness, which Core Location calls its pedestrian model, instead of .automotiveNavigation. A code review caught the first version using .otherNavigation, which is boats, trains and planes.

The bug that made the mode a default

The same review found a quieter failure. PromptComposer.start composes the opening line, "안내 시작할게. 도착까지 …", and then the next maneuver. It took an optional config, and every caller left it at the driving default. On a walk, a first turn 500 m away came out as a driving line, "500미터 앞에서 좌회전해", when the walking plan has nothing to say until 200 m. Now the parameter defaults to .forMode(route.mode), and the line becomes "다음 안내까지 500미터". The fix was not "remember to pass walking". It was making the route's own mode the thing you get when you pass nothing.

Let the mode ride the value, not the screen. A mode stored in settings is read by whoever remembers to read it. A mode carried on the route is in hand wherever the route is, and a default derived from it is right even for the caller who forgot.

Code

PathfinderKit, Guidance/GuidanceEngine.swift: the walking config and forMode (verbatim excerpt)·swift
  /// Walking: a person turns within metres, wanders a few metres off a
  /// footpath, and may face any way on the pavement, so the plan is short,
  /// off-route waits longer, and a "wrong way" course is never off-route.
  public static var walking: GuidanceConfig {
    var config = GuidanceConfig()
    config.offRouteDistance = 35
    config.offRouteMinDuration = 10
    config.oppositeCourseDegrees = 181
    config.courseTrustSpeed = 1
    config.arrivalDistance = 20
    config.joinDistance = 100
    config.minLookAhead = 200
    config.highwaySpeed = .infinity
    config.chainDistance = 40
    config.townPlan = [.early: 200, .prepare: 50, .imminent: 20]
    return config
  }

  public static func forMode(_ mode: TravelMode) -> GuidanceConfig {
    mode == .walking ? .walking : GuidanceConfig()
  }
A minimal sketch: both plans spoken stage by stage, and why 181° never fires (compiled with swiftc and run)·swift
import Foundation

// A minimal sketch: the two plans side by side, and what each stage would say.
// SpokenDistance's rounding and Geo.angleBetween are copied from PathfinderKit.
enum Stage: Int, CaseIterable { case early, prepare, imminent }

func spoken(_ metres: Double, stage: Stage) -> String {
  if stage == .imminent { return "잠시 후" }  // imminent is always "soon"
  if metres < 75 { return "\(max(10, Int((metres / 10).rounded()) * 10))미터" }
  if metres < 950 { return "\(min(max(100, Int((metres / 100).rounded()) * 100), 900))미터" }
  let tenths = max(10, Int((metres / 500).rounded()) * 5)  // half-km steps (below 2.9 km)
  return (tenths % 10 == 0 ? String(tenths / 10) : String(format: "%.1f", Double(tenths) / 10)) + "킬로미터"
}

func angleBetween(_ a: Double, _ b: Double) -> Double {
  func norm(_ x: Double) -> Double { let r = x.truncatingRemainder(dividingBy: 360); return r < 0 ? r + 360 : r }
  let d = abs(norm(a) - norm(b))
  return d > 180 ? 360 - d : d
}

let plans: [(String, [Stage: Double], Double)] = [
  ("driving (town)", [.early: 1000, .prepare: 300, .imminent: 90], 120),
  ("walking", [.early: 200, .prepare: 50, .imminent: 20], 181),
]

for (name, plan, opposite) in plans {
  let lines = Stage.allCases.map { "\($0): \(Int(plan[$0]!)) m -> \(spoken(plan[$0]!, stage: $0))" }
  print(name, lines.joined(separator: " | "))
  // Facing exactly back down the route: the largest angle there is.
  let worst = angleBetween(270, 90)
  print("  facing back at speed (\(Int(worst))°) counts as off-route:", worst >= opposite)
}
// The prepare band opens at 50 m; the line says where the fix that opened it was.
for metres in [50.0, 47.0, 44.0] { print("  prepare at \(Int(metres)) m ->", spoken(metres, stage: .prepare)) }

// Output:
// driving (town) early: 1000 m -> 1킬로미터 | prepare: 300 m -> 300미터 | imminent: 90 m -> 잠시 후
//   facing back at speed (180°) counts as off-route: true
// walking early: 200 m -> 200미터 | prepare: 50 m -> 50미터 | imminent: 20 m -> 잠시 후
//   facing back at speed (180°) counts as off-route: false
//   prepare at 50 m -> 50미터
//   prepare at 47 m -> 50미터
//   prepare at 44 m -> 40미터

External links

Exercise

Pick a project of yours with a mode switch: walking and driving, mobile and desktop, free and paid. List every constant that was tuned with the default mode in mind. For each one, write down what it means in the units the user lives in (seconds, taps, screen heights) under each mode. Then find where the mode is read: from a setting at the moment of use, or from the value being worked on. Move one read onto the value.
Hint
Grep for the setting's name. Every hit that is not at the boundary where a request is first built is a place where a reroute, a retry or a background refresh can read the wrong mode, which is exactly how a walk once came back as a driving route.

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.