// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // MapExplorerView.swift — la CARTE avancée en volet côte à côte carte|liste : // marqueurs-pilules avec prix, CLUSTERING par grille (dégroupage au zoom), // « Rechercher dans cette zone » (bbox RÉEL côté API pour lou/immo/job-ka, // filtre client sinon), fiche synchronisée carte↔liste↔détail. // Logique reprise de l'app iOS KA (MapView.swift : ClusterEngine, PricePill). import SwiftUI import MapKit // MARK: - Clustering par grille (pur) struct MapCluster: Identifiable, Hashable { let id: String let latitude: Double let longitude: Double let items: [KAItem] var isSingle: Bool { items.count == 1 } var item: KAItem { items[0] } } enum ClusterEngine { /// Regroupe les items en cellules de grille (~grid × grid cellules sur la /// région) ; une cellule d'un seul élément reste un marqueur individuel. static func clusterize(_ items: [KAItem], region: MKCoordinateRegion, grid: Double = 9) -> [MapCluster] { let cellLat = max(region.span.latitudeDelta / grid, 0.0001) let cellLon = max(region.span.longitudeDelta / grid, 0.0001) var cells: [String: [KAItem]] = [:] for it in items { guard let la = it.latitude, let lo = it.longitude else { continue } let key = "\(Int((la / cellLat).rounded(.down)))|\(Int((lo / cellLon).rounded(.down)))" cells[key, default: []].append(it) } return cells.map { key, members in let la = members.compactMap(\.latitude).reduce(0, +) / Double(members.count) let lo = members.compactMap(\.longitude).reduce(0, +) / Double(members.count) return MapCluster(id: key + ":\(members.count)", latitude: la, longitude: lo, items: members) } .sorted { $0.items.count > $1.items.count } .prefix(150) // plafond de rendu — jamais de carte qui rame .map { $0 } } } // MARK: - Carte | liste struct MapExplorerView: View { @Environment(\.colorScheme) private var scheme @State private var camera: MapCameraPosition = .region( MKCoordinateRegion(center: .init(latitude: 46.81, longitude: -71.21), span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3))) @State private var visibleRegion = MKCoordinateRegion( center: .init(latitude: 46.81, longitude: -71.21), span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3)) @State private var items: [KAItem] = [] @State private var enabled: Set = ["lou-ka", "immo-ka"] @State private var loading = false @State private var zoneDirty = false @State private var selected: KAItem? @State private var showDetail = false @State private var is3D = false /// Univers cartographiables (coordonnées disponibles) private var mapUniverses: [Universe] { Ecosystem.all.filter { ["lou-ka", "immo-ka", "job-ka", "resto-ka", "sorti-ka"].contains($0.id) } } private var visibleItems: [KAItem] { items.filter { enabled.contains($0.universeID) } } private var clusters: [MapCluster] { ClusterEngine.clusterize(visibleItems, region: visibleRegion) } var body: some View { VStack(spacing: 0) { chips Divider().opacity(0.4) HSplitView { mapPane .frame(minWidth: 480) .layoutPriority(1) sidePane .frame(minWidth: 360, idealWidth: 420, maxWidth: 560) } } .task { await loadZone() } } // MARK: chips univers private var chips: some View { HStack(spacing: 8) { ForEach(mapUniverses) { u in let on = enabled.contains(u.id) Button { if on { enabled.remove(u.id) } else { enabled.insert(u.id) } selected = nil Task { await loadZone() } } label: { Label(u.wordmark, systemImage: u.symbol) .font(.system(.caption, design: .monospaced).weight(.bold)) .padding(.horizontal, 11).padding(.vertical, 7) .background(on ? u.accent : KATheme.surface(scheme), in: Capsule()) .foregroundStyle(on ? .white : .secondary) .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1)) } .buttonStyle(.plain) .help("\(on ? "Masquer" : "Afficher") \(u.name) sur la carte") } Spacer() Button { is3D.toggle() let center = visibleRegion.center if is3D { // vue 3D : caméra inclinée (relief + bâtiments réalistes) let distance = max(visibleRegion.span.latitudeDelta, 0.005) * 111_000 * 1.4 withAnimation(.easeInOut(duration: 0.6)) { camera = .camera(MapCamera(centerCoordinate: center, distance: distance, heading: 20, pitch: 60)) } } else { withAnimation(.easeInOut(duration: 0.6)) { camera = .region(visibleRegion) } } } label: { Label(is3D ? "2D" : "3D", systemImage: is3D ? "map" : "view.3d") .font(.system(.caption, design: .monospaced).weight(.bold)) .padding(.horizontal, 11).padding(.vertical, 7) .background(is3D ? KATheme.inkLight : KATheme.surface(scheme), in: Capsule()) .foregroundStyle(is3D ? KATheme.lime : .primary) .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1)) } .buttonStyle(.plain) .help(is3D ? "Revenir à la vue 2D" : "Vue 3D inclinée (relief et bâtiments)") Text(loading ? "Chargement…" : "\(visibleItems.count) résultats dans la zone") .font(.system(.caption, design: .monospaced).weight(.bold)) .foregroundStyle(.secondary) } .padding(.horizontal, 16).padding(.vertical, 10) } // MARK: carte private var mapPane: some View { ZStack(alignment: .bottom) { Map(position: $camera) { ForEach(clusters) { cluster in Annotation("", coordinate: .init(latitude: cluster.latitude, longitude: cluster.longitude)) { if cluster.isSingle { PricePill(item: cluster.item, selected: selected?.id == cluster.item.id) .onTapGesture { withAnimation(.snappy) { selected = cluster.item; showDetail = false } } } else { ClusterBadge(cluster: cluster) .onTapGesture { // dégroupage : zoom sur le groupe withAnimation(.easeInOut(duration: 0.4)) { camera = .region(.init( center: .init(latitude: cluster.latitude, longitude: cluster.longitude), span: .init(latitudeDelta: visibleRegion.span.latitudeDelta / 3.2, longitudeDelta: visibleRegion.span.longitudeDelta / 3.2))) } } } } .annotationTitles(.hidden) } } .mapStyle(.standard(elevation: .realistic, pointsOfInterest: .excludingAll)) .mapControls { MapCompass() MapPitchToggle() MapZoomStepper() } .onMapCameraChange(frequency: .onEnd) { ctx in visibleRegion = ctx.region zoneDirty = true } if zoneDirty && !loading { Button { Task { await loadZone() } } label: { Label("Rechercher dans cette zone", systemImage: "arrow.clockwise") .font(.caption.weight(.bold)) .padding(.horizontal, 13).padding(.vertical, 10) .background(KATheme.inkLight, in: Capsule()) .foregroundStyle(KATheme.lime) } .buttonStyle(.plain) .padding(.bottom, 14) .transition(.scale.combined(with: .opacity)) } if loading { ProgressView() .controlSize(.small) .padding(10) .background(.ultraThinMaterial, in: Circle()) .padding(.bottom, 14) } } .animation(.snappy, value: zoneDirty) } // MARK: volet liste / fiche (synchronisé) @ViewBuilder private var sidePane: some View { if showDetail, let item = selected { VStack(spacing: 0) { HStack { Button { withAnimation(.snappy) { showDetail = false } } label: { Label("Retour aux résultats", systemImage: "chevron.left") .font(.caption.weight(.bold)) } .buttonStyle(.plain) Spacer() } .padding(.horizontal, 14).padding(.vertical, 9) Divider().opacity(0.4) ItemDetailPane(item: item) } .background(KATheme.paper(scheme)) } else { VStack(spacing: 0) { if let item = selected { compactCard(item) .padding(.horizontal, 12).padding(.top, 10) } ScrollView { LazyVStack(spacing: 9) { if visibleItems.isEmpty && !loading { KAEmptyState(symbol: "map", title: "Rien dans cette zone", message: "Déplacez la carte puis « Rechercher dans cette zone », ou activez d'autres univers.") } ForEach(visibleItems.prefix(80)) { item in Button { withAnimation(.snappy) { selected = item if let la = item.latitude, let lo = item.longitude { camera = .region(.init(center: .init(latitude: la, longitude: lo), span: .init(latitudeDelta: 0.02, longitudeDelta: 0.02))) } } } label: { KAItemRow(item: item, compact: true) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(Ecosystem.universe(item.universeID)?.accent ?? .gray, lineWidth: selected?.id == item.id ? 2.2 : 0) .padding(.trailing, 4).padding(.bottom, 4) ) } .buttonStyle(KAPressStyle()) } } .padding(12) } } .background(KATheme.paper(scheme)) } } private func compactCard(_ item: KAItem) -> some View { Button { withAnimation(.snappy) { showDetail = true } } label: { HStack(spacing: 10) { if let img = item.imageURL { KAImage(url: img, accent: Ecosystem.universe(item.universeID)?.accent ?? .gray) .frame(width: 54, height: 54) .clipShape(RoundedRectangle(cornerRadius: 9)) } VStack(alignment: .leading, spacing: 3) { Text(item.title).font(.subheadline.weight(.bold)).lineLimit(1) HStack(spacing: 6) { if let p = item.priceLabel { Text(p).font(.system(.caption, design: .rounded).weight(.bold)) .foregroundStyle(Ecosystem.universe(item.universeID)?.accent ?? .primary) } if let c = item.city { Text(c).font(.caption2).foregroundStyle(.secondary) } } Text("Cliquer pour la fiche complète").font(.system(size: 9, design: .monospaced)) .foregroundStyle(.tertiary) } Spacer() Button { withAnimation(.snappy) { selected = nil } } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } .buttonStyle(.plain) .accessibilityLabel("Fermer l'aperçu") } .padding(10) .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 13, style: .continuous) .strokeBorder(.primary.opacity(0.5), lineWidth: 1.3)) .shadow(color: .black.opacity(0.15), radius: 6, y: 3) } .buttonStyle(KAPressStyle()) .transition(.move(edge: .top).combined(with: .opacity)) } // MARK: données private func loadZone() async { loading = true zoneDirty = false let r = visibleRegion let west = r.center.longitude - r.span.longitudeDelta / 2 let east = r.center.longitude + r.span.longitudeDelta / 2 let south = r.center.latitude - r.span.latitudeDelta / 2 let north = r.center.latitude + r.span.latitudeDelta / 2 var all: [KAItem] = [] await withTaskGroup(of: [KAItem].self) { group in for u in mapUniverses where enabled.contains(u.id) { group.addTask { await UniverseService.mapItems(u, west: west, south: south, east: east, north: north) } } for await batch in group { all.append(contentsOf: batch) } } withAnimation(.easeOut(duration: 0.25)) { items = all } if let sel = selected, !all.contains(where: { $0.id == sel.id }) { selected = nil showDetail = false } loading = false } } // MARK: - Marqueurs /// Pilule de prix (ou symbole) — le marqueur signature, teinté par univers. struct PricePill: View { let item: KAItem var selected: Bool private var universe: Universe? { Ecosystem.universe(item.universeID) } var body: some View { HStack(spacing: 3) { Image(systemName: universe?.symbol ?? "mappin") .font(.system(size: 9, weight: .bold)) if let p = item.priceLabel?.split(separator: " ").prefix(2).joined(separator: " ") { Text(p).font(.system(size: 11, weight: .bold, design: .rounded)) } } .padding(.horizontal, 8).padding(.vertical, 5) .background(selected ? KATheme.inkLight : (universe?.accent ?? .gray), in: Capsule()) .foregroundStyle(selected ? KATheme.lime : .white) .overlay(Capsule().strokeBorder(.white.opacity(0.9), lineWidth: 1.4)) .shadow(color: .black.opacity(0.3), radius: 2, y: 1) .scaleEffect(selected ? 1.18 : 1) .animation(.spring(response: 0.3, dampingFraction: 0.6), value: selected) .accessibilityLabel("\(item.title), \(item.priceLabel ?? "")") } } /// Badge de groupe : compte teinté par l'univers dominant. struct ClusterBadge: View { let cluster: MapCluster private var dominant: Color { let counts = Dictionary(grouping: cluster.items, by: \.universeID).mapValues(\.count) let top = counts.max { $0.value < $1.value }?.key return top.flatMap { Ecosystem.universe($0)?.accent } ?? .gray } var body: some View { Text("\(cluster.items.count)") .font(.system(size: 13, weight: .bold, design: .rounded)) .frame(minWidth: 34, minHeight: 34) .background(dominant, in: Circle()) .foregroundStyle(.white) .overlay(Circle().strokeBorder(.white, lineWidth: 2)) .shadow(color: .black.opacity(0.3), radius: 2, y: 1) .accessibilityLabel("Groupe de \(cluster.items.count) résultats — cliquer pour zoomer") } }