SPB Git

spb/ka-ios

Public
20.4 KB · 451 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", "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
Files · 49720973fdf7a4e5315f9bb1e1fdf61c629116cd
Menu

Theme