// Auteur : Simon-Pierre Boucher — contact @spboucher.ai // KaMapLibre.swift — la carte MAPBOX 2D/3D de l'écosystème (comme Ka Maps sur // lou-ka.com) rendue par MapLibre Native : style Mapbox Streets v12 (jeton // public du web), BASCULE 2D ↔ 3D (inclinaison + bâtiments extrudés), // marqueurs-pilules SwiftUI hébergés dans des MLNAnnotationView. import SwiftUI import MapLibre import MapKit enum KaMapStyle { // Tuiles vectorielles OpenFreeMap (style Liberty) : libres, sans clé, rendu // vectoriel 2D/3D de classe Mapbox — les styles Mapbox natifs utilisent des // sources mapbox:// que MapLibre ne résout pas. static var url: URL { URL(string: "https://tiles.openfreemap.org/styles/liberty")! } } /// Annotation portant un cluster (1 item = pilule de prix ; n = badge). final class ClusterAnnotation: NSObject, MLNAnnotation { let cluster: MapCluster var coordinate: CLLocationCoordinate2D { .init(latitude: cluster.latitude, longitude: cluster.longitude) } var title: String? { nil } init(_ c: MapCluster) { cluster = c } } struct KaMapLibreView: UIViewRepresentable { var clusters: [MapCluster] var programRegion: MKCoordinateRegion var programEpoch: Int var pitch3D: Bool var selectedID: String? var onRegionChange: (MKCoordinateRegion) -> Void var onSelect: (MapCluster) -> Void func makeUIView(context: Context) -> MLNMapView { let map = MLNMapView(frame: .zero, styleURL: KaMapStyle.url) map.delegate = context.coordinator map.logoView.isHidden = false // attribution Mapbox requise map.attributionButtonPosition = .bottomLeft map.setCenter(programRegion.center, zoomLevel: zoom(from: programRegion), animated: false) map.allowsRotating = true map.allowsTilting = true // n'affiche le point bleu (et ne déclenche la permission) que si déjà accordée let auth = CLLocationManager().authorizationStatus map.showsUserLocation = (auth == .authorizedWhenInUse || auth == .authorizedAlways) return map } func updateUIView(_ map: MLNMapView, context: Context) { context.coordinator.parent = self // recentrage programmé (localisation, dégroupage, liste) — piloté par epoch if context.coordinator.lastEpoch != programEpoch { context.coordinator.lastEpoch = programEpoch let cam = map.camera cam.centerCoordinate = programRegion.center cam.pitch = pitch3D ? 58 : 0 map.setCamera(cam, animated: false) map.setZoomLevel(zoom(from: programRegion), animated: true) } // bascule 2D/3D if context.coordinator.lastPitch3D != pitch3D { context.coordinator.lastPitch3D = pitch3D let cam = map.camera cam.pitch = pitch3D ? 58 : 0 map.fly(to: cam, withDuration: 0.7, completionHandler: nil) } // annotations : resynchroniser (jeu de clusters OU sélection changée) let currentIDs = Set(clusters.map(\.id)) let existing = (map.annotations ?? []).compactMap { $0 as? ClusterAnnotation } let existingIDs = Set(existing.map(\.cluster.id)) if currentIDs != existingIDs || context.coordinator.lastSelectedID != selectedID { context.coordinator.lastSelectedID = selectedID map.removeAnnotations(existing) map.addAnnotations(clusters.map(ClusterAnnotation.init)) } } func makeCoordinator() -> Coordinator { Coordinator(self) } private func zoom(from region: MKCoordinateRegion) -> Double { // approximation classique span → niveau de zoom Web Mercator let span = max(region.span.longitudeDelta, 0.0005) return max(1, min(18, log2(360 / span) )) } final class Coordinator: NSObject, MLNMapViewDelegate { var parent: KaMapLibreView var lastPitch3D = false var lastEpoch = Int.min var lastSelectedID: String? private var buildingsAdded = false init(_ p: KaMapLibreView) { parent = p } // ---- style chargé : ajouter les BÂTIMENTS 3D (fill-extrusion) ---- func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) { guard !buildingsAdded else { return } // source vectorielle openmaptiles (Liberty) — repli : première source dispo let source = style.source(withIdentifier: "openmaptiles") ?? style.source(withIdentifier: "composite") ?? style.sources.first guard let composite = source else { return } buildingsAdded = true let layer = MLNFillExtrusionStyleLayer(identifier: "ka-3d-buildings", source: composite) layer.sourceLayerIdentifier = "building" // OpenMapTiles : render_height / render_min_height layer.fillExtrusionHeight = NSExpression(forKeyPath: "render_height") layer.fillExtrusionBase = NSExpression(forKeyPath: "render_min_height") layer.fillExtrusionColor = NSExpression(forConstantValue: UIColor(red: 0.82, green: 0.83, blue: 0.80, alpha: 1)) layer.fillExtrusionOpacity = NSExpression(forConstantValue: 0.75) // insérer sous les étiquettes pour garder les noms lisibles if let firstSymbol = style.layers.first(where: { $0 is MLNSymbolStyleLayer }) { style.insertLayer(layer, below: firstSymbol) } else { style.addLayer(layer) } } // ---- pilules SwiftUI comme vues d'annotation ---- func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? { guard let ann = annotation as? ClusterAnnotation else { return nil } let view = MLNAnnotationView(reuseIdentifier: nil) let cluster = ann.cluster let selected = parent.selectedID == cluster.id let content: AnyView = cluster.isSingle ? AnyView(PricePill(item: cluster.item, selected: selected)) : AnyView(ClusterBadge(cluster: cluster)) let host = UIHostingController(rootView: content) host.view.backgroundColor = .clear let size = host.sizeThatFits(in: CGSize(width: 200, height: 60)) host.view.frame = CGRect(origin: .zero, size: size) view.frame = host.view.frame view.addSubview(host.view) view.isUserInteractionEnabled = true let tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:))) view.addGestureRecognizer(tap) view.accessibilityLabel = cluster.isSingle ? cluster.item.title : "Groupe de \(cluster.items.count) résultats" objc_setAssociatedObject(view, &AssocKeys.cluster, cluster, .OBJC_ASSOCIATION_RETAIN) return view } @objc private func tapped(_ g: UITapGestureRecognizer) { guard let v = g.view, let cluster = objc_getAssociatedObject(v, &AssocKeys.cluster) as? MapCluster else { return } parent.onSelect(cluster) } func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) { let bounds = mapView.visibleCoordinateBounds let region = MKCoordinateRegion( center: mapView.centerCoordinate, span: .init(latitudeDelta: abs(bounds.ne.latitude - bounds.sw.latitude), longitudeDelta: abs(bounds.ne.longitude - bounds.sw.longitude))) parent.onRegionChange(region) } } } private enum AssocKeys { static var cluster: UInt8 = 0 }