SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
29 days agolast push
Swift 100%

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 1 mo ago (Aug 25, 2026) parent 912f385

4 changed files +226 −4

modified KA/Features/Trajet/DiscoverView.swift +59 −0
@@ -166,11 +166,15 @@ struct DiscoverCard: View {
166 166 let poi: POIItem
167 167 @EnvironmentObject var model: TrajetModel
168 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 173 private var universe: Universe? {
171 174 Ecosystem.universe(poi.kind == .louka ? "lou-ka" : "immo-ka")
172 175 }
173 176 private var accent: Color { universe?.accent ?? KATheme.green }
177 + private var vpAccent: Color { Ecosystem.universe("vrai-prix")?.accent ?? KATheme.green }
174 178
175 179 var body: some View {
176 180 VStack(spacing: 0) {
@@ -206,6 +210,10 @@ struct DiscoverCard: View {
206 210 DistanceBadge(target: poi.coordinate, accent: accent)
207 211 }
208 212
213 + if let vp = vraiPrix {
214 + vraiPrixRow(vp)
215 + }
216 +
209 217 HStack(spacing: 8) {
210 218 if let url = poi.url {
211 219 Link(destination: url) {
@@ -268,6 +276,57 @@ struct DiscoverCard: View {
268 276 insertion: .move(edge: .bottom).combined(with: .opacity).combined(with: .scale(scale: 0.85)),
269 277 removal: .move(edge: .bottom).combined(with: .opacity)))
270 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 332 @ViewBuilder
modified KA/Features/Trajet/POI.swift +6 −3
@@ -45,6 +45,7 @@ struct POIItem: Identifiable {
45 45 let name: String
46 46 let address: String
47 47 let priceLabel: String? // annonces Ka / prix de l'essence ordinaire
48 + let price: Double? // montant numérique brut (comparaison Vrai-Prix)
48 49 let coordinate: CLLocationCoordinate2D
49 50 let url: URL? // fiche de l'annonce sur le site
50 51 let extra: String? // détail (ex. ordinaire/super/diesel + source)
@@ -52,9 +53,10 @@ struct POIItem: Identifiable {
52 53
53 54 init(id: String, kind: POIKind, name: String, address: String,
54 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 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 60 self.url = url; self.extra = extra; self.imageURL = imageURL
59 61 }
60 62 }
@@ -368,7 +370,8 @@ enum POIService {
368 370 extra: bits.isEmpty ? nil : bits.joined(separator: " · "),
369 371 imageURL: (props["image"] as? String).flatMap {
370 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 10 base:
11 11 SWIFT_VERSION: "5.0"
12 12 MARKETING_VERSION: "3.0.0"
13 − CURRENT_PROJECT_VERSION: "11"
13 + CURRENT_PROJECT_VERSION: "12"
14 14 DEVELOPMENT_TEAM: "3YM54G49SN"
15 15 CODE_SIGN_STYLE: Automatic
16 16 GENERATE_INFOPLIST_FILE: true
17 17