1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// MapView.swift — la CARTE, pièce maîtresse : marqueurs-pilules avec prix,3// CLUSTERING par grille (dégroupage au zoom), « Rechercher dans cette zone »4// (bbox RÉEL côté API pour lou-ka/immo-ka/job-ka, filtre client sinon),5// fiche compacte au tap → fiche complète en feuille (état de carte conservé),6// bascule liste/carte synchronisée, chips par univers aux couleurs officielles.7import SwiftUI8import MapKit9import CoreLocation10// Rendu : MapLibre (style Mapbox streets v12 + bâtiments 3D) — voir KaMapLibre.swift1112// MARK: - Clustering par grille (pur, testé dans KATests)1314struct MapCluster: Identifiable, Hashable {15 let id: String16 let latitude: Double17 let longitude: Double18 let items: [KAItem]19 var isSingle: Bool { items.count == 1 }20 var item: KAItem { items[0] }21}2223enum ClusterEngine {24 /// Regroupe les items en cellules de grille (~grid × grid cellules sur la25 /// région) ; une cellule d'un seul élément reste un marqueur individuel.26 static func clusterize(_ items: [KAItem], region: MKCoordinateRegion, grid: Double = 9) -> [MapCluster] {27 let cellLat = max(region.span.latitudeDelta / grid, 0.0001)28 let cellLon = max(region.span.longitudeDelta / grid, 0.0001)29 var cells: [String: [KAItem]] = [:]30 for it in items {31 guard let la = it.latitude, let lo = it.longitude else { continue }32 let key = "\(Int((la / cellLat).rounded(.down)))|\(Int((lo / cellLon).rounded(.down)))"33 cells[key, default: []].append(it)34 }35 return cells.map { key, members in36 let la = members.compactMap(\.latitude).reduce(0, +) / Double(members.count)37 let lo = members.compactMap(\.longitude).reduce(0, +) / Double(members.count)38 return MapCluster(id: key + ":\(members.count)", latitude: la, longitude: lo, items: members)39 }40 .sorted { $0.items.count > $1.items.count }41 .prefix(150) // plafond de rendu — jamais de carte qui rame42 .map { $0 }43 }44}4546// MARK: - La carte unifiée4748struct UnifiedMapView: View {49 /// true quand la carte est un ONGLET (pas de bouton fermer)50 var embedded: Bool = false51 @State private var programRegion = MKCoordinateRegion(52 center: .init(latitude: 46.81, longitude: -71.21),53 span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3))54 @State private var programEpoch = 055 @State private var pitch3D = false56 @State private var visibleRegion = MKCoordinateRegion(57 center: .init(latitude: 46.81, longitude: -71.21),58 span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3))59 @State private var items: [KAItem] = []60 @State private var enabled: Set<String> = ["lou-ka", "immo-ka"]61 @State private var loading = false62 @State private var zoneDirty = false63 @State private var selectedCluster: MapCluster?64 @State private var sheetItem: KAItem?65 @State private var showList = false66 @StateObject private var location = LocationOnce()67 @Environment(\.colorScheme) private var scheme68 @Environment(\.dismiss) private var dismiss6970 /// Univers cartographiables (coordonnées disponibles)71 private var mapUniverses: [Universe] {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 charge75 /// qu'assez zoomé : au-delà, le serveur plafonne le bbox de /api/nearby76 /// (~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.2479 }80 private var visibleItems: [KAItem] { items.filter { enabled.contains($0.universeID) } }81 private var clusters: [MapCluster] { ClusterEngine.clusterize(visibleItems, region: visibleRegion) }8283 var body: some View {84 NavigationStack {85 ZStack(alignment: .bottom) {86 mapLayer87 if showList { listOverlay }88 VStack(spacing: 10) {89 if let c = selectedCluster, c.isSingle { compactCard(c.item) }90 bottomBar91 }92 .padding(.horizontal, 12)93 .padding(.bottom, 8)94 }95 .safeAreaInset(edge: .top) { chips }96 .navigationTitle("Carte")97 .navigationBarTitleDisplayMode(.inline)98 .toolbar {99 if !embedded {100 ToolbarItem(placement: .topBarLeading) {101 Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) }102 .accessibilityLabel("Fermer la carte")103 }104 }105 ToolbarItem(placement: .topBarTrailing) {106 Button {107 Haptics.tap(); withAnimation(.snappy) { showList.toggle() }108 } label: {109 Image(systemName: showList ? "map.fill" : "list.bullet")110 }111 .accessibilityLabel(showList ? "Voir la carte" : "Voir la liste des résultats")112 }113 }114 .sheet(item: $sheetItem) { item in115 NavigationStack {116 // une unité Vrai-Prix ouvre la vraie fiche d'estimation117 // (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))131 }132 .task {133 // pilotage debug : couche Vrai-Prix active + zoom quartier134 // (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ée138 let r = MKCoordinateRegion(center: .init(latitude: 45.5231, longitude: -73.5817),139 span: .init(latitudeDelta: 0.005, longitudeDelta: 0.005))140 visibleRegion = r141 recenter(r)142 }143 location.requestIfAuthorized() // HIG : pas de dialogue à l'ouverture144 await loadZone()145 }146 .onChange(of: location.coordinate != nil) {147 guard !UserDefaults.standard.bool(forKey: "ka.debug.mapvp") else { return }148 if let c = location.coordinate {149 recenter(.init(center: c, span: .init(latitudeDelta: 0.18, longitudeDelta: 0.18)))150 Task { await loadZone() }151 }152 }153 }154 }155156 // MARK: couches157158 private var mapLayer: some View {159 KaMapLibreView(160 clusters: clusters,161 programRegion: programRegion,162 programEpoch: programEpoch,163 pitch3D: pitch3D,164 selectedID: selectedCluster?.id,165 onRegionChange: { region in166 visibleRegion = region167 zoneDirty = true168 },169 onSelect: { cluster in170 if cluster.isSingle {171 Haptics.tap()172 withAnimation(.snappy) { selectedCluster = cluster }173 } else {174 Haptics.rigid()175 // dégroupage : zoom sur le groupe176 recenter(.init(center: .init(latitude: cluster.latitude, longitude: cluster.longitude),177 span: .init(latitudeDelta: visibleRegion.span.latitudeDelta / 3.2,178 longitudeDelta: visibleRegion.span.longitudeDelta / 3.2)))179 }180 })181 .ignoresSafeArea(edges: .bottom)182 }183184 /// Recentrage programmé de la carte185 private func recenter(_ region: MKCoordinateRegion) {186 programRegion = region187 programEpoch += 1188 }189190 private var chips: some View {191 ScrollView(.horizontal, showsIndicators: false) {192 HStack(spacing: 8) {193 ForEach(mapUniverses) { u in194 let on = enabled.contains(u.id)195 Button {196 Haptics.tap()197 if on { enabled.remove(u.id) } else { enabled.insert(u.id) }198 selectedCluster = nil199 Task { await loadZone() }200 } label: {201 Label(u.wordmark, systemImage: u.symbol)202 .font(.system(.caption, design: .monospaced).weight(.bold))203 .padding(.horizontal, 11).padding(.vertical, 8)204 .background(on ? u.accent : KATheme.surface(scheme), in: Capsule())205 .foregroundStyle(on ? .white : .secondary)206 .overlay(Capsule().strokeBorder(.black.opacity(0.25), lineWidth: 1))207 }208 .accessibilityLabel("\(on ? "Masquer" : "Afficher") \(u.name) sur la carte")209 }210 }211 .padding(.horizontal, 14).padding(.vertical, 8)212 }213 .background(.ultraThinMaterial)214 }215216 private var bottomBar: some View {217 HStack(spacing: 10) {218 Button {219 Haptics.rigid()220 pitch3D.toggle()221 } label: {222 Text(pitch3D ? "2D" : "3D")223 .font(.system(.caption, design: .rounded).weight(.bold))224 .frame(width: 40, height: 36)225 .background(pitch3D ? KATheme.inkLight : .clear)226 .background(.ultraThinMaterial, in: Capsule())227 .foregroundStyle(pitch3D ? KATheme.lime : .primary)228 .overlay(Capsule().strokeBorder(.primary.opacity(0.3), lineWidth: 1))229 }230 .accessibilityLabel(pitch3D ? "Repasser en vue 2D" : "Passer en vue 3D avec bâtiments")231 Button {232 Haptics.tap()233 location.request() // la permission n'est demandée QU'ICI, sur geste234 if let c = location.coordinate {235 recenter(.init(center: c, span: .init(latitudeDelta: 0.12, longitudeDelta: 0.12)))236 Task { await loadZone() }237 }238 } label: {239 Image(systemName: "location.fill")240 .font(.caption.weight(.bold))241 .frame(width: 36, height: 36)242 .background(.ultraThinMaterial, in: Circle())243 .overlay(Circle().strokeBorder(.primary.opacity(0.3), lineWidth: 1))244 }245 .accessibilityLabel("Autour de moi")246 Text(loading ? "Chargement…" : "\(visibleItems.count) résultats")247 .font(.system(.caption, design: .monospaced).weight(.bold))248 .padding(.horizontal, 11).padding(.vertical, 9)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 }259 Spacer()260 if zoneDirty && !loading {261 Button {262 Haptics.rigid()263 Task { await loadZone() }264 } label: {265 Label("Rechercher dans cette zone", systemImage: "arrow.clockwise")266 .font(.caption.weight(.bold))267 .padding(.horizontal, 13).padding(.vertical, 10)268 .background(KATheme.inkLight, in: Capsule())269 .foregroundStyle(KATheme.lime)270 }271 .transition(.scale.combined(with: .opacity))272 }273 }274 .animation(.snappy, value: zoneDirty)275 }276277 private func compactCard(_ item: KAItem) -> some View {278 Button {279 Haptics.tap()280 sheetItem = item // feuille : l'état de la carte reste intact derrière281 } label: {282 HStack(spacing: 10) {283 if let img = item.imageURL {284 KAImage(url: img, accent: Ecosystem.universe(item.universeID)?.accent ?? .gray)285 .frame(width: 58, height: 58)286 .clipShape(RoundedRectangle(cornerRadius: 9))287 }288 VStack(alignment: .leading, spacing: 3) {289 Text(item.title).font(.subheadline.weight(.bold)).lineLimit(1)290 HStack(spacing: 6) {291 if let p = item.priceLabel {292 Text(p).font(.system(.caption, design: .rounded).weight(.bold))293 .foregroundStyle(Ecosystem.universe(item.universeID)?.accent ?? .primary)294 }295 if let c = item.city { Text(c).font(.caption2).foregroundStyle(.secondary) }296 }297 Text("Toucher pour la fiche complète").font(.system(size: 9, design: .monospaced))298 .foregroundStyle(.tertiary)299 }300 Spacer()301 Button {302 withAnimation(.snappy) { selectedCluster = nil }303 } label: {304 Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)305 }306 .accessibilityLabel("Fermer l'aperçu")307 }308 .padding(11)309 .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 14, style: .continuous))310 .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(.primary.opacity(0.5), lineWidth: 1.4))311 .shadow(color: .black.opacity(0.18), radius: 8, y: 4)312 }313 .buttonStyle(KAPressStyle())314 .transition(.move(edge: .bottom).combined(with: .opacity))315 }316317 private var listOverlay: some View {318 ScrollView {319 LazyVStack(spacing: 10) {320 ForEach(visibleItems.prefix(60)) { item in321 Button {322 Haptics.tap()323 withAnimation(.snappy) {324 showList = false325 if let la = item.latitude, let lo = item.longitude {326 recenter(.init(center: .init(latitude: la, longitude: lo),327 span: .init(latitudeDelta: 0.02, longitudeDelta: 0.02)))328 }329 selectedCluster = MapCluster(id: item.id, latitude: item.latitude ?? 0,330 longitude: item.longitude ?? 0, items: [item])331 }332 } label: { KAItemRow(item: item) }333 .buttonStyle(KAPressStyle())334 }335 }336 .padding(12)337 }338 .background(KATheme.paper(scheme).opacity(0.98))339 }340341 // MARK: données342343 private func loadZone() async {344 loading = true345 zoneDirty = false346 selectedCluster = nil347 let r = visibleRegion348 let west = r.center.longitude - r.span.longitudeDelta / 2349 let east = r.center.longitude + r.span.longitudeDelta / 2350 let south = r.center.latitude - r.span.latitudeDelta / 2351 let north = r.center.latitude + r.span.latitudeDelta / 2352 var all: [KAItem] = []353 let wantVP = enabled.contains("vrai-prix") && vpZoomedEnough354 await withTaskGroup(of: [KAItem].self) { group in355 for u in mapUniverses where enabled.contains(u.id) && u.id != "vrai-prix" {356 group.addTask {357 await UniverseService.mapItems(u, west: west, south: south, east: east, north: north)358 }359 }360 if wantVP {361 group.addTask {362 await UniverseService.vraiPrixUnits(west: west, south: south, east: east, north: north)363 }364 }365 for await batch in group { all.append(contentsOf: batch) }366 }367 withAnimation(.easeOut(duration: 0.25)) { items = all }368 loading = false369 if !all.isEmpty { Haptics.tap() }370 }371}372373// MARK: - Marqueurs374375/// Pilule de prix (ou symbole) — le marqueur signature, teinté par univers.376struct PricePill: View {377 let item: KAItem378 var selected: Bool379 private var universe: Universe? { Ecosystem.universe(item.universeID) }380381 var body: some View {382 HStack(spacing: 3) {383 Image(systemName: universe?.symbol ?? "mappin")384 .font(.system(size: 9, weight: .bold))385 if let p = item.priceLabel?.split(separator: " ").prefix(2).joined(separator: " ") {386 Text(p).font(.system(size: 11, weight: .bold, design: .rounded))387 }388 }389 .padding(.horizontal, 8).padding(.vertical, 5)390 .background(selected ? KATheme.inkLight : (universe?.accent ?? .gray), in: Capsule())391 .foregroundStyle(selected ? KATheme.lime : .white)392 .overlay(Capsule().strokeBorder(.white.opacity(0.9), lineWidth: 1.4))393 .shadow(color: .black.opacity(0.3), radius: 2, y: 1)394 .scaleEffect(selected ? 1.18 : 1)395 .animation(.spring(response: 0.3, dampingFraction: 0.6), value: selected)396 .accessibilityLabel("\(item.title), \(item.priceLabel ?? "")")397 }398}399400/// Badge de groupe : compte + camembert des univers présents.401struct ClusterBadge: View {402 let cluster: MapCluster403 private var dominant: Color {404 let counts = Dictionary(grouping: cluster.items, by: \.universeID).mapValues(\.count)405 let top = counts.max { $0.value < $1.value }?.key406 return top.flatMap { Ecosystem.universe($0)?.accent } ?? .gray407 }408 var body: some View {409 Text("\(cluster.items.count)")410 .font(.system(size: 13, weight: .bold, design: .rounded))411 .frame(minWidth: 34, minHeight: 34)412 .background(dominant, in: Circle())413 .foregroundStyle(.white)414 .overlay(Circle().strokeBorder(.white, lineWidth: 2))415 .shadow(color: .black.opacity(0.3), radius: 2, y: 1)416 .accessibilityLabel("Groupe de \(cluster.items.count) résultats — toucher pour zoomer")417 }418}419420/// Demande de localisation « une fois » (jamais de suivi continu).421final class LocationOnce: NSObject, ObservableObject, CLLocationManagerDelegate {422 @Published var coordinate: CLLocationCoordinate2D?423 private let manager = CLLocationManager()424425 func request() {426 manager.delegate = self427 if manager.authorizationStatus == .notDetermined {428 manager.requestWhenInUseAuthorization()429 } else {430 manager.requestLocation()431 }432 }433434 /// Ne demande JAMAIS la permission — n'agit que si elle est déjà accordée.435 func requestIfAuthorized() {436 manager.delegate = self437 if manager.authorizationStatus == .authorizedWhenInUse || manager.authorizationStatus == .authorizedAlways {438 manager.requestLocation()439 }440 }441 func locationManagerDidChangeAuthorization(_ m: CLLocationManager) {442 if m.authorizationStatus == .authorizedWhenInUse || m.authorizationStatus == .authorizedAlways {443 m.requestLocation()444 }445 }446 func locationManager(_ m: CLLocationManager, didUpdateLocations locs: [CLLocation]) {447 coordinate = locs.first?.coordinate448 }449 func locationManager(_ m: CLLocationManager, didFailWithError error: Error) {}450}451