SPB Git forge

spb/ka-ios

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

v3.0.0 — couche VRAI-PRIX sur l'onglet Carte : chip Vrai-Prix (3 747 008 unités d'évaluation du Québec) chargée PAR ZONE VISIBLE seulement via /api/nearby (bbox plafonné ~9 km, limit 1000, cache 300 s) — jamais de chargement massif ; garde de zoom (span ≤ 0.16) avec pilule « Vrai-Prix : zoomez » sinon ; pilules de prix compactes (847 k$ / 1,7 M$) + clustering existant (plafond 150 rendus) ; tap sur une unité → EstimateView, la vraie fiche d'estimation (fourchette, confiance A-D, historique, portrait) ; pilotage debug ka.debug.mapvp ; validé simulateur Plateau (1000 unités) — 14 tests verts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent 4bb2be0

2 changed files +90 −5

modified KA/Core/Services.swift +40 −0
@@ -135,6 +135,46 @@ enum UniverseService {
135 135 }
136 136 }
137 137
138 + /// Couche VRAI-PRIX de la carte : les unités d'évaluation du Québec
139 + /// (3 747 008 au total) dans la ZONE VISIBLE seulement, via /api/nearby
140 + /// (bbox plafonné serveur ~9 km, cache APIClient). La carte ne l'appelle
141 + /// qu'assez zoomée — jamais de chargement massif.
142 + static func vraiPrixUnits(west: Double, south: Double, east: Double, north: Double,
143 + limit: Int = 1000) async -> [KAItem] {
144 + var comps = URLComponents(string: "https://www.vrai-prix.com/api/nearby")!
145 + comps.queryItems = [
146 + .init(name: "lat", value: "\((south + north) / 2)"),
147 + .init(name: "lng", value: "\((west + east) / 2)"),
148 + .init(name: "halfLat", value: "\(min((north - south) / 2, 0.08))"),
149 + .init(name: "halfLng", value: "\(min((east - west) / 2, 0.12))"),
150 + .init(name: "limit", value: String(limit)),
151 + ]
152 + guard let url = comps.url,
153 + let root = try? await APIClient.shared.json(url, ttl: 300),
154 + let results = root.object?["results"]?.array else { return [] }
155 + return results.compactMap { r -> KAItem? in
156 + guard let o = r.object, let id = o.str("id"),
157 + let lat = o.num("lat"), let lng = o.num("lng"),
158 + let est = o.num("est2026"), est > 0 else { return nil }
159 + let apt = o.str("apt").flatMap { $0.isEmpty ? nil : $0 }
160 + return KAItem(
161 + id: "vp:\(id)",
162 + universeID: "vrai-prix",
163 + title: [o.str("adresse"), apt.map { "app. \($0)" }]
164 + .compactMap { $0 }.joined(separator: " "),
165 + subtitle: o.str("typeProp"),
166 + priceLabel: "\(est.compact)$",
167 + city: o.str("municipalite"),
168 + url: URL(string: "https://www.vrai-prix.com/estimation/\(id)"),
169 + imageURL: nil,
170 + latitude: lat,
171 + longitude: lng,
172 + detail: nil,
173 + facts: [.init(label: "Estimation Vrai-Prix 2026", value: est.money0)]
174 + )
175 + }
176 + }
177 +
138 178 /// Total « en direct » depuis /api/stats (clé selon l'univers, chemins a.b acceptés)
139 179 static func liveTotal(_ u: Universe) async -> Int? {
140 180 let path = u.id == "trouve-ka" ? "/api/status" : "/api/stats"
modified KA/Features/MapView.swift +50 −5
@@ -69,7 +69,13 @@ struct UnifiedMapView: View {
69 69
70 70 /// Univers cartographiables (coordonnées disponibles)
71 71 private var mapUniverses: [Universe] {
72 Ecosystem.all.filter { ["lou-ka", "immo-ka", "job-ka", "resto-ka", "sorti-ka"].contains($0.id) }
72 + Ecosystem.all.filter { ["lou-ka", "immo-ka", "job-ka", "resto-ka", "sorti-ka", "vrai-prix"].contains($0.id) }
73 + }
74 + /// La couche Vrai-Prix (3,7 M d'unités d'évaluation) ne se charge
75 + /// qu'assez zoomé : au-delà, le serveur plafonne le bbox de /api/nearby
76 + /// (~9 km) et la zone visible ne serait couverte qu'au centre.
77 + private var vpZoomedEnough: Bool {
78 + visibleRegion.span.latitudeDelta <= 0.16 && visibleRegion.span.longitudeDelta <= 0.24
73 79 }
74 80 private var visibleItems: [KAItem] { items.filter { enabled.contains($0.universeID) } }
75 81 private var clusters: [MapCluster] { ClusterEngine.clusterize(visibleItems, region: visibleRegion) }
@@ -106,15 +112,39 @@ struct UnifiedMapView: View {
106 112 }
107 113 }
108 114 .sheet(item: $sheetItem) { item in
109 NavigationStack { ItemDetailView(item: item) }
110 .presentationDetents([.medium, .large])
111 .presentationBackgroundInteraction(.enabled(upThrough: .medium))
115 + NavigationStack {
116 + // une unité Vrai-Prix ouvre la vraie fiche d'estimation
117 + // (moteur hédonique : fourchette, confiance, historique)
118 + if item.universeID == "vrai-prix",
119 + let vp = Ecosystem.universe("vrai-prix") {
120 + EstimateView(result: VPResult(id: String(item.id.dropFirst(3)),
121 + adresse: item.title,
122 + municipalite: item.city ?? "",
123 + type: item.subtitle),
124 + universe: vp)
125 + } else {
126 + ItemDetailView(item: item)
127 + }
128 + }
129 + .presentationDetents([.medium, .large])
130 + .presentationBackgroundInteraction(.enabled(upThrough: .medium))
112 131 }
113 132 .task {
133 + // pilotage debug : couche Vrai-Prix active + zoom quartier
134 + // (defaults write com.groupeka.ka ka.debug.mapvp -bool true)
135 + if UserDefaults.standard.bool(forKey: "ka.debug.mapvp") {
136 + enabled.insert("vrai-prix")
137 + try? await Task.sleep(for: .seconds(1)) // la carte doit être layoutée
138 + let r = MKCoordinateRegion(center: .init(latitude: 45.5231, longitude: -73.5817),
139 + span: .init(latitudeDelta: 0.005, longitudeDelta: 0.005))
140 + visibleRegion = r
141 + recenter(r)
142 + }
114 143 location.requestIfAuthorized() // HIG : pas de dialogue à l'ouverture
115 144 await loadZone()
116 145 }
117 146 .onChange(of: location.coordinate != nil) {
147 + guard !UserDefaults.standard.bool(forKey: "ka.debug.mapvp") else { return }
118 148 if let c = location.coordinate {
119 149 recenter(.init(center: c, span: .init(latitudeDelta: 0.18, longitudeDelta: 0.18)))
120 150 Task { await loadZone() }
@@ -217,6 +247,15 @@ struct UnifiedMapView: View {
217 247 .font(.system(.caption, design: .monospaced).weight(.bold))
218 248 .padding(.horizontal, 11).padding(.vertical, 9)
219 249 .background(.ultraThinMaterial, in: Capsule())
250 + if enabled.contains("vrai-prix") && !vpZoomedEnough {
251 + Label("Vrai-Prix : zoomez", systemImage: "plus.magnifyingglass")
252 + .font(.system(size: 10, design: .monospaced).weight(.bold))
253 + .padding(.horizontal, 10).padding(.vertical, 9)
254 + .background(.ultraThinMaterial, in: Capsule())
255 + .foregroundStyle(Ecosystem.universe("vrai-prix")?.accent ?? .red)
256 + .transition(.scale.combined(with: .opacity))
257 + .accessibilityLabel("Zoomez la carte pour voir les estimations Vrai-Prix")
258 + }
220 259 Spacer()
221 260 if zoneDirty && !loading {
222 261 Button {
@@ -311,12 +350,18 @@ struct UnifiedMapView: View {
311 350 let south = r.center.latitude - r.span.latitudeDelta / 2
312 351 let north = r.center.latitude + r.span.latitudeDelta / 2
313 352 var all: [KAItem] = []
353 + let wantVP = enabled.contains("vrai-prix") && vpZoomedEnough
314 354 await withTaskGroup(of: [KAItem].self) { group in
315 for u in mapUniverses where enabled.contains(u.id) {
355 + for u in mapUniverses where enabled.contains(u.id) && u.id != "vrai-prix" {
316 356 group.addTask {
317 357 await UniverseService.mapItems(u, west: west, south: south, east: east, north: north)
318 358 }
319 359 }
360 + if wantVP {
361 + group.addTask {
362 + await UniverseService.vraiPrixUnits(west: west, south: south, east: east, north: north)
363 + }
364 + }
320 365 for await batch in group { all.append(contentsOf: batch) }
321 366 }
322 367 withAnimation(.easeOut(duration: 0.25)) { items = all }
323 368