SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%
15.9 KB · 429 lines swift
Raw Blame History
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