Swift 98.3%
Shell 1.7%
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