Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// MapboxAPI.swift — les services Mapbox de Ka Trajet : Geocoding (recherche3// de lieux, autocomplétion, géocodage inverse) et Directions (itinéraires4// auto/vélo/marche avec alternatives et étapes turn-by-turn).5// Jeton PUBLIC Mapbox (pk.…) partagé avec Lou-Ka (kamaps/config.ts) — conçu6// pour être exposé côté client ; restrictions gérées au tableau de bord Mapbox.7import Foundation8import CoreLocation910enum MapboxConfig {11 static let token = "pk.eyJ1Ijoic3Bib3VjaGVyIiwiYSI6ImNtc3Fyb3k4djAwOTgyenB3dWt6NHBjc2kifQ.poqLf0ADy3lIh28O-pFI2Q"12}1314// MARK: - Modèles1516struct Place: Identifiable, Equatable {17 let id: String18 let name: String // ex. « Château Frontenac »19 let address: String // ex. « 1 rue des Carrières, Québec »20 let coordinate: CLLocationCoordinate2D2122 static func == (l: Place, r: Place) -> Bool { l.id == r.id }23}2425enum TransportMode: String, CaseIterable, Identifiable {26 case auto, velo, marche27 var id: String { rawValue }28 var profile: String {29 switch self {30 case .auto: return "driving-traffic"31 case .velo: return "cycling"32 case .marche: return "walking"33 }34 }35 var icon: String {36 switch self {37 case .auto: return "car.fill"38 case .velo: return "bicycle"39 case .marche: return "figure.walk"40 }41 }42 var label: String {43 switch self {44 case .auto: return "Auto"45 case .velo: return "Vélo"46 case .marche: return "Marche"47 }48 }49}5051struct RouteStep: Identifiable {52 let id = UUID()53 let instruction: String54 let distance: Double // mètres55 let icon: String // symbole SF selon la manœuvre56 let coordinate: CLLocationCoordinate2D // où se fait la manœuvre57}5859struct Route: Identifiable {60 let id = UUID()61 let coordinates: [CLLocationCoordinate2D]62 let distance: Double // mètres63 let duration: Double // secondes64 let steps: [RouteStep]6566 var durationText: String {67 let m = Int((duration / 60).rounded())68 return m >= 60 ? "\(m / 60) h \(m % 60 == 0 ? "" : "\(m % 60) min")".trimmingCharacters(in: .whitespaces)69 : "\(max(m, 1)) min"70 }71 var distanceText: String { Route.format(meters: distance) }7273 static func format(meters: Double) -> String {74 meters >= 1000 ? String(format: "%.1f km", meters / 1000) : "\(Int(meters.rounded())) m"75 }76}7778// MARK: - Client HTTP7980enum MapboxAPI {81 // Recherche de lieux (autocomplétion), en français, biaisée vers la82 // position de l'utilisateur quand elle est connue.83 static func search(_ query: String, near: CLLocationCoordinate2D?) async throws -> [Place] {84 let q = query.trimmingCharacters(in: .whitespacesAndNewlines)85 guard !q.isEmpty else { return [] }86 var comps = URLComponents(string: "https://api.mapbox.com/geocoding/v5/mapbox.places/\(q.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? q).json")!87 var items = [88 URLQueryItem(name: "access_token", value: MapboxConfig.token),89 URLQueryItem(name: "language", value: "fr"),90 URLQueryItem(name: "limit", value: "7"),91 URLQueryItem(name: "autocomplete", value: "true"),92 ]93 if let near {94 items.append(URLQueryItem(name: "proximity", value: "\(near.longitude),\(near.latitude)"))95 }96 comps.queryItems = items97 let (data, _) = try await URLSession.shared.data(from: comps.url!)98 let decoded = try JSONDecoder().decode(GeocodeResponse.self, from: data)99 return decoded.features.map { f in100 Place(id: f.id,101 name: f.text ?? f.place_name,102 address: f.place_name,103 coordinate: .init(latitude: f.center[1], longitude: f.center[0]))104 }105 }106107 // Géocodage inverse : nomme un point déposé sur la carte.108 static func reverse(_ c: CLLocationCoordinate2D) async -> Place {109 let fallback = Place(id: "pin-\(c.latitude)-\(c.longitude)",110 name: "Repère sur la carte",111 address: String(format: "%.5f, %.5f", c.latitude, c.longitude),112 coordinate: c)113 var comps = URLComponents(string: "https://api.mapbox.com/geocoding/v5/mapbox.places/\(c.longitude),\(c.latitude).json")!114 comps.queryItems = [115 URLQueryItem(name: "access_token", value: MapboxConfig.token),116 URLQueryItem(name: "language", value: "fr"),117 URLQueryItem(name: "limit", value: "1"),118 ]119 guard let (data, _) = try? await URLSession.shared.data(from: comps.url!),120 let decoded = try? JSONDecoder().decode(GeocodeResponse.self, from: data),121 let f = decoded.features.first else { return fallback }122 return Place(id: fallback.id, name: f.text ?? f.place_name, address: f.place_name, coordinate: c)123 }124125 // Ville au point donné (pour les APIs Ka filtrées par ville).126 static func cityName(at c: CLLocationCoordinate2D) async -> String? {127 var comps = URLComponents(string: "https://api.mapbox.com/geocoding/v5/mapbox.places/\(c.longitude),\(c.latitude).json")!128 comps.queryItems = [129 URLQueryItem(name: "access_token", value: MapboxConfig.token),130 URLQueryItem(name: "types", value: "place"),131 URLQueryItem(name: "language", value: "fr"),132 URLQueryItem(name: "limit", value: "1"),133 ]134 guard let (data, _) = try? await URLSession.shared.data(from: comps.url!),135 let decoded = try? JSONDecoder().decode(GeocodeResponse.self, from: data)136 else { return nil }137 return decoded.features.first?.text138 }139140 // Coordonnées d'une ville québécoise (géocodage direct, pour les sources141 // Ka sans lat/lng comme les boutiques Fabri-Ka).142 static func geocodeCity(_ name: String) async -> CLLocationCoordinate2D? {143 let q = "\(name), Québec, Canada"144 var comps = URLComponents(string: "https://api.mapbox.com/geocoding/v5/mapbox.places/\(q.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? q).json")!145 comps.queryItems = [146 URLQueryItem(name: "access_token", value: MapboxConfig.token),147 URLQueryItem(name: "types", value: "place,locality,neighborhood"),148 URLQueryItem(name: "language", value: "fr"),149 URLQueryItem(name: "limit", value: "1"),150 ]151 guard let (data, _) = try? await URLSession.shared.data(from: comps.url!),152 let decoded = try? JSONDecoder().decode(GeocodeResponse.self, from: data),153 let f = decoded.features.first, f.center.count >= 2 else { return nil }154 return .init(latitude: f.center[1], longitude: f.center[0])155 }156157 // Itinéraires avec alternatives + étapes, en français.158 static func directions(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D,159 mode: TransportMode) async throws -> [Route] {160 let path = "\(from.longitude),\(from.latitude);\(to.longitude),\(to.latitude)"161 var comps = URLComponents(string: "https://api.mapbox.com/directions/v5/mapbox/\(mode.profile)/\(path)")!162 comps.queryItems = [163 URLQueryItem(name: "access_token", value: MapboxConfig.token),164 URLQueryItem(name: "alternatives", value: "true"),165 URLQueryItem(name: "geometries", value: "geojson"),166 URLQueryItem(name: "overview", value: "full"),167 URLQueryItem(name: "steps", value: "true"),168 URLQueryItem(name: "language", value: "fr"),169 ]170 let (data, _) = try await URLSession.shared.data(from: comps.url!)171 let decoded = try JSONDecoder().decode(DirectionsResponse.self, from: data)172 guard decoded.code == "Ok" else { throw MapboxError.api(decoded.message ?? decoded.code) }173 return decoded.routes.map { r in174 Route(coordinates: r.geometry.coordinates.map { .init(latitude: $0[1], longitude: $0[0]) },175 distance: r.distance,176 duration: r.duration,177 steps: r.legs.flatMap(\.steps).map { s in178 RouteStep(instruction: s.maneuver.instruction,179 distance: s.distance,180 icon: Self.icon(for: s.maneuver),181 coordinate: .init(latitude: s.maneuver.location[1],182 longitude: s.maneuver.location[0]))183 })184 }185 }186187 private static func icon(for m: DirectionsResponse.Maneuver) -> String {188 switch m.type {189 case "depart": return "location.fill"190 case "arrive": return "mappin.circle.fill"191 case "roundabout", "rotary": return "arrow.triangle.2.circlepath"192 case "merge": return "arrow.triangle.merge"193 case "on ramp", "off ramp": return "arrow.up.right"194 default:195 if let mod = m.modifier {196 if mod.contains("left") { return "arrow.turn.up.left" }197 if mod.contains("right") { return "arrow.turn.up.right" }198 if mod.contains("uturn") { return "arrow.uturn.down" }199 }200 return "arrow.up"201 }202 }203}204205enum MapboxError: LocalizedError {206 case api(String)207 var errorDescription: String? {208 if case .api(let m) = self { return "Mapbox : \(m)" }209 return nil210 }211}212213// MARK: - DTO214215private struct GeocodeResponse: Decodable {216 struct Feature: Decodable {217 let id: String218 let text: String?219 let place_name: String220 let center: [Double]221 }222 let features: [Feature]223}224225private struct DirectionsResponse: Decodable {226 struct Geometry: Decodable { let coordinates: [[Double]] }227 struct Maneuver: Decodable { let type: String; let modifier: String?; let instruction: String; let location: [Double] }228 struct Step: Decodable { let distance: Double; let maneuver: Maneuver }229 struct Leg: Decodable { let steps: [Step] }230 struct RouteDTO: Decodable {231 let geometry: Geometry232 let distance: Double233 let duration: Double234 let legs: [Leg]235 }236 let code: String237 let message: String?238 let routes: [RouteDTO]239}240