// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // TrajetMapView.swift — la carte de Ka Trajet rendue par MapLibre Native // (même moteur que la carte de l'app iOS KA / Lou-Ka) : style vectoriel // Liberty, bâtiments 3D extrudés, tracés d'itinéraires (sélectionné en vert // Ka + alternatives grises), marqueur de destination, appui long → repère. import SwiftUI import MapLibre import MapKit // KaMapStyle est déclaré dans KaMapLibre.swift (même style Liberty partagé). /// Caméra de suivi : derrière la voiture (conduite) ou au-dessus du marcheur /// (mode Découvrir) — inclinée, orientée selon la route ou le cap GPS. struct NavCamera: Equatable { var center: CLLocationCoordinate2D var heading: Double var epoch: Int var altitude: Double = 260 var pitch: CGFloat = 62 static func == (l: NavCamera, r: NavCamera) -> Bool { l.epoch == r.epoch } } final class DestinationAnnotation: NSObject, MLNAnnotation { let place: Place var coordinate: CLLocationCoordinate2D { place.coordinate } var title: String? { place.name } init(_ p: Place) { place = p } } final class POIAnnotation: NSObject, MLNAnnotation { let poi: POIItem var coordinate: CLLocationCoordinate2D { poi.coordinate } var title: String? { poi.name } init(_ p: POIItem) { poi = p } } struct TrajetMapView: UIViewRepresentable { var destination: Place? var routes: [Route] var routeIndex: Int var programRegion: MKCoordinateRegion var programEpoch: Int var pitch3D: Bool var navCamera: NavCamera? var pois: [POIItem] var selectedPOIID: String? var onLongPress: (CLLocationCoordinate2D) -> Void var onRegionChange: (MKCoordinateRegion) -> Void var onSelectPOI: (POIItem) -> Void private static let selSourceID = "ka-route-sel" private static let altSourceID = "ka-route-alt" func makeUIView(context: Context) -> MLNMapView { let map = MLNMapView(frame: .zero, styleURL: KaMapStyle.url) map.delegate = context.coordinator map.logoView.isHidden = false map.attributionButtonPosition = .bottomLeft map.setCenter(programRegion.center, zoomLevel: zoom(from: programRegion), animated: false) map.allowsRotating = true map.allowsTilting = true let auth = CLLocationManager().authorizationStatus map.showsUserLocation = (auth == .authorizedWhenInUse || auth == .authorizedAlways) let long = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.longPressed(_:))) map.addGestureRecognizer(long) return map } func updateUIView(_ map: MLNMapView, context: Context) { context.coordinator.parent = self // point bleu dès que la permission est accordée let auth = CLLocationManager().authorizationStatus let allowed = (auth == .authorizedWhenInUse || auth == .authorizedAlways) if map.showsUserLocation != allowed { map.showsUserLocation = allowed } // marqueurs des couches POI — synchronisés AVANT le retour anticipé de // la caméra de suivi : le mode Découvrir garde la caméra active en // continu et a besoin de ses pilules d'annonces let poiIDs = Set(pois.map(\.id)) if context.coordinator.lastPOIIDs != poiIDs || context.coordinator.lastPOISelectedID != selectedPOIID { context.coordinator.lastPOIIDs = poiIDs context.coordinator.lastPOISelectedID = selectedPOIID let old = (map.annotations ?? []).compactMap { $0 as? POIAnnotation } map.removeAnnotations(old) map.addAnnotations(pois.map(POIAnnotation.init)) } // caméra de suivi : position réelle (voiture en conduite, marcheur en // Découvrir), vue 3D inclinée orientée selon la route ou le cap if let nav = navCamera { if context.coordinator.lastNavEpoch != nav.epoch { context.coordinator.lastNavEpoch = nav.epoch let cam = MLNMapCamera(lookingAtCenter: nav.center, altitude: nav.altitude, pitch: nav.pitch, heading: nav.heading) map.setCamera(cam, withDuration: 0.9, animationTimingFunction: CAMediaTimingFunction(name: .linear)) } return // en navigation, la caméra de conduite a priorité sur tout } context.coordinator.lastNavEpoch = 0 // caméra programmée (pattern epoch de la super-app KA) 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) } // marqueur de destination let destID = destination?.id if context.coordinator.lastDestID != destID { context.coordinator.lastDestID = destID let old = (map.annotations ?? []).compactMap { $0 as? DestinationAnnotation } map.removeAnnotations(old) if let d = destination { map.addAnnotation(DestinationAnnotation(d)) } } // tracés d'itinéraires let signature = routes.map(\.id.uuidString).joined() + "#\(routeIndex)" if context.coordinator.lastRouteSignature != signature { context.coordinator.lastRouteSignature = signature context.coordinator.syncRoutes(on: map) } } func makeCoordinator() -> Coordinator { Coordinator(self) } private func zoom(from region: MKCoordinateRegion) -> Double { let span = max(region.span.longitudeDelta, 0.0005) return max(1, min(18, log2(360 / span))) } final class Coordinator: NSObject, MLNMapViewDelegate { var parent: TrajetMapView var lastEpoch = Int.min var lastPitch3D = false var lastNavEpoch = 0 var lastDestID: String? var lastRouteSignature = "" var lastPOIIDs = Set() var lastPOISelectedID: String? private var buildingsAdded = false private var styleReady = false private weak var mapView: MLNMapView? init(_ p: TrajetMapView) { parent = p } @objc func longPressed(_ g: UILongPressGestureRecognizer) { guard g.state == .began, let map = g.view as? MLNMapView else { return } let coord = map.convert(g.location(in: map), toCoordinateFrom: map) parent.onLongPress(coord) } // ---- style chargé : palette Ka + bâtiments 3D + couches d'itinéraire ---- func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) { self.mapView = mapView styleReady = true applyKaCartography(style) addBuildings(style) ensureRouteLayers(style) syncRoutes(on: mapView) } // La carte SIGNATURE Groupe Ka : papier crème, eau vert forêt, // parcs lime pâle, autoroutes lime cerclées de vert, étiquettes encre. private func applyKaCartography(_ style: MLNStyle) { let paper = UIColor(red: 0.961, green: 0.953, blue: 0.933, alpha: 1) // #f5f3ee let paper2 = UIColor(red: 0.937, green: 0.925, blue: 0.894, alpha: 1) // #efece4 let ink = UIColor(red: 0.078, green: 0.094, blue: 0.078, alpha: 1) // #141814 let water = UIColor(red: 0x2e / 255, green: 0x6f / 255, blue: 0x58 / 255, alpha: 1) // vert forêt let park = UIColor(red: 0xe2 / 255, green: 0xee / 255, blue: 0xc4 / 255, alpha: 1) // lime pâle let building = UIColor(red: 0.898, green: 0.882, blue: 0.843, alpha: 1) for layer in style.layers { let id = layer.identifier.lowercased() if let bg = layer as? MLNBackgroundStyleLayer { bg.backgroundColor = NSExpression(forConstantValue: paper) } else if let fill = layer as? MLNFillStyleLayer { if id.contains("water") { fill.fillColor = NSExpression(forConstantValue: water) fill.fillOpacity = NSExpression(forConstantValue: 1) } else if id.contains("park") || id.contains("grass") || id.contains("wood") || id.contains("landcover") || id.contains("cemetery") || id.contains("golf") || id.contains("pitch") { fill.fillColor = NSExpression(forConstantValue: park) } else if id.contains("building") { fill.fillColor = NSExpression(forConstantValue: building) } else if id.contains("residential") || id.contains("landuse") || id.contains("sand") || id.contains("aeroway") { fill.fillColor = NSExpression(forConstantValue: paper2) } } else if let line = layer as? MLNLineStyleLayer { if id.contains("motorway") || id.contains("trunk") { line.lineColor = NSExpression(forConstantValue: id.contains("casing") ? Ka.uiGreen : Ka.uiLime) } else if id.contains("water") || id.contains("river") || id.contains("stream") { line.lineColor = NSExpression(forConstantValue: water) } } else if let sym = layer as? MLNSymbolStyleLayer { if sym.text != nil { sym.textColor = NSExpression(forConstantValue: ink) sym.textHaloColor = NSExpression(forConstantValue: paper.withAlphaComponent(0.92)) } } } } private func addBuildings(_ style: MLNStyle) { guard !buildingsAdded else { return } 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" 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) if let firstSymbol = style.layers.first(where: { $0 is MLNSymbolStyleLayer }) { style.insertLayer(layer, below: firstSymbol) } else { style.addLayer(layer) } } // sources + couches (créées une fois, shapes remplacées ensuite) private func ensureRouteLayers(_ style: MLNStyle) { guard style.source(withIdentifier: TrajetMapView.selSourceID) == nil else { return } let altSource = MLNShapeSource(identifier: TrajetMapView.altSourceID, shape: MLNShapeCollectionFeature(shapes: []), options: nil) let selSource = MLNShapeSource(identifier: TrajetMapView.selSourceID, shape: MLNShapeCollectionFeature(shapes: []), options: nil) style.addSource(altSource) style.addSource(selSource) let alt = MLNLineStyleLayer(identifier: "ka-route-alt-line", source: altSource) alt.lineColor = NSExpression(forConstantValue: Ka.uiAlt) alt.lineWidth = NSExpression(forConstantValue: 5) alt.lineOpacity = NSExpression(forConstantValue: 0.65) alt.lineCap = NSExpression(forConstantValue: "round") alt.lineJoin = NSExpression(forConstantValue: "round") let casing = MLNLineStyleLayer(identifier: "ka-route-sel-casing", source: selSource) casing.lineColor = NSExpression(forConstantValue: UIColor.white) casing.lineWidth = NSExpression(forConstantValue: 9) casing.lineCap = NSExpression(forConstantValue: "round") casing.lineJoin = NSExpression(forConstantValue: "round") let sel = MLNLineStyleLayer(identifier: "ka-route-sel-line", source: selSource) sel.lineColor = NSExpression(forConstantValue: Ka.uiGreen) sel.lineWidth = NSExpression(forConstantValue: 6) sel.lineCap = NSExpression(forConstantValue: "round") sel.lineJoin = NSExpression(forConstantValue: "round") // au-dessus des routes mais sous les étiquettes if let firstSymbol = style.layers.first(where: { $0 is MLNSymbolStyleLayer }) { style.insertLayer(alt, below: firstSymbol) style.insertLayer(casing, above: alt) style.insertLayer(sel, above: casing) } else { style.addLayer(alt); style.addLayer(casing); style.addLayer(sel) } } func syncRoutes(on map: MLNMapView) { guard styleReady, let style = map.style else { return } ensureRouteLayers(style) guard let altSource = style.source(withIdentifier: TrajetMapView.altSourceID) as? MLNShapeSource, let selSource = style.source(withIdentifier: TrajetMapView.selSourceID) as? MLNShapeSource else { return } func line(_ r: Route) -> MLNPolylineFeature { var coords = r.coordinates return MLNPolylineFeature(coordinates: &coords, count: UInt(coords.count)) } let routes = parent.routes let sel = parent.routeIndex let altShapes = routes.enumerated().filter { $0.offset != sel }.map { line($0.element) } let selShapes = routes.indices.contains(sel) ? [line(routes[sel])] : [] altSource.shape = MLNShapeCollectionFeature(shapes: altShapes) selSource.shape = MLNShapeCollectionFeature(shapes: selShapes) } // ---- marqueurs : épinglette de destination + POI des couches ---- func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? { if annotation is DestinationAnnotation { let view = MLNAnnotationView(reuseIdentifier: nil) let host = UIHostingController(rootView: DestinationPin()) host.view.backgroundColor = .clear let size = host.sizeThatFits(in: CGSize(width: 60, height: 60)) host.view.frame = CGRect(origin: .zero, size: size) view.frame = host.view.frame // ancrer la pointe de l'épinglette sur la coordonnée view.centerOffset = CGVector(dx: 0, dy: -size.height / 2) view.addSubview(host.view) return view } if let ann = annotation as? POIAnnotation { let view = MLNAnnotationView(reuseIdentifier: nil) let host = UIHostingController(rootView: POIMarker(poi: ann.poi, selected: parent.selectedPOIID == ann.poi.id)) host.view.backgroundColor = .clear let size = host.sizeThatFits(in: CGSize(width: 170, height: 44)) 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(poiTapped(_:))) view.addGestureRecognizer(tap) view.accessibilityLabel = ann.poi.name objc_setAssociatedObject(view, &AssocKeys.poi, ann.poi, .OBJC_ASSOCIATION_RETAIN) return view } return nil } @objc private func poiTapped(_ g: UITapGestureRecognizer) { guard let v = g.view, let poi = objc_getAssociatedObject(v, &AssocKeys.poi) as? POIItem else { return } parent.onSelectPOI(poi) } // ---- région visible → modèle (rechargement des couches) ---- 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 poi: UInt8 = 0 } /// Marqueur d'une couche POI : pastille icône (stations, commerces) ou /// pilule de prix (annonces Lou-Ka / Immo-Ka), aux couleurs Ka. struct POIMarker: View { let poi: POIItem var selected = false // accents des univers Ka (ecosystem.json) private static let restoOrange = Color(hex: "#f08c00") private static let sortiRose = Color(hex: "#d6336c") private static let jobTeal = Color(hex: "#0c8599") private static let autoOrange = Color(hex: "#ff5a2a") private static let fabriBrun = Color(hex: "#c4532e") var body: some View { switch poi.kind { case .stations: // prix de l'essence ordinaire en pilule (source Régie de l'énergie) if poi.priceLabel != nil { pill(bg: Ka.inkLight, fg: Ka.lime) } else { badge(bg: Ka.inkLight, fg: Ka.lime) } case .commerces: badge(bg: Ka.green, fg: .white) case .louka: pill(bg: Ka.lime, fg: Ka.inkLight) case .immoka: pill(bg: Ka.green, fg: Ka.lime) case .restoka: if poi.priceLabel != nil { pill(bg: Self.restoOrange, fg: .white) } else { badge(bg: Self.restoOrange, fg: .white) } case .sortika: if poi.priceLabel != nil { pill(bg: Self.sortiRose, fg: .white) } else { badge(bg: Self.sortiRose, fg: .white) } case .jobka: if poi.priceLabel != nil { pill(bg: Self.jobTeal, fg: .white) } else { badge(bg: Self.jobTeal, fg: .white) } case .autoka: if poi.priceLabel != nil { pill(bg: Self.autoOrange, fg: .white) } else { badge(bg: Self.autoOrange, fg: .white) } case .fabrika: badge(bg: Self.fabriBrun, fg: .white) } } private func badge(bg: Color, fg: Color) -> some View { ZStack { Circle().fill(bg) .frame(width: selected ? 36 : 30, height: selected ? 36 : 30) .shadow(color: .black.opacity(0.3), radius: 3, y: 1) Image(systemName: poi.kind.icon) .font(.system(size: selected ? 15 : 13, weight: .bold)) .foregroundStyle(fg) } .overlay(Circle().strokeBorder(selected ? Ka.lime : .white, lineWidth: selected ? 2.5 : 1.5)) .padding(3) } private func pill(bg: Color, fg: Color) -> some View { HStack(spacing: 4) { Image(systemName: poi.kind.icon) .font(.system(size: selected ? 12 : 10, weight: .bold)) Text(poi.priceLabel ?? poi.name) .font(.system(size: selected ? 14 : 12, weight: .bold, design: .rounded)) .lineLimit(1) } .padding(.horizontal, selected ? 11 : 9) .padding(.vertical, selected ? 7 : 5) .background(bg, in: Capsule()) .overlay(Capsule().strokeBorder(selected ? Ka.lime : .white, lineWidth: selected ? 2.5 : 1.5)) .foregroundStyle(fg) .shadow(color: .black.opacity(selected ? 0.45 : 0.3), radius: selected ? 5 : 3, y: 1) .padding(2) } } /// Épinglette de destination aux couleurs Ka. struct DestinationPin: View { var body: some View { VStack(spacing: -2) { ZStack { Circle().fill(Ka.green) .frame(width: 34, height: 34) .shadow(color: .black.opacity(0.35), radius: 4, y: 2) Image(systemName: "mappin") .font(.system(size: 16, weight: .bold)) .foregroundStyle(Ka.lime) } Triangle() .fill(Ka.green) .frame(width: 12, height: 10) } .padding(.bottom, 2) } } struct Triangle: Shape { func path(in r: CGRect) -> Path { var p = Path() p.move(to: .init(x: r.midX, y: r.maxY)) p.addLine(to: .init(x: r.minX, y: r.minY)) p.addLine(to: .init(x: r.maxX, y: r.minY)) p.closeSubpath() return p } }