SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%
22.5 KB · 491 lines swift
Raw Blame History
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