// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // MapView.swift — la CARTE, pièce maîtresse : marqueurs-pilules avec prix, // CLUSTERING par grille (dégroupage au zoom), « Rechercher dans cette zone » // (bbox RÉEL côté API pour lou-ka/immo-ka/job-ka, filtre client sinon), // fiche compacte au tap → fiche complète en feuille (état de carte conservé), // bascule liste/carte synchronisée, chips par univers aux couleurs officielles. import SwiftUI import MapKit import CoreLocation // Rendu : MapLibre (style Mapbox streets v12 + bâtiments 3D) — voir KaMapLibre.swift // MARK: - Clustering par grille (pur, testé dans KATests) struct MapCluster: Identifiable, Hashable { let id: String let latitude: Double let longitude: Double let items: [KAItem] var isSingle: Bool { items.count == 1 } var item: KAItem { items[0] } } enum ClusterEngine { /// Regroupe les items en cellules de grille (~grid × grid cellules sur la /// région) ; une cellule d'un seul élément reste un marqueur individuel. static func clusterize(_ items: [KAItem], region: MKCoordinateRegion, grid: Double = 9) -> [MapCluster] { let cellLat = max(region.span.latitudeDelta / grid, 0.0001) let cellLon = max(region.span.longitudeDelta / grid, 0.0001) var cells: [String: [KAItem]] = [:] for it in items { guard let la = it.latitude, let lo = it.longitude else { continue } let key = "\(Int((la / cellLat).rounded(.down)))|\(Int((lo / cellLon).rounded(.down)))" cells[key, default: []].append(it) } return cells.map { key, members in let la = members.compactMap(\.latitude).reduce(0, +) / Double(members.count) let lo = members.compactMap(\.longitude).reduce(0, +) / Double(members.count) return MapCluster(id: key + ":\(members.count)", latitude: la, longitude: lo, items: members) } .sorted { $0.items.count > $1.items.count } .prefix(150) // plafond de rendu — jamais de carte qui rame .map { $0 } } } // MARK: - La carte unifiée struct UnifiedMapView: View { /// true quand la carte est un ONGLET (pas de bouton fermer) var embedded: Bool = false @State private var programRegion = MKCoordinateRegion( center: .init(latitude: 46.81, longitude: -71.21), span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3)) @State private var programEpoch = 0 @State private var pitch3D = false @State private var visibleRegion = MKCoordinateRegion( center: .init(latitude: 46.81, longitude: -71.21), span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3)) @State private var items: [KAItem] = [] // couches actives, PERSISTÉES entre les sessions @AppStorage("ka.map.layers") private var layersRaw = "lou-ka,immo-ka" @State private var enabled: Set = [] @State private var showFavorites = false @EnvironmentObject private var favorites: FavoritesStore @State private var loading = false @State private var zoneDirty = false @State private var selectedCluster: MapCluster? @State private var sheetItem: KAItem? @State private var showList = false @StateObject private var location = LocationOnce() @Environment(\.colorScheme) private var scheme @Environment(\.dismiss) private var dismiss /// Univers cartographiables (coordonnées disponibles) private var mapUniverses: [Universe] { Ecosystem.all.filter { ["lou-ka", "immo-ka", "job-ka", "resto-ka", "sorti-ka", "vrai-prix"].contains($0.id) } } /// La couche Vrai-Prix (3,7 M d'unités d'évaluation) ne se charge /// qu'assez zoomé : au-delà, le serveur plafonne le bbox de /api/nearby /// (~9 km) et la zone visible ne serait couverte qu'au centre. private var vpZoomedEnough: Bool { visibleRegion.span.latitudeDelta <= 0.16 && visibleRegion.span.longitudeDelta <= 0.24 } /// Favoris géolocalisés dans la zone visible (couche « ♥ ») private var favoriteItems: [KAItem] { guard showFavorites else { return [] } let r = visibleRegion return favorites.allItems.filter { it in guard let la = it.latitude, let lo = it.longitude else { return false } return abs(la - r.center.latitude) <= r.span.latitudeDelta / 2 && abs(lo - r.center.longitude) <= r.span.longitudeDelta / 2 } } private var visibleItems: [KAItem] { var out = items.filter { enabled.contains($0.universeID) } let ids = Set(out.map(\.id)) out += favoriteItems.filter { !ids.contains($0.id) } return out } private var clusters: [MapCluster] { ClusterEngine.clusterize(visibleItems, region: visibleRegion) } private func persistLayers() { layersRaw = enabled.sorted().joined(separator: ",") } var body: some View { NavigationStack { ZStack(alignment: .bottom) { mapLayer if showList { listOverlay } VStack(spacing: 10) { if let c = selectedCluster, c.isSingle { compactCard(c.item) } bottomBar } .padding(.horizontal, 12) .padding(.bottom, 8) } .safeAreaInset(edge: .top) { chips } .navigationTitle("Carte") .navigationBarTitleDisplayMode(.inline) .toolbar { if !embedded { ToolbarItem(placement: .topBarLeading) { Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } .accessibilityLabel("Fermer la carte") } } ToolbarItem(placement: .topBarTrailing) { Button { Haptics.tap(); withAnimation(.snappy) { showList.toggle() } } label: { Image(systemName: showList ? "map.fill" : "list.bullet") } .accessibilityLabel(showList ? "Voir la carte" : "Voir la liste des résultats") } } .sheet(item: $sheetItem) { item in NavigationStack { // une unité Vrai-Prix ouvre la vraie fiche d'estimation // (moteur hédonique : fourchette, confiance, historique) if item.universeID == "vrai-prix", let vp = Ecosystem.universe("vrai-prix") { EstimateView(result: VPResult(id: String(item.id.dropFirst(3)), adresse: item.title, municipalite: item.city ?? "", type: item.subtitle), universe: vp) } else { ItemDetailView(item: item) } } .presentationDetents([.medium, .large]) .presentationBackgroundInteraction(.enabled(upThrough: .medium)) } .task { // restaure les couches choisies à la dernière session enabled = Set(layersRaw.split(separator: ",").map(String.init)) // pilotage debug : couche Vrai-Prix active + zoom quartier // (defaults write com.groupeka.ka ka.debug.mapvp -bool true) if UserDefaults.standard.bool(forKey: "ka.debug.mapvp") { enabled.insert("vrai-prix") try? await Task.sleep(for: .seconds(1)) // la carte doit être layoutée let r = MKCoordinateRegion(center: .init(latitude: 45.5231, longitude: -73.5817), span: .init(latitudeDelta: 0.005, longitudeDelta: 0.005)) visibleRegion = r recenter(r) } location.requestIfAuthorized() // HIG : pas de dialogue à l'ouverture await loadZone() } .onChange(of: location.coordinate != nil) { guard !UserDefaults.standard.bool(forKey: "ka.debug.mapvp") else { return } if let c = location.coordinate { recenter(.init(center: c, span: .init(latitudeDelta: 0.18, longitudeDelta: 0.18))) Task { await loadZone() } } } } } // MARK: couches private var mapLayer: some View { KaMapLibreView( clusters: clusters, programRegion: programRegion, programEpoch: programEpoch, pitch3D: pitch3D, selectedID: selectedCluster?.id, onRegionChange: { region in visibleRegion = region zoneDirty = true }, onSelect: { cluster in if cluster.isSingle { Haptics.tap() withAnimation(.snappy) { selectedCluster = cluster } } else { Haptics.rigid() // dégroupage : zoom sur le groupe recenter(.init(center: .init(latitude: cluster.latitude, longitude: cluster.longitude), span: .init(latitudeDelta: visibleRegion.span.latitudeDelta / 3.2, longitudeDelta: visibleRegion.span.longitudeDelta / 3.2))) } }) .ignoresSafeArea(edges: .bottom) } /// Recentrage programmé de la carte private func recenter(_ region: MKCoordinateRegion) { programRegion = region programEpoch += 1 } private var chips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { // couche transversale : vos favoris géolocalisés, tous univers if !favorites.allItems.isEmpty { Button { Haptics.tap() showFavorites.toggle() selectedCluster = nil } label: { Label("Favoris", systemImage: showFavorites ? "heart.fill" : "heart") .font(.system(.caption, design: .monospaced).weight(.bold)) .padding(.horizontal, 11).padding(.vertical, 8) .background(showFavorites ? Color.red : KATheme.surface(scheme), in: Capsule()) .foregroundStyle(showFavorites ? .white : .secondary) .overlay(Capsule().strokeBorder(.black.opacity(0.25), lineWidth: 1)) } .accessibilityLabel("\(showFavorites ? "Masquer" : "Afficher") vos favoris sur la carte") } ForEach(mapUniverses) { u in let on = enabled.contains(u.id) Button { Haptics.tap() if on { enabled.remove(u.id) } else { enabled.insert(u.id) } persistLayers() selectedCluster = nil Task { await loadZone() } } label: { Label(u.wordmark, systemImage: u.symbol) .font(.system(.caption, design: .monospaced).weight(.bold)) .padding(.horizontal, 11).padding(.vertical, 8) .background(on ? u.accent : KATheme.surface(scheme), in: Capsule()) .foregroundStyle(on ? .white : .secondary) .overlay(Capsule().strokeBorder(.black.opacity(0.25), lineWidth: 1)) } .accessibilityLabel("\(on ? "Masquer" : "Afficher") \(u.name) sur la carte") } } .padding(.horizontal, 14).padding(.vertical, 8) } .background(.ultraThinMaterial) } private var bottomBar: some View { HStack(spacing: 10) { Button { Haptics.rigid() pitch3D.toggle() } label: { Text(pitch3D ? "2D" : "3D") .font(.system(.caption, design: .rounded).weight(.bold)) .frame(width: 40, height: 36) .background(pitch3D ? KATheme.inkLight : .clear) .background(.ultraThinMaterial, in: Capsule()) .foregroundStyle(pitch3D ? KATheme.lime : .primary) .overlay(Capsule().strokeBorder(.primary.opacity(0.3), lineWidth: 1)) } .accessibilityLabel(pitch3D ? "Repasser en vue 2D" : "Passer en vue 3D avec bâtiments") Button { Haptics.tap() location.request() // la permission n'est demandée QU'ICI, sur geste if let c = location.coordinate { recenter(.init(center: c, span: .init(latitudeDelta: 0.12, longitudeDelta: 0.12))) Task { await loadZone() } } } label: { Image(systemName: "location.fill") .font(.caption.weight(.bold)) .frame(width: 36, height: 36) .background(.ultraThinMaterial, in: Circle()) .overlay(Circle().strokeBorder(.primary.opacity(0.3), lineWidth: 1)) } .accessibilityLabel("Autour de moi") Text(loading ? "Chargement…" : "\(visibleItems.count) résultats") .font(.system(.caption, design: .monospaced).weight(.bold)) .padding(.horizontal, 11).padding(.vertical, 9) .background(.ultraThinMaterial, in: Capsule()) if enabled.contains("vrai-prix") && !vpZoomedEnough { Label("Vrai-Prix : zoomez", systemImage: "plus.magnifyingglass") .font(.system(size: 10, design: .monospaced).weight(.bold)) .padding(.horizontal, 10).padding(.vertical, 9) .background(.ultraThinMaterial, in: Capsule()) .foregroundStyle(Ecosystem.universe("vrai-prix")?.accent ?? .red) .transition(.scale.combined(with: .opacity)) .accessibilityLabel("Zoomez la carte pour voir les estimations Vrai-Prix") } Spacer() if zoneDirty && !loading { Button { Haptics.rigid() Task { await loadZone() } } label: { Label("Rechercher dans cette zone", systemImage: "arrow.clockwise") .font(.caption.weight(.bold)) .padding(.horizontal, 13).padding(.vertical, 10) .background(KATheme.inkLight, in: Capsule()) .foregroundStyle(KATheme.lime) } .transition(.scale.combined(with: .opacity)) } } .animation(.snappy, value: zoneDirty) } private func compactCard(_ item: KAItem) -> some View { Button { Haptics.tap() sheetItem = item // feuille : l'état de la carte reste intact derrière } label: { HStack(spacing: 10) { if let img = item.imageURL { KAImage(url: img, accent: Ecosystem.universe(item.universeID)?.accent ?? .gray) .frame(width: 58, height: 58) .clipShape(RoundedRectangle(cornerRadius: 9)) } VStack(alignment: .leading, spacing: 3) { Text(item.title).font(.subheadline.weight(.bold)).lineLimit(1) HStack(spacing: 6) { if let p = item.priceLabel { Text(p).font(.system(.caption, design: .rounded).weight(.bold)) .foregroundStyle(Ecosystem.universe(item.universeID)?.accent ?? .primary) } if let c = item.city { Text(c).font(.caption2).foregroundStyle(.secondary) } } Text("Toucher pour la fiche complète").font(.system(size: 9, design: .monospaced)) .foregroundStyle(.tertiary) } Spacer() Button { withAnimation(.snappy) { selectedCluster = nil } } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } .accessibilityLabel("Fermer l'aperçu") } .padding(11) .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(.primary.opacity(0.5), lineWidth: 1.4)) .shadow(color: .black.opacity(0.18), radius: 8, y: 4) } .buttonStyle(KAPressStyle()) .transition(.move(edge: .bottom).combined(with: .opacity)) } private var listOverlay: some View { ScrollView { LazyVStack(spacing: 10) { ForEach(visibleItems.prefix(60)) { item in Button { Haptics.tap() withAnimation(.snappy) { showList = false if let la = item.latitude, let lo = item.longitude { recenter(.init(center: .init(latitude: la, longitude: lo), span: .init(latitudeDelta: 0.02, longitudeDelta: 0.02))) } selectedCluster = MapCluster(id: item.id, latitude: item.latitude ?? 0, longitude: item.longitude ?? 0, items: [item]) } } label: { KAItemRow(item: item) } .buttonStyle(KAPressStyle()) } } .padding(12) } .background(KATheme.paper(scheme).opacity(0.98)) } // MARK: données private func loadZone() async { loading = true zoneDirty = false selectedCluster = nil let r = visibleRegion let west = r.center.longitude - r.span.longitudeDelta / 2 let east = r.center.longitude + r.span.longitudeDelta / 2 let south = r.center.latitude - r.span.latitudeDelta / 2 let north = r.center.latitude + r.span.latitudeDelta / 2 var all: [KAItem] = [] let wantVP = enabled.contains("vrai-prix") && vpZoomedEnough await withTaskGroup(of: [KAItem].self) { group in for u in mapUniverses where enabled.contains(u.id) && u.id != "vrai-prix" { group.addTask { await UniverseService.mapItems(u, west: west, south: south, east: east, north: north) } } if wantVP { group.addTask { await UniverseService.vraiPrixUnits(west: west, south: south, east: east, north: north) } } for await batch in group { all.append(contentsOf: batch) } } withAnimation(.easeOut(duration: 0.25)) { items = all } loading = false if !all.isEmpty { Haptics.tap() } } } // MARK: - Marqueurs /// Pilule de prix (ou symbole) — le marqueur signature, teinté par univers. struct PricePill: View { let item: KAItem var selected: Bool private var universe: Universe? { Ecosystem.universe(item.universeID) } var body: some View { HStack(spacing: 3) { Image(systemName: universe?.symbol ?? "mappin") .font(.system(size: 9, weight: .bold)) if let p = item.priceLabel?.split(separator: " ").prefix(2).joined(separator: " ") { Text(p).font(.system(size: 11, weight: .bold, design: .rounded)) } } .padding(.horizontal, 8).padding(.vertical, 5) .background(selected ? KATheme.inkLight : (universe?.accent ?? .gray), in: Capsule()) .foregroundStyle(selected ? KATheme.lime : .white) .overlay(Capsule().strokeBorder(.white.opacity(0.9), lineWidth: 1.4)) .shadow(color: .black.opacity(0.3), radius: 2, y: 1) .scaleEffect(selected ? 1.18 : 1) .animation(.spring(response: 0.3, dampingFraction: 0.6), value: selected) .accessibilityLabel("\(item.title), \(item.priceLabel ?? "")") } } /// Badge de groupe : compte + camembert des univers présents. struct ClusterBadge: View { let cluster: MapCluster private var dominant: Color { let counts = Dictionary(grouping: cluster.items, by: \.universeID).mapValues(\.count) let top = counts.max { $0.value < $1.value }?.key return top.flatMap { Ecosystem.universe($0)?.accent } ?? .gray } var body: some View { Text("\(cluster.items.count)") .font(.system(size: 13, weight: .bold, design: .rounded)) .frame(minWidth: 34, minHeight: 34) .background(dominant, in: Circle()) .foregroundStyle(.white) .overlay(Circle().strokeBorder(.white, lineWidth: 2)) .shadow(color: .black.opacity(0.3), radius: 2, y: 1) .accessibilityLabel("Groupe de \(cluster.items.count) résultats — toucher pour zoomer") } } /// Demande de localisation « une fois » (jamais de suivi continu). final class LocationOnce: NSObject, ObservableObject, CLLocationManagerDelegate { @Published var coordinate: CLLocationCoordinate2D? private let manager = CLLocationManager() func request() { manager.delegate = self if manager.authorizationStatus == .notDetermined { manager.requestWhenInUseAuthorization() } else { manager.requestLocation() } } /// Ne demande JAMAIS la permission — n'agit que si elle est déjà accordée. func requestIfAuthorized() { manager.delegate = self if manager.authorizationStatus == .authorizedWhenInUse || manager.authorizationStatus == .authorizedAlways { manager.requestLocation() } } func locationManagerDidChangeAuthorization(_ m: CLLocationManager) { if m.authorizationStatus == .authorizedWhenInUse || m.authorizationStatus == .authorizedAlways { m.requestLocation() } } func locationManager(_ m: CLLocationManager, didUpdateLocations locs: [CLLocation]) { coordinate = locs.first?.coordinate } func locationManager(_ m: CLLocationManager, didFailWithError error: Error) {} }