Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// TrajetModel.swift — l'état central de Ka Trajet : recherche (avec3// anti-rebond), lieu sélectionné, itinéraires + alternative choisie,4// caméra programmée (pattern epoch, comme la super-app KA).5import Foundation6import MapKit7import Combine89@MainActor10final class TrajetModel: ObservableObject {11 // recherche12 @Published var query = ""13 @Published var results: [Place] = []14 @Published var searching = false1516 // sélection / itinéraire17 @Published var selected: Place?18 @Published var mode: TransportMode = .auto19 @Published var routes: [Route] = []20 @Published var routeIndex = 021 @Published var routing = false22 @Published var showSteps = false23 @Published var errorMessage: String?2425 // couches POI (stations, commerces, annonces Lou-Ka / Immo-Ka)26 @Published var layers: Set<POIKind> = []27 @Published var pois: [POIItem] = []28 @Published var selectedPOI: POIItem?29 let regionSubject = PassthroughSubject<MKCoordinateRegion, Never>()30 private var visibleRegion: MKCoordinateRegion?31 private var poiGeneration = 03233 // navigation (vue conduite 3D : caméra derrière la voiture)34 @Published var navigating = false35 @Published var navCamera: NavCamera?36 @Published var currentStep: RouteStep?37 @Published var stepDistanceText = ""38 private var navEpoch = 03940 // mode Découvrir — radar piéton : les annonces Lou-Ka / Immo-Ka41 // proches de la position POPPENT en marchant42 @Published var discovering = false43 @Published var discoverPOIs: [POIItem] = []44 @Published var discoverCard: POIItem?45 @Published private(set) var discoverQueue: [POIItem] = []46 @Published var discoverRadius: Double = 250 // m47 @Published var discoverKinds: Set<POIKind> = [.louka, .immoka]48 private var discoverSeen: Set<String> = []49 private var discoverLastFetch: CLLocationCoordinate2D?50 private var discoverGeneration = 05152 // caméra programmée (epoch → la carte n'obéit qu'aux changements voulus)53 @Published var programRegion = MKCoordinateRegion(54 center: .init(latitude: 46.8139, longitude: -71.2080), // Québec55 span: .init(latitudeDelta: 0.08, longitudeDelta: 0.08))56 @Published var programEpoch = 057 @Published var pitch3D = false5859 let locator = LocationManager()60 private var searchTask: Task<Void, Never>?61 private var cancellables = Set<AnyCancellable>()62 private var centeredOnUser = false6364 var route: Route? { routes.indices.contains(routeIndex) ? routes[routeIndex] : nil }65 var hasRoute: Bool { route != nil }6667 init() {68 // anti-rebond de la recherche (300 ms)69 $query70 .removeDuplicates()71 .debounce(for: .milliseconds(300), scheduler: RunLoop.main)72 .sink { [weak self] q in self?.runSearch(q) }73 .store(in: &cancellables)7475 // fixes GPS : premier → recentrage ; en navigation → caméra conduite76 locator.$location77 .compactMap { $0 }78 .sink { [weak self] c in79 guard let self else { return }80 if !self.centeredOnUser {81 self.centeredOnUser = true82 self.center(on: c, span: 0.03)83 }84 if self.navigating { self.updateNav() }85 if self.discovering { self.updateDiscover(at: c) }86 }87 .store(in: &cancellables)8889 // carte déplacée → recharger les couches actives (anti-rebond 700 ms)90 regionSubject91 .debounce(for: .milliseconds(700), scheduler: RunLoop.main)92 .sink { [weak self] region in93 self?.visibleRegion = region94 self?.refreshPOIs()95 }96 .store(in: &cancellables)97 }9899 // MARK: couches POI100101 func toggleLayer(_ kind: POIKind) {102 if layers.contains(kind) { layers.remove(kind) } else { layers.insert(kind) }103 refreshPOIs()104 }105106 private func refreshPOIs() {107 guard !discovering else { return } // Découvrir pilote lui-même les marqueurs108 poiGeneration += 1109 let gen = poiGeneration110 guard !layers.isEmpty else { pois = []; return }111 let region = visibleRegion ?? programRegion112 guard max(region.span.latitudeDelta, region.span.longitudeDelta) <= POIService.maxSpan else {113 pois = []; return // trop dézoomé — on attend un zoom de quartier114 }115 let kinds = layers116 Task {117 var all: [POIItem] = []118 await withTaskGroup(of: [POIItem].self) { group in119 for k in kinds { group.addTask { await POIService.fetch(k, region: region) } }120 for await items in group { all.append(contentsOf: items) }121 }122 guard gen == self.poiGeneration else { return } // réponse périmée123 self.pois = all124 }125 }126127 func selectPOI(_ poi: POIItem) {128 stopNavigation() // un tap sur un POI sort toujours du mode conduite129 selectedPOI = poi130 query = ""131 results = []132 selected = Place(id: poi.id, name: poi.name,133 address: poi.address.isEmpty ? (poi.priceLabel ?? "") : poi.address,134 coordinate: poi.coordinate)135 routes = []; routeIndex = 0136 }137138 // MARK: recherche139140 private func runSearch(_ q: String) {141 searchTask?.cancel()142 guard q.trimmingCharacters(in: .whitespaces).count >= 2 else {143 results = []; searching = false; return144 }145 searching = true146 searchTask = Task { [weak self] in147 guard let self else { return }148 let found = (try? await MapboxAPI.search(q, near: self.locator.location)) ?? []149 if Task.isCancelled { return }150 self.results = found151 self.searching = false152 }153 }154155 func choose(_ place: Place) {156 query = ""157 results = []158 selected = place159 selectedPOI = nil160 routes = []; routeIndex = 0161 center(on: place.coordinate, span: 0.02)162 }163164 // point déposé par appui long sur la carte165 func dropPin(at c: CLLocationCoordinate2D) {166 Task {167 let place = await MapboxAPI.reverse(c)168 self.choose(place)169 }170 }171172 // MARK: itinéraire173174 func computeRoute() {175 guard let dest = selected else { return }176 guard let origin = locator.location else {177 errorMessage = "Position inconnue — autorisez la localisation pour calculer un trajet."178 locator.request()179 return180 }181 routing = true182 errorMessage = nil183 let m = mode184 Task {185 do {186 let found = try await MapboxAPI.directions(from: origin, to: dest.coordinate, mode: m)187 guard m == self.mode else { return } // réponse périmée188 self.routes = found189 self.routeIndex = 0190 self.routing = false191 if let r = found.first { self.fit(route: r, extra: origin) }192 if found.isEmpty { self.errorMessage = "Aucun trajet trouvé." }193 } catch {194 self.routing = false195 self.errorMessage = error.localizedDescription196 }197 }198 }199200 func setMode(_ m: TransportMode) {201 guard m != mode else { return }202 mode = m203 if selected != nil, !routes.isEmpty || routing { computeRoute() }204 }205206 func clearRoute() {207 stopNavigation()208 routes = []; routeIndex = 0; showSteps = false; errorMessage = nil209 }210211 // MARK: navigation (vue conduite)212213 func startNavigation() {214 guard route != nil else { return }215 guard locator.location != nil else {216 errorMessage = "Position inconnue — autorisez la localisation pour démarrer."217 locator.request()218 return219 }220 navigating = true221 updateNav()222 }223224 func stopNavigation() {225 guard navigating else { return }226 navigating = false227 navCamera = nil228 currentStep = nil229 stepDistanceText = ""230 if let r = route, let pos = locator.location { fit(route: r, extra: pos) }231 }232233 // caméra derrière la voiture : centre = position réelle, cap = direction234 // de la route ~60 m devant (stable), prochaine manœuvre affichée en bannière235 private func updateNav() {236 guard navigating, let r = route, let pos = locator.location,237 !r.coordinates.isEmpty else { return }238 var best = 0239 var bestD = Double.greatestFiniteMagnitude240 for (i, c) in r.coordinates.enumerated() {241 let d = Self.meters(pos, c)242 if d < bestD { bestD = d; best = i }243 }244 var j = best245 while j < r.coordinates.count - 1, Self.meters(pos, r.coordinates[j]) < 60 { j += 1 }246 let heading = Self.bearing(from: pos, to: r.coordinates[j])247 navEpoch += 1248 navCamera = NavCamera(center: pos, heading: heading, epoch: navEpoch)249250 // première manœuvre encore devant nous le long de la route251 let ahead = r.steps.first { step in252 var k = 0253 var kd = Double.greatestFiniteMagnitude254 for (i, c) in r.coordinates.enumerated() {255 let d = Self.meters(step.coordinate, c)256 if d < kd { kd = d; k = i }257 }258 return k > best || (k == best && Self.meters(pos, step.coordinate) > 20)259 }260 currentStep = ahead ?? r.steps.last261 if let s = currentStep {262 stepDistanceText = Route.format(meters: Self.meters(pos, s.coordinate))263 }264 }265266 static func meters(_ a: CLLocationCoordinate2D, _ b: CLLocationCoordinate2D) -> Double {267 CLLocation(latitude: a.latitude, longitude: a.longitude)268 .distance(from: CLLocation(latitude: b.latitude, longitude: b.longitude))269 }270271 static func bearing(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double {272 let la1 = a.latitude * .pi / 180, la2 = b.latitude * .pi / 180273 let dLon = (b.longitude - a.longitude) * .pi / 180274 let y = sin(dLon) * cos(la2)275 let x = cos(la1) * sin(la2) - sin(la1) * cos(la2) * cos(dLon)276 let deg = atan2(y, x) * 180 / .pi277 return deg < 0 ? deg + 360 : deg278 }279280 func clearAll() {281 clearRoute()282 selected = nil283 selectedPOI = nil284 }285286 // MARK: mode Découvrir (radar piéton géolocalisé)287288 func startDiscover() {289 stopNavigation()290 routes = []; routeIndex = 0; showSteps = false; errorMessage = nil291 selected = nil; selectedPOI = nil292 query = ""; results = []293 discovering = true294 discoverSeen = []; discoverQueue = []; discoverCard = nil295 discoverPOIs = []; discoverLastFetch = nil296 locator.request()297 if let c = locator.location { updateDiscover(at: c) }298 }299300 func stopDiscover() {301 guard discovering else { return }302 discovering = false303 discoverCard = nil; discoverQueue = []; discoverPOIs = []304 discoverGeneration += 1 // invalide les fetchs en vol305 navCamera = nil306 pois = []307 if let c = locator.location { center(on: c, span: 0.02) }308 refreshPOIs() // redonne la main aux couches classiques309 }310311 func toggleDiscoverKind(_ k: POIKind) {312 if discoverKinds.contains(k) {313 guard discoverKinds.count > 1 else { return } // toujours au moins une couche314 discoverKinds.remove(k)315 } else {316 discoverKinds.insert(k)317 }318 discoverLastFetch = nil319 if let c = locator.location { updateDiscover(at: c) }320 }321322 func setDiscoverRadius(_ r: Double) {323 discoverRadius = r324 if let c = locator.location { scanProximity(at: c) }325 }326327 /// Tap sur un marqueur en mode Découvrir : sa carte pop directement.328 func showDiscoverCard(_ poi: POIItem) {329 discoverSeen.insert(poi.id)330 discoverQueue.removeAll { $0.id == poi.id }331 discoverCard = poi332 }333334 func dismissDiscoverCard() {335 if discoverQueue.isEmpty {336 discoverCard = nil337 } else {338 discoverCard = discoverQueue.removeFirst()339 }340 }341342 /// Quitte Découvrir et lance l'itinéraire À PIED vers l'annonce.343 func walkTo(_ poi: POIItem) {344 stopDiscover()345 selectedPOI = poi346 selected = Place(id: poi.id, name: poi.name,347 address: poi.address.isEmpty ? (poi.priceLabel ?? "") : poi.address,348 coordinate: poi.coordinate)349 mode = .marche350 computeRoute()351 }352353 // caméra au-dessus du marcheur (orientée selon le cap GPS quand on avance)354 // + rechargement des annonces tous les ~200 m + détection de proximité355 private func updateDiscover(at c: CLLocationCoordinate2D) {356 navEpoch += 1357 let moving = (locator.speedKmh ?? 0) > 1.5358 let heading = (moving ? locator.course : nil) ?? 0359 navCamera = NavCamera(center: c, heading: heading, epoch: navEpoch,360 altitude: 700, pitch: 48)361362 if discoverLastFetch.map({ Self.meters($0, c) > 200 }) ?? true {363 discoverLastFetch = c364 discoverGeneration += 1365 let gen = discoverGeneration366 let region = MKCoordinateRegion(367 center: c,368 span: .init(latitudeDelta: 0.014, longitudeDelta: 0.018)) // ≈ 1,5 km369 let kinds = discoverKinds370 Task {371 var all: [POIItem] = []372 await withTaskGroup(of: [POIItem].self) { group in373 for k in kinds { group.addTask { await POIService.fetch(k, region: region) } }374 for await items in group { all.append(contentsOf: items) }375 }376 guard self.discovering, gen == self.discoverGeneration else { return }377 self.discoverPOIs = all378 self.pois = all379 self.scanProximity(at: self.locator.location ?? c)380 }381 } else {382 scanProximity(at: c)383 }384 }385386 private func scanProximity(at c: CLLocationCoordinate2D) {387 let near = discoverPOIs388 .filter { !discoverSeen.contains($0.id) && Self.meters(c, $0.coordinate) <= discoverRadius }389 .sorted { Self.meters(c, $0.coordinate) < Self.meters(c, $1.coordinate) }390 guard !near.isEmpty else { return }391 discoverSeen.formUnion(near.map(\.id))392 if discoverCard == nil, let first = near.first {393 discoverCard = first394 discoverQueue.append(contentsOf: near.dropFirst())395 Haptics.success()396 } else {397 discoverQueue.append(contentsOf: near)398 Haptics.tap()399 }400 }401402 // MARK: caméra403404 func center(on c: CLLocationCoordinate2D, span: Double) {405 programRegion = .init(center: c, span: .init(latitudeDelta: span, longitudeDelta: span))406 programEpoch += 1407 }408409 func centerOnUser() {410 if let c = locator.location { center(on: c, span: 0.02) }411 else { locator.request() }412 }413414 private func fit(route: Route, extra: CLLocationCoordinate2D) {415 var lats = route.coordinates.map(\.latitude); lats.append(extra.latitude)416 var lons = route.coordinates.map(\.longitude); lons.append(extra.longitude)417 guard let minLat = lats.min(), let maxLat = lats.max(),418 let minLon = lons.min(), let maxLon = lons.max() else { return }419 let center = CLLocationCoordinate2D(latitude: (minLat + maxLat) / 2,420 longitude: (minLon + maxLon) / 2)421 // marge ~35 % + place pour le panneau bas422 let span = max((maxLat - minLat) * 1.7, (maxLon - minLon) * 1.35, 0.01)423 programRegion = .init(center: .init(latitude: center.latitude - span * 0.12,424 longitude: center.longitude),425 span: .init(latitudeDelta: span, longitudeDelta: span))426 programEpoch += 1427 }428}429