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

Three Map Stacks, One App

~15 min · maps, search, providers, carplay

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

Why one map is not enough

Pathfinder started with one map. Apple Maps was Dad's first choice: no key, no cost, native CarPlay, and his own driving had found Apple's routing in Korea about as good as the domestic maps. Apple is still the canonical child, the shape every other provider is measured against.

Then the world got bigger. Korea has its own map stack, and Dad wanted it at home. A trip to Spain was coming, and abroad the strongest stack is Google's. So the app now carries three, each a whole provider: its own map, its own search, its own routes, moving together.

RegionIts own stackBeside it
KoreaKakao: Kakao's map drawn on the phone with its native key; search, routes and travel times through cwkPippa's maps door, where the REST key lives; guided by Pathfinder's own engineApple, guided by Pathfinder's engine
AbroadGoogle: Maps SDK and Navigation SDK on the phone; Google finds, draws and guides its own routes, and Pathfinder follows its turn feedApple, guided by Pathfinder's engine

What a stack rendered stays with it: a search's result list and a route's line and words are never drawn or guided by another provider. A destination is different. A place Dad saved, one Pippa handed over, a pin he dropped is just a name and a coordinate, and every stack routes to it. (Until that rule existed, Kakao refused Dad's own saved Home because Apple had found it.) Track 2 takes the adapter apart; this lesson is about choosing between them and the search problems that forced the choice.

Choosing a stack per region

The region comes first. MapRegion.near asks SouthKorea.contains, which is a hint, never a border: a plain box around the peninsula would swallow Tsushima and northern Kyushu, and a trip to Fukuoka would open on Kakao's empty map. So the box is cut south of Busan where the strait narrows, and small boxes add Ulleungdo and Dokdo. Dad can also set the region by hand: automatic, Korea or overseas.

Then the stack. Until Dad picks one, a region opens on its own map only when that map's key is actually on the phone, and on Apple otherwise. So the default never opens on a map the phone has no key for. Once he picks, the pick is kept as it is, whatever keys come and go: his choice outranks the app's guess.

A coarse hint may choose a default; it may never claim coverage. "This point is probably Korea" is good enough to pick which map opens. It is not evidence that the map will route there, and the code never treats it as such.

When every Hangul search said "not found"

The first TestFlight test found a hole in the canonical child. On Dad's phone, on-device MapKit answered every Hangul query with MKErrorDomain 4 (GEOError -8), while English queries worked. The data was there: Apple's Maps Server API found the same places. So since build 3, a search on Apple's stack asks Pippa's Apple Maps door first, whenever Dad's PIN is on the phone and nothing is being simulated. An empty or failed answer falls through to MapKit on the device. 백석역 came back on the first try. Abbreviations are still a gap ("한국외대" found nothing), which is part of why Korea also got its own stack.

There is a quieter fix inside the Apple child: MapKit's placemarkNotFound is now an answer, an empty list, not a failure. "Nothing here" and "the search broke" deserve different words on the screen.

Search near Dad

Then the results came back from the wrong continent. Sending Dad's position as a lone search location barely steers the answer: "호수공원" asked in Ilsan, home of the city's famous Lake Park, answered a park in Laos. "Hospital" near Ilsan answered one 9,614 km away; "병원", a village 154 km away; "편의점" by Kyoto Station, a shop in Laos (todo #300).

The fix asks twice. First with Dad's region as a hint, which keeps a far name far: ask for 부산역 and you want Busan, not something nearby with a similar name. If nothing comes back within 100 km, the generic word was read as a far place, so the same region is asked again as a fence (MapKit's regionPriority = .required, iOS 18 and later). The two answers merge: a far place in Dad's own country first, then the nearby places nearest first, then far places abroad. cwkPippa's search_near applies the same rule on the server's side of the door, so both paths answer alike.

CarPlay: written, not driven

Be precise about this one. The CarPlay scene exists in source: a scene delegate with a map template, Search and My Places buttons, an End button, and the same app model the phone uses, so the phone and the car would show one trip. It is not a working feature. The app's default signing carries no entitlements at all (the build setting is empty, with a comment calling CarPlay future scaffolding); the signing profile on hand lacks Apple's CarPlay maps entitlement; and Dad's present car does not support CarPlay anyway. No connection to a car, real or simulated, has been verified. Dad called it future readiness, and that is the honest name for it.

Code

cwkPathfinder, PathfinderKit/Sources/PathfinderKit/Providers/MapProvider.swift: the region and its hint (verbatim, lines 24-50)·swift
public enum MapRegion: String, Codable, CaseIterable, Sendable {
  case korea, overseas
  public var providers: [MapProviderID] { self == .korea ? [.kakao, .apple] : [.google, .apple] }
  /// A coarse default selection hint. It never asserts routing coverage.
  public static func near(_ point: Coordinate?) -> MapRegion? {
    guard let point else { return nil }
    return SouthKorea.contains(point) ? .korea : .overseas
  }
}

/// Where Korea's providers have a map. A hint for choosing the stack, never a
/// political boundary; cwkPippa's `pathfinder_maps.korea` is the same shape.
///
/// A plain box around the peninsula takes in Tsushima and northern Kyushu
/// (Fukuoka is 33.59°N 130.40°E), and a trip to Japan would open on Kakao's
/// empty map. South of Busan the strait is narrow: Geoje ends at 128.75°E and
/// Tsushima begins at 129.17°E.
public enum SouthKorea {
  public static func contains(_ point: Coordinate) -> Bool {
    // Ulleungdo and Dokdo lie east of the mainland's box.
    if (37.4...37.6).contains(point.lat) && (130.7...131.0).contains(point.lon) { return true }
    if (37.2...37.3).contains(point.lat) && (131.8...131.9).contains(point.lon) { return true }
    guard (33.0...38.7).contains(point.lat), (124.5...129.7).contains(point.lon) else { return false }
    if point.lat < 34.9 && point.lon > 128.95 { return false }
    return true
  }
}
cwkPathfinder, clients/apple/PathfinderMobile/Sources/App/AppModel.swift: Pippa's Apple Maps door first, MapKit as the fallback (verbatim)·swift
    let selected = provider
    let center = searchCenter
    let useAppleDoor = !isSimulating && selected.id == "apple" && hasPIN
    let link = pippa
    let task = Task {
      if useAppleDoor, let found = try? await link.search(text, near: center), !found.isEmpty {
        return found
      }
      return try await selected.search(text, near: center, limit: 15)
    }
A minimal sketch of the hint-then-fence merge, with synthetic places around Kyoto Station (compiled with swiftc and run; output at the end)·swift
import Foundation

// Region as a hint first; if nothing lands within reach, ask again with the
// region as a fence, then merge: far places in Dad's own country, then the
// nearby ones nearest first, then far places abroad. Synthetic data around a
// public landmark (Kyoto Station).

struct Place { var id: String; var name: String; var lat: Double; var lon: Double; var country: String }

func distance(_ a: (Double, Double), _ b: Place) -> Double {
  let r = 6_371_000.0, rad = Double.pi / 180
  let dLat = (b.lat - a.0) * rad, dLon = (b.lon - a.1) * rad
  let h = sin(dLat / 2) * sin(dLat / 2) + cos(a.0 * rad) * cos(b.lat * rad) * sin(dLon / 2) * sin(dLon / 2)
  return 2 * r * asin(min(1, sqrt(h)))
}

let reach = 100_000.0

func reaches(_ places: [Place], near: (Double, Double)) -> Bool {
  places.contains { distance(near, $0) <= reach }
}

func merge(hint: [Place], fence: [Place], near: (Double, Double)) -> [Place] {
  let nearby = fence.sorted { distance(near, $0) < distance(near, $1) }
  let held = Set(nearby.map(\.id))
  let far = hint.filter { !held.contains($0.id) }
  var counts: [String: Int] = [:]
  for place in nearby { counts[place.country, default: 0] += 1 }
  guard let home = counts.max(by: { $0.value != $1.value ? $0.value < $1.value : $0.key > $1.key })?.key
  else { return nearby + far }
  return far.filter { $0.country == home } + nearby + far.filter { $0.country != home }
}

let kyotoStation = (34.9858, 135.7588)
let hint = [
  Place(id: "h1", name: "a shop in Vientiane", lat: 17.9757, lon: 102.6331, country: "LA"),
  Place(id: "h2", name: "a shop by Tokyo Station", lat: 35.6812, lon: 139.7671, country: "JP"),
]
print("hint reaches Dad:", reaches(hint, near: kyotoStation))
let fence = [
  Place(id: "f1", name: "corner shop B", lat: 34.9890, lon: 135.7620, country: "JP"),
  Place(id: "f2", name: "corner shop A", lat: 34.9862, lon: 135.7593, country: "JP"),
]
for place in merge(hint: hint, fence: fence, near: kyotoStation) {
  print(String(format: "%8.1f km  %@", distance(kyotoStation, place) / 1000, place.name))
}

// Output:
// hint reaches Dad: false
//    371.7 km  a shop by Tokyo Station
//      0.1 km  corner shop A
//      0.5 km  corner shop B
//   3774.3 km  a shop in Vientiane

External links

Exercise

Pick any search box you own that takes a location hint (a store locator, an address field, a map search). Run five generic words ("hospital", "pharmacy", "park", "station", "café") from two places far apart, and record the distance to the first result each time. Then implement the two-step version: ask with the hint, and if nothing lands within a reach you choose, ask again with the region required, merging own-country far results, then nearby ones, then far ones abroad. Run the same ten searches and compare.
Hint
Choose the reach from the use: Pathfinder uses 100 km because anything closer counts as "near Dad" for a drive. Keep the specific names in your test set too (a city's main station, a famous landmark), because a fence that is too eager turns a far name into a nearby lookalike; the hint-first order is what protects those.

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.