// // 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 (UIKit drawing must run on main thread) let image = await MainActor.run { 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 (UIKit drawing must run on main thread) let image = await MainActor.run { 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 nonisolated 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.. 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 nonisolated 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) } }