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"].contains($0.id) }73 }74 private var visibleItems: [KAItem] { items.filter { enabled.contains($0.universeID) } }75 private var clusters: [MapCluster] { ClusterEngine.clusterize(visibleItems, region: visibleRegion) }7677 var body: some View {78 NavigationStack {79 ZStack(alignment: .bottom) {80 mapLayer81 if showList { listOverlay }82 VStack(spacing: 10) {83 if let c = selectedCluster, c.isSingle { compactCard(c.item) }84 bottomBar85 }86 .padding(.horizontal, 12)87 .padding(.bottom, 8)88 }89 .safeAreaInset(edge: .top) { chips }90 .navigationTitle("Carte")91 .navigationBarTitleDisplayMode(.inline)92 .toolbar {93 if !embedded {94 ToolbarItem(placement: .topBarLeading) {95 Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) }96 .accessibilityLabel("Fermer la carte")97 }98 }99 ToolbarItem(placement: .topBarTrailing) {100 Button {101 Haptics.tap(); withAnimation(.snappy) { showList.toggle() }102 } label: {103 Image(systemName: showList ? "map.fill" : "list.bullet")104 }105 .accessibilityLabel(showList ? "Voir la carte" : "Voir la liste des résultats")106 }107 }108 .sheet(item: $sheetItem) { item in109 NavigationStack { ItemDetailView(item: item) }110 .presentationDetents([.medium, .large])111 .presentationBackgroundInteraction(.enabled(upThrough: .medium))112 }113 .task {114 location.requestIfAuthorized() // HIG : pas de dialogue à l'ouverture115 await loadZone()116 }117 .onChange(of: location.coordinate != nil) {118 if let c = location.coordinate {119 recenter(.init(center: c, span: .init(latitudeDelta: 0.18, longitudeDelta: 0.18)))120 Task { await loadZone() }121 }122 }123 }124 }125126 // MARK: couches127128 private var mapLayer: some View {129 KaMapLibreView(130 clusters: clusters,131 programRegion: programRegion,132 programEpoch: programEpoch,133 pitch3D: pitch3D,134 selectedID: selectedCluster?.id,135 onRegionChange: { region in136 visibleRegion = region137 zoneDirty = true138 },139 onSelect: { cluster in140 if cluster.isSingle {141 Haptics.tap()142 withAnimation(.snappy) { selectedCluster = cluster }143 } else {144 Haptics.rigid()145 // dégroupage : zoom sur le groupe146 recenter(.init(center: .init(latitude: cluster.latitude, longitude: cluster.longitude),147 span: .init(latitudeDelta: visibleRegion.span.latitudeDelta / 3.2,148 longitudeDelta: visibleRegion.span.longitudeDelta / 3.2)))149 }150 })151 .ignoresSafeArea(edges: .bottom)152 }153154 /// Recentrage programmé de la carte155 private func recenter(_ region: MKCoordinateRegion) {156 programRegion = region157 programEpoch += 1158 }159160 private var chips: some View {161 ScrollView(.horizontal, showsIndicators: false) {162 HStack(spacing: 8) {163 ForEach(mapUniverses) { u in164 let on = enabled.contains(u.id)165 Button {166 Haptics.tap()167 if on { enabled.remove(u.id) } else { enabled.insert(u.id) }168 selectedCluster = nil169 Task { await loadZone() }170 } label: {171 Label(u.wordmark, systemImage: u.symbol)172 .font(.system(.caption, design: .monospaced).weight(.bold))173 .padding(.horizontal, 11).padding(.vertical, 8)174 .background(on ? u.accent : KATheme.surface(scheme), in: Capsule())175 .foregroundStyle(on ? .white : .secondary)176 .overlay(Capsule().strokeBorder(.black.opacity(0.25), lineWidth: 1))177 }178 .accessibilityLabel("\(on ? "Masquer" : "Afficher") \(u.name) sur la carte")179 }180 }181 .padding(.horizontal, 14).padding(.vertical, 8)182 }183 .background(.ultraThinMaterial)184 }185186 private var bottomBar: some View {187 HStack(spacing: 10) {188 Button {189 Haptics.rigid()190 pitch3D.toggle()191 } label: {192 Text(pitch3D ? "2D" : "3D")193 .font(.system(.caption, design: .rounded).weight(.bold))194 .frame(width: 40, height: 36)195 .background(pitch3D ? KATheme.inkLight : .clear)196 .background(.ultraThinMaterial, in: Capsule())197 .foregroundStyle(pitch3D ? KATheme.lime : .primary)198 .overlay(Capsule().strokeBorder(.primary.opacity(0.3), lineWidth: 1))199 }200 .accessibilityLabel(pitch3D ? "Repasser en vue 2D" : "Passer en vue 3D avec bâtiments")201 Button {202 Haptics.tap()203 location.request() // la permission n'est demandée QU'ICI, sur geste204 if let c = location.coordinate {205 recenter(.init(center: c, span: .init(latitudeDelta: 0.12, longitudeDelta: 0.12)))206 Task { await loadZone() }207 }208 } label: {209 Image(systemName: "location.fill")210 .font(.caption.weight(.bold))211 .frame(width: 36, height: 36)212 .background(.ultraThinMaterial, in: Circle())213 .overlay(Circle().strokeBorder(.primary.opacity(0.3), lineWidth: 1))214 }215 .accessibilityLabel("Autour de moi")216 Text(loading ? "Chargement…" : "\(visibleItems.count) résultats")217 .font(.system(.caption, design: .monospaced).weight(.bold))218 .padding(.horizontal, 11).padding(.vertical, 9)219 .background(.ultraThinMaterial, in: Capsule())220 Spacer()221 if zoneDirty && !loading {222 Button {223 Haptics.rigid()224 Task { await loadZone() }225 } label: {226 Label("Rechercher dans cette zone", systemImage: "arrow.clockwise")227 .font(.caption.weight(.bold))228 .padding(.horizontal, 13).padding(.vertical, 10)229 .background(KATheme.inkLight, in: Capsule())230 .foregroundStyle(KATheme.lime)231 }232 .transition(.scale.combined(with: .opacity))233 }234 }235 .animation(.snappy, value: zoneDirty)236 }237238 private func compactCard(_ item: KAItem) -> some View {239 Button {240 Haptics.tap()241 sheetItem = item // feuille : l'état de la carte reste intact derrière242 } label: {243 HStack(spacing: 10) {244 if let img = item.imageURL {245 KAImage(url: img, accent: Ecosystem.universe(item.universeID)?.accent ?? .gray)246 .frame(width: 58, height: 58)247 .clipShape(RoundedRectangle(cornerRadius: 9))248 }249 VStack(alignment: .leading, spacing: 3) {250 Text(item.title).font(.subheadline.weight(.bold)).lineLimit(1)251 HStack(spacing: 6) {252 if let p = item.priceLabel {253 Text(p).font(.system(.caption, design: .rounded).weight(.bold))254 .foregroundStyle(Ecosystem.universe(item.universeID)?.accent ?? .primary)255 }256 if let c = item.city { Text(c).font(.caption2).foregroundStyle(.secondary) }257 }258 Text("Toucher pour la fiche complète").font(.system(size: 9, design: .monospaced))259 .foregroundStyle(.tertiary)260 }261 Spacer()262 Button {263 withAnimation(.snappy) { selectedCluster = nil }264 } label: {265 Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)266 }267 .accessibilityLabel("Fermer l'aperçu")268 }269 .padding(11)270 .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 14, style: .continuous))271 .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(.primary.opacity(0.5), lineWidth: 1.4))272 .shadow(color: .black.opacity(0.18), radius: 8, y: 4)273 }274 .buttonStyle(KAPressStyle())275 .transition(.move(edge: .bottom).combined(with: .opacity))276 }277278 private var listOverlay: some View {279 ScrollView {280 LazyVStack(spacing: 10) {281 ForEach(visibleItems.prefix(60)) { item in282 Button {283 Haptics.tap()284 withAnimation(.snappy) {285 showList = false286 if let la = item.latitude, let lo = item.longitude {287 recenter(.init(center: .init(latitude: la, longitude: lo),288 span: .init(latitudeDelta: 0.02, longitudeDelta: 0.02)))289 }290 selectedCluster = MapCluster(id: item.id, latitude: item.latitude ?? 0,291 longitude: item.longitude ?? 0, items: [item])292 }293 } label: { KAItemRow(item: item) }294 .buttonStyle(KAPressStyle())295 }296 }297 .padding(12)298 }299 .background(KATheme.paper(scheme).opacity(0.98))300 }301302 // MARK: données303304 private func loadZone() async {305 loading = true306 zoneDirty = false307 selectedCluster = nil308 let r = visibleRegion309 let west = r.center.longitude - r.span.longitudeDelta / 2310 let east = r.center.longitude + r.span.longitudeDelta / 2311 let south = r.center.latitude - r.span.latitudeDelta / 2312 let north = r.center.latitude + r.span.latitudeDelta / 2313 var all: [KAItem] = []314 await withTaskGroup(of: [KAItem].self) { group in315 for u in mapUniverses where enabled.contains(u.id) {316 group.addTask {317 await UniverseService.mapItems(u, west: west, south: south, east: east, north: north)318 }319 }320 for await batch in group { all.append(contentsOf: batch) }321 }322 withAnimation(.easeOut(duration: 0.25)) { items = all }323 loading = false324 if !all.isEmpty { Haptics.tap() }325 }326}327328// MARK: - Marqueurs329330/// Pilule de prix (ou symbole) — le marqueur signature, teinté par univers.331struct PricePill: View {332 let item: KAItem333 var selected: Bool334 private var universe: Universe? { Ecosystem.universe(item.universeID) }335336 var body: some View {337 HStack(spacing: 3) {338 Image(systemName: universe?.symbol ?? "mappin")339 .font(.system(size: 9, weight: .bold))340 if let p = item.priceLabel?.split(separator: " ").prefix(2).joined(separator: " ") {341 Text(p).font(.system(size: 11, weight: .bold, design: .rounded))342 }343 }344 .padding(.horizontal, 8).padding(.vertical, 5)345 .background(selected ? KATheme.inkLight : (universe?.accent ?? .gray), in: Capsule())346 .foregroundStyle(selected ? KATheme.lime : .white)347 .overlay(Capsule().strokeBorder(.white.opacity(0.9), lineWidth: 1.4))348 .shadow(color: .black.opacity(0.3), radius: 2, y: 1)349 .scaleEffect(selected ? 1.18 : 1)350 .animation(.spring(response: 0.3, dampingFraction: 0.6), value: selected)351 .accessibilityLabel("\(item.title), \(item.priceLabel ?? "")")352 }353}354355/// Badge de groupe : compte + camembert des univers présents.356struct ClusterBadge: View {357 let cluster: MapCluster358 private var dominant: Color {359 let counts = Dictionary(grouping: cluster.items, by: \.universeID).mapValues(\.count)360 let top = counts.max { $0.value < $1.value }?.key361 return top.flatMap { Ecosystem.universe($0)?.accent } ?? .gray362 }363 var body: some View {364 Text("\(cluster.items.count)")365 .font(.system(size: 13, weight: .bold, design: .rounded))366 .frame(minWidth: 34, minHeight: 34)367 .background(dominant, in: Circle())368 .foregroundStyle(.white)369 .overlay(Circle().strokeBorder(.white, lineWidth: 2))370 .shadow(color: .black.opacity(0.3), radius: 2, y: 1)371 .accessibilityLabel("Groupe de \(cluster.items.count) résultats — toucher pour zoomer")372 }373}374375/// Demande de localisation « une fois » (jamais de suivi continu).376final class LocationOnce: NSObject, ObservableObject, CLLocationManagerDelegate {377 @Published var coordinate: CLLocationCoordinate2D?378 private let manager = CLLocationManager()379380 func request() {381 manager.delegate = self382 if manager.authorizationStatus == .notDetermined {383 manager.requestWhenInUseAuthorization()384 } else {385 manager.requestLocation()386 }387 }388389 /// Ne demande JAMAIS la permission — n'agit que si elle est déjà accordée.390 func requestIfAuthorized() {391 manager.delegate = self392 if manager.authorizationStatus == .authorizedWhenInUse || manager.authorizationStatus == .authorizedAlways {393 manager.requestLocation()394 }395 }396 func locationManagerDidChangeAuthorization(_ m: CLLocationManager) {397 if m.authorizationStatus == .authorizedWhenInUse || m.authorizationStatus == .authorizedAlways {398 m.requestLocation()399 }400 }401 func locationManager(_ m: CLLocationManager, didUpdateLocations locs: [CLLocation]) {402 coordinate = locs.first?.coordinate403 }404 func locationManager(_ m: CLLocationManager, didFailWithError error: Error) {}405}406