Swift 100%
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 // couches actives, PERSISTÉES entre les sessions61 @AppStorage("ka.map.layers") private var layersRaw = "lou-ka,immo-ka"62 @State private var enabled: Set<String> = []63 @State private var showFavorites = false64 @EnvironmentObject private var favorites: FavoritesStore65 @State private var loading = false66 @State private var zoneDirty = false67 @State private var selectedCluster: MapCluster?68 @State private var sheetItem: KAItem?69 @State private var showList = false70 @StateObject private var location = LocationOnce()71 @Environment(\.colorScheme) private var scheme72 @Environment(\.dismiss) private var dismiss7374 /// Univers cartographiables (coordonnées disponibles)75 private var mapUniverses: [Universe] {76 Ecosystem.all.filter { ["lou-ka", "immo-ka", "job-ka", "resto-ka", "sorti-ka", "vrai-prix"].contains($0.id) }77 }78 /// La couche Vrai-Prix (3,7 M d'unités d'évaluation) ne se charge79 /// qu'assez zoomé : au-delà, le serveur plafonne le bbox de /api/nearby80 /// (~9 km) et la zone visible ne serait couverte qu'au centre.81 private var vpZoomedEnough: Bool {82 visibleRegion.span.latitudeDelta <= 0.16 && visibleRegion.span.longitudeDelta <= 0.2483 }84 /// Favoris géolocalisés dans la zone visible (couche « ♥ »)85 private var favoriteItems: [KAItem] {86 guard showFavorites else { return [] }87 let r = visibleRegion88 return favorites.allItems.filter { it in89 guard let la = it.latitude, let lo = it.longitude else { return false }90 return abs(la - r.center.latitude) <= r.span.latitudeDelta / 291 && abs(lo - r.center.longitude) <= r.span.longitudeDelta / 292 }93 }94 private var visibleItems: [KAItem] {95 var out = items.filter { enabled.contains($0.universeID) }96 let ids = Set(out.map(\.id))97 out += favoriteItems.filter { !ids.contains($0.id) }98 return out99 }100 private var clusters: [MapCluster] { ClusterEngine.clusterize(visibleItems, region: visibleRegion) }101102 private func persistLayers() { layersRaw = enabled.sorted().joined(separator: ",") }103104 var body: some View {105 NavigationStack {106 ZStack(alignment: .bottom) {107 mapLayer108 if showList { listOverlay }109 VStack(spacing: 10) {110 if let c = selectedCluster, c.isSingle { compactCard(c.item) }111 bottomBar112 }113 .padding(.horizontal, 12)114 .padding(.bottom, 8)115 }116 .safeAreaInset(edge: .top) { chips }117 .navigationTitle("Carte")118 .navigationBarTitleDisplayMode(.inline)119 .toolbar {120 if !embedded {121 ToolbarItem(placement: .topBarLeading) {122 Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) }123 .accessibilityLabel("Fermer la carte")124 }125 }126 ToolbarItem(placement: .topBarTrailing) {127 Button {128 Haptics.tap(); withAnimation(.snappy) { showList.toggle() }129 } label: {130 Image(systemName: showList ? "map.fill" : "list.bullet")131 }132 .accessibilityLabel(showList ? "Voir la carte" : "Voir la liste des résultats")133 }134 }135 .sheet(item: $sheetItem) { item in136 NavigationStack {137 // une unité Vrai-Prix ouvre la vraie fiche d'estimation138 // (moteur hédonique : fourchette, confiance, historique)139 if item.universeID == "vrai-prix",140 let vp = Ecosystem.universe("vrai-prix") {141 EstimateView(result: VPResult(id: String(item.id.dropFirst(3)),142 adresse: item.title,143 municipalite: item.city ?? "",144 type: item.subtitle),145 universe: vp)146 } else {147 ItemDetailView(item: item)148 }149 }150 .presentationDetents([.medium, .large])151 .presentationBackgroundInteraction(.enabled(upThrough: .medium))152 }153 .task {154 // restaure les couches choisies à la dernière session155 enabled = Set(layersRaw.split(separator: ",").map(String.init))156 // pilotage debug : couche Vrai-Prix active + zoom quartier157 // (defaults write com.groupeka.ka ka.debug.mapvp -bool true)158 if UserDefaults.standard.bool(forKey: "ka.debug.mapvp") {159 enabled.insert("vrai-prix")160 try? await Task.sleep(for: .seconds(1)) // la carte doit être layoutée161 let r = MKCoordinateRegion(center: .init(latitude: 45.5231, longitude: -73.5817),162 span: .init(latitudeDelta: 0.005, longitudeDelta: 0.005))163 visibleRegion = r164 recenter(r)165 }166 location.requestIfAuthorized() // HIG : pas de dialogue à l'ouverture167 await loadZone()168 }169 .onChange(of: location.coordinate != nil) {170 guard !UserDefaults.standard.bool(forKey: "ka.debug.mapvp") else { return }171 if let c = location.coordinate {172 recenter(.init(center: c, span: .init(latitudeDelta: 0.18, longitudeDelta: 0.18)))173 Task { await loadZone() }174 }175 }176 }177 }178179 // MARK: couches180181 private var mapLayer: some View {182 KaMapLibreView(183 clusters: clusters,184 programRegion: programRegion,185 programEpoch: programEpoch,186 pitch3D: pitch3D,187 selectedID: selectedCluster?.id,188 onRegionChange: { region in189 visibleRegion = region190 zoneDirty = true191 },192 onSelect: { cluster in193 if cluster.isSingle {194 Haptics.tap()195 withAnimation(.snappy) { selectedCluster = cluster }196 } else {197 Haptics.rigid()198 // dégroupage : zoom sur le groupe199 recenter(.init(center: .init(latitude: cluster.latitude, longitude: cluster.longitude),200 span: .init(latitudeDelta: visibleRegion.span.latitudeDelta / 3.2,201 longitudeDelta: visibleRegion.span.longitudeDelta / 3.2)))202 }203 })204 .ignoresSafeArea(edges: .bottom)205 }206207 /// Recentrage programmé de la carte208 private func recenter(_ region: MKCoordinateRegion) {209 programRegion = region210 programEpoch += 1211 }212213 private var chips: some View {214 ScrollView(.horizontal, showsIndicators: false) {215 HStack(spacing: 8) {216 // couche transversale : vos favoris géolocalisés, tous univers217 if !favorites.allItems.isEmpty {218 Button {219 Haptics.tap()220 showFavorites.toggle()221 selectedCluster = nil222 } label: {223 Label("Favoris", systemImage: showFavorites ? "heart.fill" : "heart")224 .font(.system(.caption, design: .monospaced).weight(.bold))225 .padding(.horizontal, 11).padding(.vertical, 8)226 .background(showFavorites ? Color.red : KATheme.surface(scheme), in: Capsule())227 .foregroundStyle(showFavorites ? .white : .secondary)228 .overlay(Capsule().strokeBorder(.black.opacity(0.25), lineWidth: 1))229 }230 .accessibilityLabel("\(showFavorites ? "Masquer" : "Afficher") vos favoris sur la carte")231 }232 ForEach(mapUniverses) { u in233 let on = enabled.contains(u.id)234 Button {235 Haptics.tap()236 if on { enabled.remove(u.id) } else { enabled.insert(u.id) }237 persistLayers()238 selectedCluster = nil239 Task { await loadZone() }240 } label: {241 Label(u.wordmark, systemImage: u.symbol)242 .font(.system(.caption, design: .monospaced).weight(.bold))243 .padding(.horizontal, 11).padding(.vertical, 8)244 .background(on ? u.accent : KATheme.surface(scheme), in: Capsule())245 .foregroundStyle(on ? .white : .secondary)246 .overlay(Capsule().strokeBorder(.black.opacity(0.25), lineWidth: 1))247 }248 .accessibilityLabel("\(on ? "Masquer" : "Afficher") \(u.name) sur la carte")249 }250 }251 .padding(.horizontal, 14).padding(.vertical, 8)252 }253 .background(.ultraThinMaterial)254 }255256 private var bottomBar: some View {257 HStack(spacing: 10) {258 Button {259 Haptics.rigid()260 pitch3D.toggle()261 } label: {262 Text(pitch3D ? "2D" : "3D")263 .font(.system(.caption, design: .rounded).weight(.bold))264 .frame(width: 40, height: 36)265 .background(pitch3D ? KATheme.inkLight : .clear)266 .background(.ultraThinMaterial, in: Capsule())267 .foregroundStyle(pitch3D ? KATheme.lime : .primary)268 .overlay(Capsule().strokeBorder(.primary.opacity(0.3), lineWidth: 1))269 }270 .accessibilityLabel(pitch3D ? "Repasser en vue 2D" : "Passer en vue 3D avec bâtiments")271 Button {272 Haptics.tap()273 location.request() // la permission n'est demandée QU'ICI, sur geste274 if let c = location.coordinate {275 recenter(.init(center: c, span: .init(latitudeDelta: 0.12, longitudeDelta: 0.12)))276 Task { await loadZone() }277 }278 } label: {279 Image(systemName: "location.fill")280 .font(.caption.weight(.bold))281 .frame(width: 36, height: 36)282 .background(.ultraThinMaterial, in: Circle())283 .overlay(Circle().strokeBorder(.primary.opacity(0.3), lineWidth: 1))284 }285 .accessibilityLabel("Autour de moi")286 Text(loading ? "Chargement…" : "\(visibleItems.count) résultats")287 .font(.system(.caption, design: .monospaced).weight(.bold))288 .padding(.horizontal, 11).padding(.vertical, 9)289 .background(.ultraThinMaterial, in: Capsule())290 if enabled.contains("vrai-prix") && !vpZoomedEnough {291 Label("Vrai-Prix : zoomez", systemImage: "plus.magnifyingglass")292 .font(.system(size: 10, design: .monospaced).weight(.bold))293 .padding(.horizontal, 10).padding(.vertical, 9)294 .background(.ultraThinMaterial, in: Capsule())295 .foregroundStyle(Ecosystem.universe("vrai-prix")?.accent ?? .red)296 .transition(.scale.combined(with: .opacity))297 .accessibilityLabel("Zoomez la carte pour voir les estimations Vrai-Prix")298 }299 Spacer()300 if zoneDirty && !loading {301 Button {302 Haptics.rigid()303 Task { await loadZone() }304 } label: {305 Label("Rechercher dans cette zone", systemImage: "arrow.clockwise")306 .font(.caption.weight(.bold))307 .padding(.horizontal, 13).padding(.vertical, 10)308 .background(KATheme.inkLight, in: Capsule())309 .foregroundStyle(KATheme.lime)310 }311 .transition(.scale.combined(with: .opacity))312 }313 }314 .animation(.snappy, value: zoneDirty)315 }316317 private func compactCard(_ item: KAItem) -> some View {318 Button {319 Haptics.tap()320 sheetItem = item // feuille : l'état de la carte reste intact derrière321 } label: {322 HStack(spacing: 10) {323 if let img = item.imageURL {324 KAImage(url: img, accent: Ecosystem.universe(item.universeID)?.accent ?? .gray)325 .frame(width: 58, height: 58)326 .clipShape(RoundedRectangle(cornerRadius: 9))327 }328 VStack(alignment: .leading, spacing: 3) {329 Text(item.title).font(.subheadline.weight(.bold)).lineLimit(1)330 HStack(spacing: 6) {331 if let p = item.priceLabel {332 Text(p).font(.system(.caption, design: .rounded).weight(.bold))333 .foregroundStyle(Ecosystem.universe(item.universeID)?.accent ?? .primary)334 }335 if let c = item.city { Text(c).font(.caption2).foregroundStyle(.secondary) }336 }337 Text("Toucher pour la fiche complète").font(.system(size: 9, design: .monospaced))338 .foregroundStyle(.tertiary)339 }340 Spacer()341 Button {342 withAnimation(.snappy) { selectedCluster = nil }343 } label: {344 Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)345 }346 .accessibilityLabel("Fermer l'aperçu")347 }348 .padding(11)349 .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 14, style: .continuous))350 .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(.primary.opacity(0.5), lineWidth: 1.4))351 .shadow(color: .black.opacity(0.18), radius: 8, y: 4)352 }353 .buttonStyle(KAPressStyle())354 .transition(.move(edge: .bottom).combined(with: .opacity))355 }356357 private var listOverlay: some View {358 ScrollView {359 LazyVStack(spacing: 10) {360 ForEach(visibleItems.prefix(60)) { item in361 Button {362 Haptics.tap()363 withAnimation(.snappy) {364 showList = false365 if let la = item.latitude, let lo = item.longitude {366 recenter(.init(center: .init(latitude: la, longitude: lo),367 span: .init(latitudeDelta: 0.02, longitudeDelta: 0.02)))368 }369 selectedCluster = MapCluster(id: item.id, latitude: item.latitude ?? 0,370 longitude: item.longitude ?? 0, items: [item])371 }372 } label: { KAItemRow(item: item) }373 .buttonStyle(KAPressStyle())374 }375 }376 .padding(12)377 }378 .background(KATheme.paper(scheme).opacity(0.98))379 }380381 // MARK: données382383 private func loadZone() async {384 loading = true385 zoneDirty = false386 selectedCluster = nil387 let r = visibleRegion388 let west = r.center.longitude - r.span.longitudeDelta / 2389 let east = r.center.longitude + r.span.longitudeDelta / 2390 let south = r.center.latitude - r.span.latitudeDelta / 2391 let north = r.center.latitude + r.span.latitudeDelta / 2392 var all: [KAItem] = []393 let wantVP = enabled.contains("vrai-prix") && vpZoomedEnough394 await withTaskGroup(of: [KAItem].self) { group in395 for u in mapUniverses where enabled.contains(u.id) && u.id != "vrai-prix" {396 group.addTask {397 await UniverseService.mapItems(u, west: west, south: south, east: east, north: north)398 }399 }400 if wantVP {401 group.addTask {402 await UniverseService.vraiPrixUnits(west: west, south: south, east: east, north: north)403 }404 }405 for await batch in group { all.append(contentsOf: batch) }406 }407 withAnimation(.easeOut(duration: 0.25)) { items = all }408 loading = false409 if !all.isEmpty { Haptics.tap() }410 }411}412413// MARK: - Marqueurs414415/// Pilule de prix (ou symbole) — le marqueur signature, teinté par univers.416struct PricePill: View {417 let item: KAItem418 var selected: Bool419 private var universe: Universe? { Ecosystem.universe(item.universeID) }420421 var body: some View {422 HStack(spacing: 3) {423 Image(systemName: universe?.symbol ?? "mappin")424 .font(.system(size: 9, weight: .bold))425 if let p = item.priceLabel?.split(separator: " ").prefix(2).joined(separator: " ") {426 Text(p).font(.system(size: 11, weight: .bold, design: .rounded))427 }428 }429 .padding(.horizontal, 8).padding(.vertical, 5)430 .background(selected ? KATheme.inkLight : (universe?.accent ?? .gray), in: Capsule())431 .foregroundStyle(selected ? KATheme.lime : .white)432 .overlay(Capsule().strokeBorder(.white.opacity(0.9), lineWidth: 1.4))433 .shadow(color: .black.opacity(0.3), radius: 2, y: 1)434 .scaleEffect(selected ? 1.18 : 1)435 .animation(.spring(response: 0.3, dampingFraction: 0.6), value: selected)436 .accessibilityLabel("\(item.title), \(item.priceLabel ?? "")")437 }438}439440/// Badge de groupe : compte + camembert des univers présents.441struct ClusterBadge: View {442 let cluster: MapCluster443 private var dominant: Color {444 let counts = Dictionary(grouping: cluster.items, by: \.universeID).mapValues(\.count)445 let top = counts.max { $0.value < $1.value }?.key446 return top.flatMap { Ecosystem.universe($0)?.accent } ?? .gray447 }448 var body: some View {449 Text("\(cluster.items.count)")450 .font(.system(size: 13, weight: .bold, design: .rounded))451 .frame(minWidth: 34, minHeight: 34)452 .background(dominant, in: Circle())453 .foregroundStyle(.white)454 .overlay(Circle().strokeBorder(.white, lineWidth: 2))455 .shadow(color: .black.opacity(0.3), radius: 2, y: 1)456 .accessibilityLabel("Groupe de \(cluster.items.count) résultats — toucher pour zoomer")457 }458}459460/// Demande de localisation « une fois » (jamais de suivi continu).461final class LocationOnce: NSObject, ObservableObject, CLLocationManagerDelegate {462 @Published var coordinate: CLLocationCoordinate2D?463 private let manager = CLLocationManager()464465 func request() {466 manager.delegate = self467 if manager.authorizationStatus == .notDetermined {468 manager.requestWhenInUseAuthorization()469 } else {470 manager.requestLocation()471 }472 }473474 /// Ne demande JAMAIS la permission — n'agit que si elle est déjà accordée.475 func requestIfAuthorized() {476 manager.delegate = self477 if manager.authorizationStatus == .authorizedWhenInUse || manager.authorizationStatus == .authorizedAlways {478 manager.requestLocation()479 }480 }481 func locationManagerDidChangeAuthorization(_ m: CLLocationManager) {482 if m.authorizationStatus == .authorizedWhenInUse || m.authorizationStatus == .authorizedAlways {483 m.requestLocation()484 }485 }486 func locationManager(_ m: CLLocationManager, didUpdateLocations locs: [CLLocation]) {487 coordinate = locs.first?.coordinate488 }489 func locationManager(_ m: CLLocationManager, didFailWithError error: Error) {}490}491