SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%
21.0 KB · 458 lines swift
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// TrajetMapView.swift — la carte de Ka Trajet rendue par MapLibre Native3// (même moteur que la carte de l'app iOS KA / Lou-Ka) : style vectoriel4// Liberty, bâtiments 3D extrudés, tracés d'itinéraires (sélectionné en vert5// Ka + alternatives grises), marqueur de destination, appui long → repère.6import SwiftUI7import MapLibre8import MapKit910// KaMapStyle est déclaré dans KaMapLibre.swift (même style Liberty partagé).1112/// Caméra de suivi : derrière la voiture (conduite) ou au-dessus du marcheur13/// (mode Découvrir) — inclinée, orientée selon la route ou le cap GPS.14struct NavCamera: Equatable {15    var center: CLLocationCoordinate2D16    var heading: Double17    var epoch: Int18    var altitude: Double = 26019    var pitch: CGFloat = 6220    static func == (l: NavCamera, r: NavCamera) -> Bool { l.epoch == r.epoch }21}2223final class DestinationAnnotation: NSObject, MLNAnnotation {24    let place: Place25    var coordinate: CLLocationCoordinate2D { place.coordinate }26    var title: String? { place.name }27    init(_ p: Place) { place = p }28}2930final class POIAnnotation: NSObject, MLNAnnotation {31    let poi: POIItem32    var coordinate: CLLocationCoordinate2D { poi.coordinate }33    var title: String? { poi.name }34    init(_ p: POIItem) { poi = p }35}3637struct TrajetMapView: UIViewRepresentable {38    var destination: Place?39    var routes: [Route]40    var routeIndex: Int41    var programRegion: MKCoordinateRegion42    var programEpoch: Int43    var pitch3D: Bool44    var navCamera: NavCamera?45    var pois: [POIItem]46    var selectedPOIID: String?47    var onLongPress: (CLLocationCoordinate2D) -> Void48    var onRegionChange: (MKCoordinateRegion) -> Void49    var onSelectPOI: (POIItem) -> Void5051    private static let selSourceID = "ka-route-sel"52    private static let altSourceID = "ka-route-alt"5354    func makeUIView(context: Context) -> MLNMapView {55        let map = MLNMapView(frame: .zero, styleURL: KaMapStyle.url)56        map.delegate = context.coordinator57        map.logoView.isHidden = false58        map.attributionButtonPosition = .bottomLeft59        map.setCenter(programRegion.center, zoomLevel: zoom(from: programRegion), animated: false)60        map.allowsRotating = true61        map.allowsTilting = true62        let auth = CLLocationManager().authorizationStatus63        map.showsUserLocation = (auth == .authorizedWhenInUse || auth == .authorizedAlways)64        let long = UILongPressGestureRecognizer(target: context.coordinator,65                                                action: #selector(Coordinator.longPressed(_:)))66        map.addGestureRecognizer(long)67        return map68    }6970    func updateUIView(_ map: MLNMapView, context: Context) {71        context.coordinator.parent = self7273        // point bleu dès que la permission est accordée74        let auth = CLLocationManager().authorizationStatus75        let allowed = (auth == .authorizedWhenInUse || auth == .authorizedAlways)76        if map.showsUserLocation != allowed { map.showsUserLocation = allowed }7778        // marqueurs des couches POI — synchronisés AVANT le retour anticipé de79        // la caméra de suivi : le mode Découvrir garde la caméra active en80        // continu et a besoin de ses pilules d'annonces81        let poiIDs = Set(pois.map(\.id))82        if context.coordinator.lastPOIIDs != poiIDs83            || context.coordinator.lastPOISelectedID != selectedPOIID {84            context.coordinator.lastPOIIDs = poiIDs85            context.coordinator.lastPOISelectedID = selectedPOIID86            let old = (map.annotations ?? []).compactMap { $0 as? POIAnnotation }87            map.removeAnnotations(old)88            map.addAnnotations(pois.map(POIAnnotation.init))89        }9091        // caméra de suivi : position réelle (voiture en conduite, marcheur en92        // Découvrir), vue 3D inclinée orientée selon la route ou le cap93        if let nav = navCamera {94            if context.coordinator.lastNavEpoch != nav.epoch {95                context.coordinator.lastNavEpoch = nav.epoch96                let cam = MLNMapCamera(lookingAtCenter: nav.center,97                                       altitude: nav.altitude,98                                       pitch: nav.pitch,99                                       heading: nav.heading)100                map.setCamera(cam, withDuration: 0.9,101                              animationTimingFunction: CAMediaTimingFunction(name: .linear))102            }103            return // en navigation, la caméra de conduite a priorité sur tout104        }105        context.coordinator.lastNavEpoch = 0106107        // caméra programmée (pattern epoch de la super-app KA)108        if context.coordinator.lastEpoch != programEpoch {109            context.coordinator.lastEpoch = programEpoch110            let cam = map.camera111            cam.centerCoordinate = programRegion.center112            cam.pitch = pitch3D ? 58 : 0113            map.setCamera(cam, animated: false)114            map.setZoomLevel(zoom(from: programRegion), animated: true)115        }116117        // bascule 2D / 3D118        if context.coordinator.lastPitch3D != pitch3D {119            context.coordinator.lastPitch3D = pitch3D120            let cam = map.camera121            cam.pitch = pitch3D ? 58 : 0122            map.fly(to: cam, withDuration: 0.7, completionHandler: nil)123        }124125        // marqueur de destination126        let destID = destination?.id127        if context.coordinator.lastDestID != destID {128            context.coordinator.lastDestID = destID129            let old = (map.annotations ?? []).compactMap { $0 as? DestinationAnnotation }130            map.removeAnnotations(old)131            if let d = destination { map.addAnnotation(DestinationAnnotation(d)) }132        }133134        // tracés d'itinéraires135        let signature = routes.map(\.id.uuidString).joined() + "#\(routeIndex)"136        if context.coordinator.lastRouteSignature != signature {137            context.coordinator.lastRouteSignature = signature138            context.coordinator.syncRoutes(on: map)139        }140    }141142    func makeCoordinator() -> Coordinator { Coordinator(self) }143144    private func zoom(from region: MKCoordinateRegion) -> Double {145        let span = max(region.span.longitudeDelta, 0.0005)146        return max(1, min(18, log2(360 / span)))147    }148149    final class Coordinator: NSObject, MLNMapViewDelegate {150        var parent: TrajetMapView151        var lastEpoch = Int.min152        var lastPitch3D = false153        var lastNavEpoch = 0154        var lastDestID: String?155        var lastRouteSignature = ""156        var lastPOIIDs = Set<String>()157        var lastPOISelectedID: String?158        private var buildingsAdded = false159        private var styleReady = false160        private weak var mapView: MLNMapView?161162        init(_ p: TrajetMapView) { parent = p }163164        @objc func longPressed(_ g: UILongPressGestureRecognizer) {165            guard g.state == .began, let map = g.view as? MLNMapView else { return }166            let coord = map.convert(g.location(in: map), toCoordinateFrom: map)167            parent.onLongPress(coord)168        }169170        // ---- style chargé : palette Ka + bâtiments 3D + couches d'itinéraire ----171        func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) {172            self.mapView = mapView173            styleReady = true174            applyKaCartography(style)175            addBuildings(style)176            ensureRouteLayers(style)177            syncRoutes(on: mapView)178        }179180        // La carte SIGNATURE Groupe Ka : papier crème, eau vert forêt,181        // parcs lime pâle, autoroutes lime cerclées de vert, étiquettes encre.182        private func applyKaCartography(_ style: MLNStyle) {183            let paper = UIColor(red: 0.961, green: 0.953, blue: 0.933, alpha: 1)   // #f5f3ee184            let paper2 = UIColor(red: 0.937, green: 0.925, blue: 0.894, alpha: 1)  // #efece4185            let ink = UIColor(red: 0.078, green: 0.094, blue: 0.078, alpha: 1)     // #141814186            let water = UIColor(red: 0x2e / 255, green: 0x6f / 255, blue: 0x58 / 255, alpha: 1) // vert forêt187            let park = UIColor(red: 0xe2 / 255, green: 0xee / 255, blue: 0xc4 / 255, alpha: 1)  // lime pâle188            let building = UIColor(red: 0.898, green: 0.882, blue: 0.843, alpha: 1)189190            for layer in style.layers {191                let id = layer.identifier.lowercased()192                if let bg = layer as? MLNBackgroundStyleLayer {193                    bg.backgroundColor = NSExpression(forConstantValue: paper)194                } else if let fill = layer as? MLNFillStyleLayer {195                    if id.contains("water") {196                        fill.fillColor = NSExpression(forConstantValue: water)197                        fill.fillOpacity = NSExpression(forConstantValue: 1)198                    } else if id.contains("park") || id.contains("grass") || id.contains("wood")199                        || id.contains("landcover") || id.contains("cemetery") || id.contains("golf")200                        || id.contains("pitch") {201                        fill.fillColor = NSExpression(forConstantValue: park)202                    } else if id.contains("building") {203                        fill.fillColor = NSExpression(forConstantValue: building)204                    } else if id.contains("residential") || id.contains("landuse")205                        || id.contains("sand") || id.contains("aeroway") {206                        fill.fillColor = NSExpression(forConstantValue: paper2)207                    }208                } else if let line = layer as? MLNLineStyleLayer {209                    if id.contains("motorway") || id.contains("trunk") {210                        line.lineColor = NSExpression(forConstantValue:211                            id.contains("casing") ? Ka.uiGreen : Ka.uiLime)212                    } else if id.contains("water") || id.contains("river") || id.contains("stream") {213                        line.lineColor = NSExpression(forConstantValue: water)214                    }215                } else if let sym = layer as? MLNSymbolStyleLayer {216                    if sym.text != nil {217                        sym.textColor = NSExpression(forConstantValue: ink)218                        sym.textHaloColor = NSExpression(forConstantValue: paper.withAlphaComponent(0.92))219                    }220                }221            }222        }223224        private func addBuildings(_ style: MLNStyle) {225            guard !buildingsAdded else { return }226            let source = style.source(withIdentifier: "openmaptiles")227                ?? style.source(withIdentifier: "composite")228                ?? style.sources.first229            guard let composite = source else { return }230            buildingsAdded = true231            let layer = MLNFillExtrusionStyleLayer(identifier: "ka-3d-buildings", source: composite)232            layer.sourceLayerIdentifier = "building"233            layer.fillExtrusionHeight = NSExpression(forKeyPath: "render_height")234            layer.fillExtrusionBase = NSExpression(forKeyPath: "render_min_height")235            layer.fillExtrusionColor = NSExpression(forConstantValue: UIColor(red: 0.82, green: 0.83, blue: 0.80, alpha: 1))236            layer.fillExtrusionOpacity = NSExpression(forConstantValue: 0.75)237            if let firstSymbol = style.layers.first(where: { $0 is MLNSymbolStyleLayer }) {238                style.insertLayer(layer, below: firstSymbol)239            } else {240                style.addLayer(layer)241            }242        }243244        // sources + couches (créées une fois, shapes remplacées ensuite)245        private func ensureRouteLayers(_ style: MLNStyle) {246            guard style.source(withIdentifier: TrajetMapView.selSourceID) == nil else { return }247            let altSource = MLNShapeSource(identifier: TrajetMapView.altSourceID,248                                           shape: MLNShapeCollectionFeature(shapes: []), options: nil)249            let selSource = MLNShapeSource(identifier: TrajetMapView.selSourceID,250                                           shape: MLNShapeCollectionFeature(shapes: []), options: nil)251            style.addSource(altSource)252            style.addSource(selSource)253254            let alt = MLNLineStyleLayer(identifier: "ka-route-alt-line", source: altSource)255            alt.lineColor = NSExpression(forConstantValue: Ka.uiAlt)256            alt.lineWidth = NSExpression(forConstantValue: 5)257            alt.lineOpacity = NSExpression(forConstantValue: 0.65)258            alt.lineCap = NSExpression(forConstantValue: "round")259            alt.lineJoin = NSExpression(forConstantValue: "round")260261            let casing = MLNLineStyleLayer(identifier: "ka-route-sel-casing", source: selSource)262            casing.lineColor = NSExpression(forConstantValue: UIColor.white)263            casing.lineWidth = NSExpression(forConstantValue: 9)264            casing.lineCap = NSExpression(forConstantValue: "round")265            casing.lineJoin = NSExpression(forConstantValue: "round")266267            let sel = MLNLineStyleLayer(identifier: "ka-route-sel-line", source: selSource)268            sel.lineColor = NSExpression(forConstantValue: Ka.uiGreen)269            sel.lineWidth = NSExpression(forConstantValue: 6)270            sel.lineCap = NSExpression(forConstantValue: "round")271            sel.lineJoin = NSExpression(forConstantValue: "round")272273            // au-dessus des routes mais sous les étiquettes274            if let firstSymbol = style.layers.first(where: { $0 is MLNSymbolStyleLayer }) {275                style.insertLayer(alt, below: firstSymbol)276                style.insertLayer(casing, above: alt)277                style.insertLayer(sel, above: casing)278            } else {279                style.addLayer(alt); style.addLayer(casing); style.addLayer(sel)280            }281        }282283        func syncRoutes(on map: MLNMapView) {284            guard styleReady, let style = map.style else { return }285            ensureRouteLayers(style)286            guard let altSource = style.source(withIdentifier: TrajetMapView.altSourceID) as? MLNShapeSource,287                  let selSource = style.source(withIdentifier: TrajetMapView.selSourceID) as? MLNShapeSource288            else { return }289290            func line(_ r: Route) -> MLNPolylineFeature {291                var coords = r.coordinates292                return MLNPolylineFeature(coordinates: &coords, count: UInt(coords.count))293            }294            let routes = parent.routes295            let sel = parent.routeIndex296            let altShapes = routes.enumerated().filter { $0.offset != sel }.map { line($0.element) }297            let selShapes = routes.indices.contains(sel) ? [line(routes[sel])] : []298            altSource.shape = MLNShapeCollectionFeature(shapes: altShapes)299            selSource.shape = MLNShapeCollectionFeature(shapes: selShapes)300        }301302        // ---- marqueurs : épinglette de destination + POI des couches ----303        func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? {304            if annotation is DestinationAnnotation {305                let view = MLNAnnotationView(reuseIdentifier: nil)306                let host = UIHostingController(rootView: DestinationPin())307                host.view.backgroundColor = .clear308                let size = host.sizeThatFits(in: CGSize(width: 60, height: 60))309                host.view.frame = CGRect(origin: .zero, size: size)310                view.frame = host.view.frame311                // ancrer la pointe de l'épinglette sur la coordonnée312                view.centerOffset = CGVector(dx: 0, dy: -size.height / 2)313                view.addSubview(host.view)314                return view315            }316            if let ann = annotation as? POIAnnotation {317                let view = MLNAnnotationView(reuseIdentifier: nil)318                let host = UIHostingController(rootView:319                    POIMarker(poi: ann.poi, selected: parent.selectedPOIID == ann.poi.id))320                host.view.backgroundColor = .clear321                let size = host.sizeThatFits(in: CGSize(width: 170, height: 44))322                host.view.frame = CGRect(origin: .zero, size: size)323                view.frame = host.view.frame324                view.addSubview(host.view)325                view.isUserInteractionEnabled = true326                let tap = UITapGestureRecognizer(target: self, action: #selector(poiTapped(_:)))327                view.addGestureRecognizer(tap)328                view.accessibilityLabel = ann.poi.name329                objc_setAssociatedObject(view, &AssocKeys.poi, ann.poi, .OBJC_ASSOCIATION_RETAIN)330                return view331            }332            return nil333        }334335        @objc private func poiTapped(_ g: UITapGestureRecognizer) {336            guard let v = g.view,337                  let poi = objc_getAssociatedObject(v, &AssocKeys.poi) as? POIItem else { return }338            parent.onSelectPOI(poi)339        }340341        // ---- région visible → modèle (rechargement des couches) ----342        func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) {343            let bounds = mapView.visibleCoordinateBounds344            let region = MKCoordinateRegion(345                center: mapView.centerCoordinate,346                span: .init(latitudeDelta: abs(bounds.ne.latitude - bounds.sw.latitude),347                            longitudeDelta: abs(bounds.ne.longitude - bounds.sw.longitude)))348            parent.onRegionChange(region)349        }350    }351}352353private enum AssocKeys { static var poi: UInt8 = 0 }354355/// Marqueur d'une couche POI : pastille icône (stations, commerces) ou356/// pilule de prix (annonces Lou-Ka / Immo-Ka), aux couleurs Ka.357struct POIMarker: View {358    let poi: POIItem359    var selected = false360361    // accents des univers Ka (ecosystem.json)362    private static let restoOrange = Color(hex: "#f08c00")363    private static let sortiRose = Color(hex: "#d6336c")364    private static let jobTeal = Color(hex: "#0c8599")365    private static let autoOrange = Color(hex: "#ff5a2a")366    private static let fabriBrun = Color(hex: "#c4532e")367368    var body: some View {369        switch poi.kind {370        case .stations:371            // prix de l'essence ordinaire en pilule (source Régie de l'énergie)372            if poi.priceLabel != nil { pill(bg: Ka.inkLight, fg: Ka.lime) }373            else { badge(bg: Ka.inkLight, fg: Ka.lime) }374        case .commerces:375            badge(bg: Ka.green, fg: .white)376        case .louka:377            pill(bg: Ka.lime, fg: Ka.inkLight)378        case .immoka:379            pill(bg: Ka.green, fg: Ka.lime)380        case .restoka:381            if poi.priceLabel != nil { pill(bg: Self.restoOrange, fg: .white) }382            else { badge(bg: Self.restoOrange, fg: .white) }383        case .sortika:384            if poi.priceLabel != nil { pill(bg: Self.sortiRose, fg: .white) }385            else { badge(bg: Self.sortiRose, fg: .white) }386        case .jobka:387            if poi.priceLabel != nil { pill(bg: Self.jobTeal, fg: .white) }388            else { badge(bg: Self.jobTeal, fg: .white) }389        case .autoka:390            if poi.priceLabel != nil { pill(bg: Self.autoOrange, fg: .white) }391            else { badge(bg: Self.autoOrange, fg: .white) }392        case .fabrika:393            badge(bg: Self.fabriBrun, fg: .white)394        }395    }396397    private func badge(bg: Color, fg: Color) -> some View {398        ZStack {399            Circle().fill(bg)400                .frame(width: selected ? 36 : 30, height: selected ? 36 : 30)401                .shadow(color: .black.opacity(0.3), radius: 3, y: 1)402            Image(systemName: poi.kind.icon)403                .font(.system(size: selected ? 15 : 13, weight: .bold))404                .foregroundStyle(fg)405        }406        .overlay(Circle().strokeBorder(selected ? Ka.lime : .white, lineWidth: selected ? 2.5 : 1.5))407        .padding(3)408    }409410    private func pill(bg: Color, fg: Color) -> some View {411        HStack(spacing: 4) {412            Image(systemName: poi.kind.icon)413                .font(.system(size: selected ? 12 : 10, weight: .bold))414            Text(poi.priceLabel ?? poi.name)415                .font(.system(size: selected ? 14 : 12, weight: .bold, design: .rounded))416                .lineLimit(1)417        }418        .padding(.horizontal, selected ? 11 : 9)419        .padding(.vertical, selected ? 7 : 5)420        .background(bg, in: Capsule())421        .overlay(Capsule().strokeBorder(selected ? Ka.lime : .white, lineWidth: selected ? 2.5 : 1.5))422        .foregroundStyle(fg)423        .shadow(color: .black.opacity(selected ? 0.45 : 0.3), radius: selected ? 5 : 3, y: 1)424        .padding(2)425    }426}427428/// Épinglette de destination aux couleurs Ka.429struct DestinationPin: View {430    var body: some View {431        VStack(spacing: -2) {432            ZStack {433                Circle().fill(Ka.green)434                    .frame(width: 34, height: 34)435                    .shadow(color: .black.opacity(0.35), radius: 4, y: 2)436                Image(systemName: "mappin")437                    .font(.system(size: 16, weight: .bold))438                    .foregroundStyle(Ka.lime)439            }440            Triangle()441                .fill(Ka.green)442                .frame(width: 12, height: 10)443        }444        .padding(.bottom, 2)445    }446}447448struct Triangle: Shape {449    func path(in r: CGRect) -> Path {450        var p = Path()451        p.move(to: .init(x: r.midX, y: r.maxY))452        p.addLine(to: .init(x: r.minX, y: r.minY))453        p.addLine(to: .init(x: r.maxX, y: r.minY))454        p.closeSubpath()455        return p456    }457}458