| 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()` function | Implements `subwayLineColor`. Returns `Color`. |
| 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` class | Defines the `SubwayStationService` class. Conforms to ObservableObject. |