// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // POI.swift — les couches de la carte Ka Trajet : stations-service et // commerces (Mapbox Search Box par catégorie, jeton public Lou-Ka) + // annonces EN DIRECT de Lou-Ka et Immo-Ka (endpoints /api/listings.geojson // avec bbox réel, les mêmes que la carte unifiée de la super-app KA). import Foundation import CoreLocation import MapKit enum POIKind: String, CaseIterable, Identifiable { case stations, commerces, louka, immoka, restoka, sortika, jobka, autoka, fabrika var id: String { rawValue } var label: String { switch self { case .stations: return "Stations" case .commerces: return "Commerces" case .louka: return "Lou-Ka" case .immoka: return "Immo-Ka" case .restoka: return "Resto-Ka" case .sortika: return "Sorti-Ka" case .jobka: return "Job-Ka" case .autoka: return "Auto-Ka" case .fabrika: return "Fabri-Ka" } } var icon: String { switch self { case .stations: return "fuelpump.fill" case .commerces: return "bag.fill" case .louka: return "key.fill" case .immoka: return "house.fill" case .restoka: return "fork.knife" case .sortika: return "ticket.fill" case .jobka: return "briefcase.fill" case .autoka: return "car.fill" case .fabrika: return "shippingbox.fill" } } } struct POIItem: Identifiable { let id: String let kind: POIKind let name: String let address: String let priceLabel: String? // annonces Ka / prix de l'essence ordinaire let price: Double? // montant numérique brut (comparaison Vrai-Prix) let coordinate: CLLocationCoordinate2D let url: URL? // fiche de l'annonce sur le site let extra: String? // détail (ex. ordinaire/super/diesel + source) let imageURL: URL? // photo de l'annonce (mode Découvrir) init(id: String, kind: POIKind, name: String, address: String, priceLabel: String?, coordinate: CLLocationCoordinate2D, url: URL?, extra: String? = nil, imageURL: URL? = nil, price: Double? = nil) { self.id = id; self.kind = kind; self.name = name; self.address = address self.priceLabel = priceLabel; self.price = price; self.coordinate = coordinate self.url = url; self.extra = extra; self.imageURL = imageURL } } /// Cache de géocodage des villes + annuaire des boutiques Fabri-Ka /// (chargé une fois par session). actor CityGeoCache { private var coords: [String: CLLocationCoordinate2D] = [:] private var failed: Set = [] private var storeList: [[String: Any]]? func stores() async -> [[String: Any]]? { if let storeList { return storeList } var req = URLRequest(url: URL(string: "https://www.fabri-ka.com/api/stores?limit=300")!) req.setValue("KaTrajet/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent") guard let (data, _) = try? await URLSession.shared.data(for: req), let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let items = root["items"] as? [[String: Any]] else { return nil } storeList = items return items } func coordinate(for city: String) async -> CLLocationCoordinate2D? { if let c = coords[city] { return c } if failed.contains(city) { return nil } guard let c = await MapboxAPI.geocodeCity(city) else { failed.insert(city); return nil } coords[city] = c return c } } enum POIService { // au-delà de cette étendue, on ne charge pas (trop de territoire) static let maxSpan = 0.35 static func fetch(_ kind: POIKind, region: MKCoordinateRegion) async -> [POIItem] { let west = region.center.longitude - region.span.longitudeDelta / 2 let east = region.center.longitude + region.span.longitudeDelta / 2 let south = region.center.latitude - region.span.latitudeDelta / 2 let north = region.center.latitude + region.span.latitudeDelta / 2 switch kind { case .stations: return await gasStations(region: region) case .commerces: return await searchbox("shopping", kind: kind, west, south, east, north) case .louka: return await kaListings(kind: kind, host: "www.lou-ka.com", detail: "/logement/", west, south, east, north) case .immoka: return await kaListings(kind: kind, host: "www.immo-ka.com", detail: "/propriete/", west, south, east, north) case .restoka: return await restoKa(region: region, west, south, east, north) case .sortika: return await sortiKa(region: region, west, south, east, north) case .jobka: return await kaListings(kind: kind, host: "www.job-ka.com", detail: "/emploi/", west, south, east, north, path: "/api/jobs.geojson") case .autoka: return await autoKa(region: region, west, south, east, north) case .fabrika: return await fabriKa(west, south, east, north) } } // MARK: Auto-Ka — véhicules géolocalisés chez leur concessionnaire private static func autoKa(region: MKCoordinateRegion, _ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] { guard let city = await MapboxAPI.cityName(at: region.center), let root = await cityJSON(host: "www.auto-ka.com", path: "/api/vehicles", city: city), let items = root["vehicles"] as? [[String: Any]] else { return [] } return items.compactMap { v -> POIItem? in guard let lat = v["lat"] as? Double, let lng = v["lng"] as? Double, inBBox(lat, lng, w, s, e, n), let uid = v["uid"] as? String, let title = v["title"] as? String else { return nil } let dealer = (v["dealer_name"] as? String).flatMap { $0.isEmpty ? nil : $0 } let km = v["mileage_label"] as? String return POIItem(id: "autoka:\(uid)", kind: .autoka, name: title, address: [dealer.map { "Concessionnaire : \($0)" }, v["city"] as? String] .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "), priceLabel: v["price_label"] as? String, coordinate: .init(latitude: lat, longitude: lng), url: URL(string: "https://www.auto-ka.com/vehicule/\(uid.replacingOccurrences(of: "/", with: "%2F"))"), extra: km.map { "\($0) · \(v["fuel"] as? String ?? "")" }) }.prefix(40).map { $0 } } // MARK: Fabri-Ka — boutiques québécoises, localisées par géocodage de // leur ville (aucune coordonnée dans l'API ; cache de géocodage partagé) private static let fabriGeo = CityGeoCache() private static func fabriKa(_ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] { guard let stores = await fabriGeo.stores() else { return [] } var out: [POIItem] = [] for st in stores { guard out.count < 30, let id = st["id"] as? String, let name = st["name"] as? String, let city = (st["city"] as? String), !city.isEmpty, let coord = await fabriGeo.coordinate(for: city) else { continue } // léger décalage déterministe pour séparer les boutiques d'une même ville let h = Double(id.unicodeScalars.reduce(0) { ($0 &+ UInt32($1.value)) % 997 }) let lat = coord.latitude + (h.truncatingRemainder(dividingBy: 31) - 15) * 0.0004 let lng = coord.longitude + (h.truncatingRemainder(dividingBy: 29) - 14) * 0.0005 guard inBBox(lat, lng, w, s, e, n) else { continue } let count = (st["product_count"] as? Double).map(Int.init) ?? (st["product_count"] as? Int) ?? 0 out.append(POIItem(id: "fabrika:\(id)", kind: .fabrika, name: name, address: city, priceLabel: nil, coordinate: .init(latitude: lat, longitude: lng), url: (st["url"] as? String).flatMap(URL.init(string:)), extra: count > 0 ? "\(count) produits fabriqués au Québec" : nil)) } return out } // MARK: Resto-Ka / Sorti-Ka — filtre par ville (pas de bbox côté API) : // la ville visible est résolue par géocodage inverse Mapbox, puis les // items sont refiltrés client sur la région (même approche que la // super-app KA). private static func cityJSON(host: String, path: String, city: String, extraQuery: [URLQueryItem] = []) async -> [String: Any]? { var comps = URLComponents(string: "https://\(host)\(path)")! comps.queryItems = [ .init(name: "city", value: city), .init(name: "limit", value: "120"), ] + extraQuery var req = URLRequest(url: comps.url!) req.setValue("KaTrajet/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent") guard let (data, _) = try? await URLSession.shared.data(for: req) else { return nil } return try? JSONSerialization.jsonObject(with: data) as? [String: Any] } private static func inBBox(_ lat: Double, _ lng: Double, _ w: Double, _ s: Double, _ e: Double, _ n: Double) -> Bool { lat >= s && lat <= n && lng >= w && lng <= e } private static func restoKa(region: MKCoordinateRegion, _ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] { guard let city = await MapboxAPI.cityName(at: region.center), let root = await cityJSON(host: "www.resto-ka.com", path: "/api/restaurants", city: city), let items = root["restaurants"] as? [[String: Any]] else { return [] } return items.compactMap { r -> POIItem? in guard let lat = r["lat"] as? Double, let lng = r["lng"] as? Double, inBBox(lat, lng, w, s, e, n), let uid = r["uid"] as? String, let name = r["name"] as? String else { return nil } let cuisines = (r["cuisines"] as? [String] ?? []).prefix(3).joined(separator: " · ") return POIItem(id: "restoka:\(uid)", kind: .restoka, name: name, address: [r["address"] as? String, r["city"] as? String] .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "), priceLabel: (r["price_range"] as? String).flatMap { $0.isEmpty ? nil : $0 }, coordinate: .init(latitude: lat, longitude: lng), url: URL(string: "https://www.resto-ka.com/resto/\(uid.replacingOccurrences(of: "/", with: "%2F"))"), extra: cuisines.isEmpty ? nil : cuisines) }.prefix(40).map { $0 } } private static func sortiKa(region: MKCoordinateRegion, _ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] { guard let city = await MapboxAPI.cityName(at: region.center), let root = await cityJSON(host: "www.sorti-ka.com", path: "/api/events", city: city, extraQuery: [.init(name: "upcoming", value: "true")]), let items = root["events"] as? [[String: Any]] else { return [] } return items.compactMap { ev -> POIItem? in guard let lat = ev["lat"] as? Double, let lng = ev["lng"] as? Double, inBBox(lat, lng, w, s, e, n), let uid = ev["uid"] as? String, let title = ev["title"] as? String else { return nil } var price: String? if (ev["is_free"] as? Bool) == true { price = "Gratuit" } else if let m = ev["price_min"] as? Double, m > 1 { price = "dès \(Int(m)) $" } let date = ((ev["starts_at"] as? String)?.prefix(10)).map(String.init) return POIItem(id: "sortika:\(uid)", kind: .sortika, name: title, address: [ev["venue_name"] as? String, date] .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · "), priceLabel: price, coordinate: .init(latitude: lat, longitude: lng), url: URL(string: "https://www.sorti-ka.com/evenement/\(uid.replacingOccurrences(of: "/", with: "%2F"))"), extra: nil) }.prefix(40).map { $0 } } // MARK: stations-service avec PRIX RÉELS (Gas Québec / Régie de l'énergie) private static func gasStations(region: MKCoordinateRegion) async -> [POIItem] { // rayon ≈ demi-étendue visible, borné 1–15 km (limite de l'API) let radiusKm = min(15.0, max(1.0, region.span.latitudeDelta * 111.0 / 2)) var comps = URLComponents(string: "https://www.gasquebec.ca/api/stations/nearby")! comps.queryItems = [ .init(name: "lat", value: "\(region.center.latitude)"), .init(name: "lng", value: "\(region.center.longitude)"), .init(name: "radius", value: String(format: "%.1f", radiusKm)), .init(name: "limit", value: "30"), ] var req = URLRequest(url: comps.url!) req.setValue("KaTrajet/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent") guard let (data, _) = try? await URLSession.shared.data(for: req), let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let stations = root["stations"] as? [[String: Any]] else { return [] } func cents(_ v: Any?) -> String? { guard let n = v as? Double else { return nil } return String(format: "%.1f", n).replacingOccurrences(of: ".", with: ",") } return stations.compactMap { s in guard let lat = s["lat"] as? Double, let lng = s["lng"] as? Double, let name = s["name"] as? String else { return nil } let sid = (s["stationId"] as? String) ?? "\(lat),\(lng)" let details = [ cents(s["prixOrdinaire"]).map { "Ordinaire \($0)" }, cents(s["prixSuper"]).map { "Super \($0)" }, cents(s["prixDiesel"]).map { "Diesel \($0)" }, ].compactMap { $0 } return POIItem(id: "stations:\(sid)", kind: .stations, name: name, address: (s["address"] as? String) ?? "", priceLabel: cents(s["prixOrdinaire"]).map { "\($0) ¢" }, coordinate: .init(latitude: lat, longitude: lng), url: nil, extra: details.isEmpty ? nil : details.joined(separator: " · ") + " ¢/L — Régie de l'énergie") } } // MARK: Mapbox Search Box — POI par catégorie private static func searchbox(_ category: String, kind: POIKind, _ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] { var comps = URLComponents(string: "https://api.mapbox.com/search/searchbox/v1/category/\(category)")! comps.queryItems = [ .init(name: "access_token", value: MapboxConfig.token), .init(name: "bbox", value: "\(w),\(s),\(e),\(n)"), .init(name: "limit", value: "25"), .init(name: "language", value: "fr"), ] guard let (data, _) = try? await URLSession.shared.data(from: comps.url!), let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let features = root["features"] as? [[String: Any]] else { return [] } return features.compactMap { f in guard let geo = f["geometry"] as? [String: Any], let coords = geo["coordinates"] as? [Double], coords.count >= 2, let props = f["properties"] as? [String: Any], let name = props["name"] as? String else { return nil } let mid = (props["mapbox_id"] as? String) ?? "\(coords[0]),\(coords[1])" return POIItem(id: "\(kind.rawValue):\(mid)", kind: kind, name: name, address: (props["full_address"] as? String) ?? (props["place_formatted"] as? String) ?? "", priceLabel: nil, coordinate: .init(latitude: coords[1], longitude: coords[0]), url: nil) } } // MARK: annonces Lou-Ka / Immo-Ka (geojson bbox en direct) private static func kaListings(kind: POIKind, host: String, detail: String, _ w: Double, _ s: Double, _ e: Double, _ n: Double, path: String = "/api/listings.geojson") async -> [POIItem] { var comps = URLComponents(string: "https://\(host)\(path)")! comps.queryItems = [ .init(name: "bbox", value: "\(w),\(s),\(e),\(n)"), .init(name: "limit", value: "40"), ] guard let (data, _) = try? await URLSession.shared.data(from: comps.url!), let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let features = root["features"] as? [[String: Any]] else { return [] } return features.compactMap { f in guard let geo = f["geometry"] as? [String: Any], let coords = geo["coordinates"] as? [Double], coords.count >= 2, let props = f["properties"] as? [String: Any], let uid = props["uid"] as? String else { return nil } let title = (props["title"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? (props["property_type"] as? String) ?? (props["unit_type"] as? String) ?? (kind == .louka ? "Logement" : kind == .jobka ? "Offre d'emploi" : "Propriété") // Job-Ka : salaire en pilule quand il est connu, employeur en adresse var price = props["price_label"] as? String if kind == .jobka, price == nil, let m = props["salary_min"] as? Double { let unit = (props["salary_unit"] as? String) ?? "h" price = "dès \(Int(m)) $/\(unit)" } let addr = kind == .jobka ? [props["employer"] as? String, props["city"] as? String] : [props["address"] as? String, props["city"] as? String] // détails riches pour la carte Découvrir (type, secteur, pièces) let bits = [ props["unit_type"] as? String, props["property_type"] as? String, (props["bedrooms"] as? Double).map { "\(Int($0)) ch." }, (props["area_sqft"] as? Double).flatMap { $0 > 50 ? "\(Int($0)) pi²" : nil }, props["sector"] as? String, ].compactMap { $0 }.filter { !$0.isEmpty } return POIItem(id: "\(kind.rawValue):\(uid)", kind: kind, name: title, address: addr.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "), priceLabel: price, coordinate: .init(latitude: coords[1], longitude: coords[0]), url: URL(string: "https://\(host)\(detail)\(uid)"), extra: bits.isEmpty ? nil : bits.joined(separator: " · "), imageURL: (props["image"] as? String).flatMap { $0.hasPrefix("http") ? URL(string: $0) : nil }, price: props["price"] as? Double) } } }