SPB Git

spb/ka-ios

Public

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>
Simon-Pierre Boucher committed (Aug 25, 2026) parent 912f385 Browse files

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
Menu

Theme