| Code | What It Does | How It Does It |
| ▶ IMPORTS | | |
| import SwiftUI
import MapKit
import CoreLocation
import Contacts | Framework imports | Imports 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` class | Defines 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` class | Defines 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 symbols | Documentation comment | Describes 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 comment | Describes 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 comment | Describes 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 comment | Describes 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 comment | Describes 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()` function | Implements `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 comment | Describes 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 comment | Describes 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 comment | Describes 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` let | Property `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` let | Property `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 comment | Describes 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 comment | Describes 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 comment | Describes 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 comment | Describes 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()` function | Implements `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 comment | Describes 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()` function | Implements `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 comment | Describes 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` let | Property `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 comment | Describes 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` class | Defines 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` class | Defines 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` class | Defines 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 comment | Describes 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` struct | Defines 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` class | Defines 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` var | Property `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` var | Property `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 comment | Describes 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 comment | Describes 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 comment | Describes 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()` function | Implements `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 block | See 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()` function | Implements `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()` function | Implements `subwayLineUIColor`. Returns `UIColor`. |
| private func subwayLineTextUIColor(for line: String) -> UIColor {
switch line {
case "N", "Q", "R", "W": return .black
default: return .white
}
} | `subwayLineTextUIColor()` function | Implements `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` struct | Defines 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` struct | Defines 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` struct | Defines 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` struct | Defines 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` struct | Defines 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 comment | Describes 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 comment | Describes 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` struct | Defines 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` struct | Defines 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)¢er=\(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)¢er=\(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` struct | Defines 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 = true | Code block | See 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 block | See 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()` function | Implements `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 comment | Describes 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` extension | Defines the `MKMapItem` extension. |