Files
Sportstime/SportsTime/Export/Services/MapSnapshotService.swift
Trey t fbb5ae683e Enhance PDF export with maps, images, and progress UI
- Add MapSnapshotService for route maps and city maps using MKMapSnapshotter
- Add RemoteImageService for team logos and stadium photos with caching
- Add POISearchService for nearby attractions using MKLocalSearch
- Add PDFAssetPrefetcher to orchestrate parallel asset fetching
- Rewrite PDFGenerator with rich page layouts: cover, route overview,
  day-by-day itinerary, city spotlights, and summary pages
- Add export progress overlay in TripDetailView with animated progress ring

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:53:03 -06:00

292 lines
8.7 KiB
Swift

//
// MapSnapshotService.swift
// SportsTime
//
// Generates static map images for PDF export using MKMapSnapshotter.
//
import Foundation
import MapKit
import UIKit
actor MapSnapshotService {
// MARK: - Errors
enum MapSnapshotError: Error, LocalizedError {
case noStops
case snapshotFailed(String)
case invalidCoordinates
var errorDescription: String? {
switch self {
case .noStops:
return "No stops provided for map generation"
case .snapshotFailed(let reason):
return "Map snapshot failed: \(reason)"
case .invalidCoordinates:
return "Invalid coordinates for map region"
}
}
}
// MARK: - Route Map
/// Generate a full route map showing all stops with route line
func generateRouteMap(
stops: [TripStop],
size: CGSize,
routeColor: UIColor = UIColor(red: 0.12, green: 0.23, blue: 0.54, alpha: 1.0)
) async throws -> UIImage {
guard !stops.isEmpty else {
throw MapSnapshotError.noStops
}
let coordinates = stops.compactMap { $0.coordinate }
guard !coordinates.isEmpty else {
throw MapSnapshotError.invalidCoordinates
}
// Calculate region to fit all stops with padding
let region = regionToFit(coordinates: coordinates, paddingPercent: 0.2)
// Configure snapshotter
let options = MKMapSnapshotter.Options()
options.region = region
options.size = size
options.mapType = .standard
options.showsBuildings = false
options.pointOfInterestFilter = .excludingAll
// Generate snapshot
let snapshotter = MKMapSnapshotter(options: options)
let snapshot = try await snapshotter.start()
// Draw route and markers on snapshot
let image = drawRouteOverlay(
on: snapshot,
coordinates: coordinates,
stops: stops,
routeColor: routeColor,
size: size
)
return image
}
// MARK: - City Map
/// Generate a city-level map showing stadium location
func generateCityMap(
stop: TripStop,
size: CGSize,
radiusMeters: Double = 5000
) async throws -> UIImage {
guard let coordinate = stop.coordinate else {
throw MapSnapshotError.invalidCoordinates
}
// Create region centered on stadium
let region = MKCoordinateRegion(
center: coordinate,
latitudinalMeters: radiusMeters * 2,
longitudinalMeters: radiusMeters * 2
)
// Configure snapshotter
let options = MKMapSnapshotter.Options()
options.region = region
options.size = size
options.mapType = .standard
options.showsBuildings = true
options.pointOfInterestFilter = .includingAll
// Generate snapshot
let snapshotter = MKMapSnapshotter(options: options)
let snapshot = try await snapshotter.start()
// Draw stadium marker
let image = drawStadiumMarker(
on: snapshot,
coordinate: coordinate,
cityName: stop.city,
size: size
)
return image
}
// MARK: - Helper Methods
/// Calculate a region that fits all coordinates with padding
private func regionToFit(coordinates: [CLLocationCoordinate2D], paddingPercent: Double) -> MKCoordinateRegion {
var minLat = coordinates[0].latitude
var maxLat = coordinates[0].latitude
var minLon = coordinates[0].longitude
var maxLon = coordinates[0].longitude
for coord in coordinates {
minLat = min(minLat, coord.latitude)
maxLat = max(maxLat, coord.latitude)
minLon = min(minLon, coord.longitude)
maxLon = max(maxLon, coord.longitude)
}
let latSpan = (maxLat - minLat) * (1 + paddingPercent)
let lonSpan = (maxLon - minLon) * (1 + paddingPercent)
let center = CLLocationCoordinate2D(
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2
)
let span = MKCoordinateSpan(
latitudeDelta: max(latSpan, 0.5),
longitudeDelta: max(lonSpan, 0.5)
)
return MKCoordinateRegion(center: center, span: span)
}
/// Draw route line and numbered markers on snapshot
private func drawRouteOverlay(
on snapshot: MKMapSnapshotter.Snapshot,
coordinates: [CLLocationCoordinate2D],
stops: [TripStop],
routeColor: UIColor,
size: CGSize
) -> UIImage {
UIGraphicsBeginImageContextWithOptions(size, true, 0)
defer { UIGraphicsEndImageContext() }
// Draw base map
snapshot.image.draw(at: .zero)
guard let context = UIGraphicsGetCurrentContext() else {
return snapshot.image
}
// Draw route line connecting stops
if coordinates.count > 1 {
context.setStrokeColor(routeColor.cgColor)
context.setLineWidth(3.0)
context.setLineCap(.round)
context.setLineJoin(.round)
let points = coordinates.map { snapshot.point(for: $0) }
context.move(to: points[0])
for i in 1..<points.count {
context.addLine(to: points[i])
}
context.strokePath()
}
// Draw numbered markers for each stop
for (index, coordinate) in coordinates.enumerated() {
let point = snapshot.point(for: coordinate)
drawNumberedMarker(
at: point,
number: index + 1,
color: routeColor,
in: context
)
}
return UIGraphicsGetImageFromCurrentImageContext() ?? snapshot.image
}
/// Draw stadium marker on city map
private func drawStadiumMarker(
on snapshot: MKMapSnapshotter.Snapshot,
coordinate: CLLocationCoordinate2D,
cityName: String,
size: CGSize
) -> UIImage {
UIGraphicsBeginImageContextWithOptions(size, true, 0)
defer { UIGraphicsEndImageContext() }
// Draw base map
snapshot.image.draw(at: .zero)
guard let context = UIGraphicsGetCurrentContext() else {
return snapshot.image
}
let point = snapshot.point(for: coordinate)
// Draw stadium pin
let pinSize: CGFloat = 30
let pinRect = CGRect(
x: point.x - pinSize / 2,
y: point.y - pinSize,
width: pinSize,
height: pinSize
)
// Draw pin shadow
context.saveGState()
context.setShadow(offset: CGSize(width: 0, height: 2), blur: 4, color: UIColor.black.withAlphaComponent(0.3).cgColor)
// Draw pin body (red marker)
context.setFillColor(UIColor.systemRed.cgColor)
context.fillEllipse(in: pinRect.insetBy(dx: 2, dy: 2))
// Draw pin center (white dot)
context.setFillColor(UIColor.white.cgColor)
let centerRect = CGRect(
x: point.x - 5,
y: point.y - pinSize / 2 - 5,
width: 10,
height: 10
)
context.fillEllipse(in: centerRect)
context.restoreGState()
return UIGraphicsGetImageFromCurrentImageContext() ?? snapshot.image
}
/// Draw a numbered circular marker
private func drawNumberedMarker(
at point: CGPoint,
number: Int,
color: UIColor,
in context: CGContext
) {
let radius: CGFloat = 14
let rect = CGRect(
x: point.x - radius,
y: point.y - radius,
width: radius * 2,
height: radius * 2
)
// Draw white outline
context.saveGState()
context.setShadow(offset: CGSize(width: 0, height: 1), blur: 2, color: UIColor.black.withAlphaComponent(0.3).cgColor)
context.setFillColor(UIColor.white.cgColor)
context.fillEllipse(in: rect.insetBy(dx: -2, dy: -2))
context.restoreGState()
// Draw colored circle
context.setFillColor(color.cgColor)
context.fillEllipse(in: rect)
// Draw number text
let numberString = "\(number)" as NSString
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 12),
.foregroundColor: UIColor.white
]
let textSize = numberString.size(withAttributes: attributes)
let textRect = CGRect(
x: point.x - textSize.width / 2,
y: point.y - textSize.height / 2,
width: textSize.width,
height: textSize.height
)
numberString.draw(in: textRect, withAttributes: attributes)
}
}