SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%
7.6 KB · 161 lines swift
Raw Blame History
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