Swift 100%
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