SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
29 days agolast push
Swift 100%
20.1 KB · 378 lines swift
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// POI.swift — les couches de la carte Ka Trajet : stations-service et3// commerces (Mapbox Search Box par catégorie, jeton public Lou-Ka) +4// annonces EN DIRECT de Lou-Ka et Immo-Ka (endpoints /api/listings.geojson5// avec bbox réel, les mêmes que la carte unifiée de la super-app KA).6import Foundation7import CoreLocation8import MapKit910enum POIKind: String, CaseIterable, Identifiable {11    case stations, commerces, louka, immoka, restoka, sortika, jobka, autoka, fabrika12    var id: String { rawValue }1314    var label: String {15        switch self {16        case .stations: return "Stations"17        case .commerces: return "Commerces"18        case .louka: return "Lou-Ka"19        case .immoka: return "Immo-Ka"20        case .restoka: return "Resto-Ka"21        case .sortika: return "Sorti-Ka"22        case .jobka: return "Job-Ka"23        case .autoka: return "Auto-Ka"24        case .fabrika: return "Fabri-Ka"25        }26    }27    var icon: String {28        switch self {29        case .stations: return "fuelpump.fill"30        case .commerces: return "bag.fill"31        case .louka: return "key.fill"32        case .immoka: return "house.fill"33        case .restoka: return "fork.knife"34        case .sortika: return "ticket.fill"35        case .jobka: return "briefcase.fill"36        case .autoka: return "car.fill"37        case .fabrika: return "shippingbox.fill"38        }39    }40}4142struct POIItem: Identifiable {43    let id: String44    let kind: POIKind45    let name: String46    let address: String47    let priceLabel: String?      // annonces Ka / prix de l'essence ordinaire48    let price: Double?           // montant numérique brut (comparaison Vrai-Prix)49    let coordinate: CLLocationCoordinate2D50    let url: URL?                // fiche de l'annonce sur le site51    let extra: String?           // détail (ex. ordinaire/super/diesel + source)52    let imageURL: URL?           // photo de l'annonce (mode Découvrir)5354    init(id: String, kind: POIKind, name: String, address: String,55         priceLabel: String?, coordinate: CLLocationCoordinate2D,56         url: URL?, extra: String? = nil, imageURL: URL? = nil,57         price: Double? = nil) {58        self.id = id; self.kind = kind; self.name = name; self.address = address59        self.priceLabel = priceLabel; self.price = price; self.coordinate = coordinate60        self.url = url; self.extra = extra; self.imageURL = imageURL61    }62}6364/// Cache de géocodage des villes + annuaire des boutiques Fabri-Ka65/// (chargé une fois par session).66actor CityGeoCache {67    private var coords: [String: CLLocationCoordinate2D] = [:]68    private var failed: Set<String> = []69    private var storeList: [[String: Any]]?7071    func stores() async -> [[String: Any]]? {72        if let storeList { return storeList }73        var req = URLRequest(url: URL(string: "https://www.fabri-ka.com/api/stores?limit=300")!)74        req.setValue("KaTrajet/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")75        guard let (data, _) = try? await URLSession.shared.data(for: req),76              let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],77              let items = root["items"] as? [[String: Any]] else { return nil }78        storeList = items79        return items80    }8182    func coordinate(for city: String) async -> CLLocationCoordinate2D? {83        if let c = coords[city] { return c }84        if failed.contains(city) { return nil }85        guard let c = await MapboxAPI.geocodeCity(city) else {86            failed.insert(city); return nil87        }88        coords[city] = c89        return c90    }91}9293enum POIService {94    // au-delà de cette étendue, on ne charge pas (trop de territoire)95    static let maxSpan = 0.359697    static func fetch(_ kind: POIKind, region: MKCoordinateRegion) async -> [POIItem] {98        let west = region.center.longitude - region.span.longitudeDelta / 299        let east = region.center.longitude + region.span.longitudeDelta / 2100        let south = region.center.latitude - region.span.latitudeDelta / 2101        let north = region.center.latitude + region.span.latitudeDelta / 2102        switch kind {103        case .stations: return await gasStations(region: region)104        case .commerces: return await searchbox("shopping", kind: kind, west, south, east, north)105        case .louka:106            return await kaListings(kind: kind, host: "www.lou-ka.com", detail: "/logement/",107                                    west, south, east, north)108        case .immoka:109            return await kaListings(kind: kind, host: "www.immo-ka.com", detail: "/propriete/",110                                    west, south, east, north)111        case .restoka: return await restoKa(region: region, west, south, east, north)112        case .sortika: return await sortiKa(region: region, west, south, east, north)113        case .jobka:114            return await kaListings(kind: kind, host: "www.job-ka.com", detail: "/emploi/",115                                    west, south, east, north, path: "/api/jobs.geojson")116        case .autoka: return await autoKa(region: region, west, south, east, north)117        case .fabrika: return await fabriKa(west, south, east, north)118        }119    }120121    // MARK: Auto-Ka — véhicules géolocalisés chez leur concessionnaire122123    private static func autoKa(region: MKCoordinateRegion,124                               _ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] {125        guard let city = await MapboxAPI.cityName(at: region.center),126              let root = await cityJSON(host: "www.auto-ka.com", path: "/api/vehicles", city: city),127              let items = root["vehicles"] as? [[String: Any]] else { return [] }128        return items.compactMap { v -> POIItem? in129            guard let lat = v["lat"] as? Double, let lng = v["lng"] as? Double,130                  inBBox(lat, lng, w, s, e, n),131                  let uid = v["uid"] as? String, let title = v["title"] as? String else { return nil }132            let dealer = (v["dealer_name"] as? String).flatMap { $0.isEmpty ? nil : $0 }133            let km = v["mileage_label"] as? String134            return POIItem(id: "autoka:\(uid)",135                           kind: .autoka,136                           name: title,137                           address: [dealer.map { "Concessionnaire : \($0)" },138                                     v["city"] as? String]139                               .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "),140                           priceLabel: v["price_label"] as? String,141                           coordinate: .init(latitude: lat, longitude: lng),142                           url: URL(string: "https://www.auto-ka.com/vehicule/\(uid.replacingOccurrences(of: "/", with: "%2F"))"),143                           extra: km.map { "\($0) · \(v["fuel"] as? String ?? "")" })144        }.prefix(40).map { $0 }145    }146147    // MARK: Fabri-Ka — boutiques québécoises, localisées par géocodage de148    // leur ville (aucune coordonnée dans l'API ; cache de géocodage partagé)149150    private static let fabriGeo = CityGeoCache()151152    private static func fabriKa(_ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] {153        guard let stores = await fabriGeo.stores() else { return [] }154        var out: [POIItem] = []155        for st in stores {156            guard out.count < 30,157                  let id = st["id"] as? String,158                  let name = st["name"] as? String,159                  let city = (st["city"] as? String), !city.isEmpty,160                  let coord = await fabriGeo.coordinate(for: city) else { continue }161            // léger décalage déterministe pour séparer les boutiques d'une même ville162            let h = Double(id.unicodeScalars.reduce(0) { ($0 &+ UInt32($1.value)) % 997 })163            let lat = coord.latitude + (h.truncatingRemainder(dividingBy: 31) - 15) * 0.0004164            let lng = coord.longitude + (h.truncatingRemainder(dividingBy: 29) - 14) * 0.0005165            guard inBBox(lat, lng, w, s, e, n) else { continue }166            let count = (st["product_count"] as? Double).map(Int.init) ?? (st["product_count"] as? Int) ?? 0167            out.append(POIItem(id: "fabrika:\(id)",168                               kind: .fabrika,169                               name: name,170                               address: city,171                               priceLabel: nil,172                               coordinate: .init(latitude: lat, longitude: lng),173                               url: (st["url"] as? String).flatMap(URL.init(string:)),174                               extra: count > 0 ? "\(count) produits fabriqués au Québec" : nil))175        }176        return out177    }178179    // MARK: Resto-Ka / Sorti-Ka — filtre par ville (pas de bbox côté API) :180    // la ville visible est résolue par géocodage inverse Mapbox, puis les181    // items sont refiltrés client sur la région (même approche que la182    // super-app KA).183184    private static func cityJSON(host: String, path: String, city: String,185                                 extraQuery: [URLQueryItem] = []) async -> [String: Any]? {186        var comps = URLComponents(string: "https://\(host)\(path)")!187        comps.queryItems = [188            .init(name: "city", value: city),189            .init(name: "limit", value: "120"),190        ] + extraQuery191        var req = URLRequest(url: comps.url!)192        req.setValue("KaTrajet/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")193        guard let (data, _) = try? await URLSession.shared.data(for: req) else { return nil }194        return try? JSONSerialization.jsonObject(with: data) as? [String: Any]195    }196197    private static func inBBox(_ lat: Double, _ lng: Double,198                               _ w: Double, _ s: Double, _ e: Double, _ n: Double) -> Bool {199        lat >= s && lat <= n && lng >= w && lng <= e200    }201202    private static func restoKa(region: MKCoordinateRegion,203                                _ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] {204        guard let city = await MapboxAPI.cityName(at: region.center),205              let root = await cityJSON(host: "www.resto-ka.com", path: "/api/restaurants", city: city),206              let items = root["restaurants"] as? [[String: Any]] else { return [] }207        return items.compactMap { r -> POIItem? in208            guard let lat = r["lat"] as? Double, let lng = r["lng"] as? Double,209                  inBBox(lat, lng, w, s, e, n),210                  let uid = r["uid"] as? String, let name = r["name"] as? String else { return nil }211            let cuisines = (r["cuisines"] as? [String] ?? []).prefix(3).joined(separator: " · ")212            return POIItem(id: "restoka:\(uid)",213                           kind: .restoka,214                           name: name,215                           address: [r["address"] as? String, r["city"] as? String]216                               .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "),217                           priceLabel: (r["price_range"] as? String).flatMap { $0.isEmpty ? nil : $0 },218                           coordinate: .init(latitude: lat, longitude: lng),219                           url: URL(string: "https://www.resto-ka.com/resto/\(uid.replacingOccurrences(of: "/", with: "%2F"))"),220                           extra: cuisines.isEmpty ? nil : cuisines)221        }.prefix(40).map { $0 }222    }223224    private static func sortiKa(region: MKCoordinateRegion,225                                _ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] {226        guard let city = await MapboxAPI.cityName(at: region.center),227              let root = await cityJSON(host: "www.sorti-ka.com", path: "/api/events", city: city,228                                        extraQuery: [.init(name: "upcoming", value: "true")]),229              let items = root["events"] as? [[String: Any]] else { return [] }230        return items.compactMap { ev -> POIItem? in231            guard let lat = ev["lat"] as? Double, let lng = ev["lng"] as? Double,232                  inBBox(lat, lng, w, s, e, n),233                  let uid = ev["uid"] as? String, let title = ev["title"] as? String else { return nil }234            var price: String?235            if (ev["is_free"] as? Bool) == true { price = "Gratuit" }236            else if let m = ev["price_min"] as? Double, m > 1 { price = "dès \(Int(m)) $" }237            let date = ((ev["starts_at"] as? String)?.prefix(10)).map(String.init)238            return POIItem(id: "sortika:\(uid)",239                           kind: .sortika,240                           name: title,241                           address: [ev["venue_name"] as? String, date]242                               .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · "),243                           priceLabel: price,244                           coordinate: .init(latitude: lat, longitude: lng),245                           url: URL(string: "https://www.sorti-ka.com/evenement/\(uid.replacingOccurrences(of: "/", with: "%2F"))"),246                           extra: nil)247        }.prefix(40).map { $0 }248    }249250    // MARK: stations-service avec PRIX RÉELS (Gas Québec / Régie de l'énergie)251252    private static func gasStations(region: MKCoordinateRegion) async -> [POIItem] {253        // rayon ≈ demi-étendue visible, borné 1–15 km (limite de l'API)254        let radiusKm = min(15.0, max(1.0, region.span.latitudeDelta * 111.0 / 2))255        var comps = URLComponents(string: "https://www.gasquebec.ca/api/stations/nearby")!256        comps.queryItems = [257            .init(name: "lat", value: "\(region.center.latitude)"),258            .init(name: "lng", value: "\(region.center.longitude)"),259            .init(name: "radius", value: String(format: "%.1f", radiusKm)),260            .init(name: "limit", value: "30"),261        ]262        var req = URLRequest(url: comps.url!)263        req.setValue("KaTrajet/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")264        guard let (data, _) = try? await URLSession.shared.data(for: req),265              let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],266              let stations = root["stations"] as? [[String: Any]] else { return [] }267268        func cents(_ v: Any?) -> String? {269            guard let n = v as? Double else { return nil }270            return String(format: "%.1f", n).replacingOccurrences(of: ".", with: ",")271        }272        return stations.compactMap { s in273            guard let lat = s["lat"] as? Double, let lng = s["lng"] as? Double,274                  let name = s["name"] as? String else { return nil }275            let sid = (s["stationId"] as? String) ?? "\(lat),\(lng)"276            let details = [277                cents(s["prixOrdinaire"]).map { "Ordinaire \($0)" },278                cents(s["prixSuper"]).map { "Super \($0)" },279                cents(s["prixDiesel"]).map { "Diesel \($0)" },280            ].compactMap { $0 }281            return POIItem(id: "stations:\(sid)",282                           kind: .stations,283                           name: name,284                           address: (s["address"] as? String) ?? "",285                           priceLabel: cents(s["prixOrdinaire"]).map { "\($0) ¢" },286                           coordinate: .init(latitude: lat, longitude: lng),287                           url: nil,288                           extra: details.isEmpty ? nil289                               : details.joined(separator: " · ") + " ¢/L — Régie de l'énergie")290        }291    }292293    // MARK: Mapbox Search Box — POI par catégorie294295    private static func searchbox(_ category: String, kind: POIKind,296                                  _ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] {297        var comps = URLComponents(string: "https://api.mapbox.com/search/searchbox/v1/category/\(category)")!298        comps.queryItems = [299            .init(name: "access_token", value: MapboxConfig.token),300            .init(name: "bbox", value: "\(w),\(s),\(e),\(n)"),301            .init(name: "limit", value: "25"),302            .init(name: "language", value: "fr"),303        ]304        guard let (data, _) = try? await URLSession.shared.data(from: comps.url!),305              let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],306              let features = root["features"] as? [[String: Any]] else { return [] }307        return features.compactMap { f in308            guard let geo = f["geometry"] as? [String: Any],309                  let coords = geo["coordinates"] as? [Double], coords.count >= 2,310                  let props = f["properties"] as? [String: Any],311                  let name = props["name"] as? String else { return nil }312            let mid = (props["mapbox_id"] as? String) ?? "\(coords[0]),\(coords[1])"313            return POIItem(id: "\(kind.rawValue):\(mid)",314                           kind: kind,315                           name: name,316                           address: (props["full_address"] as? String)317                               ?? (props["place_formatted"] as? String) ?? "",318                           priceLabel: nil,319                           coordinate: .init(latitude: coords[1], longitude: coords[0]),320                           url: nil)321        }322    }323324    // MARK: annonces Lou-Ka / Immo-Ka (geojson bbox en direct)325326    private static func kaListings(kind: POIKind, host: String, detail: String,327                                   _ w: Double, _ s: Double, _ e: Double, _ n: Double,328                                   path: String = "/api/listings.geojson") async -> [POIItem] {329        var comps = URLComponents(string: "https://\(host)\(path)")!330        comps.queryItems = [331            .init(name: "bbox", value: "\(w),\(s),\(e),\(n)"),332            .init(name: "limit", value: "40"),333        ]334        guard let (data, _) = try? await URLSession.shared.data(from: comps.url!),335              let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],336              let features = root["features"] as? [[String: Any]] else { return [] }337        return features.compactMap { f in338            guard let geo = f["geometry"] as? [String: Any],339                  let coords = geo["coordinates"] as? [Double], coords.count >= 2,340                  let props = f["properties"] as? [String: Any],341                  let uid = props["uid"] as? String else { return nil }342            let title = (props["title"] as? String).flatMap { $0.isEmpty ? nil : $0 }343                ?? (props["property_type"] as? String)344                ?? (props["unit_type"] as? String)345                ?? (kind == .louka ? "Logement" : kind == .jobka ? "Offre d'emploi" : "Propriété")346            // Job-Ka : salaire en pilule quand il est connu, employeur en adresse347            var price = props["price_label"] as? String348            if kind == .jobka, price == nil, let m = props["salary_min"] as? Double {349                let unit = (props["salary_unit"] as? String) ?? "h"350                price = "dès \(Int(m)) $/\(unit)"351            }352            let addr = kind == .jobka353                ? [props["employer"] as? String, props["city"] as? String]354                : [props["address"] as? String, props["city"] as? String]355            // détails riches pour la carte Découvrir (type, secteur, pièces)356            let bits = [357                props["unit_type"] as? String,358                props["property_type"] as? String,359                (props["bedrooms"] as? Double).map { "\(Int($0)) ch." },360                (props["area_sqft"] as? Double).flatMap { $0 > 50 ? "\(Int($0)) pi²" : nil },361                props["sector"] as? String,362            ].compactMap { $0 }.filter { !$0.isEmpty }363            return POIItem(id: "\(kind.rawValue):\(uid)",364                           kind: kind,365                           name: title,366                           address: addr.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "),367                           priceLabel: price,368                           coordinate: .init(latitude: coords[1], longitude: coords[0]),369                           url: URL(string: "https://\(host)\(detail)\(uid)"),370                           extra: bits.isEmpty ? nil : bits.joined(separator: " · "),371                           imageURL: (props["image"] as? String).flatMap {372                               $0.hasPrefix("http") ? URL(string: $0) : nil373                           },374                           price: props["price"] as? Double)375        }376    }377}378