← Back to index

NearbyMapView

Spots
CodeWhat It DoesHow It Does It
▶ IMPORTS
import SwiftUI import MapKit import CoreLocation import ContactsFramework importsImports SwiftUI, MapKit, CoreLocation, Contacts.
▶ SEARCH RESULT ANNOTATION
class SearchResultAnnotation: NSObject, MKAnnotation { let mapItem: MKMapItem var coordinate: CLLocationCoordinate2D { mapItem.location.coordinate } var title: String? { mapItem.name } init(_ item: MKMapItem) { self.mapItem = item } }`SearchResultAnnotation` classDefines the `SearchResultAnnotation` class. Conforms to NSObject, MKAnnotation.
▶ SPOT ANNOTATION
class SpotAnnotation: NSObject, MKAnnotation { let spotID: UUID let coordinate: CLLocationCoordinate2D let title: String? let isTried: Bool let priority: Int // 0 = unrated, 1–5 (To Try) let starRating: Int // 0 = unrated, 1–5 (Been There) let entryMode: EntryMode let consider: Bool // show entries only: true = Consider, false = Upcoming init(id: UUID, coordinate: CLLocationCoordinate2D, name: String, isTried: Bool, priority: Int, starRating: Int, entryMode: EntryMode = .food, consider: Bool = false) { self.spotID = id self.coordinate = coordinate self.title = name self.isTried = isTried self.priority = priority self.starRating = starRating self.entryMode = entryMode self.consider = consider } }`SpotAnnotation` classDefines the `SpotAnnotation` class. Conforms to NSObject, MKAnnotation.
▶ PRIORITY DOT GLYPH
// Both glyphs use the same canvas. Stars use glyphStar; dots use glyphDot (smaller, // since a solid circle at the same pt size looks visually heavier than a star glyph). private let glyphCanvas: CGFloat = 22 // fixed square canvas for all marker glyphs private let glyphStar: CGFloat = 6 // bounding box of each star.fill symbol private let glyphDot: CGFloat = 4 // diameter of each filled circle private let glyphGap: CGFloat = 2 // spacing between symbolsDocumentation commentDescribes the following declaration.
/// Returns the top-left origins for `count` symbols of `symbolSize` on the shared canvas. private func glyphPositions(count: Int, symbolSize sz: CGFloat) -> [CGPoint] { let gap = glyphGap let c = glyphCanvas switch count { case 5: // Regular pentagon, top point first (matches starsGlyphImage pentagon) let cx: CGFloat = c / 2 let cy: CGFloat = c / 2 let r: CGFloat = (c - sz) / 2 return (0..<5).map { i in let angle = CGFloat(i) * (2 * .pi / 5) - .pi / 2 return CGPoint(x: cx + r * cos(angle) - sz / 2, y: cy + r * sin(angle) - sz / 2) } case 4: // 2×2 square let step = sz + gap let start = (c - (2 * sz + gap)) / 2 return [CGPoint(x: start, y: start), CGPoint(x: start + step, y: start), CGPoint(x: start, y: start + step), CGPoint(x: start + step, y: start + step)] default: // Centered horizontal row (1, 2, 3) let n = min(count, 3) let total = CGFloat(n) * sz + CGFloat(n - 1) * gap let startX = (c - total) / 2 let startY = (c - sz) / 2 return (0..<n).map { i in CGPoint(x: startX + CGFloat(i) * (sz + gap), y: startY) } } }Documentation commentDescribes the following declaration.
/// Renders a row of 5 coloured dots for use as a menu-item image. /// Dots at position >= threshold are blue; dots below are grey. /// Uses .alwaysOriginal so iOS doesn't strip the colours in native menu cells. private func toTryMenuDots(threshold: Int) -> UIImage { let d: CGFloat = 9 // dot diameter let gap: CGFloat = 4 // gap between dots let w = 5 * d + 4 * gap let renderer = UIGraphicsImageRenderer(size: CGSize(width: w, height: d)) return renderer.image { ctx in for i in 0..<5 { let color: UIColor = (i + 1) >= threshold ? .systemBlue : .systemGray3 color.setFill() ctx.cgContext.fillEllipse(in: CGRect(x: CGFloat(i) * (d + gap), y: 0, width: d, height: d)) } }.withRenderingMode(.alwaysOriginal) }Documentation commentDescribes the following declaration.
/// Renders exactly `count` filled white circles, centered on the shared square canvas. private func priorityDotImage(count: Int) -> UIImage? { guard count > 0 else { return nil } let sz = glyphDot let renderer = UIGraphicsImageRenderer(size: CGSize(width: glyphCanvas, height: glyphCanvas)) return renderer.image { ctx in UIColor.white.setFill() for pt in glyphPositions(count: count, symbolSize: sz) { ctx.cgContext.fillEllipse(in: CGRect(origin: pt, size: CGSize(width: sz, height: sz))) } } }Documentation commentDescribes the following declaration.
/// Renders exactly `count` white star.fill symbols on the shared square canvas. /// Count 1–3: centered row. Count 4: square. Count 5: pentagon. private func starsGlyphImage(count: Int) -> UIImage? { guard count > 0, let star = UIImage(systemName: "star.fill")? .withTintColor(.white, renderingMode: .alwaysOriginal) else { return nil } let sz = glyphStar let renderer = UIGraphicsImageRenderer(size: CGSize(width: glyphCanvas, height: glyphCanvas)) return renderer.image { _ in for pt in glyphPositions(count: count, symbolSize: sz) { star.draw(in: CGRect(origin: pt, size: CGSize(width: sz, height: sz))) } } }Documentation commentDescribes the following declaration.
▶ SPOT PIN IMAGE (CUSTOM CIRCLE, REPLACES MKMARKERANNOTATIONVIEW BALLOON)
private func spotPinImage(isTried: Bool, priority: Int, starRating: Int, entryMode: EntryMode = .food, consider: Bool = false) -> UIImage { let d: CGFloat = 20 // overall circle diameter let inset: CGFloat = 1.5 // white border width let glyphPt: CGFloat = 14 // glyph drawn at this size inside the circle let renderer = UIGraphicsImageRenderer(size: CGSize(width: d, height: d)) return renderer.image { ctx in let cg = ctx.cgContext`spotPinImage()` functionImplements `spotPinImage`.
// Drop shadow behind the white border cg.setShadow(offset: CGSize(width: 0, height: 1), blur: 2, color: UIColor.black.withAlphaComponent(0.35).cgColor) UIColor.white.setFill() cg.fillEllipse(in: CGRect(x: 0, y: 0, width: d, height: d))Documentation commentDescribes the following declaration.
// Coloured fill — no shadow on fill or glyph cg.setShadow(offset: .zero, blur: 0, color: nil) let color: UIColor if entryMode == .place { // Place entries: purple (Visit = not yet) or green (Visited = been) color = isTried ? UIColor.systemGreen // Visited : UIColor(red: 103/255, green: 58/255, blue: 183/255, alpha: 1) // Visit (purple) } else if entryMode == .show { // Show entries: teal (Upcoming) or orange (Consider) color = consider ? UIColor.systemOrange // Consider : UIColor(red: 0/255, green: 150/255, blue: 136/255, alpha: 1) // Upcoming (teal) } else { // Food entries: blue (to try) or crimson (been there) color = isTried ? UIColor(red: 220/255, green: 20/255, blue: 60/255, alpha: 1) : .systemBlue } color.setFill() cg.fillEllipse(in: CGRect(x: inset, y: inset, width: d - 2 * inset, height: d - 2 * inset))Documentation commentDescribes the following declaration.
// Glyph centered in the circle let off = (d - glyphPt) / 2 let glyphImage = isTried ? starsGlyphImage(count: starRating) : priorityDotImage(count: priority) glyphImage?.draw(in: CGRect(x: off, y: off, width: glyphPt, height: glyphPt)) } }Documentation commentDescribes the following declaration.
▶ STATION ICON (TRAM.FILL.TUNNEL SF SYMBOL, MATCHES THE SUBWAY LINES TOOLBAR BUTTON)
private let stationDotImage: UIImage = { let pt: CGFloat = 10 let cfg = UIImage.SymbolConfiguration(pointSize: pt, weight: .regular) guard let symbol = UIImage(systemName: "tram.fill.tunnel", withConfiguration: cfg) else { return UIImage() } let iv = UIImageView(image: symbol.withRenderingMode(.alwaysTemplate)) iv.tintColor = UIColor(red: 0/255, green: 57/255, blue: 166/255, alpha: 1.0) // #0039A6 iv.backgroundColor = .white iv.frame = CGRect(origin: .zero, size: symbol.size) let renderer = UIGraphicsImageRenderer(bounds: iv.bounds) return renderer.image { ctx in UIColor.white.setFill() ctx.fill(iv.bounds) iv.layer.render(in: ctx.cgContext) } }()`stationDotImage` letProperty `stationDotImage`. Type: `UIImage`.
▶ ENTRANCE ICONS (STAIR + ELEVATOR, SEA-GREEN ON SEMI-TRANSPARENT ROUNDED RECT WITH BORDER)
private let entranceGreen = UIColor(red: 46/255, green: 139/255, blue: 87/255, alpha: 1.0) private let entranceIconSize: CGFloat = 12`entranceGreen` letProperty `entranceGreen`. Type: `46/255,`.
/// Shared helper: draws the white rounded-rect background with a thin sea-green border. private func drawEntranceBackground(in ctx: UIGraphicsImageRendererContext, size: CGFloat) { let bg = UIBezierPath(roundedRect: CGRect(x: 0.5, y: 0.5, width: size - 1, height: size - 1), cornerRadius: 3) UIColor.white.withAlphaComponent(0.65).setFill() bg.fill() entranceGreen.withAlphaComponent(0.9).setStroke() bg.lineWidth = 0.5 bg.stroke() }Documentation commentDescribes the following declaration.
/// Staircase icon — 3-step polyline. private let entranceIconImage: UIImage = { let size = entranceIconSize let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) return renderer.image { ctx in drawEntranceBackground(in: ctx, size: size) let cg = ctx.cgContext let pad: CGFloat = 2.5 let span: CGFloat = size - 2 * pad // 7 let s: CGFloat = span / 3 // ≈ 2.33 let path = UIBezierPath() path.move(to: CGPoint(x: pad, y: size - pad)) path.addLine(to: CGPoint(x: pad + s, y: size - pad)) path.addLine(to: CGPoint(x: pad + s, y: size - pad - s)) path.addLine(to: CGPoint(x: pad + 2*s, y: size - pad - s)) path.addLine(to: CGPoint(x: pad + 2*s, y: size - pad - 2*s)) path.addLine(to: CGPoint(x: pad + 3*s, y: size - pad - 2*s)) path.addLine(to: CGPoint(x: pad + 3*s, y: size - pad - 3*s)) cg.setLineWidth(1.5) cg.setLineCap(.square) cg.setLineJoin(.miter) entranceGreen.setStroke() path.stroke() } }()Documentation commentDescribes the following declaration.
/// Elevator icon — two door panels (the universal elevator-door symbol). private let elevatorIconImage: UIImage = { let size = entranceIconSize let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) return renderer.image { ctx in drawEntranceBackground(in: ctx, size: size) entranceGreen.setFill() // Two vertical door panels, centred, with a 1 pt gap between them. let doorW: CGFloat = 3.0 let doorH: CGFloat = 6.0 let gap: CGFloat = 1.0 let originX = (size - (doorW * 2 + gap)) / 2 // 2.5 let originY = (size - doorH) / 2 // 3.0 UIBezierPath(roundedRect: CGRect(x: originX, y: originY, width: doorW, height: doorH), cornerRadius: 0.5).fill() UIBezierPath(roundedRect: CGRect(x: originX + doorW + gap, y: originY, width: doorW, height: doorH), cornerRadius: 0.5).fill() } }()Documentation commentDescribes the following declaration.
▶ BUS VEHICLE PILL IMAGE
// // A horizontal capsule with a triangular arrow tip on the right end and the // route label centred in the body. The MKAnnotationView is then rotated by // (bearing − 90°) so the tip points in the direction of travel. // // ╭─────────╮ // │ M15 ▶ // ╰─────────╯ // // Colours follow the per-borough palette already used for route badges.Documentation commentDescribes the following declaration.
private func busVehiclePillImage(route: String) -> UIImage { let height: CGFloat = 15 let radius: CGFloat = height / 2 // semicircle radius = half-height let bodyW: CGFloat = 28 // rectangular body (not counting the semicircle cap) let tipW: CGFloat = 7 // arrow tip on the right (direction of travel) let total = radius + bodyW + tipW // full image width let renderer = UIGraphicsImageRenderer(size: CGSize(width: total, height: height)) return renderer.image { _ in // Shape: convex semicircle cap on left (rear) + rectangular body + arrow tip on right. // The image points RIGHT; CGAffineTransform rotation is applied at render time. let capCx = radius // x-centre of the left semicircle let bodyEnd = capCx + bodyW let path = UIBezierPath() path.move(to: CGPoint(x: capCx, y: 0)) // top of cap path.addLine(to: CGPoint(x: bodyEnd, y: 0)) // top edge path.addLine(to: CGPoint(x: total, y: height / 2)) // arrow tip path.addLine(to: CGPoint(x: bodyEnd, y: height)) // bottom edge path.addLine(to: CGPoint(x: capCx, y: height)) // bottom of cap // Convex semicircle (rear): clockwise in UIKit screen coords sweeps through // the left side (x < capCx), so the cap bows outward to the left. path.addArc(withCenter: CGPoint(x: capCx, y: height / 2), radius: radius, startAngle: .pi / 2, // bottom junction endAngle: -.pi / 2, // top junction clockwise: true) // clockwise → through left → bulges outward ✓ path.close() busRouteUIColor(for: route).setFill() path.fill() UIColor.white.withAlphaComponent(0.9).setStroke() path.lineWidth = 1.2 path.stroke() // Label centred in the body portion let attrs: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 8, weight: .bold), .foregroundColor: UIColor.white, ] let textSize = route.size(withAttributes: attrs) let bodyMidX = capCx + bodyW / 2 let textX = bodyMidX - textSize.width / 2 let textY = (height - textSize.height) / 2 route.draw(at: CGPoint(x: textX, y: textY), withAttributes: attrs) } }`busVehiclePillImage()` functionImplements `busVehiclePillImage`. Returns `UIImage`.
▶ SUBWAY VEHICLE PILL IMAGE
// // Same pill shape as bus vehicles but filled with the official MTA subway line // colour and labelled with the route letter/number in white (or black for NQRW).Documentation commentDescribes the following declaration.
private func subwayVehiclePillImage(route: String) -> UIImage { // Circle with white ring (subway bullet style) + wide white directional arrow. // The image points right; the caller rotates it via CGAffineTransform. // Route text is NOT drawn here — the caller adds a counter-rotating UILabel // so the number always appears upright regardless of track angle. let ringR: CGFloat = 11 // outer radius (includes white ring) let innerR: CGFloat = 9 // colored circle radius (ring thickness = 2 pt) let arrowLen: CGFloat = 9 // padding on each side so circle center = image center let arrowHalf: CGFloat = 8 // half-width of arrow base ≈ innerR → base ≈ diameter wide let diameter = ringR * 2 // 22 let W = arrowLen + diameter + arrowLen // 9 + 22 + 9 = 40 let H = diameter // 22 (arrowHalf 8 < ringR 11, fits cleanly) let cx = arrowLen + ringR // 9 + 11 = 20 (circle center x = image center x) let cy = ringR // 11 (circle center y = image center y) let renderer = UIGraphicsImageRenderer(size: CGSize(width: W, height: H)) return renderer.image { _ in let color = subwayLineUIColor(for: route) let outline = UIColor(white: 0.15, alpha: 0.75) // thin dark outline colour let strokeW: CGFloat = 0.75 // 1. White outer circle — forms the ring around the bullet let whiteCircle = UIBezierPath(arcCenter: CGPoint(x: cx, y: cy), radius: ringR, startAngle: 0, endAngle: .pi * 2, clockwise: true) UIColor.white.setFill() whiteCircle.fill() // Thin outline around the outside of the white ring so it reads against light backgrounds. whiteCircle.lineWidth = strokeW outline.setStroke() whiteCircle.stroke() // 2. Colored inner circle let colorCircle = UIBezierPath(arcCenter: CGPoint(x: cx, y: cy), radius: innerR, startAngle: 0, endAngle: .pi * 2, clockwise: true) color.setFill() colorCircle.fill() // 3. White arrow — wide base (≈ circle diameter), tip at canvas right edge. // White makes it visible against any track color. let arrow = UIBezierPath() arrow.move(to: CGPoint(x: cx + innerR, y: cy - arrowHalf)) arrow.addLine(to: CGPoint(x: W - 1, y: cy)) // tip arrow.addLine(to: CGPoint(x: cx + innerR, y: cy + arrowHalf)) arrow.close() UIColor.white.setFill() arrow.fill() // Thin outline on the two diagonal sides of the arrow (base is hidden behind the circle). let arrowSides = UIBezierPath() arrowSides.move(to: CGPoint(x: cx + innerR, y: cy - arrowHalf)) arrowSides.addLine(to: CGPoint(x: W - 1, y: cy)) arrowSides.addLine(to: CGPoint(x: cx + innerR, y: cy + arrowHalf)) arrowSides.lineWidth = strokeW arrowSides.lineCapStyle = .round arrowSides.lineJoinStyle = .round outline.setStroke() arrowSides.stroke() } }`subwayVehiclePillImage()` functionImplements `subwayVehiclePillImage`. Returns `UIImage`.
/// UIColor version of busRouteColor() for use in CoreGraphics contexts. private func busRouteUIColor(for route: String) -> UIColor { let up = route.uppercased() if up.hasPrefix("BX") { return UIColor(red: 0, green: 166/255, blue: 189/255, alpha: 1) } // Bronx – teal if up.hasPrefix("B") && !up.hasPrefix("BX") { return UIColor(red: 255/255, green: 99/255, blue: 25/255, alpha: 1) } // Brooklyn – orange if up.hasPrefix("Q") { return UIColor(red: 185/255, green: 51/255, blue: 173/255, alpha: 1) } // Queens – purple if up.hasPrefix("S") { return UIColor(red: 180/255, green: 130/255, blue: 220/255, alpha: 1) } // SI – lavender if up.hasPrefix("M") { return UIColor(red: 112/255, green: 48/255, blue: 160/255, alpha: 1) } // Manhattan – purple return UIColor.darkGray }Documentation commentDescribes the following declaration.
▶ BUS STOP DOT (SMALL ORANGE CIRCLE WITH A BUS ICON)
private let busStopDotImage: UIImage = { let pt: CGFloat = 10 let cfg = UIImage.SymbolConfiguration(pointSize: pt, weight: .regular) guard let symbol = UIImage(systemName: "bus.fill", withConfiguration: cfg) else { return UIImage() } let iv = UIImageView(image: symbol.withRenderingMode(.alwaysTemplate)) iv.tintColor = UIColor(red: 255/255, green: 99/255, blue: 25/255, alpha: 1.0) // MTA bus orange iv.backgroundColor = .white iv.frame = CGRect(origin: .zero, size: symbol.size) let renderer = UIGraphicsImageRenderer(bounds: iv.bounds) return renderer.image { ctx in UIColor.white.setFill() ctx.fill(iv.bounds) iv.layer.render(in: ctx.cgContext) } }()`busStopDotImage` letProperty `busStopDotImage`. Type: `UIImage`.
// Coordinate string key used for annotation diffing — precision matches Open Data (%.5f ≈ 1m). private func coordKey(_ c: CLLocationCoordinate2D) -> String { String(format: "%.5f,%.5f", c.latitude, c.longitude) }Documentation commentDescribes the following declaration.
▶ STATION ANNOTATION
class StationAnnotation: NSObject, MKAnnotation { let station: SubwayStation let coordinate: CLLocationCoordinate2D let title: String? init(station: SubwayStation) { self.station = station self.coordinate = station.coordinate self.title = station.name } }`StationAnnotation` classDefines the `StationAnnotation` class. Conforms to NSObject, MKAnnotation.
class EntranceAnnotation: NSObject, MKAnnotation { let entrance: SubwayEntrance let coordinate: CLLocationCoordinate2D let title: String? init(entrance: SubwayEntrance) { self.entrance = entrance self.coordinate = entrance.coordinate self.title = entrance.stationName } }`EntranceAnnotation` classDefines the `EntranceAnnotation` class. Conforms to NSObject, MKAnnotation.
▶ THEATER ANNOTATION (PIN FOR A MINDTHESHOW VENUE)
final class TheaterAnnotation: NSObject, MKAnnotation { let coordinate: CLLocationCoordinate2D let title: String? init(coordinate: CLLocationCoordinate2D, name: String) { self.coordinate = coordinate self.title = name.isEmpty ? nil : name } }`TheaterAnnotation` classDefines the `TheaterAnnotation` class. Conforms to NSObject, MKAnnotation.
// Custom annotation view: red circle + theater mask icon + always-visible name label. // Mirrors the ShowAnnotationView in MindTheShow for visual consistency. final class TheaterAnnotationView: MKAnnotationView { private let circleView = UIView() private let iconView = UIImageView() private let nameLabel = UILabel() private static let pinD: CGFloat = 22 private static let maxW: CGFloat = 130 private static let pinGap: CGFloat = 3 override init(annotation: MKAnnotation?, reuseIdentifier: String?) { super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) circleView.backgroundColor = .systemRed circleView.layer.cornerRadius = Self.pinD / 2 circleView.clipsToBounds = true addSubview(circleView) let cfg = UIImage.SymbolConfiguration(pointSize: 8, weight: .semibold) iconView.image = UIImage(systemName: "theatermasks.fill", withConfiguration: cfg)? .withTintColor(.white, renderingMode: .alwaysOriginal) iconView.contentMode = .center circleView.addSubview(iconView) nameLabel.textAlignment = .center nameLabel.numberOfLines = 2 nameLabel.font = .systemFont(ofSize: 11, weight: .semibold) nameLabel.textColor = .label nameLabel.layer.shadowColor = UIColor.black.cgColor nameLabel.layer.shadowOffset = CGSize(width: 0, height: 0.5) nameLabel.layer.shadowRadius = 1 nameLabel.layer.shadowOpacity = 0.45 nameLabel.layer.shouldRasterize = true nameLabel.layer.rasterizationScale = traitCollection.displayScale addSubview(nameLabel) canShowCallout = false displayPriority = .required } required init?(coder: NSCoder) { fatalError() } override var annotation: MKAnnotation? { didSet { nameLabel.text = annotation?.title as? String applyLayout() } } private func applyLayout() { let d = Self.pinD, maxW = Self.maxW, gap = Self.pinGap let nSz = nameLabel.sizeThatFits(CGSize(width: maxW, height: 60)) let totalW = max(d, nSz.width) let totalH = d + gap + nSz.height let cx = totalW / 2 circleView.frame = CGRect(x: cx - d/2, y: 0, width: d, height: d) iconView.frame = circleView.bounds nameLabel.frame = CGRect(x: cx - nSz.width/2, y: d + gap, width: nSz.width, height: nSz.height) bounds = CGRect(x: 0, y: 0, width: totalW, height: totalH) centerOffset = CGPoint(x: 0, y: d/2 - totalH/2) } }Documentation commentDescribes the following declaration.
▶ UIVIEWREPRESENTABLE
struct TransitMapView: UIViewRepresentable { @Binding var region: MKCoordinateRegion @Binding var centerOnUser: Bool let annotations: [SpotAnnotation] let stationAnnotations: [StationAnnotation] let entranceAnnotations: [EntranceAnnotation] var searchAnnotations: [SearchResultAnnotation] = [] var theaterAnnotations: [TheaterAnnotation] = [] var busStopAnnotations: [BusStopAnnotation] = [] var showBusStops: Bool = false var showLiveVehicles: Bool = false var showLiveSubway: Bool = false var subwayPolylines: [SubwayPolyline] = [] var showApplePOIs: Bool = false var showNames: Bool = true var onSelectSpot: ((UUID) -> Void)? var onLongPressSpot: ((UUID) -> Void)? // MARK: — Directions (revert: remove) var onTransitTapped: ((SubwayStation) -> Void)? var onSearchResultTapped: ((MKMapItem) -> Void)? var onBusStopTapped: ((BusStop) -> Void)? var onRegionSettled: (() -> Void)? func makeCoordinator() -> Coordinator { Coordinator(region: $region, onSelectSpot: onSelectSpot, onTransitTapped: onTransitTapped, onSearchResultTapped: onSearchResultTapped, onBusStopTapped: onBusStopTapped) } func makeUIView(context: Context) -> MKMapView { let map = MKMapView() map.delegate = context.coordinator map.showsUserLocation = true map.preferredConfiguration = Self.makeConfig(showFood: false) map.setRegion(region, animated: false) if #available(iOS 16, *) { map.selectableMapFeatures = [.pointsOfInterest] } // Single long-press on the map itself — handler hit-tests which // transit annotation was pressed rather than attaching per-view recognizers. let lp = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleMapLongPress(_:))) lp.minimumPressDuration = 0.5 lp.delegate = context.coordinator // allows simultaneous firing with MapKit's recognizers map.addGestureRecognizer(lp) return map } private static func makeConfig(showFood: Bool) -> MKStandardMapConfiguration { let config = MKStandardMapConfiguration(elevationStyle: .flat) // We render transit stations ourselves, so exclude .publicTransport from // the native POI filter to prevent unclickable Apple Maps "M" icons appearing. config.pointOfInterestFilter = showFood ? MKPointOfInterestFilter(including: [.restaurant, .cafe, .bakery, .brewery, .nightlife]) : .excludingAll return config } func updateUIView(_ map: MKMapView, context: Context) { context.coordinator.onSelectSpot = onSelectSpot context.coordinator.onLongPressSpot = onLongPressSpot context.coordinator.onTransitTapped = onTransitTapped context.coordinator.onSearchResultTapped = onSearchResultTapped context.coordinator.onBusStopTapped = onBusStopTapped context.coordinator.onRegionSettled = onRegionSettled // Center on live user location when the button is tapped. if centerOnUser { let liveLoc = map.userLocation.coordinate if liveLoc.latitude != 0 || liveLoc.longitude != 0 { map.setCenter(liveLoc, animated: true) } DispatchQueue.main.async { self.centerOnUser = false } } // Start or stop live bus vehicle polling when the Live toggle changes. if showLiveVehicles != context.coordinator.showsLiveVehicles { context.coordinator.showsLiveVehicles = showLiveVehicles if showLiveVehicles { context.coordinator.startVehiclePolling(map: map) } else { context.coordinator.stopVehiclePolling(map: map) } } // Start or stop live subway vehicle polling when the LiveSub toggle changes. if showLiveSubway != context.coordinator.showsLiveSubway { context.coordinator.showsLiveSubway = showLiveSubway if showLiveSubway { context.coordinator.startSubwayPolling(map: map) } else { context.coordinator.stopSubwayPolling(map: map) } } if !regionsApproximatelyEqual(map.region, region) { map.setRegion(region, animated: true) } // Sync spot annotations let existing = map.annotations.compactMap { $0 as? SpotAnnotation } let existingIDs = Set(existing.map(\.spotID)) let newIDs = Set(annotations.map(\.spotID)) map.removeAnnotations(existing.filter { !newIDs.contains($0.spotID) }) map.addAnnotations(annotations.filter { !existingIDs.contains($0.spotID) }) // Refresh pins whose priority / star rating / isTried changed since last render let newByID = Dictionary(uniqueKeysWithValues: annotations.map { ($0.spotID, $0) }) let stale = existing.filter { ann in guard let updated = newByID[ann.spotID] else { return false } return ann.isTried != updated.isTried || ann.priority != updated.priority || ann.starRating != updated.starRating || ann.consider != updated.consider } if !stale.isEmpty { map.removeAnnotations(stale) map.addAnnotations(stale.compactMap { newByID[$0.spotID] }) } // Sync station annotations let existingStations = map.annotations.compactMap { $0 as? StationAnnotation } let existingSKeys = Set(existingStations.map { coordKey($0.coordinate) }) let newSKeys = Set(stationAnnotations.map { coordKey($0.coordinate) }) map.removeAnnotations(existingStations.filter { !newSKeys.contains(coordKey($0.coordinate)) }) map.addAnnotations(stationAnnotations.filter { !existingSKeys.contains(coordKey($0.coordinate)) }) // Sync entrance annotations let existingEntrances = map.annotations.compactMap { $0 as? EntranceAnnotation } let existingEKeys = Set(existingEntrances.map { coordKey($0.coordinate) }) let newEKeys = Set(entranceAnnotations.map { coordKey($0.coordinate) }) map.removeAnnotations(existingEntrances.filter { !newEKeys.contains(coordKey($0.coordinate)) }) map.addAnnotations(entranceAnnotations.filter { !existingEKeys.contains(coordKey($0.coordinate)) }) // Sync Apple POI filter (only rebuild config when the toggle actually changes) if showApplePOIs != context.coordinator.showApplePOIs { context.coordinator.showApplePOIs = showApplePOIs map.preferredConfiguration = Self.makeConfig(showFood: showApplePOIs) } // Sync name label visibility — update all visible spot annotation views if showNames != context.coordinator.showNames { context.coordinator.showNames = showNames for ann in map.annotations.compactMap({ $0 as? SpotAnnotation }) { if let av = map.view(for: ann) { av.viewWithTag(101)?.isHidden = !showNames } } } // Sync search result annotations — diff by coordinate so no churn when unchanged let existingSearch = map.annotations.compactMap { $0 as? SearchResultAnnotation } let existingSRKeys = Set(existingSearch.map { coordKey($0.coordinate) }) let newSRKeys = Set(searchAnnotations.map { coordKey($0.coordinate) }) map.removeAnnotations(existingSearch.filter { !newSRKeys.contains(coordKey($0.coordinate)) }) map.addAnnotations(searchAnnotations.filter { !existingSRKeys.contains(coordKey($0.coordinate)) }) // Sync bus stop annotations let existingBus = map.annotations.compactMap { $0 as? BusStopAnnotation } let existingBKeys = Set(existingBus.map { coordKey($0.coordinate) }) let newBKeys = Set(busStopAnnotations.map { coordKey($0.coordinate) }) map.removeAnnotations(existingBus.filter { !newBKeys.contains(coordKey($0.coordinate)) }) map.addAnnotations(busStopAnnotations.filter { !existingBKeys.contains(coordKey($0.coordinate)) }) // Bus vehicle annotations are managed entirely by the Coordinator's polling // task — not through the SwiftUI diff path. See startVehiclePolling(map:). // Sync theater annotations (one per MindTheShow venue) let existingTheaters = map.annotations.compactMap { $0 as? TheaterAnnotation } let existingTKeys = Set(existingTheaters.map { coordKey($0.coordinate) }) let newTKeys = Set(theaterAnnotations.map { coordKey($0.coordinate) }) map.removeAnnotations(existingTheaters.filter { !newTKeys.contains(coordKey($0.coordinate)) }) map.addAnnotations(theaterAnnotations.filter { !existingTKeys.contains(coordKey($0.coordinate)) }) // Sync subway overlays — driven by the subwayPolylines parameter (async-loaded externally). // Diff by object identity: SubwayPolyline objects are created once by SubwayLinesService // and never mutated, so ObjectIdentifier is a stable and cheap key. let currentLines = map.overlays.compactMap { $0 as? SubwayPolyline } let currentLineSet = Set(currentLines.map { ObjectIdentifier($0) }) let newLineSet = Set(subwayPolylines.map { ObjectIdentifier($0) }) let toRemove = currentLines.filter { !newLineSet.contains(ObjectIdentifier($0)) } let toAdd = subwayPolylines.filter { !currentLineSet.contains(ObjectIdentifier($0)) } if !toRemove.isEmpty { map.removeOverlays(toRemove) } if !toAdd.isEmpty { map.addOverlays(toAdd, level: .aboveRoads) } } private func regionsApproximatelyEqual(_ a: MKCoordinateRegion, _ b: MKCoordinateRegion) -> Bool { abs(a.center.latitude - b.center.latitude) < 0.0001 && abs(a.center.longitude - b.center.longitude) < 0.0001 && abs(a.span.latitudeDelta - b.span.latitudeDelta) < 0.001 && abs(a.span.longitudeDelta - b.span.longitudeDelta) < 0.001 }`TransitMapView` structDefines the `TransitMapView` struct. Conforms to UIViewRepresentable.
▶ COORDINATOR
class Coordinator: NSObject, MKMapViewDelegate, UIGestureRecognizerDelegate { @Binding var region: MKCoordinateRegion var onSelectSpot: ((UUID) -> Void)? var onLongPressSpot: ((UUID) -> Void)? // MARK: — Directions (revert: remove) var onTransitTapped: ((SubwayStation) -> Void)? var onSearchResultTapped: ((MKMapItem) -> Void)? var onBusStopTapped: ((BusStop) -> Void)? var onRegionSettled: (() -> Void)? var showApplePOIs: Bool = false // mirrors last-applied value to avoid redundant updates var showNames: Bool = true // mirrors last-applied value for label visibility private var lastRegionUpdate: Date = .distantPast`Coordinator` classDefines the `Coordinator` class. Conforms to NSObject, MKMapViewDelegate, UIGestureRecognizerDelegate.
▶ BUS VEHICLE POLLING (COORDINATOR-OWNED, NO SWIFTUI STATE INVOLVED)
var showsLiveVehicles: Bool = false private var vehicleTask: Task<Void, Never>? private var liveVehicleAnnotations: [BusVehicleAnnotation] = []`showsLiveVehicles` varProperty `showsLiveVehicles`. Type: `Bool`.
▶ SUBWAY VEHICLE POLLING
var showsLiveSubway: Bool = false private var subwayTask: Task<Void, Never>? private var liveSubwayAnnotations: [SubwayVehicleAnnotation] = [] /// The map heading (degrees clockwise from true north) the last time we applied /// vehicle transforms. Used to skip redundant work when only panning, not rotating. private var lastAppliedHeading: CLLocationDirection = -1`showsLiveSubway` varProperty `showsLiveSubway`. Type: `Bool`.
▶ SUBWAY VEHICLE TIMER STATE MACHINE
// Tracks each trip's last known stop and when we first saw it there. // While elapsed < 37 s the icon sits at the station; after that it moves // to the midpoint towards the next stop so the train appears in-transit. private struct VehicleStopState { let stopId: String let stationCoord: CLLocationCoordinate2D let since: Date } private var vehicleState: [String: VehicleStopState] = [:] /// Start (or restart) the 30-second polling loop. Cancels any existing loop first, /// so calling this after a pan immediately fires a fresh fetch for the new region. func startVehiclePolling(map: MKMapView) { vehicleTask?.cancel() vehicleTask = Task { [weak self, weak map] in guard let self, let map else { return } while !Task.isCancelled { await self.fetchAndApplyVehicles(map: map) try? await Task.sleep(for: .seconds(30)) } } } /// Stop polling and clear all vehicle pins from the map. func stopVehiclePolling(map: MKMapView) { vehicleTask?.cancel() vehicleTask = nil map.removeAnnotations(liveVehicleAnnotations) liveVehicleAnnotations = [] } /// Fetch current vehicle positions and atomically replace the annotations on the map. /// Reads `map.region` directly — never touches SwiftUI state. @MainActor private func fetchAndApplyVehicles(map: MKMapView) async { let region = map.region let latM = region.span.latitudeDelta * 111_320 let lonM = region.span.longitudeDelta * 111_320 * cos(region.center.latitude * .pi / 180) let maxSpan = max(latM, lonM) // Hide vehicles when zoomed out beyond ~5 miles. guard maxSpan <= 8_047 else { map.removeAnnotations(liveVehicleAnnotations) liveVehicleAnnotations = [] return } // Discover routes from stops within the visible region (up to 3 km radius). let discoveryRadius = min(maxSpan / 2, 3_000) let lineRefs = Set( BusStopService.shared.stops(near: region.center, radiusMeters: discoveryRadius) .flatMap { $0.routes } ).map { "MTA NYCT_\($0)" } guard !lineRefs.isEmpty else { return } // Fan-out one SIRI request per route in parallel. let vehicles = await withTaskGroup(of: [BusVehicle].self) { group in for lineRef in lineRefs { group.addTask { await BusVehicleService.fetch(lineRef: lineRef) } } var all: [BusVehicle] = [] for await batch in group { all += batch } return all } // If the task was cancelled while fetching, don't touch the map. guard !Task.isCancelled else { return } // Only replace annotations when the fetch returned actual vehicles. // An empty result usually means a transient API/network failure — keeping // the last-known-good pins is far better than blanking the map. guard !vehicles.isEmpty else { return } let newAnnotations = vehicles.map { BusVehicleAnnotation($0, displayCoordinate: self.deadReckon($0)) } map.removeAnnotations(liveVehicleAnnotations) map.addAnnotations(newAnnotations) liveVehicleAnnotations = newAnnotations }Documentation commentDescribes the following declaration.
▶ SUBWAY VEHICLE TRACK-SNAPPING
/// Snaps `coord` to the nearest point on any polyline whose routeID matches `route`. /// Returns the snapped coordinate and the bearing (degrees, true-north) of the track /// at that point, oriented to match `intendedBearing` (0 = northbound, 180 = southbound). private func snapToTrack( coord: CLLocationCoordinate2D, route: String, intendedBearing: Double, polylinesByRoute: [String: [SubwayPolyline]] ) -> (coordinate: CLLocationCoordinate2D, bearing: Double) { guard let polylines = polylinesByRoute[route], !polylines.isEmpty else { return (coord, intendedBearing) } // Convert degrees to approximate meters for distance math. let latScale = 111_320.0 let lonScale = 111_320.0 * cos(coord.latitude * .pi / 180) let px = coord.longitude * lonScale let py = coord.latitude * latScale var bestDist2 = Double.infinity var bestCoord = coord var bestBearing = intendedBearing for poly in polylines { let n = poly.pointCount guard n >= 2 else { continue } var pts = [CLLocationCoordinate2D](repeating: .init(), count: n) poly.getCoordinates(&pts, range: NSRange(location: 0, length: n)) for i in 0 ..< n - 1 { let a = pts[i], b = pts[i + 1] let ax = a.longitude * lonScale, ay = a.latitude * latScale let bx = b.longitude * lonScale, by = b.latitude * latScale let dx = bx - ax, dy = by - ay let len2 = dx * dx + dy * dy guard len2 > 0 else { continue } let t = max(0, min(1, ((px - ax) * dx + (py - ay) * dy) / len2)) let cx = ax + t * dx, cy = ay + t * dy let d2 = (px - cx) * (px - cx) + (py - cy) * (py - cy) if d2 < bestDist2 { bestDist2 = d2 bestCoord = CLLocationCoordinate2D(latitude: cy / latScale, longitude: cx / lonScale) // Bearing of the segment in true-north degrees. var raw = atan2(dx / lonScale, dy / latScale) * 180 / .pi if raw < 0 { raw += 360 } // Flip if segment direction opposes intended direction of travel. let diff = abs(raw - intendedBearing) if min(diff, 360 - diff) > 90 { raw = (raw + 180).truncatingRemainder(dividingBy: 360) } bestBearing = raw } } } return (bestCoord, bestBearing) }Documentation commentDescribes the following declaration.
▶ VEHICLE TRANSFORM HELPERS
/// Rotation angle (radians) to apply to an annotation view so its image — /// which points right (east = 90°) — faces `bearing` degrees true north, /// corrected for the map's current camera heading. /// `siriOffset` absorbs the calibration offset baked into SIRI bus bearings. private func vehicleAngle(bearing: Double, mapHeading: CLLocationDirection, siriOffset: Double = 0) -> CGFloat { CGFloat((bearing - siriOffset - 90 - mapHeading) * .pi / 180) } /// Dead-reckon a bus position forward from its SIRI timestamp. /// Buses travel at roughly 10 mph (4.5 m/s) on NYC streets; we project /// at 4 m/s and cap at 150 m so a stopped bus isn't pushed too far ahead. private func deadReckon(_ vehicle: BusVehicle) -> CLLocationCoordinate2D { let elapsed = max(0, Date().timeIntervalSince(vehicle.recordedAt)) let meters = min(elapsed * 4.0, 150.0) guard meters > 1 else { return CLLocationCoordinate2D(latitude: vehicle.latitude, longitude: vehicle.longitude) } return offsetCoord( CLLocationCoordinate2D(latitude: vehicle.latitude, longitude: vehicle.longitude), bearing: vehicle.bearing, meters: meters ) } /// Re-applies icon rotation to every visible vehicle annotation view so arrows /// stay correct when the user rotates the map. Called from viewFor (on creation) /// and from mapViewDidChangeVisibleRegion (on each heading change). private func applyVehicleTransforms(map: MKMapView) { let heading = map.camera.heading lastAppliedHeading = heading for ann in liveSubwayAnnotations { guard let view = map.view(for: ann) else { continue } let angle = vehicleAngle(bearing: ann.vehicle.bearing, mapHeading: heading) view.transform = CGAffineTransform(rotationAngle: angle) if let label = view.subviews.first(where: { $0.tag == 9901 }) as? UILabel { // Reset to identity before re-centering so the center is in un-transformed space. label.transform = .identity let imgSize = view.image?.size ?? CGSize(width: 40, height: 22) label.center = CGPoint(x: imgSize.width / 2, y: imgSize.height / 2) label.transform = CGAffineTransform(rotationAngle: -angle) } } for ann in liveVehicleAnnotations { guard let view = map.view(for: ann) else { continue } let b = ann.vehicle.bearing let isEW = (b < 16) || (b >= 106 && b < 196) || (b >= 286) let offset = isEW ? 38.0 : 25.0 let angle = vehicleAngle(bearing: b, mapHeading: heading, siriOffset: offset) view.transform = CGAffineTransform(rotationAngle: angle) } } /// Moves `coord` by `meters` along `bearing` (positive = forward, negative = backward). /// Used to push vehicle icons away from station dots along the track direction. private func offsetCoord(_ coord: CLLocationCoordinate2D, bearing: Double, meters: Double) -> CLLocationCoordinate2D { let rad = bearing * .pi / 180 let dLat = cos(rad) * meters / 111_320 let dLon = sin(rad) * meters / (111_320 * cos(coord.latitude * .pi / 180)) return CLLocationCoordinate2D(latitude: coord.latitude + dLat, longitude: coord.longitude + dLon) } /// Finds the midpoint along the polyline arc between the projections of `fromCoord` /// and `toCoord` onto the best-matching polyline for `route`. Correctly handles /// curved segments (e.g. 8th Ave → 53rd St turn) because it walks the actual /// track geometry rather than snapping from a geographic midpoint. /// Falls back to the straight-line midpoint with inter-station bearing when no /// polyline is available. private func midpointOnTrack( from fromCoord: CLLocationCoordinate2D, to toCoord: CLLocationCoordinate2D, route: String, intendedBearing: Double, polylinesByRoute: [String: [SubwayPolyline]], fraction: Double = 0.5 // 0=from, 1=to; 0.75 = 3/4 of the way toward next stop ) -> (coordinate: CLLocationCoordinate2D, bearing: Double) { let midLat = (fromCoord.latitude + toCoord.latitude) / 2 let latScale = 111_320.0 let lonScale = latScale * cos(midLat * .pi / 180) // Project a coordinate onto a polyline; returns (segment index, t∈[0,1], dist²). func project(_ c: CLLocationCoordinate2D, onto pts: [CLLocationCoordinate2D]) -> (seg: Int, t: Double, dist2: Double) { let px = c.longitude * lonScale, py = c.latitude * latScale var bSeg = 0, bT = 0.0, bD2 = Double.infinity for i in 0 ..< pts.count - 1 { let ax = pts[i].longitude * lonScale, ay = pts[i].latitude * latScale let bx = pts[i+1].longitude * lonScale, by = pts[i+1].latitude * latScale let dx = bx - ax, dy = by - ay, len2 = dx*dx + dy*dy guard len2 > 0 else { continue } let t = max(0, min(1, ((px-ax)*dx + (py-ay)*dy) / len2)) let cx = ax + t*dx, cy = ay + t*dy let d2 = (px-cx)*(px-cx) + (py-cy)*(py-cy) if d2 < bD2 { bD2 = d2; bSeg = i; bT = t } } return (bSeg, bT, bD2) } func ptAt(seg: Int, t: Double, in pts: [CLLocationCoordinate2D]) -> CLLocationCoordinate2D { let j = min(seg + 1, pts.count - 1) return CLLocationCoordinate2D( latitude: pts[seg].latitude + t * (pts[j].latitude - pts[seg].latitude), longitude: pts[seg].longitude + t * (pts[j].longitude - pts[seg].longitude)) } // Geographic fallback with inter-station bearing. func geoMid() -> (coordinate: CLLocationCoordinate2D, bearing: Double) { let mid = CLLocationCoordinate2D( latitude: fromCoord.latitude + fraction * (toCoord.latitude - fromCoord.latitude), longitude: fromCoord.longitude + fraction * (toCoord.longitude - fromCoord.longitude)) let dLat = toCoord.latitude - fromCoord.latitude let dLon = (toCoord.longitude - fromCoord.longitude) * cos(toCoord.latitude * .pi / 180) var b = atan2(dLon, dLat) * 180 / .pi if b < 0 { b += 360 } return (mid, b) } guard let polylines = polylinesByRoute[route], !polylines.isEmpty else { return geoMid() } var bestCoord: CLLocationCoordinate2D? var bestBearing: Double = intendedBearing var bestQuality = Double.infinity for poly in polylines { let n = poly.pointCount guard n >= 2 else { continue } var pts = [CLLocationCoordinate2D](repeating: .init(), count: n) poly.getCoordinates(&pts, range: NSRange(location: 0, length: n)) let (iF, tF, dF2) = project(fromCoord, onto: pts) let (iT, tT, dT2) = project(toCoord, onto: pts) let quality = dF2 + dT2 guard quality < bestQuality else { continue } // Build the ordered waypoint list along the polyline from fromCoord→toCoord. var wpts: [CLLocationCoordinate2D] = [ptAt(seg: iF, t: tF, in: pts)] if iF < iT { for i in (iF + 1) ... iT { wpts.append(pts[i]) } } else if iF > iT { for i in stride(from: iF, through: iT + 1, by: -1) { wpts.append(pts[i]) } } wpts.append(ptAt(seg: iT, t: tT, in: pts)) // Cumulative arc length. var cum = [0.0] for i in 1 ..< wpts.count { let dLat = (wpts[i].latitude - wpts[i-1].latitude) * latScale let dLon = (wpts[i].longitude - wpts[i-1].longitude) * lonScale cum.append(cum.last! + sqrt(dLat*dLat + dLon*dLon)) } guard let totalLen = cum.last, totalLen > 0 else { continue } // Walk to the target fraction along the arc. let half = totalLen * fraction for i in 0 ..< wpts.count - 1 { guard half <= cum[i + 1] else { continue } let segLen = cum[i+1] - cum[i] let t = segLen > 0 ? (half - cum[i]) / segLen : 0 let lat = wpts[i].latitude + t * (wpts[i+1].latitude - wpts[i].latitude) let lon = wpts[i].longitude + t * (wpts[i+1].longitude - wpts[i].longitude) let dxS = (wpts[i+1].longitude - wpts[i].longitude) * lonScale let dyS = (wpts[i+1].latitude - wpts[i].latitude) * latScale var rawB = atan2(dxS, dyS) * 180 / .pi if rawB < 0 { rawB += 360 } let diff = abs(rawB - intendedBearing) if min(diff, 360 - diff) > 90 { rawB = (rawB + 180).truncatingRemainder(dividingBy: 360) } bestCoord = CLLocationCoordinate2D(latitude: lat, longitude: lon) bestBearing = rawB bestQuality = quality break } } if let c = bestCoord { return (c, bestBearing) } return geoMid() } /// Returns the coordinate of the stop the vehicle just departed, by finding /// the nearest station in `stopCoords` that lies "behind" `nextCoord` along /// the direction opposite to `bearing`. Returns nil if none is found within 3 km. private func findPreviousStopCoord( nextCoord: CLLocationCoordinate2D, bearing: Double, stopCoords: [String: CLLocationCoordinate2D], trunkPrefix: String // first char(s) of stop_id — restricts search to same trunk line ) -> CLLocationCoordinate2D? { // Unit vector pointing backward (opposite to direction of travel). let backRad = (bearing + 180) * .pi / 180 let backDx = sin(backRad) // east component let backDy = cos(backRad) // north component var bestCoord: CLLocationCoordinate2D? = nil var bestDist = Double.infinity for (key, coord) in stopCoords { guard trunkPrefix.isEmpty || key.hasPrefix(trunkPrefix) else { continue } let dLat = coord.latitude - nextCoord.latitude let dLon = (coord.longitude - nextCoord.longitude) * cos(nextCoord.latitude * .pi / 180) // Must be in the backward half-plane. let dot = dLat * backDy + dLon * backDx guard dot > 0 else { continue } let dist = sqrt((dLat * 111_320) * (dLat * 111_320) + (dLon * 111_320) * (dLon * 111_320)) guard dist < 3_000 else { continue } if dist < bestDist { bestDist = dist; bestCoord = coord } } return bestCoord } /// Returns the nearest station in `stopCoords` that lies *ahead* of `fromCoord` /// along `bearing`. Mirror of findPreviousStopCoord. private func findNextStopCoord( fromCoord: CLLocationCoordinate2D, bearing: Double, stopCoords: [String: CLLocationCoordinate2D], trunkPrefix: String ) -> CLLocationCoordinate2D? { let fwdRad = bearing * .pi / 180 let fwdDx = sin(fwdRad) let fwdDy = cos(fwdRad) var bestCoord: CLLocationCoordinate2D? = nil var bestDist = Double.infinity for (key, coord) in stopCoords { guard trunkPrefix.isEmpty || key.hasPrefix(trunkPrefix) else { continue } let dLat = coord.latitude - fromCoord.latitude let dLon = (coord.longitude - fromCoord.longitude) * cos(fromCoord.latitude * .pi / 180) // Must be in the forward half-plane. let dot = dLat * fwdDy + dLon * fwdDx guard dot > 0 else { continue } let dist = sqrt((dLat * 111_320) * (dLat * 111_320) + (dLon * 111_320) * (dLon * 111_320)) guard dist < 3_000 else { continue } if dist < bestDist { bestDist = dist; bestCoord = coord } } return bestCoord }Documentation commentDescribes the following declaration.
▶ SUBWAY VEHICLE POLLING METHODS
func startSubwayPolling(map: MKMapView) { subwayTask?.cancel() subwayTask = Task { [weak self, weak map] in guard let self, let map else { return } while !Task.isCancelled { await self.fetchAndApplySubwayVehicles(map: map) try? await Task.sleep(for: .seconds(30)) } } } func stopSubwayPolling(map: MKMapView) { subwayTask?.cancel() subwayTask = nil map.removeAnnotations(liveSubwayAnnotations) liveSubwayAnnotations = [] } @MainActor private func fetchAndApplySubwayVehicles(map: MKMapView) async { let region = map.region let latM = region.span.latitudeDelta * 111_320 let lonM = region.span.longitudeDelta * 111_320 * cos(region.center.latitude * .pi / 180) let maxSpan = max(latM, lonM) guard maxSpan <= 8_047 else { map.removeAnnotations(liveSubwayAnnotations) liveSubwayAnnotations = [] return } // Build stop→coord lookup from all known stations. var stopCoords: [String: CLLocationCoordinate2D] = [:] for station in SubwayStationService.shared.stations { for stopID in station.stopIDs { stopCoords[stopID] = station.coordinate } } // Fetch all MTA feeds concurrently (all lines, no server-side region filter). let vehicles = await SubwayVehicleService.fetchAll(stopCoords: stopCoords) guard !Task.isCancelled else { return } guard !vehicles.isEmpty else { return } // Build route→polylines lookup for O(1) access in the snap loop. let allPolylines = SubwayLinesService.shared.polylines var polylinesByRoute: [String: [SubwayPolyline]] = [:] for poly in allPolylines { polylinesByRoute[poly.routeID, default: []].append(poly) } // Shared-track aliases: routes that run on the same physical tracks as another. // W runs on N/Q tracks; S (shuttle) lines have no useful shape anyway. let sharedTrackAliases: [(from: String, to: String)] = [("W", "N"), ("W", "Q")] for (from, to) in sharedTrackAliases { if polylinesByRoute[from] == nil, let shared = polylinesByRoute[to] { polylinesByRoute[from] = shared } } // Snap each vehicle to its route's track geometry, recompute bearing, // then offset it perpendicular to the direction of travel so the icon // sits to the side of the station rather than on top of it. // // Timer state machine (per trip_id): // • MTA stop_id = the station the train most recently arrived at // (whether stopped or already departed — field 6 / currentStatus is // absent for most vehicles so we can't rely on it). // • We show the icon AT the station for the first 37 seconds after the // stop_id changes. This covers the dwell time while doors are open. // • After 37 s we assume the train has departed and place the icon at // the midpoint between that station and the next one in the direction // of travel. The next fetch will move the icon when the train arrives // at the following station. var snappedVehicles: [SubwayVehicle] = [] let fetchTime = Date() for v in vehicles { let stationCoord = CLLocationCoordinate2D(latitude: v.latitude, longitude: v.longitude) let trunkPrefix = String(v.stopId.prefix(1)) // Key: prefer stable trip_id; fall back to route+stop for trains without one. let stateKey = v.tripId.isEmpty ? "\(v.route)-\(v.stopId)" : v.tripId // Determine when we first saw this trip at this stop. let since: Date if let existing = vehicleState[stateKey], existing.stopId == v.stopId { since = existing.since // same stop — keep original arrival time } else { since = fetchTime // new stop (or first time seeing this trip) vehicleState[stateKey] = VehicleStopState( stopId: v.stopId, stationCoord: stationCoord, since: fetchTime) } let elapsed = fetchTime.timeIntervalSince(since) let baseCoord: CLLocationCoordinate2D let bearingForOffset: Double let lateralMeters: Double if elapsed >= 37, let nextCoord = findNextStopCoord(fromCoord: stationCoord, bearing: v.bearing, stopCoords: stopCoords, trunkPrefix: trunkPrefix) { // 37 s have passed since the train was last seen at this station: // it has departed — show it halfway to the next stop along the track. let (mid, trackBearing) = midpointOnTrack( from: stationCoord, to: nextCoord, route: v.route, intendedBearing: v.bearing, polylinesByRoute: polylinesByRoute) baseCoord = mid bearingForOffset = trackBearing lateralMeters = 20 } else { // Train recently arrived (or no next stop found) — pin to the station. baseCoord = stationCoord let (_, trackBearing) = snapToTrack(coord: stationCoord, route: v.route, intendedBearing: v.bearing, polylinesByRoute: polylinesByRoute) bearingForOffset = trackBearing lateralMeters = 15 } // Perpendicular-right of direction of travel = bearing + 90°. let lateralBearing = (bearingForOffset + 90).truncatingRemainder(dividingBy: 360) let displayCoord = offsetCoord(baseCoord, bearing: lateralBearing, meters: lateralMeters) snappedVehicles.append(SubwayVehicle(id: v.id, route: v.route, tripId: v.tripId, stopId: v.stopId, latitude: displayCoord.latitude, longitude: displayCoord.longitude, bearing: bearingForOffset, currentStatus: v.currentStatus, timestamp: v.timestamp)) } // Prune state entries for trips no longer in the feed (trains out of service). let activeKeys = Set(vehicles.map { v -> String in v.tripId.isEmpty ? "\(v.route)-\(v.stopId)" : v.tripId }) vehicleState = vehicleState.filter { activeKeys.contains($0.key) } let margin = 1.5 let minLat = region.center.latitude - region.span.latitudeDelta * margin / 2 let maxLat = region.center.latitude + region.span.latitudeDelta * margin / 2 let minLon = region.center.longitude - region.span.longitudeDelta * margin / 2 let maxLon = region.center.longitude + region.span.longitudeDelta * margin / 2 let visible = snappedVehicles.filter { $0.latitude >= minLat && $0.latitude <= maxLat && $0.longitude >= minLon && $0.longitude <= maxLon } guard !visible.isEmpty else { return } let newAnnotations = visible.map { SubwayVehicleAnnotation($0) } map.removeAnnotations(liveSubwayAnnotations) map.addAnnotations(newAnnotations) liveSubwayAnnotations = newAnnotations } init(region: Binding<MKCoordinateRegion>, onSelectSpot: ((UUID) -> Void)?, onTransitTapped: ((SubwayStation) -> Void)?, onSearchResultTapped: ((MKMapItem) -> Void)?, onBusStopTapped: ((BusStop) -> Void)?) { _region = region self.onSelectSpot = onSelectSpot self.onTransitTapped = onTransitTapped self.onSearchResultTapped = onSearchResultTapped self.onBusStopTapped = onBusStopTapped } // Allow our long-press to fire simultaneously with MapKit's own gesture // recognizers (which would otherwise swallow the touch on annotation views). func gestureRecognizer(_ gr: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { true } @objc func handleMapLongPress(_ recognizer: UILongPressGestureRecognizer) { guard recognizer.state == .began, let map = recognizer.view as? MKMapView else { return } let touch = recognizer.location(in: map) let hitRadius: CGFloat = 44 // generous tap target in points`startSubwayPolling()` functionImplements `startSubwayPolling`.
▶ // CHECK SPOT ANNOTATIONS FIRST — LONG PRESS FETCHES DIRECTIONS. — DIRECTIONS (REVERT: REMOVE BLOCK)
for annotation in map.annotations { guard let spot = annotation as? SpotAnnotation else { continue } let annPt = map.convert(spot.coordinate, toPointTo: map) if hypot(touch.x - annPt.x, touch.y - annPt.y) < hitRadius { UIImpactFeedbackGenerator(style: .medium).impactOccurred() onLongPressSpot?(spot.spotID) return // don't fall through to transit handling } } // MARK: — Directions (revert: end block) // Find the first transit annotation under the touch. var hitAnnotation: MKAnnotation? for annotation in map.annotations { guard annotation is StationAnnotation || annotation is BusStopAnnotation else { continue } let annPt = map.convert(annotation.coordinate, toPointTo: map) if hypot(touch.x - annPt.x, touch.y - annPt.y) < hitRadius { hitAnnotation = annotation break } } guard let hitAnnotation else { return } // Best-effort deep links — MTA doesn't publish its URL scheme, so these are // educated guesses based on common transit-app patterns. // Query-param format: info.mta.mymta://subway?stopId=<gtfsStopId> let urlString: String if let station = (hitAnnotation as? StationAnnotation)?.station, let stopId = station.stopIDs.first { urlString = "info.mta.mymta://subway?stopId=\(stopId)" } else if let stop = (hitAnnotation as? BusStopAnnotation)?.stop { urlString = "info.mta.mymta://bus?stopId=\(stop.id)" } else { urlString = "info.mta.mymta://open" } if let url = URL(string: urlString) { UIApplication.shared.open(url) } } // Tap recognizer on SpotAnnotation views — opens the directions bubble on tap. @objc func handleSpotTap(_ recognizer: UITapGestureRecognizer) { guard recognizer.state == .ended, let av = recognizer.view as? MKAnnotationView, let spot = av.annotation as? SpotAnnotation else { return } UIImpactFeedbackGenerator(style: .light).impactOccurred() onLongPressSpot?(spot.spotID) } // Fires once after a pan/zoom gesture ends or a programmatic setRegion completes. func mapView(_ map: MKMapView, regionDidChangeAnimated _: Bool) { let newRegion = map.region DispatchQueue.main.async { self.region = newRegion self.onRegionSettled?() // Restart vehicle polling so a fresh fetch fires immediately for the // new region. startVehiclePolling cancels any in-flight task first. if self.showsLiveVehicles { self.startVehiclePolling(map: map) } if self.showsLiveSubway { self.startSubwayPolling(map: map) } } } // Fires on every frame during a pan or pinch-zoom gesture — keeps the // speech-bubble callout tracking the station in real time. // Throttled to 30fps to avoid generating hundreds of SwiftUI re-renders // per second (each re-render allocates new annotation arrays and calls // updateUIView, creating substantial memory pressure over time). func mapViewDidChangeVisibleRegion(_ map: MKMapView) { let now = Date() guard now.timeIntervalSince(lastRegionUpdate) >= 1.0 / 30.0 else { return } lastRegionUpdate = now let newRegion = map.region DispatchQueue.main.async { self.region = newRegion } // Re-apply vehicle transforms whenever the heading changes so arrows // stay correct as the user rotates the map. let heading = map.camera.heading if abs(heading - lastAppliedHeading) > 0.5 { applyVehicleTransforms(map: map) } } func mapView(_ map: MKMapView, didSelect annotation: MKAnnotation) { if let spot = annotation as? SpotAnnotation { // Navigation handled by UITapGestureRecognizer on the annotation view, // so that a long-press doesn't race with immediate didSelect firing. map.deselectAnnotation(spot, animated: false) } else if let station = annotation as? StationAnnotation { map.deselectAnnotation(station, animated: false) onTransitTapped?(station.station) } else if let result = annotation as? SearchResultAnnotation { map.deselectAnnotation(result, animated: false) onSearchResultTapped?(result.mapItem) } else if annotation is EntranceAnnotation { // Entrance icons are visual-only. Deselect immediately and redirect // the tap to whichever station annotation is closest. map.deselectAnnotation(annotation, animated: false) let tapped = CLLocation(latitude: annotation.coordinate.latitude, longitude: annotation.coordinate.longitude) let nearest = map.annotations .compactMap { $0 as? StationAnnotation } .min { CLLocation(latitude: $0.coordinate.latitude, longitude: $0.coordinate.longitude) .distance(from: tapped) < CLLocation(latitude: $1.coordinate.latitude, longitude: $1.coordinate.longitude) .distance(from: tapped) } if let nearest { map.selectAnnotation(nearest, animated: false) } } else if let busStop = annotation as? BusStopAnnotation { map.deselectAnnotation(busStop, animated: false) onBusStopTapped?(busStop.stop) } else if #available(iOS 16, *), let feature = annotation as? MKMapFeatureAnnotation, feature.featureType == .pointOfInterest { // Native Apple POI pin (e.g. restaurant shown by "More"). // Fetch the full MKMapItem then open it in Apple Maps. map.deselectAnnotation(feature, animated: false) let req = MKMapItemRequest(mapFeatureAnnotation: feature) req.getMapItem { item, _ in DispatchQueue.main.async { if let item { self.onSearchResultTapped?(item) } else { let fallbackLoc = CLLocation(latitude: feature.coordinate.latitude, longitude: feature.coordinate.longitude) let fallback = MKMapItem(location: fallbackLoc, address: nil) fallback.name = feature.title self.onSearchResultTapped?(fallback) } } } } }Code blockSee source code for full implementation.
▶ SUBWAY LINE RENDERING
func mapView(_ map: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { guard let poly = overlay as? SubwayPolyline else { return MKOverlayRenderer(overlay: overlay) } // Casing (white border drawn first, slightly wider) // We use a single renderer with a border effect by layering two renderers. // MapKit only calls this once per overlay, so we approximate the casing // by making the line slightly wider with a contrasting stroke. let renderer = MKPolylineRenderer(polyline: poly) let uiColor = subwayLineUIColor(for: poly.routeID) renderer.strokeColor = uiColor renderer.lineWidth = 3 renderer.lineCap = .round renderer.lineJoin = .round return renderer } func mapView(_ map: MKMapView, didUpdate userLocation: MKUserLocation) { // Keep the user-location dot non-interactive so it never blocks taps // on station or spot annotations that share the same screen position. map.view(for: userLocation)?.isUserInteractionEnabled = false } func mapView(_ map: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { // User location: use MapKit's default blue dot but strip its touch target // so station/spot annotations underneath it remain tappable. if annotation is MKUserLocation { DispatchQueue.main.async { [weak map] in guard let map else { return } map.view(for: map.userLocation)?.isUserInteractionEnabled = false } return nil } if let theater = annotation as? TheaterAnnotation { let reuseID = "theater" let view = (map.dequeueReusableAnnotationView(withIdentifier: reuseID) as? TheaterAnnotationView) ?? TheaterAnnotationView(annotation: annotation, reuseIdentifier: reuseID) view.annotation = theater return view } if let result = annotation as? SearchResultAnnotation { let reuseID = "searchResult" let view = map.dequeueReusableAnnotationView(withIdentifier: reuseID) as? MKMarkerAnnotationView ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: reuseID) view.annotation = result view.canShowCallout = false view.markerTintColor = UIColor.systemIndigo view.glyphImage = UIImage(systemName: "magnifyingglass") view.displayPriority = .required return view } if let spot = annotation as? SpotAnnotation { let reuseID = "spot" let view = map.dequeueReusableAnnotationView(withIdentifier: reuseID) ?? MKAnnotationView(annotation: annotation, reuseIdentifier: reuseID) view.annotation = spot let img = spotPinImage(isTried: spot.isTried, priority: spot.priority, starRating: spot.starRating, entryMode: spot.entryMode, consider: spot.consider) view.image = img view.canShowCallout = false view.displayPriority = .required // Name label — reuse existing or create fresh let labelTag = 101 let label: UILabel if let existing = view.viewWithTag(labelTag) as? UILabel { label = existing } else { label = UILabel() label.tag = labelTag label.font = UIFont.systemFont(ofSize: 10, weight: .semibold) label.textColor = .darkText label.backgroundColor = UIColor.white.withAlphaComponent(0.75) label.layer.cornerRadius = 3 label.layer.masksToBounds = true view.addSubview(label) } label.text = spot.title ?? "" label.sizeToFit() // Position just to the right of the pin image label.frame.origin = CGPoint( x: img.size.width + 4, y: (img.size.height - label.frame.height) / 2 ) label.isHidden = !showNames // Tap recognizer handles navigation; long-press is caught by the // map-level GR in handleMapLongPress. Remove stale GRs on reuse. view.gestureRecognizers? .compactMap { $0 as? UITapGestureRecognizer } .forEach { view.removeGestureRecognizer($0) } let tapGR = UITapGestureRecognizer(target: self, action: #selector(handleSpotTap(_:))) view.addGestureRecognizer(tapGR) return view } if let station = annotation as? StationAnnotation { let reuseID = "station" let view = map.dequeueReusableAnnotationView(withIdentifier: reuseID) ?? MKAnnotationView(annotation: annotation, reuseIdentifier: reuseID) view.annotation = station view.image = stationDotImage view.canShowCallout = false view.displayPriority = .required view.layer.zPosition = 1 // render above user-location dot return view } if let busStop = annotation as? BusStopAnnotation { let reuseID = "busStop" let view = map.dequeueReusableAnnotationView(withIdentifier: reuseID) ?? MKAnnotationView(annotation: annotation, reuseIdentifier: reuseID) view.annotation = busStop view.image = busStopDotImage view.canShowCallout = false view.displayPriority = .required return view } if let busVehicle = annotation as? BusVehicleAnnotation { let reuseID = "busVehicle" let view = map.dequeueReusableAnnotationView(withIdentifier: reuseID) ?? MKAnnotationView(annotation: annotation, reuseIdentifier: reuseID) view.annotation = busVehicle view.image = busVehiclePillImage(route: busVehicle.vehicle.route) view.canShowCallout = false view.displayPriority = .required // never hidden by MapKit de-cluttering // Rotate pill to direction of travel. Image points right (east = 90°). // SIRI bearing has a direction-dependent offset from true-north map: // avenue routes (N/S): offset ≈ 25° (SIRI northbound ≈ 54°, southbound ≈ 234°) // cross-street (E/W): offset ≈ 38° (SIRI eastbound ≈ 157°, westbound ≈ 337°) // Thresholds are midpoints between the four clusters: 16°, 106°, 196°, 286°. let b = busVehicle.vehicle.bearing let isEW = (b < 16) || (b >= 106 && b < 196) || (b >= 286) let offset = isEW ? 38.0 : 25.0 let angle = vehicleAngle(bearing: b, mapHeading: map.camera.heading, siriOffset: offset) view.transform = CGAffineTransform(rotationAngle: angle) // Dead-reckoned position is already baked into annotation.coordinate at creation time. return view } if let subwayVehicle = annotation as? SubwayVehicleAnnotation { let reuseID = "subwayVehicle" let view = map.dequeueReusableAnnotationView(withIdentifier: reuseID) ?? MKAnnotationView(annotation: annotation, reuseIdentifier: reuseID) view.annotation = subwayVehicle view.image = subwayVehiclePillImage(route: subwayVehicle.vehicle.route) view.canShowCallout = false view.displayPriority = .required // Rotate the icon to point along the track, corrected for map heading. let angle = vehicleAngle(bearing: subwayVehicle.vehicle.bearing, mapHeading: map.camera.heading) view.transform = CGAffineTransform(rotationAngle: angle) // Route label — counter-rotated so it always appears upright on screen. // The label sits at the circle center (image center after transform). let labelTag = 9901 let route = subwayVehicle.vehicle.route let label: UILabel if let existing = view.subviews.first(where: { $0.tag == labelTag }) as? UILabel { label = existing } else { label = UILabel() label.tag = labelTag label.textAlignment = .center label.font = UIFont.systemFont(ofSize: 9, weight: .bold) label.isUserInteractionEnabled = false // don't swallow taps view.addSubview(label) } label.text = route label.textColor = subwayLineTextUIColor(for: route) label.sizeToFit() // IMPORTANT: reset transform to identity before setting center — // setting center while a transform is active applies in the transformed // coordinate space and produces the wrong position. label.transform = .identity // The image width = arrowLen + diameter + arrowLen = 9 + 22 + 9 = 40 // Circle center x = arrowLen + ringR = 9 + 11 = 20 (= image midX). let imgSize = view.image?.size ?? CGSize(width: 40, height: 22) label.center = CGPoint(x: imgSize.width / 2, y: imgSize.height / 2) // Counter-rotate so the text stays upright on screen regardless of // track angle AND map rotation. label.transform = CGAffineTransform(rotationAngle: -angle) return view } if let entrance = annotation as? EntranceAnnotation { let isElev = entrance.entrance.isElevator let reuseID = isElev ? "elevator" : "entrance" let view = map.dequeueReusableAnnotationView(withIdentifier: reuseID) ?? MKAnnotationView(annotation: annotation, reuseIdentifier: reuseID) view.annotation = entrance view.image = isElev ? elevatorIconImage : entranceIconImage view.canShowCallout = false view.displayPriority = .required view.isUserInteractionEnabled = false // taps pass through to stations beneath return view } return nil } } }`mapView()` functionImplements `mapView`. Returns `MKOverlayRenderer`.
▶ UICOLOR VERSION OF SUBWAY LINE COLOURS (FOR MKPOLYLINERENDERER)
private func subwayLineUIColor(for line: String) -> UIColor { switch line { case "1", "2", "3": return UIColor(red: 238/255, green: 53/255, blue: 46/255, alpha: 1) case "4", "5", "6": return UIColor(red: 0/255, green: 147/255, blue: 60/255, alpha: 1) case "7": return UIColor(red: 185/255, green: 51/255, blue: 173/255, alpha: 1) case "A", "C", "E": return UIColor(red: 0/255, green: 57/255, blue: 166/255, alpha: 1) case "B", "D", "F", "M": return UIColor(red: 255/255, green: 99/255, blue: 25/255, alpha: 1) case "G": return UIColor(red: 108/255, green: 190/255, blue: 69/255, alpha: 1) case "J", "Z": return UIColor(red: 153/255, green: 102/255, blue: 51/255, alpha: 1) case "L": return UIColor(red: 167/255, green: 169/255, blue: 172/255, alpha: 1) case "N", "Q", "R", "W": return UIColor(red: 252/255, green: 204/255, blue: 10/255, alpha: 1) case "S", "FS", "GS", "H", "SI": return UIColor(red: 128/255, green: 129/255, blue: 131/255, alpha: 1) default: return .gray } }`subwayLineUIColor()` functionImplements `subwayLineUIColor`. Returns `UIColor`.
private func subwayLineTextUIColor(for line: String) -> UIColor { switch line { case "N", "Q", "R", "W": return .black default: return .white } }`subwayLineTextUIColor()` functionImplements `subwayLineTextUIColor`. Returns `UIColor`.
▶ / APP STORE ICON FETCHER (USED FOR THIRD-PARTY MAP APP LOGOS IN THE DIRECTIONS BUBBLE)
private struct AppStoreIcon: View { let appStoreID: Int // numeric App Store ID — more reliable than bundle ID let size: CGFloat @State private var iconURL: URL? var body: some View { Group { if let url = iconURL { AsyncImage(url: url) { phase in if let img = phase.image { img.resizable().scaledToFit() .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: size * 0.2237)) } else { placeholder } } } else { placeholder } } .task(id: appStoreID) { guard iconURL == nil, let url = URL(string: "https://itunes.apple.com/lookup?id=\(appStoreID)"), let (data, _) = try? await URLSession.shared.data(from: url), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let results = json["results"] as? [[String: Any]], let artwork = results.first?["artworkUrl60"] as? String, let artURL = URL(string: artwork) else { return } iconURL = artURL } } private var placeholder: some View { RoundedRectangle(cornerRadius: size * 0.2237) .fill(Color.white.opacity(0.25)) .frame(width: size, height: size) } }`AppStoreIcon` structDefines the `AppStoreIcon` struct. Conforms to View.
▶ BUBBLE HEIGHT PREFERENCE KEY (USED TO PRECISELY POSITION THE CALLOUT)
private struct BubbleHeightKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } }`BubbleHeightKey` structDefines the `BubbleHeightKey` struct. Conforms to PreferenceKey.
▶ TRIANGLE SHAPES FOR SPEECH BUBBLE CALLOUT
private struct DownwardTriangle: Shape { func path(in rect: CGRect) -> Path { var p = Path() p.move(to: CGPoint(x: rect.minX, y: rect.minY)) p.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) p.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) p.closeSubpath() return p } }`DownwardTriangle` structDefines the `DownwardTriangle` struct. Conforms to Shape.
private struct UpwardTriangle: Shape { func path(in rect: CGRect) -> Path { var p = Path() p.move(to: CGPoint(x: rect.midX, y: rect.minY)) p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) p.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) p.closeSubpath() return p } }`UpwardTriangle` structDefines the `UpwardTriangle` struct. Conforms to Shape.
▶ SUBWAY LINE BADGE
private struct LineBadge: View { let line: String var body: some View { Text(line) .font(.system(size: 12, weight: .bold)) .foregroundColor(subwayLineTextColor(for: line)) .frame(width: 22, height: 22) .background(subwayLineColor(for: line), in: Circle()) } }`LineBadge` structDefines the `LineBadge` struct. Conforms to View.
▶ INLINE ALERT-HEADER RENDERING HELPERS
/// Renders a subway line badge as a UIImage so it can be embedded inline in a SwiftUI Text. private func lineBadgeImage(for line: String, size: CGFloat = 13) -> UIImage { let bgColor = subwayLineUIColor(for: line) let fgColor: UIColor = (line == "N" || line == "Q" || line == "R" || line == "W") ? .black : .white let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) return renderer.image { _ in bgColor.setFill() UIBezierPath(ovalIn: CGRect(origin: .zero, size: CGSize(width: size, height: size))).fill() let font = UIFont.systemFont(ofSize: size * 0.62, weight: .bold) let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: fgColor] let s = line as NSString let sz = s.size(withAttributes: attrs) s.draw(at: CGPoint(x: (size - sz.width) / 2, y: (size - sz.height) / 2), withAttributes: attrs) } }Documentation commentDescribes the following declaration.
/// Parses `[X]` / `[XY]` tokens in an alert header and returns a concatenated SwiftUI Text /// where each token is replaced by a coloured circle badge matching the subway line. private func alertHeaderText(_ header: String) -> Text { let plain: Text = Text(header) .font(.system(size: 10)) .foregroundColor(.white.opacity(0.85)) guard let regex = try? NSRegularExpression(pattern: #"\[([A-Z0-9]{1,2})\]"#) else { return plain } let ns = header as NSString let matches = regex.matches(in: header, range: NSRange(location: 0, length: ns.length)) guard !matches.isEmpty else { return plain } let style = Font.system(size: 10) let color = Color.white.opacity(0.85) var result = Text("") var lastEnd = 0 for match in matches { let matchRange = match.range if matchRange.location > lastEnd { let seg = ns.substring(with: NSRange(location: lastEnd, length: matchRange.location - lastEnd)) result = Text("\(result)\(Text(seg).font(style).foregroundColor(color))") } if let lineRange = Range(match.range(at: 1), in: header) { let line = String(header[lineRange]) let img = lineBadgeImage(for: line) result = Text("\(result)\(Text(Image(uiImage: img)).baselineOffset(-1))") } lastEnd = matchRange.location + matchRange.length } if lastEnd < ns.length { result = Text("\(result)\(Text(ns.substring(from: lastEnd)).font(style).foregroundColor(color))") } return result }Documentation commentDescribes the following declaration.
▶ SUBWAY STATION BUBBLE CONTENT (EXTRACTED TO KEEP GEOMETRYREADER BODY TYPE-CHECKER-FRIENDLY)
private struct SubwayBubbleContent: View { let station: SubwayStation let arrivals: [ArrivalInfo] let arrivalsLoading: Bool let alertsService: SubwayAlertsService let width: CGFloat let onClose: () -> Void @State private var expandedAlertIDs: Set<String> = [] var body: some View { ScrollView { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 4) { Text(station.name) .font(.system(size: 12, weight: .semibold)) .foregroundColor(.white) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 4) Button(action: onClose) { Image(systemName: "xmark.circle.fill") .font(.system(size: 14)) .foregroundColor(.white.opacity(0.7)) } } if !station.lines.isEmpty { LazyVGrid( columns: [GridItem(.adaptive(minimum: 22, maximum: 22), spacing: 4)], alignment: .leading, spacing: 4 ) { ForEach(station.lines, id: \.self) { LineBadge(line: $0) } } } Divider().background(Color.white.opacity(0.25)) if arrivalsLoading { Text("Loading…").font(.system(size: 11)).foregroundColor(.white.opacity(0.6)) } else if arrivals.isEmpty { Text("No upcoming trains").font(.system(size: 11)).foregroundColor(.white.opacity(0.6)) } else { ForEach(Array(arrivals.enumerated()), id: \.offset) { idx, arrival in if idx > 0 && arrival.directionId != arrivals[idx - 1].directionId { Divider().background(Color.gray.opacity(0.4)) } HStack(spacing: 6) { LineBadge(line: arrival.route) Text(arrival.headsign).font(.system(size: 11)).foregroundColor(.white).lineLimit(1) Spacer(minLength: 2) Text(arrival.minuteShort).font(.system(size: 11, weight: .semibold)).foregroundColor(.white).frame(minWidth: 20, alignment: .trailing) Text(arrival.secondMinuteShort ?? "—").font(.system(size: 11)).foregroundColor(.white).frame(minWidth: 20, alignment: .trailing) Text(arrival.thirdMinuteShort ?? "—").font(.system(size: 11)).foregroundColor(.white).frame(minWidth: 20, alignment: .trailing) Text("min").font(.system(size: 10)).foregroundColor(.white).padding(.leading, 4) } } } let stationAlerts = alertsService.alerts(for: station) if !stationAlerts.isEmpty { Divider().background(Color.white.opacity(0.25)) ForEach(stationAlerts) { alert in let expanded = expandedAlertIDs.contains(alert.id) Button { if alert.description.isEmpty { return } if expanded { expandedAlertIDs.remove(alert.id) } else { expandedAlertIDs.insert(alert.id) } } label: { HStack(alignment: .top, spacing: 5) { Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 9)).foregroundColor(.orange).padding(.top, 2) VStack(alignment: .leading, spacing: 2) { if !alert.alertType.isEmpty { Text(alert.alertType).font(.system(size: 10, weight: .semibold)).foregroundColor(.orange) } alertHeaderText(alert.header).fixedSize(horizontal: false, vertical: true) if expanded && !alert.description.isEmpty { alertHeaderText(alert.description) .fixedSize(horizontal: false, vertical: true) .padding(.top, 3) } } if !alert.description.isEmpty { Spacer(minLength: 2) Image(systemName: expanded ? "chevron.up" : "chevron.down") .font(.system(size: 8)) .foregroundColor(.white.opacity(0.4)) .padding(.top, 2) } } } .buttonStyle(.plain) } } let tomorrowAlerts = alertsService.tomorrowAlerts(for: station) if !tomorrowAlerts.isEmpty { Divider().background(Color.white.opacity(0.25)) Text("Tomorrow").font(.system(size: 10, weight: .semibold)).foregroundColor(.white.opacity(0.45)) ForEach(tomorrowAlerts) { alert in let expanded = expandedAlertIDs.contains(alert.id) Button { if alert.description.isEmpty { return } if expanded { expandedAlertIDs.remove(alert.id) } else { expandedAlertIDs.insert(alert.id) } } label: { HStack(alignment: .top, spacing: 5) { Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 9)).foregroundColor(.orange.opacity(0.6)).padding(.top, 2) VStack(alignment: .leading, spacing: 2) { if !alert.alertType.isEmpty { Text(alert.alertType).font(.system(size: 10, weight: .semibold)).foregroundColor(.orange.opacity(0.6)) } alertHeaderText(alert.header).fixedSize(horizontal: false, vertical: true) if expanded && !alert.description.isEmpty { alertHeaderText(alert.description) .fixedSize(horizontal: false, vertical: true) .padding(.top, 3) } } if !alert.description.isEmpty { Spacer(minLength: 2) Image(systemName: expanded ? "chevron.up" : "chevron.down") .font(.system(size: 8)) .foregroundColor(.white.opacity(0.4)) .padding(.top, 2) } } } .buttonStyle(.plain) } } } .padding(.horizontal, 10) .padding(.vertical, 8) .frame(width: width, alignment: .leading) } .frame(width: width) .frame(maxHeight: 600) .background(Color.black, in: RoundedRectangle(cornerRadius: 10)) } }`SubwayBubbleContent` structDefines the `SubwayBubbleContent` struct. Conforms to View.
▶ BUS STOP BUBBLE CONTENT (EXTRACTED TO KEEP GEOMETRYREADER BODY TYPE-CHECKER-FRIENDLY)
private struct BusBubbleContent: View { let stop: BusStop let arrivals: [BusArrivalInfo] let arrivalsLoading: Bool let width: CGFloat let onClose: () -> Void var body: some View { ScrollView { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 4) { Image(systemName: "bus.fill").font(.system(size: 11)).foregroundColor(.white.opacity(0.8)) Text(stop.name).font(.system(size: 12, weight: .semibold)).foregroundColor(.white).lineLimit(2).fixedSize(horizontal: false, vertical: true) Spacer(minLength: 4) Button(action: onClose) { Image(systemName: "xmark.circle.fill").font(.system(size: 14)).foregroundColor(.white.opacity(0.7)) } } LazyVGrid(columns: [GridItem(.adaptive(minimum: 44), spacing: 4)], alignment: .leading, spacing: 4) { ForEach(stop.routes, id: \.self) { route in Text(route).font(.system(size: 10, weight: .bold)).foregroundColor(.white) .padding(.horizontal, 5).padding(.vertical, 2) .background(busRouteColor(for: route), in: RoundedRectangle(cornerRadius: 4)) } } Divider().background(Color.white.opacity(0.25)) if BusArrivalsService.apiKey.isEmpty { Text("Add BusTime API key for arrivals").font(.system(size: 11)).foregroundColor(.white.opacity(0.5)) } else if arrivalsLoading { Text("Loading…").font(.system(size: 11)).foregroundColor(.white.opacity(0.6)) } else if arrivals.isEmpty { Text("No upcoming buses").font(.system(size: 11)).foregroundColor(.white.opacity(0.6)) } else { ForEach(arrivals) { arrival in HStack(spacing: 6) { Text(arrival.route).font(.system(size: 10, weight: .bold)).foregroundColor(.white) .padding(.horizontal, 4).padding(.vertical, 2) .background(busRouteColor(for: arrival.route), in: RoundedRectangle(cornerRadius: 4)) Text(arrival.headsign).font(.system(size: 11)).foregroundColor(.white).lineLimit(1) Spacer(minLength: 2) Text(arrival.minuteShort).font(.system(size: 11, weight: .semibold)).foregroundColor(.white).frame(minWidth: 20, alignment: .trailing) Text(arrival.secondMinuteShort ?? "—").font(.system(size: 11)).foregroundColor(.white).frame(minWidth: 20, alignment: .trailing) Text(arrival.thirdMinuteShort ?? "—").font(.system(size: 11)).foregroundColor(.white).frame(minWidth: 20, alignment: .trailing) Text("min").font(.system(size: 10)).foregroundColor(.white).padding(.leading, 4) } } } } .padding(.horizontal, 10) .padding(.vertical, 8) .frame(width: width, alignment: .leading) } .frame(width: width) .frame(maxHeight: 600) .background(Color(red: 60/255, green: 60/255, blue: 60/255), in: RoundedRectangle(cornerRadius: 10)) } }`BusBubbleContent` structDefines the `BusBubbleContent` struct. Conforms to View.
▶ NEARBYMAPVIEW
struct NearbyMapView: View { @Binding var entries: [SpotEntry] let userLocation: CLLocation var isLoading: Bool = false @State private var region: MKCoordinateRegion @State private var selectedIndex: Int? @State private var selectedStation: SubwayStation? @State private var stationArrivals: [ArrivalInfo] = [] @State private var arrivalsLoading = false @State private var bubbleHeight: CGFloat = 80 // Search @State private var searchText = "" @State private var searchResults: [MKMapItem] = [] @State private var searchAnnotations: [SearchResultAnnotation] = [] // stable; rebuilt only when results change @State private var selectedResult: MKMapItem? @State private var resultBubbleHeight: CGFloat = 60 @State private var isSearching = false @State private var showResults = false @State private var showSubwayLines = true @State private var showEntrances = false @State private var showApplePOIs = false @State private var showShops = false // saved for future Stores button @State private var showLiveSubway = false @State private var showToTry = true @State private var minToTryPriority = 1 // 1 = show all, 5 = show only 5-bullet entries @State private var showToTryPicker = false // long-press popover @State private var showBeenThere = true @State private var minBeenThereStar = 1 // 1 = show all, 5 = show only 5-star entries @State private var showBeenTherePicker = false // long-press popover // Place-mode toggles @State private var showVisit = true @State private var minVisitPriority = 1 @State private var showVisitPicker = false @State private var showVisited = true @State private var minVisitedStar = 1 @State private var showVisitedPicker = false // Show-mode toggles (Col 3) @State private var showUpcoming = true @State private var showConsider = true @State private var showNames = true @State private var centerOnUser = false // Spot directions bubble (long-press) @State private var directionsSpot: (id: UUID, name: String, coordinate: CLLocationCoordinate2D, address: String, phone: String)? @State private var spotBubbleHeight: CGFloat = 60 @State private var detailSheetEntryID: UUID? // Bus stops & live vehicles @State private var showBusStops = false @State private var showLiveVehicles = false @State private var busStopAnnotationCache: [BusStopAnnotation] = [] @State private var selectedBusStop: BusStop? @State private var busStopBubbleHeight: CGFloat = 60 @State private var busArrivals: [BusArrivalInfo] = [] @State private var busArrivalsLoading = false // Cached annotation arrays — rebuilt once when data loads, not on every render @State private var stationAnnotationCache: [StationAnnotation] = [] @State private var entranceAnnotationCache: [EntranceAnnotation] = [] @ObservedObject private var stationService = SubwayStationService.shared @ObservedObject private var linesService = SubwayLinesService.shared @ObservedObject private var alertsService = SubwayAlertsService.shared let initialSearchQuery: String let initialCenter: CLLocationCoordinate2D? let initialSpan: MKCoordinateSpan? let initialName: String? let initialTheaters: [TheaterAnnotation] let initialShowSubway: Bool let initialShowBus: Bool /// Controls which spot icons are shown when the map first appears. /// nil → subway only (standalone map tab, no prior content tab) /// .food → To Try / Been There pins /// .place → Visit / Visited pins /// .show → Upcoming / Consider pins let initialEntryMode: EntryMode? /// Live address search injected by the main Map tab when the user taps an address /// in an Entry. Consumed once in .task; binding is reset to "" after use. /// Sub-screen instances (navigationDestination) leave this as .constant(""). @Binding var tabSearchQuery: String /// Entry whose bubble should auto-open after the map centers. Consumed once in .task. @Binding var tabFocusEntryID: UUID? init(entries: Binding<[SpotEntry]>, userLocation: CLLocation, isLoading: Bool = false, initialSearchQuery: String = "", initialCenter: CLLocationCoordinate2D? = nil, initialSpan: MKCoordinateSpan? = nil, initialName: String? = nil, initialTheaters: [TheaterAnnotation] = [], initialEntryMode: EntryMode? = nil, initialShowSubway: Bool = true, initialShowBus: Bool = false, tabSearchQuery: Binding<String> = .constant(""), tabFocusEntryID: Binding<UUID?> = .constant(nil)) { _entries = entries self.userLocation = userLocation self.isLoading = isLoading self.initialSearchQuery = initialSearchQuery self.initialCenter = initialCenter self.initialSpan = initialSpan self.initialName = initialName self.initialTheaters = initialTheaters self.initialEntryMode = initialEntryMode self.initialShowSubway = initialShowSubway self.initialShowBus = initialShowBus _tabSearchQuery = tabSearchQuery _tabFocusEntryID = tabFocusEntryID let center = initialCenter ?? userLocation.coordinate if let span = initialSpan { _region = State(initialValue: MKCoordinateRegion(center: center, span: span)) } else { _region = State(initialValue: MKCoordinateRegion( center: center, latitudinalMeters: 2200, longitudinalMeters: 2200 )) } // Set initial spot-visibility based on which content tab launched the map. // nil means standalone map tab — subway only, no spot pins. _showToTry = State(initialValue: initialEntryMode == .food) _showBeenThere = State(initialValue: initialEntryMode == .food) _showVisit = State(initialValue: initialEntryMode == .place) _showVisited = State(initialValue: initialEntryMode == .place) _showUpcoming = State(initialValue: initialEntryMode == .show) _showConsider = State(initialValue: initialEntryMode == .show) _showSubwayLines = State(initialValue: initialShowSubway) _showBusStops = State(initialValue: initialShowBus) } /// Opens the directions bubble for a specific entry — same logic as onLongPressSpot. private func openBubble(for id: UUID) { guard let entry = entries.first(where: { $0.id == id }), let lat = entry.latitude, let lon = entry.longitude else { return } let street = entry.address .components(separatedBy: ",").first? .trimmingCharacters(in: .whitespaces) ?? entry.address directionsSpot = (id: id, name: entry.playName, coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon), address: street, phone: entry.phone) if entry.phone.isEmpty { PhoneExtractor.extract(for: entry) { phone, _ in guard !phone.isEmpty else { return } if let idx = entries.firstIndex(where: { $0.id == id }) { entries[idx].phone = phone } if directionsSpot?.id == id { directionsSpot?.phone = phone } } } } private func regionContains(_ coord: CLLocationCoordinate2D) -> Bool { abs(coord.latitude - region.center.latitude) <= region.span.latitudeDelta / 2 && abs(coord.longitude - region.center.longitude) <= region.span.longitudeDelta / 2 } private var visibleStationAnnotations: [StationAnnotation] { showSubwayLines ? stationAnnotationCache : [] } private var visibleEntranceAnnotations: [EntranceAnnotation] { showEntrances ? entranceAnnotationCache : [] } private var visiblePolylines: [SubwayPolyline] { showSubwayLines ? linesService.polylines : [] } private var visibleBusStopAnnotations: [BusStopAnnotation] { showBusStops ? busStopAnnotationCache : [] } private var visibleAnnotations: [SpotAnnotation] { entries.compactMap { entry in guard let lat = entry.latitude, let lon = entry.longitude else { return nil } let coord = CLLocationCoordinate2D(latitude: lat, longitude: lon) guard regionContains(coord) else { return nil } let isTried = entry.beenThere let isPlace = entry.entryMode == .place let isShow = entry.entryMode == .show || entry.entryMode == .event if isPlace { // Place entries: controlled by Visit / Visited toggles if isTried && !showVisited { return nil } if !isTried && !showVisit { return nil } if !isTried && minVisitPriority > 1 && entry.rating < minVisitPriority { return nil } if isTried && minVisitedStar > 1 && entry.starRating < minVisitedStar { return nil } } else if isShow { // Show entries: controlled by Upcoming / Consider toggles if entry.consider && !showConsider { return nil } if !entry.consider && !showUpcoming { return nil } // Only show upcoming (non-consider) shows that haven't happened yet if !entry.consider && entry.dateTime < Date() { return nil } } else if entry.entryMode == .shop { // Shop entries: master Stores toggle if !showShops { return nil } } else { // Food entries: controlled by To Try / Been There toggles if isTried && !showBeenThere { return nil } if !isTried && !showToTry { return nil } if !isTried && minToTryPriority > 1 && entry.rating < minToTryPriority { return nil } if isTried && minBeenThereStar > 1 && entry.starRating < minBeenThereStar { return nil } } return SpotAnnotation(id: entry.id, coordinate: coord, name: entry.playName, isTried: isTried, priority: entry.rating, starRating: entry.starRating, entryMode: entry.entryMode, consider: entry.consider) } } // Extracted to help the compiler type-check body in reasonable time @ViewBuilder private var transitMapLayer: some View { TransitMapView( region: $region, centerOnUser: $centerOnUser, annotations: visibleAnnotations, stationAnnotations: visibleStationAnnotations, entranceAnnotations: visibleEntranceAnnotations, searchAnnotations: searchAnnotations, theaterAnnotations: initialTheaters, busStopAnnotations: visibleBusStopAnnotations, showBusStops: showBusStops, showLiveVehicles: showLiveVehicles, showLiveSubway: showLiveSubway, subwayPolylines: visiblePolylines, showApplePOIs: showApplePOIs, showNames: showNames, onSelectSpot: { id in if let idx = entries.firstIndex(where: { $0.id == id }) { selectedIndex = idx } }, onLongPressSpot: { id in openBubble(for: id) }, onTransitTapped: { station in selectedStation = station selectedResult = nil selectedBusStop = nil stationArrivals = [] arrivalsLoading = true }, onSearchResultTapped: { item in selectedResult = item selectedStation = nil selectedBusStop = nil showResults = false }, onBusStopTapped: { stop in selectedBusStop = stop selectedStation = nil selectedResult = nil busArrivals = [] busArrivalsLoading = true }, onRegionSettled: { if showBusStops { rebuildBusStops() } } ) .ignoresSafeArea(edges: .bottom) } var body: some View { ZStack(alignment: .bottom) { transitMapLayer .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .navigationDestination(isPresented: Binding( get: { selectedIndex != nil }, set: { if !$0 { selectedIndex = nil } } )) { if let idx = selectedIndex { EntryDetailView( entry: $entries[idx], onOpenInAppMap: { address, _ in // Already on the map — dismiss detail and search for this address selectedIndex = nil searchText = address performSearch() } ) } } // Station speech-bubble callout — tracks station coordinate as map pans/zooms .overlay { if let station = selectedStation { GeometryReader { geo in // Convert geographic coordinate → screen point using linear map projection. // This recalculates automatically whenever `region` changes (pan/zoom). let latFraction = (station.coordinate.latitude - region.center.latitude) / region.span.latitudeDelta let lonFraction = (station.coordinate.longitude - region.center.longitude) / region.span.longitudeDelta let rawX = geo.size.width / 2 + CGFloat(lonFraction) * geo.size.width let rawY = geo.size.height / 2 - CGFloat(latFraction) * geo.size.height let bubbleMaxW: CGFloat = 270 let tipY = rawY + 9 // just below the ~10pt station icon let halfW = bubbleMaxW / 2 let clampX = min(max(rawX, halfW + 8), geo.size.width - halfW - 8) let tipOffsetX = rawX - clampX // shifts triangle to still point at station VStack(spacing: 0) { UpwardTriangle() .fill(Color.black) .frame(width: 14, height: 7) .offset(x: tipOffsetX) SubwayBubbleContent( station: station, arrivals: stationArrivals, arrivalsLoading: arrivalsLoading, alertsService: alertsService, width: bubbleMaxW, onClose: { selectedStation = nil; stationArrivals = [] } ) } .fixedSize(horizontal: false, vertical: true) .background(GeometryReader { bGeo in Color.clear.preference(key: BubbleHeightKey.self, value: bGeo.size.height) }) .position(x: clampX, y: tipY + bubbleHeight / 2) .transition(.opacity.combined(with: .scale(scale: 0.92, anchor: .top))) .animation(.easeInOut(duration: 0.18), value: station.name) } .onPreferenceChange(BubbleHeightKey.self) { h in if h > 0 { bubbleHeight = h } } } } // Search result speech-bubble callout .overlay { if let result = selectedResult { let coord = result.coordinate GeometryReader { geo in let latFraction = (coord.latitude - region.center.latitude) / region.span.latitudeDelta let lonFraction = (coord.longitude - region.center.longitude) / region.span.longitudeDelta let rawX = geo.size.width / 2 + CGFloat(lonFraction) * geo.size.width let rawY = geo.size.height / 2 - CGFloat(latFraction) * geo.size.height let bubbleW: CGFloat = 226 let tipY = rawY - 44 // MKMarkerAnnotationView is taller than station dot let halfW = bubbleW / 2 let clampX = min(max(rawX, halfW + 8), geo.size.width - halfW - 8) let tipOffsetX = rawX - clampX VStack(spacing: 0) { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 4) { Text(result.name ?? "Place") .font(.system(size: 12, weight: .semibold)) .foregroundColor(.white) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 4) Button { selectedResult = nil } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 14)) .foregroundColor(.white.opacity(0.7)) } } if let cat = result.pointOfInterestCategory?.rawValue .replacingOccurrences(of: "MKPOICategory", with: "") { Text(cat) .font(.system(size: 11)) .foregroundColor(.white.opacity(0.75)) } if let addr = result.shortAddress, !addr.isEmpty { Text(addr) .font(.system(size: 11)) .foregroundColor(.white.opacity(0.75)) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) } if let phone = result.phoneNumber, !phone.isEmpty { Button { let digits = phone.filter { $0.isNumber || $0 == "+" } if let url = URL(string: "tel://\(digits)") { UIApplication.shared.open(url) } } label: { Label(phone, systemImage: "phone.fill") .font(.system(size: 11)) .foregroundColor(.white.opacity(0.9)) } } HStack(spacing: 8) { Button { result.openInMaps(launchOptions: nil) } label: { Text("Apple Maps") .font(.system(size: 11, weight: .medium)) .foregroundColor(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color.white.opacity(0.2), in: RoundedRectangle(cornerRadius: 6)) } Button { let lat = result.coordinate.latitude let lon = result.coordinate.longitude let name = (result.name ?? "").addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" if let url = URL(string: "comgooglemaps://?q=\(name)&center=\(lat),\(lon)"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } else if let url = URL(string: "https://maps.google.com/?q=\(name)&ll=\(lat),\(lon)") { UIApplication.shared.open(url) } } label: { Text("Google Maps") .font(.system(size: 11, weight: .medium)) .foregroundColor(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color.white.opacity(0.2), in: RoundedRectangle(cornerRadius: 6)) } Spacer() } .padding(.top, 2) } .padding(.horizontal, 10) .padding(.vertical, 8) .frame(width: bubbleW, alignment: .leading) .background(Color.indigo, in: RoundedRectangle(cornerRadius: 10)) DownwardTriangle() .fill(Color.indigo) .frame(width: 14, height: 7) .offset(x: tipOffsetX) } .fixedSize(horizontal: false, vertical: true) .background(GeometryReader { bGeo in Color.clear.preference(key: BubbleHeightKey.self, value: bGeo.size.height) }) .position(x: clampX, y: tipY - resultBubbleHeight / 2) .transition(.opacity.combined(with: .scale(scale: 0.92, anchor: .bottom))) .animation(.easeInOut(duration: 0.18), value: result.name) } .onPreferenceChange(BubbleHeightKey.self) { h in if h > 0 { resultBubbleHeight = h } } } } // Spot directions bubble (long-press) .overlay { if let spot = directionsSpot { let coord = spot.coordinate GeometryReader { geo in let latFraction = (coord.latitude - region.center.latitude) / region.span.latitudeDelta let lonFraction = (coord.longitude - region.center.longitude) / region.span.longitudeDelta let rawX = geo.size.width / 2 + CGFloat(lonFraction) * geo.size.width let rawY = geo.size.height / 2 - CGFloat(latFraction) * geo.size.height let bubbleW: CGFloat = 220 let tipY = rawY - 16 // just above the spot pin image let halfW = bubbleW / 2 let clampX = min(max(rawX, halfW + 8), geo.size.width - halfW - 8) let tipOffsetX = rawX - clampX VStack(spacing: 0) { VStack(alignment: .leading, spacing: 5) { let hasEntry = entries.contains(where: { $0.id == spot.id }) // Header row: name + close button HStack(alignment: .top) { HStack(spacing: 3) { Text(spot.name) .font(.system(size: 13, weight: .semibold)) .foregroundColor(.white) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) if hasEntry { Image(systemName: "chevron.right") .font(.system(size: 9, weight: .semibold)) .foregroundColor(.white.opacity(0.6)) } } .contentShape(Rectangle()) .onTapGesture { if hasEntry { detailSheetEntryID = spot.id } } Spacer(minLength: 4) Button { directionsSpot = nil } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 14)) .foregroundColor(.white.opacity(0.7)) } } // Street address (no city/state/zip) if !spot.address.isEmpty { Text(spot.address) .font(.system(size: 11)) .foregroundColor(.white.opacity(0.8)) .lineLimit(1) .contentShape(Rectangle()) .onTapGesture { if hasEntry { detailSheetEntryID = spot.id } } } // Phone number if !spot.phone.isEmpty { Button { let digits = spot.phone.filter { $0.isNumber || $0 == "+" } if let url = URL(string: "tel://\(digits)") { UIApplication.shared.open(url) } } label: { Text(spot.phone) .font(.system(size: 11)) .foregroundColor(.white.opacity(0.8)) } } Divider().background(Color.white.opacity(0.3)) // Direction logo buttons HStack(spacing: 20) { // Apple Maps Button { let item = MKMapItem(placemark: MKPlacemark(coordinate: coord)) item.name = spot.name item.openInMaps(launchOptions: [ MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeTransit ]) } label: { VStack(spacing: 3) { AppStoreIcon(appStoreID: 915056765, size: 26) Text("Maps") .font(.system(size: 9)) .foregroundColor(.white.opacity(0.75)) } } // Google Maps Button { let lat = coord.latitude let lon = coord.longitude let name = spot.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" if let url = URL(string: "comgooglemaps://?q=\(name)&center=\(lat),\(lon)"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } else if let url = URL(string: "https://maps.google.com/?q=\(name)&ll=\(lat),\(lon)") { UIApplication.shared.open(url) } } label: { VStack(spacing: 3) { AppStoreIcon(appStoreID: 585027354, size: 26) Text("Maps") .font(.system(size: 9)) .foregroundColor(.white.opacity(0.75)) } } // Citymapper Button { let lat = coord.latitude let lon = coord.longitude let name = spot.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" if let url = URL(string: "citymapper://directions?endcoord=\(lat),\(lon)&endname=\(name)"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } else if let url = URL(string: "https://citymapper.com/directions?endcoord=\(lat),\(lon)&endname=\(name)") { UIApplication.shared.open(url) } } label: { VStack(spacing: 3) { AppStoreIcon(appStoreID: 469463298, size: 26) Text("City") .font(.system(size: 9)) .foregroundColor(.white.opacity(0.75)) } } // MTA Button { let fromLat = userLocation.coordinate.latitude let fromLon = userLocation.coordinate.longitude let toLat = coord.latitude let toLon = coord.longitude let name = spot.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" // MTA doesn't publish its URL scheme; this is the best-known pattern let deepLink = "info.mta.mymta://trip-planner?fromLat=\(fromLat)&fromLon=\(fromLon)&toLat=\(toLat)&toLon=\(toLon)&toName=\(name)" if let url = URL(string: deepLink), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } else if let url = URL(string: "info.mta.mymta://open") { UIApplication.shared.open(url)`NearbyMapView` structDefines the `NearbyMapView` struct. Conforms to View.
} } label: { VStack(spacing: 3) { AppStoreIcon(appStoreID: 1297605670, size: 26) Text("MTA") .font(.system(size: 9)) .foregroundColor(.white.opacity(0.75)) } } Spacer() } .padding(.top, 2) } .padding(.horizontal, 10) .padding(.vertical, 8) .frame(width: bubbleW, alignment: .leading) .background(Color.indigo, in: RoundedRectangle(cornerRadius: 10)) DownwardTriangle() .fill(Color.indigo) .frame(width: 14, height: 7) .offset(x: tipOffsetX) } .fixedSize(horizontal: false, vertical: true) .background(GeometryReader { bGeo in Color.clear.preference(key: BubbleHeightKey.self, value: bGeo.size.height) }) .position(x: clampX, y: tipY - spotBubbleHeight / 2) .transition(.opacity.combined(with: .scale(scale: 0.92, anchor: .bottom))) .animation(.easeInOut(duration: 0.18), value: spot.name) } .onPreferenceChange(BubbleHeightKey.self) { h in if h > 0 { spotBubbleHeight = h } } } } // Bus stop speech-bubble callout .overlay { if let stop = selectedBusStop { let coord = stop.coordinate GeometryReader { geo in let latFraction = (coord.latitude - region.center.latitude) / region.span.latitudeDelta let lonFraction = (coord.longitude - region.center.longitude) / region.span.longitudeDelta let rawX = geo.size.width / 2 + CGFloat(lonFraction) * geo.size.width let rawY = geo.size.height / 2 - CGFloat(latFraction) * geo.size.height let bubbleW: CGFloat = 226 let tipY = rawY + 16 let halfW = bubbleW / 2 let clampX = min(max(rawX, halfW + 8), geo.size.width - halfW - 8) let tipOffX = rawX - clampX VStack(spacing: 0) { UpwardTriangle() .fill(Color(red: 60/255, green: 60/255, blue: 60/255)) .frame(width: 14, height: 7) .offset(x: tipOffX) BusBubbleContent( stop: stop, arrivals: busArrivals, arrivalsLoading: busArrivalsLoading, width: bubbleW, onClose: { selectedBusStop = nil } ) } .fixedSize(horizontal: false, vertical: true) .background(GeometryReader { bGeo in Color.clear.preference(key: BubbleHeightKey.self, value: bGeo.size.height) }) .position(x: clampX, y: tipY + busStopBubbleHeight / 2) .transition(.opacity.combined(with: .scale(scale: 0.92, anchor: .top))) .animation(.easeInOut(duration: 0.18), value: stop.name) } .onPreferenceChange(BubbleHeightKey.self) { h in if h > 0 { busStopBubbleHeight = h } } } } // Names toggle — bottom-left, above the bottom panel (mirrors location button) .overlay(alignment: .bottomLeading) { Button { showNames.toggle() } label: { Image(systemName: "tag.fill") .font(.system(size: 15, weight: .medium)) .foregroundStyle(showNames ? Color.primary : Color.secondary) .padding(10) .background(.ultraThinMaterial, in: Circle()) .shadow(color: .black.opacity(0.15), radius: 4, x: 0, y: 2) } .padding(.leading, 14) .padding(.bottom, 120) } // Current-location button — bottom-right, above the bottom panel .overlay(alignment: .bottomTrailing) { Button { centerOnUser = true } label: { Image(systemName: "location.fill") .font(.system(size: 15, weight: .medium)) .foregroundStyle(Color.primary) .padding(10) .background(.ultraThinMaterial, in: Circle()) .shadow(color: .black.opacity(0.15), radius: 4, x: 0, y: 2) } .padding(.trailing, 14) .padding(.bottom, 120) } // Loading spinner .overlay { if isLoading { ZStack { Color.black.opacity(0.35).ignoresSafeArea() VStack(spacing: 12) { ProgressView().tint(.white).scaleEffect(1.4) Text("Finding nearby spots…") .font(.system(size: 15, weight: .medium)) .foregroundColor(.white) } .padding(24) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) } } } .task { // Load bus stop data in the background so it's ready when the user taps "Bus" BusStopService.shared.loadIfNeeded() await stationService.loadIfNeeded() stationAnnotationCache = stationService.stations.map { StationAnnotation(station: $0) } await SubwayLinesService.shared.loadIfNeeded() // Two paths that trigger an address search on mount: // • initialSearchQuery — sub-screen mode (navigationDestination) // • tabSearchQuery — tab-switch mode (user tapped address in an Entry, // we switched to the Map tab instead of a sub-screen) // The tab-switch path resets the binding after reading it so the same address // doesn't re-search if the user leaves and returns to the tab. let effectiveQuery: String if !initialSearchQuery.isEmpty { effectiveQuery = initialSearchQuery } else if !tabSearchQuery.isEmpty { effectiveQuery = tabSearchQuery tabSearchQuery = "" // consume — prevents re-search on next recreation } else { effectiveQuery = "" } if !effectiveQuery.isEmpty { searchText = effectiveQuery isSearching = true let req = MKLocalSearch.Request() req.naturalLanguageQuery = effectiveQuery req.region = region if let response = try? await MKLocalSearch(request: req).start() { let items = response.mapItems searchResults = items // builds annotations via onChange if let first = items.first { selectedResult = first withAnimation { region = MKCoordinateRegion( center: first.coordinate, latitudinalMeters: 800, longitudinalMeters: 800 ) } } } isSearching = false } // If an entry was specified, center on its exact pin and open its bubble. // We do this after the address search (which already panned the map) so the // entry's stored coordinate takes precedence over the MKLocalSearch result. if let focusID = tabFocusEntryID { tabFocusEntryID = nil // consume if let entry = entries.first(where: { $0.id == focusID }), let lat = entry.latitude, let lon = entry.longitude { withAnimation { region = MKCoordinateRegion( center: CLLocationCoordinate2D(latitude: lat, longitude: lon), latitudinalMeters: 800, longitudinalMeters: 800 ) } // Brief pause so the map finishes panning before the bubble appears. try? await Task.sleep(for: .milliseconds(350)) openBubble(for: focusID) } } // If the map was opened with bus stops pre-selected, build them now — // after any address search has re-centered the region. if initialShowBus { rebuildBusStops() } } // Auto-refresh arrivals every 60 s while the station bubble is visible. // Keyed on station name: starts a fresh loop when a new station is tapped, // and cancels automatically when the bubble is dismissed (selectedStation → nil). .task(id: selectedStation?.stopIDs.first) { guard let station = selectedStation else { return } // Fetch arrivals and (if stale) alerts in parallel async let arrivals = SubwayArrivalsService.fetch(for: station) async let alertsFetch: Void = alertsService.fetchIfNeeded() stationArrivals = await arrivals _ = await alertsFetch arrivalsLoading = false // Refresh every 60 s until task is cancelled while !Task.isCancelled { try? await Task.sleep(for: .seconds(60)) guard !Task.isCancelled, selectedStation?.name == station.name else { break } let refreshed = await SubwayArrivalsService.fetch(for: station) stationArrivals = refreshed } } // Auto-refresh bus arrivals every 60 s while the bus stop bubble is visible. .task(id: selectedBusStop?.id) { guard let stop = selectedBusStop else { return } busArrivals = await BusArrivalsService.fetch(for: stop) busArrivalsLoading = false while !Task.isCancelled { try? await Task.sleep(for: .seconds(60)) guard !Task.isCancelled, selectedBusStop?.id == stop.id else { break } busArrivals = await BusArrivalsService.fetch(for: stop) } } .onChange(of: searchResults) { _, newResults in // Rebuild annotation objects only when results actually change — // NOT on every region update — so updateUIView sees stable objects // and its diff produces zero add/remove operations each frame. searchAnnotations = newResults.map { SearchResultAnnotation($0) } } .onChange(of: showEntrances) { _, newValue in guard newValue else { return } if stationService.entrances.isEmpty { // Not loaded yet (or a previous network call failed) — fetch now. Task { await stationService.loadEntrancesIfNeeded() entranceAnnotationCache = stationService.entrances.map { EntranceAnnotation(entrance: $0) } } } else { // Already loaded — just populate the cache so the annotations appear. entranceAnnotationCache = stationService.entrances.map { EntranceAnnotation(entrance: $0) } } } // Legend / layer toggles — ZStack sibling of TransitMapView so that Menu // presents from the proper SwiftUI hosting context (avoids _UIReparentingView warning). if !isLoading { VStack(alignment: .leading, spacing: 0) { // Search bar + More — pinned above the filter row if initialSearchQuery.isEmpty { Divider() HStack(spacing: 0) { Button { showEntrances.toggle() } label: { HStack(spacing: 4) { Image(systemName: "stairs") .foregroundStyle(showEntrances ? Color(red: 46/255, green: 139/255, blue: 87/255) : Color.secondary) Text("Entry") .foregroundStyle(showEntrances ? Color(red: 46/255, green: 139/255, blue: 87/255) : Color.secondary) } } .font(.system(size: 11)) .frame(width: 74, height: 28) Divider().frame(height: 28) HStack(spacing: 6) { Image(systemName: isSearching ? "circle.dotted" : "magnifyingglass") .font(.system(size: 12)) .foregroundColor(.secondary) .symbolEffect(.rotate, isActive: isSearching) TextField("Search…", text: $searchText) .font(.system(size: 12)) .submitLabel(.search) .onSubmit { performSearch() } if !searchText.isEmpty { Button { clearSearch() } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 12)) .foregroundColor(.secondary) } } } .padding(.horizontal, 8) .padding(.vertical, 3) .background(Color(UIColor.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8)) .frame(maxWidth: .infinity) .padding(.leading, 10).padding(.trailing, 2) Divider().frame(height: 28) // Stores button — saved for future use: // Button { showShops.toggle() } label: { // HStack(spacing: 4) { // Image(systemName: "bag").foregroundStyle(showShops ? Color.primary : Color.secondary) // Text("Stores").foregroundStyle(showShops ? Color.primary : Color.secondary) // } // } // .font(.system(size: 11)).frame(width: 79, height: 28) Button { showApplePOIs.toggle() } label: { HStack(spacing: 4) { Image(systemName: "fork.knife") .foregroundStyle(showApplePOIs ? Color.orange : Color.secondary) Text("More") .foregroundStyle(showApplePOIs ? Color.orange : Color.secondary) } } .font(.system(size: 11)) .frame(width: 91, height: 28) } .frame(height: 28) // Results list — shown below the search bar if showResults && !searchResults.isEmpty { Divider() ScrollView { LazyVStack(alignment: .leading, spacing: 0) { ForEach(searchResults, id: \.self) { item in Button { selectedResult = item selectedStation = nil showResults = false withAnimation { region = MKCoordinateRegion( center: item.coordinate, latitudinalMeters: 800, longitudinalMeters: 800 ) } } label: { HStack(spacing: 10) { Image(systemName: "mappin.circle.fill") .font(.system(size: 22)) .foregroundColor(.indigo) VStack(alignment: .leading, spacing: 1) { Text(item.name ?? "Place") .font(.system(size: 13, weight: .medium)) .foregroundColor(.primary) if let addr = item.shortAddress, !addr.isEmpty { Text(addr) .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) } } Spacer() } .padding(.horizontal, 14) .padding(.vertical, 8) } Divider().padding(.leading, 46) } } } .frame(maxHeight: 220) } } Divider() // Single filter row — 5 columns // Col 1: Subway + Live(sub) Col 2: Bus + Live(bus) Col 3: Visit + Visited Col 4: Upcoming + Consider Col 5: Try + Been let crimson = Color(red: 220/255, green: 20/255, blue: 60/255) let purple = Color(red: 103/255, green: 58/255, blue: 183/255) let teal = Color(red: 0/255, green: 150/255, blue: 136/255) HStack(spacing: 0) { // Col 1: Subway + Live (subway live vehicles) VStack(spacing: 0) { Button { showSubwayLines.toggle() } label: { HStack(spacing: 4) { Image(systemName: "tram.fill.tunnel") Text("Subway") } .foregroundStyle(showSubwayLines ? Color.primary : Color.secondary) .frame(maxWidth: .infinity, minHeight: 28, maxHeight: 28) } Divider() Button { showLiveSubway.toggle() } label: { HStack(spacing: 4) { Image(systemName: "tram.fill") .font(.system(size: 11)) Text("Live") } .foregroundStyle(showLiveSubway ? Color(red: 0, green: 57/255, blue: 166/255) : Color.secondary) .frame(maxWidth: .infinity, minHeight: 28, maxHeight: 28) } } .frame(width: 74) Divider().frame(height: 56) // Col 2: Bus + Live (bus live vehicles) VStack(spacing: 0) { Button { showBusStops.toggle() if showBusStops { rebuildBusStops() } } label: { HStack(spacing: 6) { Image(systemName: "bus.fill") .foregroundStyle(showBusStops ? Color(red: 255/255, green: 99/255, blue: 25/255) : Color.secondary) Text("Bus") .foregroundStyle(showBusStops ? Color.primary : Color.secondary) } .frame(maxWidth: .infinity, minHeight: 28, maxHeight: 28, alignment: .leading) .padding(.horizontal, 12) } Divider() Button { showLiveVehicles.toggle() } label: { HStack(spacing: 6) { Image(systemName: "dot.radiowaves.left.and.right") .font(.system(size: 11)) Text("Live") } .foregroundStyle(showLiveVehicles ? Color(red: 255/255, green: 99/255, blue: 25/255) : Color.secondary) .frame(maxWidth: .infinity, minHeight: 28, maxHeight: 28, alignment: .leading) .padding(.horizontal, 12) } } .frame(width: 76) Divider().frame(height: 56) // Col 3: Visit (purple) + Visited (teal) VStack(spacing: 0) { Button { showVisit.toggle() } label: { HStack(spacing: 4) { Text(String(repeating: "●", count: minVisitPriority) + (minVisitPriority < 5 ? "+" : "")) .foregroundStyle(showVisit ? purple : Color.secondary) Text("Visit") .foregroundStyle(showVisit ? purple : Color.secondary) } .frame(maxWidth: .infinity, minHeight: 28, maxHeight: 28, alignment: .leading) .padding(.horizontal, 12) } .simultaneousGesture( LongPressGesture(minimumDuration: 0.4).onEnded { _ in showVisitPicker = true } ) .popover(isPresented: $showVisitPicker, attachmentAnchor: .point(.top), arrowEdge: .bottom) { VStack(spacing: 0) { ForEach([5, 4, 3, 2, 1], id: \.self) { threshold in Button { showVisit = true minVisitPriority = threshold showVisitPicker = false } label: { Text(String(repeating: "●", count: threshold) + (threshold < 5 ? "+" : "")) .font(.system(size: 15)) .foregroundStyle(purple) .padding(.horizontal, 20) .padding(.vertical, 10) } if threshold > 1 { Divider() } } } .presentationCompactAdaptation(.popover) } Divider() Button { showVisited.toggle() } label: { HStack(spacing: 4) { Text(String(repeating: "★", count: minVisitedStar) + (minVisitedStar < 5 ? "+" : "")) .foregroundStyle(showVisited ? teal : Color.secondary) Text("Visited") .foregroundStyle(showVisited ? teal : Color.secondary) } .frame(maxWidth: .infinity, minHeight: 28, maxHeight: 28, alignment: .leading) .padding(.horizontal, 12) } .simultaneousGesture( LongPressGesture(minimumDuration: 0.4).onEnded { _ in showVisitedPicker = true } ) .popover(isPresented: $showVisitedPicker, attachmentAnchor: .point(.top), arrowEdge: .bottom) { VStack(spacing: 0) { ForEach([5, 4, 3, 2, 1], id: \.self) { threshold in Button { showVisited = true minVisitedStar = threshold showVisitedPicker = false } label: { Text(String(repeating: "★", count: threshold) + (threshold < 5 ? "+" : "")) .font(.system(size: 15)) .foregroundStyle(teal) .padding(.horizontal, 20) .padding(.vertical, 10) } if threshold > 1 { Divider() } } } .presentationCompactAdaptation(.popover) } } .frame(width: 90) Divider().frame(height: 56) // Col 4: Upcoming (teal) + Consider (orange) let showTeal = Color(red: 0/255, green: 150/255, blue: 136/255) VStack(spacing: 0) { Button { showUpcoming.toggle() } label: { HStack(spacing: 6) { Image(systemName: "calendar") .foregroundStyle(showUpcoming ? showTeal : Color.secondary) Text("Upcoming") .foregroundStyle(showUpcoming ? showTeal : Color.secondary) } .frame(maxWidth: .infinity, minHeight: 28, maxHeight: 28, alignment: .leading) .padding(.horizontal, 12) } Divider() Button { showConsider.toggle() } label: { HStack(spacing: 6) { Image(systemName: "theatermasks") .foregroundStyle(showConsider ? Color.orange : Color.secondary) Text("Consider") .foregroundStyle(showConsider ? Color.orange : Color.secondary) } .frame(maxWidth: .infinity, minHeight: 28, maxHeight: 28, alignment: .leading) .padding(.horizontal, 12) } } .frame(width: 108) Divider().frame(height: 56) // Col 5: Try (blue) + Been (crimson) VStack(spacing: 0) { Button { showToTry.toggle() } label: { HStack(spacing: 4) { Text(String(repeating: "●", count: minToTryPriority) + (minToTryPriority < 5 ? "+" : "")) .foregroundStyle(showToTry ? Color.blue : Color.secondary) Text("Try") .foregroundStyle(showToTry ? Color.blue : Color.secondary) } .frame(maxWidth: .infinity, minHeight: 28, maxHeight: 28, alignment: .leading) .padding(.horizontal, 12) } .simultaneousGesture( LongPressGesture(minimumDuration: 0.4).onEnded { _ in showToTryPicker = true } ) .popover(isPresented: $showToTryPicker, attachmentAnchor: .point(.top), arrowEdge: .bottom) { VStack(spacing: 0) { ForEach([5, 4, 3, 2, 1], id: \.self) { threshold in Button { showToTry = true minToTryPriority = threshold showToTryPicker = false } label: { Text(String(repeating: "●", count: threshold) + (threshold < 5 ? "+" : "")) .font(.system(size: 15)) .foregroundStyle(Color.blue) .padding(.horizontal, 20) .padding(.vertical, 10) } if threshold > 1 { Divider() } } } .presentationCompactAdaptation(.popover) } Divider() Button { showBeenThere.toggle() } label: { HStack(spacing: 4) { Text(String(repeating: "★", count: minBeenThereStar) + (minBeenThereStar < 5 ? "+" : "")) .foregroundStyle(showBeenThere ? crimson : Color.secondary) Text("Been") .foregroundStyle(showBeenThere ? crimson : Color.secondary) } .frame(maxWidth: .infinity, minHeight: 28, maxHeight: 28, alignment: .leading) .padding(.horizontal, 12) } .simultaneousGesture( LongPressGesture(minimumDuration: 0.4).onEnded { _ in showBeenTherePicker = trueCode blockSee source code for full implementation.
} ) .popover(isPresented: $showBeenTherePicker, attachmentAnchor: .point(.top), arrowEdge: .bottom) { VStack(spacing: 0) { ForEach([5, 4, 3, 2, 1], id: \.self) { threshold in Button { showBeenThere = true minBeenThereStar = threshold showBeenTherePicker = false } label: { Text(String(repeating: "★", count: threshold) + (threshold < 5 ? "+" : "")) .font(.system(size: 15)) .foregroundStyle(crimson) .padding(.horizontal, 20) .padding(.vertical, 10) } if threshold > 1 { Divider() } } } .presentationCompactAdaptation(.popover) } } .frame(width: 79) } } .font(.system(size: 12, weight: .medium)) .background(.regularMaterial) } } // end ZStack .sheet(isPresented: Binding( get: { detailSheetEntryID != nil }, set: { if !$0 { detailSheetEntryID = nil } } )) { if let id = detailSheetEntryID, let idx = entries.firstIndex(where: { $0.id == id }) { EntryDetailView( entry: $entries[idx], onOpenInAppMap: { _, _ in // Already on the map with the bubble showing — just close the sheet. detailSheetEntryID = nil } ) } } }Code blockSee source code for full implementation.
▶ SEARCH
func performSearch() { let query = searchText.trimmingCharacters(in: .whitespaces) guard !query.isEmpty else { return } isSearching = true showResults = true selectedResult = nil let req = MKLocalSearch.Request() req.naturalLanguageQuery = query req.region = region MKLocalSearch(request: req).start { response, _ in DispatchQueue.main.async { searchResults = response?.mapItems ?? [] isSearching = false } } } func clearSearch() { searchText = "" searchResults = [] searchAnnotations = [] selectedResult = nil showResults = false }`performSearch()` functionImplements `performSearch`.
▶ BUS STOPS
▶ BUS STOPS
/// Rebuild the bus stop annotation cache for the current visible region. /// Called when the Bus toggle is turned on or (future) when the region changes enough. func rebuildBusStops() { let svc = BusStopService.shared guard svc.isLoaded else { // Service still loading — retry once it finishes (check again in 0.5s) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { if svc.isLoaded { self.rebuildBusStops() } } return } // Show stops within the visible region (use the longer span axis as radius, capped at 800m) let latMeters = region.span.latitudeDelta * 111_320 let lonMeters = region.span.longitudeDelta * 111_320 * cos(region.center.latitude * .pi / 180) let radius = min(max(latMeters, lonMeters) / 2, 800) let nearby = svc.stops(near: region.center, radiusMeters: radius) busStopAnnotationCache = nearby.map { BusStopAnnotation($0) } } }Documentation commentDescribes the following declaration.
▶ MKMAPITEM HELPERS (IOS 26 PLACEMARK-FREE)
private extension MKMapItem { /// Coordinate from the non-optional location property (iOS 26+). var coordinate: CLLocationCoordinate2D { location.coordinate } /// Short formatted address using MKAddress (iOS 26+). var shortAddress: String? { guard let addr = address, let s = addr.shortAddress, !s.isEmpty else { return nil } return s } }`MKMapItem` extensionDefines the `MKMapItem` extension.