← Back to index

SubwayLinesService

Spots
CodeWhat It DoesHow It Does It
▶ IMPORTS
import Combine import Foundation import MapKitFramework importsImports Combine, Foundation, MapKit.
▶ TAGGED POLYLINE (CARRIES ITS ROUTE ID FOR COLOUR LOOKUP)
final class SubwayPolyline: MKPolyline { var routeID: String = "" }`SubwayPolyline` classDefines the `SubwayPolyline` class. Conforms to MKPolyline.
▶ SERVICE
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` classDefines the `SubwayLinesService` class. Conforms to ObservableObject.
▶ DECODABLE MODELS
private struct SubwayBundle: Decodable { let routes: [String: [[BundleCoord]]] nonisolated init(from decoder: any Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) self.routes = try c.decode([String: [[BundleCoord]]].self, forKey: .routes) } private enum CodingKeys: String, CodingKey { case routes } }`SubwayBundle` structDefines the `SubwayBundle` struct. Conforms to Decodable.
private struct BundleCoord: Decodable { let lat: Double let lon: Double nonisolated init(from decoder: any Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) self.lat = try c.decode(Double.self, forKey: .lat) self.lon = try c.decode(Double.self, forKey: .lon) } private enum CodingKeys: String, CodingKey { case lat, lon } }`BundleCoord` structDefines the `BundleCoord` struct. Conforms to Decodable.