| final class SubwayLinesService: ObservableObject {
static let shared = SubwayLinesService()
private init() {}
@Published private(set) var polylines: [SubwayPolyline] = []
private var loaded = false
private let routeAliases: [String: String] = [
"FS.N01R": "FS", "FS.S01R": "FS",
"GS.N01R": "GS", "GS.N04R": "GS", "GS.S01R": "GS", "GS.S04R": "GS"
]
/// Lateral spacing in metres between co-located routes.
private let laneSpacingMetres: Double = 4.0
// MARK: - Public API
/// Releases all decoded polyline data to free memory under pressure.
/// Resets the loaded flag so loadIfNeeded() will reload on next map open.
func clearData() {
polylines = []
loaded = false
}
/// Loads and processes subway lines on a background thread, then publishes
/// the result on the main thread. Safe to call multiple times — only runs once.
///
/// On the first launch after install (or after an app update that changes the
/// bundle file), geometry is computed from scratch and the result is cached to
/// disk. On all subsequent launches the cache is read directly, skipping the
/// JSON decode and all geometry work entirely.
func loadIfNeeded() async {
guard !loaded else { return }
loaded = true // set immediately to prevent concurrent re-entry
guard let bundleURL = Bundle.main.url(forResource: "subway_bundle", withExtension: "json") else {
print("🚇 SubwayLinesService: subway_bundle.json not found in bundle")
return
}
let result: [SubwayPolyline] = await withCheckedContinuation { cont in
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self else { cont.resume(returning: []); return }
// Use the bundle file's byte size as a cheap cache-validity key.
// It changes whenever the app ships an updated subway_bundle.json.
let sourceSize = (try? bundleURL.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
if sourceSize > 0, let cached = self.loadFromCache(sourceSize: sourceSize) {
print("🚇 SubwayLinesService: \(cached.count) polylines loaded from cache (skipped geometry)")
cont.resume(returning: cached)
return
}
// Cache miss — decode the bundle and compute offset geometry
guard let data = try? Data(contentsOf: bundleURL),
let bundle = try? JSONDecoder().decode(SubwayBundle.self, from: data) else {
print("🚇 SubwayLinesService: failed to decode subway_bundle.json")
cont.resume(returning: [])
return
}
var raw: [SubwayPolyline] = []
for (rawID, branches) in bundle.routes {
let displayID = self.routeAliases[rawID] ?? rawID
for branch in branches {
guard branch.count >= 2 else { continue }
var coords = branch.map { CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon) }
let poly = SubwayPolyline(coordinates: &coords, count: coords.count)
poly.routeID = displayID
raw.append(poly)
}
}
let computed = self.applyParallelOffsets(raw)
// Persist so future launches skip this work entirely
self.saveToCache(computed, sourceSize: sourceSize)
cont.resume(returning: computed)
}
}
await MainActor.run {
self.polylines = result
print("🚇 SubwayLinesService: \(result.count) polyline segments ready")
}
}
// MARK: - Disk cache
/// Versioned cache file in the system Caches directory (cleared by iOS under storage
/// pressure; the service simply recomputes if it's gone).
private static var cacheURL: URL? {
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("subway_lines_v1.json")
}
/// Flat, compact representation of one offset-adjusted polyline.
private struct CachedPolyline: Codable {
let routeID: String
/// Coordinates stored as interleaved doubles: lat0, lon0, lat1, lon1, …
let coords: [Double]
}
/// Top-level cache envelope — stores the source-file size alongside the data
/// so stale caches from a previous app version are automatically rejected.
private struct PolylineCache: Codable {
let sourceSize: Int
let polylines: [CachedPolyline]
}
private func loadFromCache(sourceSize: Int) -> [SubwayPolyline]? {
guard let url = Self.cacheURL,
let data = try? Data(contentsOf: url),
let cache = try? JSONDecoder().decode(PolylineCache.self, from: data),
cache.sourceSize == sourceSize
else { return nil }
return cache.polylines.compactMap { entry in
guard entry.coords.count >= 4,
entry.coords.count.isMultiple(of: 2) else { return nil }
var clCoords = stride(from: 0, to: entry.coords.count, by: 2).map {
CLLocationCoordinate2D(latitude: entry.coords[$0],
longitude: entry.coords[$0 + 1])
}
let poly = SubwayPolyline(coordinates: &clCoords, count: clCoords.count)
poly.routeID = entry.routeID
return poly
}
}
private func saveToCache(_ polys: [SubwayPolyline], sourceSize: Int) {
guard let url = Self.cacheURL else { return }
let entries = polys.map { poly -> CachedPolyline in
let pts = coordinates(of: poly)
var flat = [Double]()
flat.reserveCapacity(pts.count * 2)
for pt in pts { flat.append(pt.latitude); flat.append(pt.longitude) }
return CachedPolyline(routeID: poly.routeID, coords: flat)
}
let cache = PolylineCache(sourceSize: sourceSize, polylines: entries)
if let data = try? JSONEncoder().encode(cache) {
try? data.write(to: url, options: .atomic)
print("🚇 SubwayLinesService: cache written (\(data.count / 1024) KB)")
}
}
// MARK: - Parallel offset
private func applyParallelOffsets(_ polys: [SubwayPolyline]) -> [SubwayPolyline] {
// 1. Index every segment: canonical key → [poly indices that contain it]
var segIdx: [String: [Int]] = [:]
for (i, poly) in polys.enumerated() {
let pts = coordinates(of: poly)
for j in 0..<(pts.count - 1) {
segIdx[segKey(pts[j], pts[j + 1]), default: []].append(i)
}
}
// 2. For each shared segment, group by ROUTE ID (not poly index) so that
// multiple branches of the same route all receive the same offset.
// Sort unique route IDs alphabetically and assign centred offsets.
var offsetFor: [String: [Int: Double]] = [:] // segKey → [polyIdx → metres]
for (key, idxs) in segIdx where idxs.count > 1 {
let uniqueRoutes = Array(Set(idxs.map { polys[$0].routeID })).sorted()
guard uniqueRoutes.count > 1 else { continue } // same route, no offset needed
let n = Double(uniqueRoutes.count)
for (rank, routeID) in uniqueRoutes.enumerated() {
let offset = (Double(rank) - (n - 1) / 2.0) * laneSpacingMetres
for idx in idxs where polys[idx].routeID == routeID {
offsetFor[key, default: [:]][idx] = offset
}
}
}
// 3. Rebuild each polyline: for every vertex, apply the offset of the
// OUTGOING segment (or incoming for the last vertex). Using a single
// segment's direction — rather than averaging adjacent segments —
// avoids miter-join squiggles at corners and route-count transitions.
return polys.enumerated().map { (i, poly) in
let pts = coordinates(of: poly)
var newCoords = [CLLocationCoordinate2D]()
for j in 0..<pts.count {
// Pick the segment whose direction we'll use for the perpendicular
let (a, b): (CLLocationCoordinate2D, CLLocationCoordinate2D) =
j < pts.count - 1
? (pts[j], pts[j + 1]) // outgoing
: (pts[j - 1], pts[j]) // incoming (last vertex)
let key = segKey(a, b)
if let off = offsetFor[key]?[i] {
let perp = perpUnit(from: a, to: b)
newCoords.append(CLLocationCoordinate2D(
latitude: pts[j].latitude + perp.lat * off,
longitude: pts[j].longitude + perp.lon * off
))
} else {
newCoords.append(pts[j])
}
}
var coords = newCoords
let p = SubwayPolyline(coordinates: &coords, count: coords.count)
p.routeID = poly.routeID
return p
}
}
// MARK: - Geometry helpers
private func coordinates(of poly: MKPolyline) -> [CLLocationCoordinate2D] {
var coords = [CLLocationCoordinate2D](repeating: .init(), count: poly.pointCount)
poly.getCoordinates(&coords, range: NSRange(location: 0, length: poly.pointCount))
return coords
}
/// Order-independent segment key rounded to ~11 m (4 decimal places).
private func segKey(_ a: CLLocationCoordinate2D, _ b: CLLocationCoordinate2D) -> String {
let s1 = String(format: "%.4f,%.4f", a.latitude, a.longitude)
let s2 = String(format: "%.4f,%.4f", b.latitude, b.longitude)
return s1 <= s2 ? "\(s1)|\(s2)" : "\(s2)|\(s1)"
}
/// Unit vector perpendicular (90° CCW) to the segment a→b, in (Δlat, Δlon) per metre.
private func perpUnit(from a: CLLocationCoordinate2D,
to b: CLLocationCoordinate2D) -> (lat: Double, lon: Double) {
let k = 111_319.5
let cosLat = cos((a.latitude + b.latitude) * 0.5 * .pi / 180)
let dx = (b.longitude - a.longitude) * cosLat * k // east, metres
let dy = (b.latitude - a.latitude) * k // north, metres
let len = sqrt(dx * dx + dy * dy)
guard len > 1e-6 else { return (0, 0) }
let px = -dy / len // east component of unit perp
let py = dx / len // north component of unit perp
return (lat: py / k, lon: px / (k * cosLat))
}
} | `SubwayLinesService` class | Defines the `SubwayLinesService` class. Conforms to ObservableObject. |