← Back to index

SubwayStationService

Spots
CodeWhat It DoesHow It Does It
▶ IMPORTS
import Foundation import Combine import CoreLocation import SwiftUIFramework importsImports Foundation, Combine, CoreLocation, SwiftUI.
▶ MODELS
struct SubwayEntrance: Codable { let latitude: Double let longitude: Double let stationName: String let entranceType: String? // "Stair", "Elevator", "Escalator", etc. — nil = legacy cache var isElevator: Bool { entranceType?.localizedCaseInsensitiveContains("elevator") == true } var coordinate: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: latitude, longitude: longitude) } }`SubwayEntrance` structDefines the `SubwayEntrance` struct. Conforms to Codable.
struct SubwayStation: Codable, Equatable { let name: String let latitude: Double let longitude: Double let lines: [String] var stopIDs: [String] // GTFS parent stop IDs (no N/S suffix), one per merged stop var coordinate: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: latitude, longitude: longitude) } }`SubwayStation` structDefines the `SubwayStation` struct. Conforms to Codable, Equatable.
▶ LINE COLORS (OFFICIAL MTA PALETTE)
func subwayLineColor(for line: String) -> Color { switch line { case "1", "2", "3": return Color(red: 238/255, green: 53/255, blue: 46/255) case "4", "5", "6": return Color(red: 0/255, green: 147/255, blue: 60/255) case "7": return Color(red: 185/255, green: 51/255, blue: 173/255) case "A", "C", "E": return Color(red: 0/255, green: 57/255, blue: 166/255) case "B", "D", "F", "M": return Color(red: 255/255, green: 99/255, blue: 25/255) case "G": return Color(red: 108/255, green: 190/255, blue: 69/255) case "J", "Z": return Color(red: 153/255, green: 102/255, blue: 51/255) case "L": return Color(red: 167/255, green: 169/255, blue: 172/255) case "N", "Q", "R", "W": return Color(red: 252/255, green: 204/255, blue: 10/255) case "S", "FS", "GS", "H": return Color(red: 128/255, green: 129/255, blue: 131/255) default: return .gray } }`subwayLineColor()` functionImplements `subwayLineColor`. Returns `Color`.
func subwayLineTextColor(for line: String) -> Color { switch line { case "N", "Q", "R", "W": return .black default: return .white } }`subwayLineTextColor()` functionImplements `subwayLineTextColor`. Returns `Color`.
▶ SERVICE
class SubwayStationService: ObservableObject { static let shared = SubwayStationService() @Published private(set) var stations: [SubwayStation] = [] @Published private(set) var entrances: [SubwayEntrance] = [] private(set) var isLoaded = false private var entrancesAttempted = false private let cacheKey = "subwayStationsData" private let cacheDateKey = "subwayStationsCacheDate" private let cacheVersionKey = "subwayStationsCacheVersion" private let cacheVersion = "v13" private let entranceCacheKey = "subwayEntrancesData" private let entranceCacheDateKey = "subwayEntrancesCacheDate" private let entranceCacheVersionKey = "subwayEntrancesCacheVersion" private let entranceCacheVersion = "v3" // bumped: added entranceType field private let maxCacheAge: TimeInterval = 30 * 24 * 3600 // 30 days (stations) private let entranceMaxCacheAge: TimeInterval = 90 * 24 * 3600 // 90 days (entrances rarely change) /// Merge stops whose centroids are within this many metres of each other. private let mergeRadius: Double = 100 private init() {} /// Releases all decoded station and entrance data to free memory under pressure. /// Resets the loaded flags so loadIfNeeded() will reload on next map open. func clearData() { stations = [] entrances = [] isLoaded = false entrancesAttempted = false } func loadIfNeeded() async { guard !isLoaded else { return } // Use cache when available — both datasets (stations and entrances) change // very rarely, so hitting the API on every launch just burns rate-limit quota. if let cachedStations = loadFromCache(), let cachedEntrances = loadEntrancesFromCache() { await MainActor.run { self.stations = cachedStations self.entrances = cachedEntrances self.isLoaded = true self.entrancesAttempted = true } print("🚇 loadIfNeeded: restored \(cachedStations.count) stations + \(cachedEntrances.count) entrances from cache") return } await buildStations() } /// Loads entrance data, using the disk cache if available. /// Called when the Entrances toggle is turned on and `entrances` is still empty. func loadEntrancesIfNeeded() async { guard !entrancesAttempted || entrances.isEmpty else { return } entrancesAttempted = true // Cache hit — no API call needed if let cached = loadEntrancesFromCache(), !cached.isEmpty { await MainActor.run { self.entrances = cached } print("🚇 loadEntrancesIfNeeded: restored \(cached.count) entrances from cache") return } // Cache miss — read from bundle print("🚇 loadEntrancesIfNeeded: cache miss, reading from bundle…") let (_, rawEntrances) = await fetchEntranceData() print("🚇 loadEntrancesIfNeeded: got \(rawEntrances.count) entrances from bundle") guard !rawEntrances.isEmpty else { return } saveEntrancesToCache(rawEntrances) await MainActor.run { self.entrances = rawEntrances } } func nearest(to coordinate: CLLocationCoordinate2D, within meters: Double = 400) -> SubwayStation? { let loc = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) return stations .map { ($0, CLLocation(latitude: $0.latitude, longitude: $0.longitude).distance(from: loc)) } .filter { $0.1 <= meters } .min(by: { $0.1 < $1.1 }) .map(\.0) } /// Returns a deduplicated, sorted list of subway lines served by stations /// within `radiusMeters` of `coordinate`. func lines(near coordinate: CLLocationCoordinate2D, radiusMeters: Double = 400) -> [String] { guard isLoaded, !stations.isEmpty else { return [] } let loc = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) var seen = Set<String>() var result: [String] = [] stations .filter { CLLocation(latitude: $0.latitude, longitude: $0.longitude).distance(from: loc) <= radiusMeters } .flatMap(\.lines) .forEach { line in if seen.insert(line).inserted { result.append(line) } } return result.sorted(by: sortLines) } // MARK: - Bundle types private struct BundleFile: Decodable { let stops: [BundleStop] } private struct BundleStop: Decodable { let id: String let name: String let lat: Double let lon: Double } // MARK: - Open Data types private struct GTFSEntrance: Decodable { let gtfs_stop_id: String? let station_name: String? let entrance_latitude: String? let entrance_longitude: String? } // MARK: - Build private func buildStations() async { print("🚇 buildStations v9: starting — cache bypassed, loading fresh") // 1. Load bundle stops (stop IDs + names; we'll override coords from entrance data below) guard let url = Bundle.main.url(forResource: "subway_bundle", withExtension: "json"), let data = try? Data(contentsOf: url), let bundle = try? JSONDecoder().decode(BundleFile.self, from: data) else { print("🚇 buildStations: could not load subway_bundle.json") return } let bundleStops = bundle.stops print("🚇 buildStations: \(bundleStops.count) bundle stops loaded") // 2. Fetch both datasets concurrently async let linesTask = fetchLines() async let entrancesTask = fetchEntranceData() let (linesByStopID, (entranceCoordsByStopID, rawEntrances)) = await (linesTask, entrancesTask) print("🚇 buildStations: \(linesByStopID.count) stops with lines, \(entranceCoordsByStopID.count) stops with entrance coords, \(rawEntrances.count) raw entrances") // 3. Enrich each bundle stop with its lines and street-level coordinates. // bundleLat/bundleLon are the original bundle coordinates, kept separately so // the proximity-merge step uses them (they place co-located stations within // metres of each other) rather than entrance-averaged coords (which can // spread the same complex across 300m+ when entrances cluster on opposite // sides of a block — e.g. 59 St-Columbus Circle IRT vs IND). struct RawStop { let id: String let name: String let lat: Double // entrance-averaged (used for final dot position) let lon: Double let bundleLat: Double // original bundle coord (used for proximity merge) let bundleLon: Double let lines: [String] } let enriched: [RawStop] = bundleStops.map { s in let lines = linesByStopID[s.id].map { dedup($0).sorted(by: sortLines) } ?? [] // Use street-level entrance coords if available; fall back to bundle coords let coord = entranceCoordsByStopID[s.id] let lat = coord?.lat ?? s.lat let lon = coord?.lon ?? s.lon return RawStop(id: s.id, name: s.name, lat: lat, lon: lon, bundleLat: s.lat, bundleLon: s.lon, lines: lines) } // 4. Proximity-merge stops within mergeRadius metres. // Use bundle coordinates for the distance check — they correctly place // co-located stations (e.g. "125" and "A24" are ~17m apart in the bundle) // even when entrance averaging would push them farther apart. // Build adjacency list, then find connected components via BFS. var adj = Array(repeating: [Int](), count: enriched.count) for i in 0..<enriched.count { let li = CLLocation(latitude: enriched[i].bundleLat, longitude: enriched[i].bundleLon) for j in (i+1)..<enriched.count { let lj = CLLocation(latitude: enriched[j].bundleLat, longitude: enriched[j].bundleLon) if li.distance(from: lj) <= mergeRadius { adj[i].append(j) adj[j].append(i) } } } var visited = Array(repeating: false, count: enriched.count) var groups: [[Int]] = [] for start in 0..<enriched.count { guard !visited[start] else { continue } var component: [Int] = [] var queue = [start] visited[start] = true while !queue.isEmpty { let node = queue.removeFirst() component.append(node) for neighbor in adj[node] where !visited[neighbor] { visited[neighbor] = true queue.append(neighbor) } } groups.append(component) } let result: [SubwayStation] = groups.compactMap { indices in guard !indices.isEmpty else { return nil } // Centroid let lat = indices.map { enriched[$0].lat }.reduce(0, +) / Double(indices.count) let lon = indices.map { enriched[$0].lon }.reduce(0, +) / Double(indices.count) // Merge lines var allLines: [String] = [] for i in indices { allLines.append(contentsOf: enriched[i].lines) } let mergedLines = dedup(allLines).sorted(by: sortLines) // Best name: prefer longer/more-descriptive names (e.g. "14 St-Union Sq" over "14 St") let name = indices.map { enriched[$0].name }.max(by: { $0.count < $1.count }) ?? "Station" // Collect all GTFS stop IDs for this merged group (used for real-time arrivals) let stopIDs = indices.map { enriched[$0].id }.filter { !$0.isEmpty } return SubwayStation(name: name, latitude: lat, longitude: lon, lines: mergedLines, stopIDs: stopIDs) }.sorted { $0.name < $1.name } await MainActor.run { self.stations = result self.entrances = rawEntrances self.isLoaded = true self.entrancesAttempted = true } saveToCache(result) if !rawEntrances.isEmpty { saveEntrancesToCache(rawEntrances) } print("🚇 SubwayStationService: built \(result.count) station groups, \(rawEntrances.count) entrances") } // MARK: - Fetch helpers /// Reads daytime route data from the bundled entrances.json (Socrata rows.json export). /// The entrances dataset includes a daytime_routes column, so no separate file is needed. private func fetchLines() async -> [String: [String]] { guard let bundleURL = Bundle.main.url(forResource: "entrances", withExtension: "json"), let data = try? Data(contentsOf: bundleURL) else { print("🚇 fetchLines: entrances.json not found in bundle") return [:] } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let meta = json["meta"] as? [String: Any], let view = meta["view"] as? [String: Any], let columns = view["columns"] as? [[String: Any]], let rows = json["data"] as? [[Any]] else { print("🚇 fetchLines: failed to parse Socrata structure in entrances.json") return [:] } let fieldNames = columns.compactMap { $0["fieldName"] as? String } guard let stopIdx = fieldNames.firstIndex(of: "gtfs_stop_id"), let routesIdx = fieldNames.firstIndex(of: "daytime_routes") else { print("🚇 fetchLines: missing required columns in entrances.json") return [:] } var result: [String: [String]] = [:] for row in rows { guard stopIdx < row.count, routesIdx < row.count, let sid = row[stopIdx] as? String, !sid.isEmpty else { continue } let lines = parseLines(row[routesIdx] as? String ?? "") guard !lines.isEmpty else { continue } let normID = normaliseStopID(sid) var existing = result[normID] ?? [] existing.append(contentsOf: lines) result[normID] = existing } // The MTA entrances dataset assigns all routes of an entire complex to every // entrance in that complex. Override the stops we know are wrong. // Full line set for the Times Square complex — all four stops are part of the same // transfer hub and should display the complete route list. let tsLines = ["1", "2", "3", "7", "N", "Q", "R", "W", "S"] let lineOverrides: [String: [String]] = [ "A27": ["A", "C", "E"], // Port Authority — not the whole 42nd St complex "127": tsLines, // Times Sq IRT "725": tsLines, // Times Sq 7 line "R16": tsLines, // Times Sq BMT "902": tsLines, // GS shuttle – Times Sq end ] for (sid, lines) in lineOverrides { result[sid] = lines } print("🚇 fetchLines: \(result.count) stops with lines from bundle") return result } private struct AvgCoord { let lat: Double; let lon: Double } /// Reads entrance data from the bundled entrances.json (Socrata rows.json export). /// Returns averaged coords per stop ID and all raw entrances. No network call made. private func fetchEntranceData() async -> ([String: AvgCoord], [SubwayEntrance]) { guard let bundleURL = Bundle.main.url(forResource: "entrances", withExtension: "json"), let data = try? Data(contentsOf: bundleURL) else { print("🚇 fetchEntranceData: entrances.json not found in app bundle") return ([:], []) } // Parse Socrata rows.json format: { "meta": { "view": { "columns": [...] } }, "data": [[...], ...] } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let meta = json["meta"] as? [String: Any], let view = meta["view"] as? [String: Any], let columns = view["columns"] as? [[String: Any]], let rows = json["data"] as? [[Any]] else { print("🚇 fetchEntranceData: failed to parse Socrata structure in entrances.json") return ([:], []) } let fieldNames = columns.compactMap { $0["fieldName"] as? String } guard let stopIdx = fieldNames.firstIndex(of: "gtfs_stop_id"), let nameIdx = fieldNames.firstIndex(of: "stop_name"), let latIdx = fieldNames.firstIndex(of: "entrance_latitude"), let lonIdx = fieldNames.firstIndex(of: "entrance_longitude") else { print("🚇 fetchEntranceData: entrances.json missing required columns") return ([:], []) } let typeIdx = fieldNames.firstIndex(of: "entrance_type") var sumLat: [String: Double] = [:] var sumLon: [String: Double] = [:] var count: [String: Int] = [:] var entrances: [SubwayEntrance] = [] var skipped = 0 for row in rows { guard stopIdx < row.count, latIdx < row.count, lonIdx < row.count, let sid = row[stopIdx] as? String, !sid.isEmpty, let latStr = row[latIdx] as? String, let lat = Double(latStr), let lonStr = row[lonIdx] as? String, let lon = Double(lonStr) else { skipped += 1 continue } let name = nameIdx < row.count ? (row[nameIdx] as? String ?? "") : "" let type = typeIdx.flatMap { $0 < row.count ? row[$0] as? String : nil } let normID = normaliseStopID(sid) sumLat[normID, default: 0] += lat sumLon[normID, default: 0] += lon count[normID, default: 0] += 1 entrances.append(SubwayEntrance(latitude: lat, longitude: lon, stationName: name, entranceType: type)) } print("🚇 fetchEntranceData: \(entrances.count) entrances from bundle, \(skipped) skipped") var coords: [String: AvgCoord] = [:] for (sid, n) in count where n > 0 { coords[sid] = AvgCoord(lat: sumLat[sid]! / Double(n), lon: sumLon[sid]! / Double(n)) } return (coords, entrances) } // MARK: - Helpers /// Strip trailing direction letter from GTFS stop IDs (e.g. "A31N" → "A31", "132S" → "132"). private func normaliseStopID(_ sid: String) -> String { if let last = sid.last, last == "N" || last == "S" { let trimmed = String(sid.dropLast()) // Only strip if what remains looks like a real stop ID (non-empty, not purely numeric suffix) if !trimmed.isEmpty { return trimmed } } return sid } private func parseLines(_ raw: String) -> [String] { raw.components(separatedBy: .whitespaces) .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } } private func dedup(_ lines: [String]) -> [String] { var seen = Set<String>() return lines.filter { seen.insert($0).inserted } } private func sortLines(_ a: String, _ b: String) -> Bool { let order = ["1","2","3","4","5","6","7","A","C","E","B","D","F","M","G","J","Z","L","N","Q","R","W","S","FS","GS","H"] return (order.firstIndex(of: a) ?? 99) < (order.firstIndex(of: b) ?? 99) } // MARK: - Cache private func saveToCache(_ stations: [SubwayStation]) { if let data = try? JSONEncoder().encode(stations) { UserDefaults.standard.set(data, forKey: cacheKey) UserDefaults.standard.set(Date(), forKey: cacheDateKey) UserDefaults.standard.set(cacheVersion, forKey: cacheVersionKey) } } private func loadFromCache() -> [SubwayStation]? { guard UserDefaults.standard.string(forKey: cacheVersionKey) == cacheVersion else { UserDefaults.standard.removeObject(forKey: cacheKey) UserDefaults.standard.removeObject(forKey: cacheDateKey) UserDefaults.standard.removeObject(forKey: cacheVersionKey) return nil } guard let data = UserDefaults.standard.data(forKey: cacheKey), let date = UserDefaults.standard.object(forKey: cacheDateKey) as? Date, Date().timeIntervalSince(date) < maxCacheAge else { return nil } return try? JSONDecoder().decode([SubwayStation].self, from: data) } private func saveEntrancesToCache(_ entrances: [SubwayEntrance]) { if let data = try? JSONEncoder().encode(entrances) { UserDefaults.standard.set(data, forKey: entranceCacheKey) UserDefaults.standard.set(Date(), forKey: entranceCacheDateKey) UserDefaults.standard.set(entranceCacheVersion, forKey: entranceCacheVersionKey) } } private func loadEntrancesFromCache() -> [SubwayEntrance]? { guard UserDefaults.standard.string(forKey: entranceCacheVersionKey) == entranceCacheVersion else { UserDefaults.standard.removeObject(forKey: entranceCacheKey) UserDefaults.standard.removeObject(forKey: entranceCacheDateKey) UserDefaults.standard.removeObject(forKey: entranceCacheVersionKey) return nil } guard let data = UserDefaults.standard.data(forKey: entranceCacheKey), let date = UserDefaults.standard.object(forKey: entranceCacheDateKey) as? Date, Date().timeIntervalSince(date) < entranceMaxCacheAge else { return nil } return try? JSONDecoder().decode([SubwayEntrance].self, from: data) } }`SubwayStationService` classDefines the `SubwayStationService` class. Conforms to ObservableObject.