// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // MapboxAPI.swift — les services Mapbox de Ka Trajet : Geocoding (recherche // de lieux, autocomplétion, géocodage inverse) et Directions (itinéraires // auto/vélo/marche avec alternatives et étapes turn-by-turn). // Jeton PUBLIC Mapbox (pk.…) partagé avec Lou-Ka (kamaps/config.ts) — conçu // pour être exposé côté client ; restrictions gérées au tableau de bord Mapbox. import Foundation import CoreLocation enum MapboxConfig { static let token = "pk.eyJ1Ijoic3Bib3VjaGVyIiwiYSI6ImNtc3Fyb3k4djAwOTgyenB3dWt6NHBjc2kifQ.poqLf0ADy3lIh28O-pFI2Q" } // MARK: - Modèles struct Place: Identifiable, Equatable { let id: String let name: String // ex. « Château Frontenac » let address: String // ex. « 1 rue des Carrières, Québec » let coordinate: CLLocationCoordinate2D static func == (l: Place, r: Place) -> Bool { l.id == r.id } } enum TransportMode: String, CaseIterable, Identifiable { case auto, velo, marche var id: String { rawValue } var profile: String { switch self { case .auto: return "driving-traffic" case .velo: return "cycling" case .marche: return "walking" } } var icon: String { switch self { case .auto: return "car.fill" case .velo: return "bicycle" case .marche: return "figure.walk" } } var label: String { switch self { case .auto: return "Auto" case .velo: return "Vélo" case .marche: return "Marche" } } } struct RouteStep: Identifiable { let id = UUID() let instruction: String let distance: Double // mètres let icon: String // symbole SF selon la manœuvre let coordinate: CLLocationCoordinate2D // où se fait la manœuvre } struct Route: Identifiable { let id = UUID() let coordinates: [CLLocationCoordinate2D] let distance: Double // mètres let duration: Double // secondes let steps: [RouteStep] var durationText: String { let m = Int((duration / 60).rounded()) return m >= 60 ? "\(m / 60) h \(m % 60 == 0 ? "" : "\(m % 60) min")".trimmingCharacters(in: .whitespaces) : "\(max(m, 1)) min" } var distanceText: String { Route.format(meters: distance) } static func format(meters: Double) -> String { meters >= 1000 ? String(format: "%.1f km", meters / 1000) : "\(Int(meters.rounded())) m" } } // MARK: - Client HTTP enum MapboxAPI { // Recherche de lieux (autocomplétion), en français, biaisée vers la // position de l'utilisateur quand elle est connue. static func search(_ query: String, near: CLLocationCoordinate2D?) async throws -> [Place] { let q = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !q.isEmpty else { return [] } var comps = URLComponents(string: "https://api.mapbox.com/geocoding/v5/mapbox.places/\(q.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? q).json")! var items = [ URLQueryItem(name: "access_token", value: MapboxConfig.token), URLQueryItem(name: "language", value: "fr"), URLQueryItem(name: "limit", value: "7"), URLQueryItem(name: "autocomplete", value: "true"), ] if let near { items.append(URLQueryItem(name: "proximity", value: "\(near.longitude),\(near.latitude)")) } comps.queryItems = items let (data, _) = try await URLSession.shared.data(from: comps.url!) let decoded = try JSONDecoder().decode(GeocodeResponse.self, from: data) return decoded.features.map { f in Place(id: f.id, name: f.text ?? f.place_name, address: f.place_name, coordinate: .init(latitude: f.center[1], longitude: f.center[0])) } } // Géocodage inverse : nomme un point déposé sur la carte. static func reverse(_ c: CLLocationCoordinate2D) async -> Place { let fallback = Place(id: "pin-\(c.latitude)-\(c.longitude)", name: "Repère sur la carte", address: String(format: "%.5f, %.5f", c.latitude, c.longitude), coordinate: c) var comps = URLComponents(string: "https://api.mapbox.com/geocoding/v5/mapbox.places/\(c.longitude),\(c.latitude).json")! comps.queryItems = [ URLQueryItem(name: "access_token", value: MapboxConfig.token), URLQueryItem(name: "language", value: "fr"), URLQueryItem(name: "limit", value: "1"), ] guard let (data, _) = try? await URLSession.shared.data(from: comps.url!), let decoded = try? JSONDecoder().decode(GeocodeResponse.self, from: data), let f = decoded.features.first else { return fallback } return Place(id: fallback.id, name: f.text ?? f.place_name, address: f.place_name, coordinate: c) } // Ville au point donné (pour les APIs Ka filtrées par ville). static func cityName(at c: CLLocationCoordinate2D) async -> String? { var comps = URLComponents(string: "https://api.mapbox.com/geocoding/v5/mapbox.places/\(c.longitude),\(c.latitude).json")! comps.queryItems = [ URLQueryItem(name: "access_token", value: MapboxConfig.token), URLQueryItem(name: "types", value: "place"), URLQueryItem(name: "language", value: "fr"), URLQueryItem(name: "limit", value: "1"), ] guard let (data, _) = try? await URLSession.shared.data(from: comps.url!), let decoded = try? JSONDecoder().decode(GeocodeResponse.self, from: data) else { return nil } return decoded.features.first?.text } // Coordonnées d'une ville québécoise (géocodage direct, pour les sources // Ka sans lat/lng comme les boutiques Fabri-Ka). static func geocodeCity(_ name: String) async -> CLLocationCoordinate2D? { let q = "\(name), Québec, Canada" var comps = URLComponents(string: "https://api.mapbox.com/geocoding/v5/mapbox.places/\(q.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? q).json")! comps.queryItems = [ URLQueryItem(name: "access_token", value: MapboxConfig.token), URLQueryItem(name: "types", value: "place,locality,neighborhood"), URLQueryItem(name: "language", value: "fr"), URLQueryItem(name: "limit", value: "1"), ] guard let (data, _) = try? await URLSession.shared.data(from: comps.url!), let decoded = try? JSONDecoder().decode(GeocodeResponse.self, from: data), let f = decoded.features.first, f.center.count >= 2 else { return nil } return .init(latitude: f.center[1], longitude: f.center[0]) } // Itinéraires avec alternatives + étapes, en français. static func directions(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D, mode: TransportMode) async throws -> [Route] { let path = "\(from.longitude),\(from.latitude);\(to.longitude),\(to.latitude)" var comps = URLComponents(string: "https://api.mapbox.com/directions/v5/mapbox/\(mode.profile)/\(path)")! comps.queryItems = [ URLQueryItem(name: "access_token", value: MapboxConfig.token), URLQueryItem(name: "alternatives", value: "true"), URLQueryItem(name: "geometries", value: "geojson"), URLQueryItem(name: "overview", value: "full"), URLQueryItem(name: "steps", value: "true"), URLQueryItem(name: "language", value: "fr"), ] let (data, _) = try await URLSession.shared.data(from: comps.url!) let decoded = try JSONDecoder().decode(DirectionsResponse.self, from: data) guard decoded.code == "Ok" else { throw MapboxError.api(decoded.message ?? decoded.code) } return decoded.routes.map { r in Route(coordinates: r.geometry.coordinates.map { .init(latitude: $0[1], longitude: $0[0]) }, distance: r.distance, duration: r.duration, steps: r.legs.flatMap(\.steps).map { s in RouteStep(instruction: s.maneuver.instruction, distance: s.distance, icon: Self.icon(for: s.maneuver), coordinate: .init(latitude: s.maneuver.location[1], longitude: s.maneuver.location[0])) }) } } private static func icon(for m: DirectionsResponse.Maneuver) -> String { switch m.type { case "depart": return "location.fill" case "arrive": return "mappin.circle.fill" case "roundabout", "rotary": return "arrow.triangle.2.circlepath" case "merge": return "arrow.triangle.merge" case "on ramp", "off ramp": return "arrow.up.right" default: if let mod = m.modifier { if mod.contains("left") { return "arrow.turn.up.left" } if mod.contains("right") { return "arrow.turn.up.right" } if mod.contains("uturn") { return "arrow.uturn.down" } } return "arrow.up" } } } enum MapboxError: LocalizedError { case api(String) var errorDescription: String? { if case .api(let m) = self { return "Mapbox : \(m)" } return nil } } // MARK: - DTO private struct GeocodeResponse: Decodable { struct Feature: Decodable { let id: String let text: String? let place_name: String let center: [Double] } let features: [Feature] } private struct DirectionsResponse: Decodable { struct Geometry: Decodable { let coordinates: [[Double]] } struct Maneuver: Decodable { let type: String; let modifier: String?; let instruction: String; let location: [Double] } struct Step: Decodable { let distance: Double; let maneuver: Maneuver } struct Leg: Decodable { let steps: [Step] } struct RouteDTO: Decodable { let geometry: Geometry let distance: Double let duration: Double let legs: [Leg] } let code: String let message: String? let routes: [RouteDTO] }