v3.0.0 — estimation VRAI-PRIX dans le mode Découvrir : chaque carte d'annonce Lou-Ka/Immo-Ka affiche la valeur du modèle hédonique de www.vrai-prix.com (VraiPrixService : UNE requête /api/nearby bbox ~60 m par carte affichée, cache mémoire par annonce, échec silencieux — zéro overload) + badge « affiché ±N % » quand l'unité est confirmée ; appariement durci validé live : civique = dernier nombre du 1er groupe (format QC apt-civique), rue contradictoire disqualifiée, condos = apt apparié sinon médiane étiquetée (immeuble), écart borné 25 k$/±60 % (coupe stationnements et fractions) ; POIItem.price numérique du geojson — 14 tests verts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 changed files +226 −4
modified
KA/Features/Trajet/DiscoverView.swift
+59 −0
@@ -166,11 +166,15 @@ struct DiscoverCard: View { | |||||
| 166 | let poi: POIItem | 166 | let poi: POIItem | ||
| 167 | @EnvironmentObject var model: TrajetModel | 167 | @EnvironmentObject var model: TrajetModel | ||
| 168 | @Environment(\.colorScheme) private var scheme | 168 | @Environment(\.colorScheme) private var scheme | ||
| 169 | + // estimation Vrai-Prix, chargée paresseusement (une requête par carte, | ||||
| 170 | + // cache mémoire dans VraiPrixService — voir VraiPrixService.swift) | ||||
| 171 | + @State private var vraiPrix: VraiPrixEstimate? | ||||
| 169 | 172 | ||||
| 170 | private var universe: Universe? { | 173 | private var universe: Universe? { | ||
| 171 | Ecosystem.universe(poi.kind == .louka ? "lou-ka" : "immo-ka") | 174 | Ecosystem.universe(poi.kind == .louka ? "lou-ka" : "immo-ka") | ||
| 172 | } | 175 | } | ||
| 173 | private var accent: Color { universe?.accent ?? KATheme.green } | 176 | private var accent: Color { universe?.accent ?? KATheme.green } | ||
| 177 | + private var vpAccent: Color { Ecosystem.universe("vrai-prix")?.accent ?? KATheme.green } | ||||
| 174 | 178 | ||||
| 175 | var body: some View { | 179 | var body: some View { | ||
| 176 | VStack(spacing: 0) { | 180 | VStack(spacing: 0) { | ||
@@ -206,6 +210,10 @@ struct DiscoverCard: View { | |||||
| 206 | DistanceBadge(target: poi.coordinate, accent: accent) | 210 | DistanceBadge(target: poi.coordinate, accent: accent) | ||
| 207 | } | 211 | } | ||
| 208 | 212 | ||||
| 213 | + if let vp = vraiPrix { | ||||
| 214 | + vraiPrixRow(vp) | ||||
| 215 | + } | ||||
| 216 | + | ||||
| 209 | HStack(spacing: 8) { | 217 | HStack(spacing: 8) { | ||
| 210 | if let url = poi.url { | 218 | if let url = poi.url { | ||
| 211 | Link(destination: url) { | 219 | Link(destination: url) { | ||
@@ -268,6 +276,57 @@ struct DiscoverCard: View { | |||||
| 268 | insertion: .move(edge: .bottom).combined(with: .opacity).combined(with: .scale(scale: 0.85)), | 276 | insertion: .move(edge: .bottom).combined(with: .opacity).combined(with: .scale(scale: 0.85)), | ||
| 269 | removal: .move(edge: .bottom).combined(with: .opacity))) | 277 | removal: .move(edge: .bottom).combined(with: .opacity))) | ||
| 270 | .id(poi.id) // chaque trouvaille rejoue l'animation d'entrée | 278 | .id(poi.id) // chaque trouvaille rejoue l'animation d'entrée | ||
| 279 | + .task(id: poi.id) { | ||||
| 280 | + guard poi.kind == .louka || poi.kind == .immoka else { return } | ||||
| 281 | + let e = await VraiPrixService.shared.estimate(for: poi) | ||||
| 282 | + guard !Task.isCancelled else { return } | ||||
| 283 | + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { vraiPrix = e } | ||||
| 284 | + } | ||||
| 285 | + } | ||||
| 286 | + | ||||
| 287 | + /// La ligne « estimation Vrai-Prix » : valeur du modèle hédonique de | ||||
| 288 | + /// www.vrai-prix.com + écart du prix affiché quand l'adresse est appariée. | ||||
| 289 | + @ViewBuilder | ||||
| 290 | + private func vraiPrixRow(_ vp: VraiPrixEstimate) -> some View { | ||||
| 291 | + let delta = poi.kind == .immoka ? vp.deltaPct(asking: poi.price) : nil | ||||
| 292 | + HStack(spacing: 8) { | ||||
| 293 | + Image(systemName: "chart.line.uptrend.xyaxis") | ||||
| 294 | + .font(.system(size: 12, weight: .bold)) | ||||
| 295 | + .foregroundStyle(vpAccent) | ||||
| 296 | + Group { | ||||
| 297 | + if poi.kind == .immoka { | ||||
| 298 | + Text(vp.unitConfirmed ? "Vrai-Prix : " | ||||
| 299 | + : vp.sameAddress ? "Vrai-Prix (immeuble) : " : "Vrai-Prix (secteur) : ") | ||||
| 300 | + + Text("~\(vp.label)").bold() | ||||
| 301 | + } else { | ||||
| 302 | + Text(vp.sameAddress ? "Immeuble estimé " : "Secteur estimé ") | ||||
| 303 | + + Text("~\(vp.label)").bold() | ||||
| 304 | + + Text(" · Vrai-Prix") | ||||
| 305 | + } | ||||
| 306 | + } | ||||
| 307 | + .font(.system(.caption, design: .rounded)) | ||||
| 308 | + .foregroundStyle(KATheme.ink(scheme)) | ||||
| 309 | + .lineLimit(1) | ||||
| 310 | + .minimumScaleFactor(0.75) | ||||
| 311 | + Spacer(minLength: 4) | ||||
| 312 | + if let d = delta { | ||||
| 313 | + Text("affiché \(d >= 0 ? "+" : "")\(d) %") | ||||
| 314 | + .font(KAFont.mono(9)) | ||||
| 315 | + .padding(.horizontal, 7).padding(.vertical, 3) | ||||
| 316 | + .background((d > 5 ? Color.red : d < -5 ? Color.green : Color.gray) | ||||
| 317 | + .opacity(0.15), in: Capsule()) | ||||
| 318 | + .foregroundStyle(d > 5 ? .red : d < -5 ? .green : KATheme.ink2(scheme)) | ||||
| 319 | + } | ||||
| 320 | + } | ||||
| 321 | + .padding(.horizontal, 10).padding(.vertical, 8) | ||||
| 322 | + .background(vpAccent.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) | ||||
| 323 | + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) | ||||
| 324 | + .strokeBorder(vpAccent.opacity(0.25), lineWidth: 1)) | ||||
| 325 | + .transition(.opacity.combined(with: .move(edge: .bottom))) | ||||
| 326 | + .accessibilityElement(children: .combine) | ||||
| 327 | + .accessibilityLabel( | ||||
| 328 | + "Estimation Vrai-Prix : environ \(vp.label)" | ||||
| 329 | + + (delta.map { ", prix affiché \($0 >= 0 ? "plus" : "moins") élevé de \(abs($0)) pour cent" } ?? "")) | ||||
| 271 | } | 330 | } | ||
| 272 | 331 | ||||
| 273 | @ViewBuilder | 332 | @ViewBuilder | ||
modified
KA/Features/Trajet/POI.swift
+6 −3
@@ -45,6 +45,7 @@ struct POIItem: Identifiable { | |||||
| 45 | let name: String | 45 | let name: String | ||
| 46 | let address: String | 46 | let address: String | ||
| 47 | let priceLabel: String? // annonces Ka / prix de l'essence ordinaire | 47 | let priceLabel: String? // annonces Ka / prix de l'essence ordinaire | ||
| 48 | + let price: Double? // montant numérique brut (comparaison Vrai-Prix) | ||||
| 48 | let coordinate: CLLocationCoordinate2D | 49 | let coordinate: CLLocationCoordinate2D | ||
| 49 | let url: URL? // fiche de l'annonce sur le site | 50 | let url: URL? // fiche de l'annonce sur le site | ||
| 50 | let extra: String? // détail (ex. ordinaire/super/diesel + source) | 51 | let extra: String? // détail (ex. ordinaire/super/diesel + source) | ||
@@ -52,9 +53,10 @@ struct POIItem: Identifiable { | |||||
| 52 | 53 | ||||
| 53 | init(id: String, kind: POIKind, name: String, address: String, | 54 | init(id: String, kind: POIKind, name: String, address: String, | ||
| 54 | priceLabel: String?, coordinate: CLLocationCoordinate2D, | 55 | priceLabel: String?, coordinate: CLLocationCoordinate2D, | ||
| 55 | − url: URL?, extra: String? = nil, imageURL: URL? = nil) { | 56 | + url: URL?, extra: String? = nil, imageURL: URL? = nil, | ||
| 57 | + price: Double? = nil) { | ||||
| 56 | self.id = id; self.kind = kind; self.name = name; self.address = address | 58 | self.id = id; self.kind = kind; self.name = name; self.address = address | ||
| 57 | − self.priceLabel = priceLabel; self.coordinate = coordinate | 59 | + self.priceLabel = priceLabel; self.price = price; self.coordinate = coordinate | ||
| 58 | self.url = url; self.extra = extra; self.imageURL = imageURL | 60 | self.url = url; self.extra = extra; self.imageURL = imageURL | ||
| 59 | } | 61 | } | ||
| 60 | } | 62 | } | ||
@@ -368,7 +370,8 @@ enum POIService { | |||||
| 368 | extra: bits.isEmpty ? nil : bits.joined(separator: " · "), | 370 | extra: bits.isEmpty ? nil : bits.joined(separator: " · "), | ||
| 369 | imageURL: (props["image"] as? String).flatMap { | 371 | imageURL: (props["image"] as? String).flatMap { | ||
| 370 | $0.hasPrefix("http") ? URL(string: $0) : nil | 372 | $0.hasPrefix("http") ? URL(string: $0) : nil | ||
| 371 | − }) | 373 | + }, | ||
| 374 | + price: props["price"] as? Double) | ||||
| 372 | } | 375 | } | ||
| 373 | } | 376 | } | ||
| 374 | } | 377 | } | ||
added
KA/Features/Trajet/VraiPrixService.swift
+160 −0
@@ -0,0 +1,160 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// VraiPrixService.swift — l'estimation VRAI-PRIX (www.vrai-prix.com, modèle | ||||
| 3 | +// hédonique du Groupe Ka) dans le mode Découvrir. Volontairement frugal : | ||||
| 4 | +// UNE seule requête légère (/api/nearby, boîte ~60 m, JSON ~2 Ko) par carte | ||||
| 5 | +// affichée — jamais pour la file ni les marqueurs — avec cache mémoire par | ||||
| 6 | +// annonce (résultats vides compris) et échec silencieux. Aucun overload. | ||||
| 7 | +import Foundation | ||||
| 8 | +import CoreLocation | ||||
| 9 | + | ||||
| 10 | +/// Estimation Vrai-Prix appariée à une annonce Lou-Ka / Immo-Ka. | ||||
| 11 | +struct VraiPrixEstimate { | ||||
| 12 | + let value: Int // est. 2026 ($) du modèle hédonique Vrai-Prix | ||||
| 13 | + let sameAddress: Bool // le numéro civique correspond (même immeuble) | ||||
| 14 | + let unitConfirmed: Bool // l'unité précise est confirmée (unité unique ou apt apparié) | ||||
| 15 | + | ||||
| 16 | + /// Montant à la québécoise : « 452 000 $ » ou « 1,17 M$ ». | ||||
| 17 | + var label: String { | ||||
| 18 | + if value >= 1_000_000 { | ||||
| 19 | + return String(format: "%.2f", Double(value) / 1_000_000) | ||||
| 20 | + .replacingOccurrences(of: ".", with: ",") + " M$" | ||||
| 21 | + } | ||||
| 22 | + let rounded = (value + 500) / 1000 * 1000 | ||||
| 23 | + return rounded.formatted(.number.locale(Locale(identifier: "fr_CA"))) + " $" | ||||
| 24 | + } | ||||
| 25 | + | ||||
| 26 | + /// Écart % du prix affiché vs l'estimation. Seulement quand l'unité est | ||||
| 27 | + /// confirmée (jamais sur une médiane d'immeuble ni un voisinage), ventes | ||||
| 28 | + /// seulement (un loyer mensuel passe sous le plancher), et borné — un | ||||
| 29 | + /// écart énorme trahit un mauvais appariement (stationnement, etc.). | ||||
| 30 | + func deltaPct(asking: Double?) -> Int? { | ||||
| 31 | + guard unitConfirmed, let asking, asking >= 25_000 else { return nil } | ||||
| 32 | + let pct = (asking - Double(value)) / Double(value) * 100 | ||||
| 33 | + guard abs(pct) <= 60 else { return nil } | ||||
| 34 | + return Int(pct.rounded()) | ||||
| 35 | + } | ||||
| 36 | +} | ||||
| 37 | + | ||||
| 38 | +actor VraiPrixService { | ||||
| 39 | + static let shared = VraiPrixService() | ||||
| 40 | + private var cache: [String: VraiPrixEstimate?] = [:] | ||||
| 41 | + | ||||
| 42 | + func estimate(for poi: POIItem) async -> VraiPrixEstimate? { | ||||
| 43 | + if let hit = cache[poi.id] { return hit } | ||||
| 44 | + let r = await fetch(poi) | ||||
| 45 | + cache[poi.id] = r | ||||
| 46 | + return r | ||||
| 47 | + } | ||||
| 48 | + | ||||
| 49 | + private func fetch(_ poi: POIItem) async -> VraiPrixEstimate? { | ||||
| 50 | + var comps = URLComponents(string: "https://www.vrai-prix.com/api/nearby")! | ||||
| 51 | + comps.queryItems = [ | ||||
| 52 | + .init(name: "lat", value: "\(poi.coordinate.latitude)"), | ||||
| 53 | + .init(name: "lng", value: "\(poi.coordinate.longitude)"), | ||||
| 54 | + .init(name: "halfLat", value: "0.0005"), // ≈ 55 m | ||||
| 55 | + .init(name: "halfLng", value: "0.0007"), | ||||
| 56 | + .init(name: "limit", value: "20"), | ||||
| 57 | + ] | ||||
| 58 | + var req = URLRequest(url: comps.url!) | ||||
| 59 | + req.timeoutInterval = 6 | ||||
| 60 | + req.setValue("KaTrajet/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent") | ||||
| 61 | + guard let (data, _) = try? await URLSession.shared.data(for: req), | ||||
| 62 | + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], | ||||
| 63 | + let results = root["results"] as? [[String: Any]] else { return nil } | ||||
| 64 | + | ||||
| 65 | + struct Candidate { | ||||
| 66 | + let value: Int, civic: Bool, apt: String, meters: Double, terrain: Bool | ||||
| 67 | + } | ||||
| 68 | + let civic = Self.civicNumber(in: poi.address) | ||||
| 69 | + let streetTokens = Self.streetTokens(poi.address) | ||||
| 70 | + let aptTokens = Self.aptTokens(in: poi.address, civic: civic) | ||||
| 71 | + | ||||
| 72 | + let units: [Candidate] = results.compactMap { u in | ||||
| 73 | + guard let est = u["est2026"] as? Double, est > 0, | ||||
| 74 | + let lat = u["lat"] as? Double, let lng = u["lng"] as? Double, | ||||
| 75 | + let adresse = u["adresse"] as? String else { return nil } | ||||
| 76 | + // une rue clairement différente disqualifie l'unité, même toute proche | ||||
| 77 | + let uTokens = Self.streetTokens(adresse) | ||||
| 78 | + if !streetTokens.isEmpty, !uTokens.isEmpty, uTokens.isDisjoint(with: streetTokens) { | ||||
| 79 | + return nil | ||||
| 80 | + } | ||||
| 81 | + let range = Self.civicRange(in: adresse) | ||||
| 82 | + return Candidate( | ||||
| 83 | + value: Int(est), | ||||
| 84 | + civic: civic.flatMap { c in range.map { $0.contains(c) } } ?? false, | ||||
| 85 | + apt: Self.normalized(u["apt"] as? String ?? ""), | ||||
| 86 | + meters: CLLocation(latitude: lat, longitude: lng) | ||||
| 87 | + .distance(from: CLLocation(latitude: poi.coordinate.latitude, | ||||
| 88 | + longitude: poi.coordinate.longitude)), | ||||
| 89 | + terrain: (u["typeProp"] as? String) == "terrain") | ||||
| 90 | + } | ||||
| 91 | + | ||||
| 92 | + let sameCivic = units.filter(\.civic) | ||||
| 93 | + if let only = sameCivic.first, sameCivic.count == 1 { | ||||
| 94 | + return VraiPrixEstimate(value: only.value, sameAddress: true, unitConfirmed: true) | ||||
| 95 | + } | ||||
| 96 | + if !sameCivic.isEmpty { | ||||
| 97 | + // plusieurs unités au même civique (condos) : l'apt apparié confirme | ||||
| 98 | + // l'unité ; sinon, médiane de l'immeuble sans écart de prix | ||||
| 99 | + if !aptTokens.isEmpty, | ||||
| 100 | + let am = sameCivic.first(where: { !$0.apt.isEmpty && aptTokens.contains($0.apt) }) { | ||||
| 101 | + return VraiPrixEstimate(value: am.value, sameAddress: true, unitConfirmed: true) | ||||
| 102 | + } | ||||
| 103 | + let sorted = sameCivic.map(\.value).sorted() | ||||
| 104 | + return VraiPrixEstimate(value: sorted[sorted.count / 2], | ||||
| 105 | + sameAddress: true, unitConfirmed: false) | ||||
| 106 | + } | ||||
| 107 | + // repli : unité compatible la plus proche (≤ 30 m, pas un terrain) | ||||
| 108 | + return units.filter { !$0.terrain && $0.meters <= 30 } | ||||
| 109 | + .min { $0.meters < $1.meters } | ||||
| 110 | + .map { VraiPrixEstimate(value: $0.value, sameAddress: false, unitConfirmed: false) } | ||||
| 111 | + } | ||||
| 112 | + | ||||
| 113 | + // MARK: appariement d'adresses | ||||
| 114 | + | ||||
| 115 | + /// Numéro civique d'une annonce : dernier nombre du premier groupe | ||||
| 116 | + /// (les annonces QC écrivent souvent « apt-civique Rue », ex. « 311-1225 | ||||
| 117 | + /// Rue Notre-Dame » → 1225 ; « SS2-224-98 Rue Charlotte » → 98). | ||||
| 118 | + private static func civicNumber(in address: String) -> Int? { | ||||
| 119 | + guard let m = address.firstMatch(of: #/(?:\w{1,4}\s*-\s*)*(\d{1,6})/#) else { return nil } | ||||
| 120 | + return Int(m.output.1) | ||||
| 121 | + } | ||||
| 122 | + | ||||
| 123 | + /// Candidats « appartement » d'une annonce : courts jetons avec chiffre | ||||
| 124 | + /// (« #14 », « App S3 », « 311- ») autres que le civique lui-même. | ||||
| 125 | + private static func aptTokens(in address: String, civic: Int?) -> Set<String> { | ||||
| 126 | + let civicStr = civic.map(String.init) | ||||
| 127 | + return Set(address.uppercased() | ||||
| 128 | + .components(separatedBy: CharacterSet.alphanumerics.inverted) | ||||
| 129 | + .filter { t in | ||||
| 130 | + t.count <= 5 && !t.isEmpty && t.contains(where: \.isNumber) && t != civicStr | ||||
| 131 | + }) | ||||
| 132 | + } | ||||
| 133 | + | ||||
| 134 | + /// Apt d'une unité d'évaluation, réduit à l'alphanumérique majuscule. | ||||
| 135 | + private static func normalized(_ apt: String) -> String { | ||||
| 136 | + String(apt.uppercased().unicodeScalars.filter(CharacterSet.alphanumerics.contains)) | ||||
| 137 | + } | ||||
| 138 | + | ||||
| 139 | + /// « 1152-1154 Boulevard X » → 1152...1154 ; « 1163 Rue CLARK » → 1163...1163 | ||||
| 140 | + private static func civicRange(in address: String) -> ClosedRange<Int>? { | ||||
| 141 | + guard let m = address.firstMatch(of: #/(\d{1,6})(?:\s*-\s*(\d{1,6}))?/#), | ||||
| 142 | + let lo = Int(m.output.1) else { return nil } | ||||
| 143 | + let hi = m.output.2.flatMap { Int($0) } ?? lo | ||||
| 144 | + return min(lo, hi)...max(lo, hi) | ||||
| 145 | + } | ||||
| 146 | + | ||||
| 147 | + /// Jetons de nom de rue (≥ 4 lettres, sans accents, génériques exclus) | ||||
| 148 | + /// pour un rapprochement souple « boul. Saint-Laurent » ↔ « Boulevard SAINT-LAURENT ». | ||||
| 149 | + private static let genericTokens: Set<String> = [ | ||||
| 150 | + "RUE", "AVENUE", "BOULEVARD", "BOUL", "CHEMIN", "PLACE", "ROUTE", | ||||
| 151 | + "MONTREAL", "QUEBEC", "LAVAL", "LONGUEUIL", "GATINEAU", | ||||
| 152 | + "APPARTEMENT", "SAINT", "SAINTE", "OUEST", "NORD", | ||||
| 153 | + ] | ||||
| 154 | + private static func streetTokens(_ s: String) -> Set<String> { | ||||
| 155 | + Set(s.folding(options: .diacriticInsensitive, locale: Locale(identifier: "fr_CA")) | ||||
| 156 | + .uppercased() | ||||
| 157 | + .components(separatedBy: CharacterSet.letters.inverted) | ||||
| 158 | + .filter { $0.count >= 4 && !genericTokens.contains($0) }) | ||||
| 159 | + } | ||||
| 160 | +} | ||||
modified
project.yml
+1 −1
@@ -10,7 +10,7 @@ settings: | |||||
| 10 | base: | 10 | base: | ||
| 11 | SWIFT_VERSION: "5.0" | 11 | SWIFT_VERSION: "5.0" | ||
| 12 | MARKETING_VERSION: "3.0.0" | 12 | MARKETING_VERSION: "3.0.0" | ||
| 13 | − CURRENT_PROJECT_VERSION: "11" | 13 | + CURRENT_PROJECT_VERSION: "12" | ||
| 14 | DEVELOPMENT_TEAM: "3YM54G49SN" | 14 | DEVELOPMENT_TEAM: "3YM54G49SN" | ||
| 15 | CODE_SIGN_STYLE: Automatic | 15 | CODE_SIGN_STYLE: Automatic | ||
| 16 | GENERATE_INFOPLIST_FILE: true | 16 | GENERATE_INFOPLIST_FILE: true | ||
| 17 | 17 | ||||