| @MainActor
final class WatchBusArrivalsService: ObservableObject {
static let shared = WatchBusArrivalsService()
private init() {}
@Published private(set) var stops: [WatchNearbyBusStop] = []
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String? = nil
private let apiKey = "a0ef7464-ed93-468f-9f98-e26336a1d378"
private let baseOBA = "https://bustime.mta.info/api/where"
private let baseSIRI = "https://bustime.mta.info/api/siri"
// MARK: - Public
func refresh(near location: CLLocation) async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
// 1. Find nearby stops via OneBusAway stops-for-location
let lat = location.coordinate.latitude
let lon = location.coordinate.longitude
var nearbyStopIDs: [(id: String, name: String, lat: Double, lon: Double)] = []
var obaComps = URLComponents(string: "\(baseOBA)/stops-for-location.json")!
obaComps.queryItems = [
URLQueryItem(name: "key", value: apiKey),
URLQueryItem(name: "lat", value: "\(lat)"),
URLQueryItem(name: "lon", value: "\(lon)"),
URLQueryItem(name: "radius", value: "400"),
URLQueryItem(name: "maxCount", value: "8"),
]
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 8
cfg.timeoutIntervalForResource = 12
let session = URLSession(configuration: cfg)
defer { session.invalidateAndCancel() }
if let url = obaComps.url,
let (data, _) = try? await session.data(for: URLRequest(url: url, timeoutInterval: 8)),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let data2 = json["data"] as? [String: Any],
let list = data2["stops"] as? [[String: Any]] { // key is "stops", not "list"
for item in list {
// "code" is the plain numeric stop ID used by SIRI MonitoringRef
guard let code = item["code"] as? String,
let name = item["name"] as? String,
let sLat = item["lat"] as? Double,
let sLon = item["lon"] as? Double else { continue }
nearbyStopIDs.append((id: code, name: name, lat: sLat, lon: sLon))
}
}
// Sort by our own computed distance so order is stable across refreshes.
nearbyStopIDs.sort { a, b in
let da = (a.lat - lat) * (a.lat - lat) + (a.lon - lon) * (a.lon - lon)
let db = (b.lat - lat) * (b.lat - lat) + (b.lon - lon) * (b.lon - lon)
return da < db
}
let topStops = Array(nearbyStopIDs.prefix(4))
if topStops.isEmpty {
errorMessage = "No bus stops found nearby"
return
}
// 2. Fetch SIRI stop-monitoring for each stop concurrently
let now = Date().timeIntervalSince1970
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let results: [WatchNearbyBusStop?] = await withTaskGroup(of: WatchNearbyBusStop?.self) { group in
for stop in topStops {
var siriComps = URLComponents(string: "\(baseSIRI)/stop-monitoring.json")!
siriComps.queryItems = [
URLQueryItem(name: "key", value: apiKey),
URLQueryItem(name: "OperatorRef", value: "MTA"),
URLQueryItem(name: "MonitoringRef", value: stop.id),
URLQueryItem(name: "MaximumStopVisits", value: "6"),
]
guard let url = siriComps.url else { continue }
let stopID = stop.id
let stopName = stop.name
group.addTask {
let req = URLRequest(url: url, timeoutInterval: 8)
guard let (data, _) = try? await session.data(for: req),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let siri = json["Siri"] as? [String: Any],
let delivery = siri["ServiceDelivery"] as? [String: Any],
let smdArr = delivery["StopMonitoringDelivery"] as? [[String: Any]],
let smd = smdArr.first,
let visits = smd["MonitoredStopVisit"] as? [[String: Any]]
else { return nil }
var arrivals: [WatchBusArrival] = []
for visit in visits {
guard let mvj = visit["MonitoredVehicleJourney"] as? [String: Any],
let route = (mvj["PublishedLineName"] as? String)
?? (mvj["LineRef"] as? String)
else { continue }
let headsign = (mvj["DestinationName"] as? String) ?? ""
let call = mvj["MonitoredCall"] as? [String: Any]
let timeStr = (call?["ExpectedArrivalTime"] as? String)
?? (call?["AimedArrivalTime"] as? String) ?? ""
guard let date = isoFormatter.date(from: timeStr) else { continue }
let secs = date.timeIntervalSince1970 - now
guard secs >= -60 else { continue }
let cleanRoute = route.contains("_")
? String(route.split(separator: "_", maxSplits: 1).last ?? Substring(route))
: route
arrivals.append(WatchBusArrival(route: cleanRoute,
headsign: headsign,
seconds: secs))
}
guard !arrivals.isEmpty else { return nil }
return WatchNearbyBusStop(id: stopID, name: stopName, arrivals: arrivals)
}
}
var out: [WatchNearbyBusStop?] = []
for await r in group { out.append(r) }
return out
}
// Preserve the OBA distance order
let orderedIDs = topStops.map(\.id)
stops = results
.compactMap { $0 }
.sorted { a, b in
let ia = orderedIDs.firstIndex(of: a.id) ?? Int.max
let ib = orderedIDs.firstIndex(of: b.id) ?? Int.max
return ia < ib
}
if stops.isEmpty {
errorMessage = "No buses found nearby"
}
}
} | Code block | See source code for full implementation. |
| func watchBusRouteColor(for route: String) -> Color {
let up = route.uppercased()
if up.hasPrefix("BX") { return Color(red: 0/255, green: 147/255, blue: 60/255) }
if up.hasPrefix("B") && !up.hasPrefix("BX") { return Color(red: 255/255, green: 99/255, blue: 25/255) }
if up.hasPrefix("Q") { return Color(red: 185/255, green: 51/255, blue: 173/255) }
if up.hasPrefix("S") { return Color(red: 128/255, green: 129/255, blue: 131/255) }
return Color(red: 0/255, green: 57/255, blue: 166/255) // Manhattan / default blue
} | `watchBusRouteColor()` function | Implements `watchBusRouteColor`. Returns `Color`. |