SPB Git

spb/ka-ios

Public
17.7 KB · 406 lines swift
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
Files · d3e6430fa5643e75d57de28dddeb06090050d705
Menu

Theme