Swift 100%
1// Auteur : Simon-Pierre Boucher — contact @spboucher.ai2// KaMapLibre.swift — la carte MAPBOX 2D/3D de l'écosystème (comme Ka Maps sur3// lou-ka.com) rendue par MapLibre Native : style Mapbox Streets v12 (jeton4// public du web), BASCULE 2D ↔ 3D (inclinaison + bâtiments extrudés),5// marqueurs-pilules SwiftUI hébergés dans des MLNAnnotationView.6import SwiftUI7import MapLibre8import MapKit910enum KaMapStyle {11 // Tuiles vectorielles OpenFreeMap (style Liberty) : libres, sans clé, rendu12 // vectoriel 2D/3D de classe Mapbox — les styles Mapbox natifs utilisent des13 // sources mapbox:// que MapLibre ne résout pas.14 static var url: URL { URL(string: "https://tiles.openfreemap.org/styles/liberty")! }15}1617/// Annotation portant un cluster (1 item = pilule de prix ; n = badge).18final class ClusterAnnotation: NSObject, MLNAnnotation {19 let cluster: MapCluster20 var coordinate: CLLocationCoordinate2D {21 .init(latitude: cluster.latitude, longitude: cluster.longitude)22 }23 var title: String? { nil }24 init(_ c: MapCluster) { cluster = c }25}2627struct KaMapLibreView: UIViewRepresentable {28 var clusters: [MapCluster]29 var programRegion: MKCoordinateRegion30 var programEpoch: Int31 var pitch3D: Bool32 var selectedID: String?33 var onRegionChange: (MKCoordinateRegion) -> Void34 var onSelect: (MapCluster) -> Void3536 func makeUIView(context: Context) -> MLNMapView {37 let map = MLNMapView(frame: .zero, styleURL: KaMapStyle.url)38 map.delegate = context.coordinator39 map.logoView.isHidden = false // attribution Mapbox requise40 map.attributionButtonPosition = .bottomLeft41 map.setCenter(programRegion.center, zoomLevel: zoom(from: programRegion), animated: false)42 map.allowsRotating = true43 map.allowsTilting = true44 // n'affiche le point bleu (et ne déclenche la permission) que si déjà accordée45 let auth = CLLocationManager().authorizationStatus46 map.showsUserLocation = (auth == .authorizedWhenInUse || auth == .authorizedAlways)47 return map48 }4950 func updateUIView(_ map: MLNMapView, context: Context) {51 context.coordinator.parent = self52 // recentrage programmé (localisation, dégroupage, liste) — piloté par epoch53 if context.coordinator.lastEpoch != programEpoch {54 context.coordinator.lastEpoch = programEpoch55 let cam = map.camera56 cam.centerCoordinate = programRegion.center57 cam.pitch = pitch3D ? 58 : 058 map.setCamera(cam, animated: false)59 map.setZoomLevel(zoom(from: programRegion), animated: true)60 }61 // bascule 2D/3D62 if context.coordinator.lastPitch3D != pitch3D {63 context.coordinator.lastPitch3D = pitch3D64 let cam = map.camera65 cam.pitch = pitch3D ? 58 : 066 map.fly(to: cam, withDuration: 0.7, completionHandler: nil)67 }68 // annotations : resynchroniser (jeu de clusters OU sélection changée)69 let currentIDs = Set(clusters.map(\.id))70 let existing = (map.annotations ?? []).compactMap { $0 as? ClusterAnnotation }71 let existingIDs = Set(existing.map(\.cluster.id))72 if currentIDs != existingIDs || context.coordinator.lastSelectedID != selectedID {73 context.coordinator.lastSelectedID = selectedID74 map.removeAnnotations(existing)75 map.addAnnotations(clusters.map(ClusterAnnotation.init))76 }77 }7879 func makeCoordinator() -> Coordinator { Coordinator(self) }8081 private func zoom(from region: MKCoordinateRegion) -> Double {82 // approximation classique span → niveau de zoom Web Mercator83 let span = max(region.span.longitudeDelta, 0.0005)84 return max(1, min(18, log2(360 / span) ))85 }8687 final class Coordinator: NSObject, MLNMapViewDelegate {88 var parent: KaMapLibreView89 var lastPitch3D = false90 var lastEpoch = Int.min91 var lastSelectedID: String?92 private var buildingsAdded = false9394 init(_ p: KaMapLibreView) { parent = p }9596 // ---- style chargé : ajouter les BÂTIMENTS 3D (fill-extrusion) ----97 func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) {98 guard !buildingsAdded else { return }99 // source vectorielle openmaptiles (Liberty) — repli : première source dispo100 let source = style.source(withIdentifier: "openmaptiles")101 ?? style.source(withIdentifier: "composite")102 ?? style.sources.first103 guard let composite = source else { return }104 buildingsAdded = true105 let layer = MLNFillExtrusionStyleLayer(identifier: "ka-3d-buildings", source: composite)106 layer.sourceLayerIdentifier = "building"107 // OpenMapTiles : render_height / render_min_height108 layer.fillExtrusionHeight = NSExpression(forKeyPath: "render_height")109 layer.fillExtrusionBase = NSExpression(forKeyPath: "render_min_height")110 layer.fillExtrusionColor = NSExpression(forConstantValue: UIColor(red: 0.82, green: 0.83, blue: 0.80, alpha: 1))111 layer.fillExtrusionOpacity = NSExpression(forConstantValue: 0.75)112 // insérer sous les étiquettes pour garder les noms lisibles113 if let firstSymbol = style.layers.first(where: { $0 is MLNSymbolStyleLayer }) {114 style.insertLayer(layer, below: firstSymbol)115 } else {116 style.addLayer(layer)117 }118 }119120 // ---- pilules SwiftUI comme vues d'annotation ----121 func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? {122 guard let ann = annotation as? ClusterAnnotation else { return nil }123 let view = MLNAnnotationView(reuseIdentifier: nil)124 let cluster = ann.cluster125 let selected = parent.selectedID == cluster.id126 let content: AnyView = cluster.isSingle127 ? AnyView(PricePill(item: cluster.item, selected: selected))128 : AnyView(ClusterBadge(cluster: cluster))129 let host = UIHostingController(rootView: content)130 host.view.backgroundColor = .clear131 let size = host.sizeThatFits(in: CGSize(width: 200, height: 60))132 host.view.frame = CGRect(origin: .zero, size: size)133 view.frame = host.view.frame134 view.addSubview(host.view)135 view.isUserInteractionEnabled = true136 let tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))137 view.addGestureRecognizer(tap)138 view.accessibilityLabel = cluster.isSingle ? cluster.item.title : "Groupe de \(cluster.items.count) résultats"139 objc_setAssociatedObject(view, &AssocKeys.cluster, cluster, .OBJC_ASSOCIATION_RETAIN)140 return view141 }142143 @objc private func tapped(_ g: UITapGestureRecognizer) {144 guard let v = g.view,145 let cluster = objc_getAssociatedObject(v, &AssocKeys.cluster) as? MapCluster else { return }146 parent.onSelect(cluster)147 }148149 func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) {150 let bounds = mapView.visibleCoordinateBounds151 let region = MKCoordinateRegion(152 center: mapView.centerCoordinate,153 span: .init(latitudeDelta: abs(bounds.ne.latitude - bounds.sw.latitude),154 longitudeDelta: abs(bounds.ne.longitude - bounds.sw.longitude)))155 parent.onRegionChange(region)156 }157 }158}159160private enum AssocKeys { static var cluster: UInt8 = 0 }161