- 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>
292 lines
8.7 KiB
Swift
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)
|
|
}
|
|
}
|