Swift 100%
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