// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // TrajetModel.swift — l'état central de Ka Trajet : recherche (avec // anti-rebond), lieu sélectionné, itinéraires + alternative choisie, // caméra programmée (pattern epoch, comme la super-app KA). import Foundation import MapKit import Combine @MainActor final class TrajetModel: ObservableObject { // recherche @Published var query = "" @Published var results: [Place] = [] @Published var searching = false // sélection / itinéraire @Published var selected: Place? @Published var mode: TransportMode = .auto @Published var routes: [Route] = [] @Published var routeIndex = 0 @Published var routing = false @Published var showSteps = false @Published var errorMessage: String? // couches POI (stations, commerces, annonces Lou-Ka / Immo-Ka) @Published var layers: Set = [] @Published var pois: [POIItem] = [] @Published var selectedPOI: POIItem? let regionSubject = PassthroughSubject() private var visibleRegion: MKCoordinateRegion? private var poiGeneration = 0 // navigation (vue conduite 3D : caméra derrière la voiture) @Published var navigating = false @Published var navCamera: NavCamera? @Published var currentStep: RouteStep? @Published var stepDistanceText = "" private var navEpoch = 0 // mode Découvrir — radar piéton : les annonces Lou-Ka / Immo-Ka // proches de la position POPPENT en marchant @Published var discovering = false @Published var discoverPOIs: [POIItem] = [] @Published var discoverCard: POIItem? @Published private(set) var discoverQueue: [POIItem] = [] @Published var discoverRadius: Double = 250 // m @Published var discoverKinds: Set = [.louka, .immoka] private var discoverSeen: Set = [] private var discoverLastFetch: CLLocationCoordinate2D? private var discoverGeneration = 0 // caméra programmée (epoch → la carte n'obéit qu'aux changements voulus) @Published var programRegion = MKCoordinateRegion( center: .init(latitude: 46.8139, longitude: -71.2080), // Québec span: .init(latitudeDelta: 0.08, longitudeDelta: 0.08)) @Published var programEpoch = 0 @Published var pitch3D = false let locator = LocationManager() private var searchTask: Task? private var cancellables = Set() private var centeredOnUser = false var route: Route? { routes.indices.contains(routeIndex) ? routes[routeIndex] : nil } var hasRoute: Bool { route != nil } init() { // anti-rebond de la recherche (300 ms) $query .removeDuplicates() .debounce(for: .milliseconds(300), scheduler: RunLoop.main) .sink { [weak self] q in self?.runSearch(q) } .store(in: &cancellables) // fixes GPS : premier → recentrage ; en navigation → caméra conduite locator.$location .compactMap { $0 } .sink { [weak self] c in guard let self else { return } if !self.centeredOnUser { self.centeredOnUser = true self.center(on: c, span: 0.03) } if self.navigating { self.updateNav() } if self.discovering { self.updateDiscover(at: c) } } .store(in: &cancellables) // carte déplacée → recharger les couches actives (anti-rebond 700 ms) regionSubject .debounce(for: .milliseconds(700), scheduler: RunLoop.main) .sink { [weak self] region in self?.visibleRegion = region self?.refreshPOIs() } .store(in: &cancellables) } // MARK: couches POI func toggleLayer(_ kind: POIKind) { if layers.contains(kind) { layers.remove(kind) } else { layers.insert(kind) } refreshPOIs() } private func refreshPOIs() { guard !discovering else { return } // Découvrir pilote lui-même les marqueurs poiGeneration += 1 let gen = poiGeneration guard !layers.isEmpty else { pois = []; return } let region = visibleRegion ?? programRegion guard max(region.span.latitudeDelta, region.span.longitudeDelta) <= POIService.maxSpan else { pois = []; return // trop dézoomé — on attend un zoom de quartier } let kinds = layers Task { var all: [POIItem] = [] await withTaskGroup(of: [POIItem].self) { group in for k in kinds { group.addTask { await POIService.fetch(k, region: region) } } for await items in group { all.append(contentsOf: items) } } guard gen == self.poiGeneration else { return } // réponse périmée self.pois = all } } func selectPOI(_ poi: POIItem) { stopNavigation() // un tap sur un POI sort toujours du mode conduite selectedPOI = poi query = "" results = [] selected = Place(id: poi.id, name: poi.name, address: poi.address.isEmpty ? (poi.priceLabel ?? "") : poi.address, coordinate: poi.coordinate) routes = []; routeIndex = 0 } // MARK: recherche private func runSearch(_ q: String) { searchTask?.cancel() guard q.trimmingCharacters(in: .whitespaces).count >= 2 else { results = []; searching = false; return } searching = true searchTask = Task { [weak self] in guard let self else { return } let found = (try? await MapboxAPI.search(q, near: self.locator.location)) ?? [] if Task.isCancelled { return } self.results = found self.searching = false } } func choose(_ place: Place) { query = "" results = [] selected = place selectedPOI = nil routes = []; routeIndex = 0 center(on: place.coordinate, span: 0.02) } // point déposé par appui long sur la carte func dropPin(at c: CLLocationCoordinate2D) { Task { let place = await MapboxAPI.reverse(c) self.choose(place) } } // MARK: itinéraire func computeRoute() { guard let dest = selected else { return } guard let origin = locator.location else { errorMessage = "Position inconnue — autorisez la localisation pour calculer un trajet." locator.request() return } routing = true errorMessage = nil let m = mode Task { do { let found = try await MapboxAPI.directions(from: origin, to: dest.coordinate, mode: m) guard m == self.mode else { return } // réponse périmée self.routes = found self.routeIndex = 0 self.routing = false if let r = found.first { self.fit(route: r, extra: origin) } if found.isEmpty { self.errorMessage = "Aucun trajet trouvé." } } catch { self.routing = false self.errorMessage = error.localizedDescription } } } func setMode(_ m: TransportMode) { guard m != mode else { return } mode = m if selected != nil, !routes.isEmpty || routing { computeRoute() } } func clearRoute() { stopNavigation() routes = []; routeIndex = 0; showSteps = false; errorMessage = nil } // MARK: navigation (vue conduite) func startNavigation() { guard route != nil else { return } guard locator.location != nil else { errorMessage = "Position inconnue — autorisez la localisation pour démarrer." locator.request() return } navigating = true updateNav() } func stopNavigation() { guard navigating else { return } navigating = false navCamera = nil currentStep = nil stepDistanceText = "" if let r = route, let pos = locator.location { fit(route: r, extra: pos) } } // caméra derrière la voiture : centre = position réelle, cap = direction // de la route ~60 m devant (stable), prochaine manœuvre affichée en bannière private func updateNav() { guard navigating, let r = route, let pos = locator.location, !r.coordinates.isEmpty else { return } var best = 0 var bestD = Double.greatestFiniteMagnitude for (i, c) in r.coordinates.enumerated() { let d = Self.meters(pos, c) if d < bestD { bestD = d; best = i } } var j = best while j < r.coordinates.count - 1, Self.meters(pos, r.coordinates[j]) < 60 { j += 1 } let heading = Self.bearing(from: pos, to: r.coordinates[j]) navEpoch += 1 navCamera = NavCamera(center: pos, heading: heading, epoch: navEpoch) // première manœuvre encore devant nous le long de la route let ahead = r.steps.first { step in var k = 0 var kd = Double.greatestFiniteMagnitude for (i, c) in r.coordinates.enumerated() { let d = Self.meters(step.coordinate, c) if d < kd { kd = d; k = i } } return k > best || (k == best && Self.meters(pos, step.coordinate) > 20) } currentStep = ahead ?? r.steps.last if let s = currentStep { stepDistanceText = Route.format(meters: Self.meters(pos, s.coordinate)) } } static func meters(_ a: CLLocationCoordinate2D, _ b: CLLocationCoordinate2D) -> Double { CLLocation(latitude: a.latitude, longitude: a.longitude) .distance(from: CLLocation(latitude: b.latitude, longitude: b.longitude)) } static func bearing(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double { let la1 = a.latitude * .pi / 180, la2 = b.latitude * .pi / 180 let dLon = (b.longitude - a.longitude) * .pi / 180 let y = sin(dLon) * cos(la2) let x = cos(la1) * sin(la2) - sin(la1) * cos(la2) * cos(dLon) let deg = atan2(y, x) * 180 / .pi return deg < 0 ? deg + 360 : deg } func clearAll() { clearRoute() selected = nil selectedPOI = nil } // MARK: mode Découvrir (radar piéton géolocalisé) func startDiscover() { stopNavigation() routes = []; routeIndex = 0; showSteps = false; errorMessage = nil selected = nil; selectedPOI = nil query = ""; results = [] discovering = true discoverSeen = []; discoverQueue = []; discoverCard = nil discoverPOIs = []; discoverLastFetch = nil locator.request() if let c = locator.location { updateDiscover(at: c) } } func stopDiscover() { guard discovering else { return } discovering = false discoverCard = nil; discoverQueue = []; discoverPOIs = [] discoverGeneration += 1 // invalide les fetchs en vol navCamera = nil pois = [] if let c = locator.location { center(on: c, span: 0.02) } refreshPOIs() // redonne la main aux couches classiques } func toggleDiscoverKind(_ k: POIKind) { if discoverKinds.contains(k) { guard discoverKinds.count > 1 else { return } // toujours au moins une couche discoverKinds.remove(k) } else { discoverKinds.insert(k) } discoverLastFetch = nil if let c = locator.location { updateDiscover(at: c) } } func setDiscoverRadius(_ r: Double) { discoverRadius = r if let c = locator.location { scanProximity(at: c) } } /// Tap sur un marqueur en mode Découvrir : sa carte pop directement. func showDiscoverCard(_ poi: POIItem) { discoverSeen.insert(poi.id) discoverQueue.removeAll { $0.id == poi.id } discoverCard = poi } func dismissDiscoverCard() { if discoverQueue.isEmpty { discoverCard = nil } else { discoverCard = discoverQueue.removeFirst() } } /// Quitte Découvrir et lance l'itinéraire À PIED vers l'annonce. func walkTo(_ poi: POIItem) { stopDiscover() selectedPOI = poi selected = Place(id: poi.id, name: poi.name, address: poi.address.isEmpty ? (poi.priceLabel ?? "") : poi.address, coordinate: poi.coordinate) mode = .marche computeRoute() } // caméra au-dessus du marcheur (orientée selon le cap GPS quand on avance) // + rechargement des annonces tous les ~200 m + détection de proximité private func updateDiscover(at c: CLLocationCoordinate2D) { navEpoch += 1 let moving = (locator.speedKmh ?? 0) > 1.5 let heading = (moving ? locator.course : nil) ?? 0 navCamera = NavCamera(center: c, heading: heading, epoch: navEpoch, altitude: 700, pitch: 48) if discoverLastFetch.map({ Self.meters($0, c) > 200 }) ?? true { discoverLastFetch = c discoverGeneration += 1 let gen = discoverGeneration let region = MKCoordinateRegion( center: c, span: .init(latitudeDelta: 0.014, longitudeDelta: 0.018)) // ≈ 1,5 km let kinds = discoverKinds Task { var all: [POIItem] = [] await withTaskGroup(of: [POIItem].self) { group in for k in kinds { group.addTask { await POIService.fetch(k, region: region) } } for await items in group { all.append(contentsOf: items) } } guard self.discovering, gen == self.discoverGeneration else { return } self.discoverPOIs = all self.pois = all self.scanProximity(at: self.locator.location ?? c) } } else { scanProximity(at: c) } } private func scanProximity(at c: CLLocationCoordinate2D) { let near = discoverPOIs .filter { !discoverSeen.contains($0.id) && Self.meters(c, $0.coordinate) <= discoverRadius } .sorted { Self.meters(c, $0.coordinate) < Self.meters(c, $1.coordinate) } guard !near.isEmpty else { return } discoverSeen.formUnion(near.map(\.id)) if discoverCard == nil, let first = near.first { discoverCard = first discoverQueue.append(contentsOf: near.dropFirst()) Haptics.success() } else { discoverQueue.append(contentsOf: near) Haptics.tap() } } // MARK: caméra func center(on c: CLLocationCoordinate2D, span: Double) { programRegion = .init(center: c, span: .init(latitudeDelta: span, longitudeDelta: span)) programEpoch += 1 } func centerOnUser() { if let c = locator.location { center(on: c, span: 0.02) } else { locator.request() } } private func fit(route: Route, extra: CLLocationCoordinate2D) { var lats = route.coordinates.map(\.latitude); lats.append(extra.latitude) var lons = route.coordinates.map(\.longitude); lons.append(extra.longitude) guard let minLat = lats.min(), let maxLat = lats.max(), let minLon = lons.min(), let maxLon = lons.max() else { return } let center = CLLocationCoordinate2D(latitude: (minLat + maxLat) / 2, longitude: (minLon + maxLon) / 2) // marge ~35 % + place pour le panneau bas let span = max((maxLat - minLat) * 1.7, (maxLon - minLon) * 1.35, 0.01) programRegion = .init(center: .init(latitude: center.latitude - span * 0.12, longitude: center.longitude), span: .init(latitudeDelta: span, longitudeDelta: span)) programEpoch += 1 } }