SPB Git forge

spb/ka-macos

Public
3commits 1branches 0releases
6.1 MBsize
maindefault branch
1 mo agolast push
Swift 98.3% Shell 1.7%
16.9 KB · 381 lines swift
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// MapExplorerView.swift — la CARTE avancée en volet côte à côte carte|liste :3// marqueurs-pilules avec prix, CLUSTERING par grille (dégroupage au zoom),4// « Rechercher dans cette zone » (bbox RÉEL côté API pour lou/immo/job-ka,5// filtre client sinon), fiche synchronisée carte↔liste↔détail.6// Logique reprise de l'app iOS KA (MapView.swift : ClusterEngine, PricePill).7import SwiftUI8import MapKit910// MARK: - Clustering par grille (pur)1112struct MapCluster: Identifiable, Hashable {13    let id: String14    let latitude: Double15    let longitude: Double16    let items: [KAItem]17    var isSingle: Bool { items.count == 1 }18    var item: KAItem { items[0] }19}2021enum ClusterEngine {22    /// Regroupe les items en cellules de grille (~grid × grid cellules sur la23    /// région) ; une cellule d'un seul élément reste un marqueur individuel.24    static func clusterize(_ items: [KAItem], region: MKCoordinateRegion, grid: Double = 9) -> [MapCluster] {25        let cellLat = max(region.span.latitudeDelta / grid, 0.0001)26        let cellLon = max(region.span.longitudeDelta / grid, 0.0001)27        var cells: [String: [KAItem]] = [:]28        for it in items {29            guard let la = it.latitude, let lo = it.longitude else { continue }30            let key = "\(Int((la / cellLat).rounded(.down)))|\(Int((lo / cellLon).rounded(.down)))"31            cells[key, default: []].append(it)32        }33        return cells.map { key, members in34            let la = members.compactMap(\.latitude).reduce(0, +) / Double(members.count)35            let lo = members.compactMap(\.longitude).reduce(0, +) / Double(members.count)36            return MapCluster(id: key + ":\(members.count)", latitude: la, longitude: lo, items: members)37        }38        .sorted { $0.items.count > $1.items.count }39        .prefix(150) // plafond de rendu — jamais de carte qui rame40        .map { $0 }41    }42}4344// MARK: - Carte | liste4546struct MapExplorerView: View {47    @Environment(\.colorScheme) private var scheme4849    @State private var camera: MapCameraPosition = .region(50        MKCoordinateRegion(center: .init(latitude: 46.81, longitude: -71.21),51                           span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3)))52    @State private var visibleRegion = MKCoordinateRegion(53        center: .init(latitude: 46.81, longitude: -71.21),54        span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3))55    @State private var items: [KAItem] = []56    @State private var enabled: Set<String> = ["lou-ka", "immo-ka"]57    @State private var loading = false58    @State private var zoneDirty = false59    @State private var selected: KAItem?60    @State private var showDetail = false61    @State private var is3D = false6263    /// Univers cartographiables (coordonnées disponibles)64    private var mapUniverses: [Universe] {65        Ecosystem.all.filter { ["lou-ka", "immo-ka", "job-ka", "resto-ka", "sorti-ka"].contains($0.id) }66    }67    private var visibleItems: [KAItem] { items.filter { enabled.contains($0.universeID) } }68    private var clusters: [MapCluster] { ClusterEngine.clusterize(visibleItems, region: visibleRegion) }6970    var body: some View {71        VStack(spacing: 0) {72            chips73            Divider().opacity(0.4)74            HSplitView {75                mapPane76                    .frame(minWidth: 480)77                    .layoutPriority(1)78                sidePane79                    .frame(minWidth: 360, idealWidth: 420, maxWidth: 560)80            }81        }82        .task { await loadZone() }83    }8485    // MARK: chips univers8687    private var chips: some View {88        HStack(spacing: 8) {89            ForEach(mapUniverses) { u in90                let on = enabled.contains(u.id)91                Button {92                    if on { enabled.remove(u.id) } else { enabled.insert(u.id) }93                    selected = nil94                    Task { await loadZone() }95                } label: {96                    Label(u.wordmark, systemImage: u.symbol)97                        .font(.system(.caption, design: .monospaced).weight(.bold))98                        .padding(.horizontal, 11).padding(.vertical, 7)99                        .background(on ? u.accent : KATheme.surface(scheme), in: Capsule())100                        .foregroundStyle(on ? .white : .secondary)101                        .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))102                }103                .buttonStyle(.plain)104                .help("\(on ? "Masquer" : "Afficher") \(u.name) sur la carte")105            }106            Spacer()107            Button {108                is3D.toggle()109                let center = visibleRegion.center110                if is3D {111                    // vue 3D : caméra inclinée (relief + bâtiments réalistes)112                    let distance = max(visibleRegion.span.latitudeDelta, 0.005) * 111_000 * 1.4113                    withAnimation(.easeInOut(duration: 0.6)) {114                        camera = .camera(MapCamera(centerCoordinate: center, distance: distance,115                                                   heading: 20, pitch: 60))116                    }117                } else {118                    withAnimation(.easeInOut(duration: 0.6)) {119                        camera = .region(visibleRegion)120                    }121                }122            } label: {123                Label(is3D ? "2D" : "3D", systemImage: is3D ? "map" : "view.3d")124                    .font(.system(.caption, design: .monospaced).weight(.bold))125                    .padding(.horizontal, 11).padding(.vertical, 7)126                    .background(is3D ? KATheme.inkLight : KATheme.surface(scheme), in: Capsule())127                    .foregroundStyle(is3D ? KATheme.lime : .primary)128                    .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))129            }130            .buttonStyle(.plain)131            .help(is3D ? "Revenir à la vue 2D" : "Vue 3D inclinée (relief et bâtiments)")132            Text(loading ? "Chargement…" : "\(visibleItems.count) résultats dans la zone")133                .font(.system(.caption, design: .monospaced).weight(.bold))134                .foregroundStyle(.secondary)135        }136        .padding(.horizontal, 16).padding(.vertical, 10)137    }138139    // MARK: carte140141    private var mapPane: some View {142        ZStack(alignment: .bottom) {143            Map(position: $camera) {144                ForEach(clusters) { cluster in145                    Annotation("", coordinate: .init(latitude: cluster.latitude, longitude: cluster.longitude)) {146                        if cluster.isSingle {147                            PricePill(item: cluster.item,148                                      selected: selected?.id == cluster.item.id)149                                .onTapGesture {150                                    withAnimation(.snappy) { selected = cluster.item; showDetail = false }151                                }152                        } else {153                            ClusterBadge(cluster: cluster)154                                .onTapGesture {155                                    // dégroupage : zoom sur le groupe156                                    withAnimation(.easeInOut(duration: 0.4)) {157                                        camera = .region(.init(158                                            center: .init(latitude: cluster.latitude, longitude: cluster.longitude),159                                            span: .init(latitudeDelta: visibleRegion.span.latitudeDelta / 3.2,160                                                        longitudeDelta: visibleRegion.span.longitudeDelta / 3.2)))161                                    }162                                }163                        }164                    }165                    .annotationTitles(.hidden)166                }167            }168            .mapStyle(.standard(elevation: .realistic, pointsOfInterest: .excludingAll))169            .mapControls {170                MapCompass()171                MapPitchToggle()172                MapZoomStepper()173            }174            .onMapCameraChange(frequency: .onEnd) { ctx in175                visibleRegion = ctx.region176                zoneDirty = true177            }178179            if zoneDirty && !loading {180                Button {181                    Task { await loadZone() }182                } label: {183                    Label("Rechercher dans cette zone", systemImage: "arrow.clockwise")184                        .font(.caption.weight(.bold))185                        .padding(.horizontal, 13).padding(.vertical, 10)186                        .background(KATheme.inkLight, in: Capsule())187                        .foregroundStyle(KATheme.lime)188                }189                .buttonStyle(.plain)190                .padding(.bottom, 14)191                .transition(.scale.combined(with: .opacity))192            }193            if loading {194                ProgressView()195                    .controlSize(.small)196                    .padding(10)197                    .background(.ultraThinMaterial, in: Circle())198                    .padding(.bottom, 14)199            }200        }201        .animation(.snappy, value: zoneDirty)202    }203204    // MARK: volet liste / fiche (synchronisé)205206    @ViewBuilder207    private var sidePane: some View {208        if showDetail, let item = selected {209            VStack(spacing: 0) {210                HStack {211                    Button {212                        withAnimation(.snappy) { showDetail = false }213                    } label: {214                        Label("Retour aux résultats", systemImage: "chevron.left")215                            .font(.caption.weight(.bold))216                    }217                    .buttonStyle(.plain)218                    Spacer()219                }220                .padding(.horizontal, 14).padding(.vertical, 9)221                Divider().opacity(0.4)222                ItemDetailPane(item: item)223            }224            .background(KATheme.paper(scheme))225        } else {226            VStack(spacing: 0) {227                if let item = selected {228                    compactCard(item)229                        .padding(.horizontal, 12).padding(.top, 10)230                }231                ScrollView {232                    LazyVStack(spacing: 9) {233                        if visibleItems.isEmpty && !loading {234                            KAEmptyState(symbol: "map",235                                         title: "Rien dans cette zone",236                                         message: "Déplacez la carte puis « Rechercher dans cette zone », ou activez d'autres univers.")237                        }238                        ForEach(visibleItems.prefix(80)) { item in239                            Button {240                                withAnimation(.snappy) {241                                    selected = item242                                    if let la = item.latitude, let lo = item.longitude {243                                        camera = .region(.init(center: .init(latitude: la, longitude: lo),244                                                               span: .init(latitudeDelta: 0.02, longitudeDelta: 0.02)))245                                    }246                                }247                            } label: {248                                KAItemRow(item: item, compact: true)249                                    .overlay(250                                        RoundedRectangle(cornerRadius: 12, style: .continuous)251                                            .strokeBorder(Ecosystem.universe(item.universeID)?.accent ?? .gray,252                                                          lineWidth: selected?.id == item.id ? 2.2 : 0)253                                            .padding(.trailing, 4).padding(.bottom, 4)254                                    )255                            }256                            .buttonStyle(KAPressStyle())257                        }258                    }259                    .padding(12)260                }261            }262            .background(KATheme.paper(scheme))263        }264    }265266    private func compactCard(_ item: KAItem) -> some View {267        Button {268            withAnimation(.snappy) { showDetail = true }269        } label: {270            HStack(spacing: 10) {271                if let img = item.imageURL {272                    KAImage(url: img, accent: Ecosystem.universe(item.universeID)?.accent ?? .gray)273                        .frame(width: 54, height: 54)274                        .clipShape(RoundedRectangle(cornerRadius: 9))275                }276                VStack(alignment: .leading, spacing: 3) {277                    Text(item.title).font(.subheadline.weight(.bold)).lineLimit(1)278                    HStack(spacing: 6) {279                        if let p = item.priceLabel {280                            Text(p).font(.system(.caption, design: .rounded).weight(.bold))281                                .foregroundStyle(Ecosystem.universe(item.universeID)?.accent ?? .primary)282                        }283                        if let c = item.city { Text(c).font(.caption2).foregroundStyle(.secondary) }284                    }285                    Text("Cliquer pour la fiche complète").font(.system(size: 9, design: .monospaced))286                        .foregroundStyle(.tertiary)287                }288                Spacer()289                Button {290                    withAnimation(.snappy) { selected = nil }291                } label: {292                    Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)293                }294                .buttonStyle(.plain)295                .accessibilityLabel("Fermer l'aperçu")296            }297            .padding(10)298            .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 13, style: .continuous))299            .overlay(RoundedRectangle(cornerRadius: 13, style: .continuous)300                .strokeBorder(.primary.opacity(0.5), lineWidth: 1.3))301            .shadow(color: .black.opacity(0.15), radius: 6, y: 3)302        }303        .buttonStyle(KAPressStyle())304        .transition(.move(edge: .top).combined(with: .opacity))305    }306307    // MARK: données308309    private func loadZone() async {310        loading = true311        zoneDirty = false312        let r = visibleRegion313        let west = r.center.longitude - r.span.longitudeDelta / 2314        let east = r.center.longitude + r.span.longitudeDelta / 2315        let south = r.center.latitude - r.span.latitudeDelta / 2316        let north = r.center.latitude + r.span.latitudeDelta / 2317        var all: [KAItem] = []318        await withTaskGroup(of: [KAItem].self) { group in319            for u in mapUniverses where enabled.contains(u.id) {320                group.addTask {321                    await UniverseService.mapItems(u, west: west, south: south, east: east, north: north)322                }323            }324            for await batch in group { all.append(contentsOf: batch) }325        }326        withAnimation(.easeOut(duration: 0.25)) { items = all }327        if let sel = selected, !all.contains(where: { $0.id == sel.id }) {328            selected = nil329            showDetail = false330        }331        loading = false332    }333}334335// MARK: - Marqueurs336337/// Pilule de prix (ou symbole) — le marqueur signature, teinté par univers.338struct PricePill: View {339    let item: KAItem340    var selected: Bool341    private var universe: Universe? { Ecosystem.universe(item.universeID) }342343    var body: some View {344        HStack(spacing: 3) {345            Image(systemName: universe?.symbol ?? "mappin")346                .font(.system(size: 9, weight: .bold))347            if let p = item.priceLabel?.split(separator: " ").prefix(2).joined(separator: " ") {348                Text(p).font(.system(size: 11, weight: .bold, design: .rounded))349            }350        }351        .padding(.horizontal, 8).padding(.vertical, 5)352        .background(selected ? KATheme.inkLight : (universe?.accent ?? .gray), in: Capsule())353        .foregroundStyle(selected ? KATheme.lime : .white)354        .overlay(Capsule().strokeBorder(.white.opacity(0.9), lineWidth: 1.4))355        .shadow(color: .black.opacity(0.3), radius: 2, y: 1)356        .scaleEffect(selected ? 1.18 : 1)357        .animation(.spring(response: 0.3, dampingFraction: 0.6), value: selected)358        .accessibilityLabel("\(item.title), \(item.priceLabel ?? "")")359    }360}361362/// Badge de groupe : compte teinté par l'univers dominant.363struct ClusterBadge: View {364    let cluster: MapCluster365    private var dominant: Color {366        let counts = Dictionary(grouping: cluster.items, by: \.universeID).mapValues(\.count)367        let top = counts.max { $0.value < $1.value }?.key368        return top.flatMap { Ecosystem.universe($0)?.accent } ?? .gray369    }370    var body: some View {371        Text("\(cluster.items.count)")372            .font(.system(size: 13, weight: .bold, design: .rounded))373            .frame(minWidth: 34, minHeight: 34)374            .background(dominant, in: Circle())375            .foregroundStyle(.white)376            .overlay(Circle().strokeBorder(.white, lineWidth: 2))377            .shadow(color: .black.opacity(0.3), radius: 2, y: 1)378            .accessibilityLabel("Groupe de \(cluster.items.count) résultats — cliquer pour zoomer")379    }380}381