SPB Git

spb/ka-ios

Public

v3.0.0 (9) — MAJOR : rendu légendaire + Ka Trajet intégré — dock flottant signature (capsule encre/lime, matchedGeometry, KA Agent intégré, s'efface en conduite), accueil héros éditorial (kicker mono, salutation surlignée lime animée, ticker encre marquee du pouls live, compteurs à chiffres roulants, kaScrollPop, aurora), onboarding 4 piliers + aurora, et FUSION de l'app Ka Trajet en 6e onglet (GPS complet : Mapbox recherche/itinéraires/conduite 3D, 9 couches POI, cartographie signature Ka) sous Features/Trajet — 12 tests verts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 25, 2026) parent f06ddb5 Browse files

13 changed files +2,283 −50

modified KA/App/KAApp.swift +43 −36
@@ -1,6 +1,7 @@
1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 // KAApp.swift — point d'entrée : onboarding animé au premier lancement, puis 2 // KAApp.swift — point d'entrée : onboarding animé au premier lancement, puis
3−// la super-app (5 onglets + bulle KA Agent flottante partout). 3+// la super-app v3 : 6 onglets (dont Ka Trajet, le GPS maison) servis par le
4+// dock flottant signature (encre + lime) avec KA Agent intégré.
4 import SwiftUI 5 import SwiftUI
5 6
6 @main 7 @main
@@ -31,56 +32,62 @@ struct KAApp: App {
31 } 32 }
32 } 33 }
33 34
34−// MARK: - Racine : 5 onglets + bulle Agent 35+// MARK: - Racine : 6 onglets servis par le dock flottant signature
35 36
36 struct RootView: View { 37 struct RootView: View {
37 @State private var showAgent = false 38 @State private var showAgent = false
38 // onglet initial pilotable en debug — JAMAIS pendant les tests (les threads 39 // onglet initial pilotable en debug — JAMAIS pendant les tests (les threads
39 // MapLibre de la carte feraient une course avec le exit() de XCTest) 40 // MapLibre de la carte feraient une course avec le exit() de XCTest)
40− @State private var tab = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil 41+ @State private var tab: Int
41− ? 0 : UserDefaults.standard.integer(forKey: "ka.debug.tab") 42+ // onglets déjà visités : montés une fois, gardés vivants (état préservé,
43+ // cartes MapLibre non réinitialisées) — les autres restent paresseux
44+ @State private var loaded: Set<Int>
42 @StateObject private var recents = RecentsStore.shared 45 @StateObject private var recents = RecentsStore.shared
46+ @StateObject private var trajet = TrajetModel()
43 47
44− var body: some View { 48+ init() {
45− ZStack(alignment: .bottomTrailing) { 49+ let initial = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
46− TabView(selection: $tab) { 50+ ? 0 : UserDefaults.standard.integer(forKey: "ka.debug.tab")
47− HomeView() 51+ _tab = State(initialValue: initial)
48− .tabItem { Label("Accueil", systemImage: "house.fill") }.tag(0) 52+ _loaded = State(initialValue: [initial])
49− SearchView() 53+ }
50− .tabItem { Label("Recherche", systemImage: "magnifyingglass") }.tag(1)
51− UniversesView()
52− .tabItem { Label("Explorer", systemImage: "circle.grid.3x3.fill") }.tag(2)
53− MapTab()
54− .tabItem { Label("Carte", systemImage: "map.fill") }.tag(3)
55− MoreTab()
56− .tabItem { Label("Profil", systemImage: "person.crop.circle") }.tag(4)
57− .badge(recents.alertCount)
58− }
59− .tint(KATheme.green)
60− .task { await recents.refreshAlerts() }
61 54
62− // Bulle KA Agent — flottante au-dessus de la tab bar, partout 55+ var body: some View {
63− Button { 56+ ZStack {
64− Haptics.rigid() 57+ pane(0) { HomeView() }
65− showAgent = true 58+ pane(1) { SearchView() }
66− } label: { 59+ pane(2) { UniversesView() }
67− Text("Ka") 60+ pane(3) { MapTab() }
68− .font(.system(size: 19, weight: .bold, design: .rounded)) 61+ pane(4) { TrajetView().environmentObject(trajet) }
69− .frame(width: 54, height: 54) 62+ pane(5) { MoreTab() }
70− .background(KATheme.lime, in: Circle()) 63+ }
71− .foregroundStyle(KATheme.inkLight) 64+ .tint(KATheme.green)
72− .overlay(Circle().strokeBorder(KATheme.inkLight, lineWidth: 2)) 65+ .safeAreaInset(edge: .bottom) {
73− .shadow(color: .black.opacity(0.25), radius: 1, x: 3, y: 3) 66+ // le dock s'efface en mode conduite (Trajet plein écran)
67+ if !trajet.navigating {
68+ KADock(tab: $tab, alertCount: recents.alertCount) { showAgent = true }
69+ .padding(.bottom, 4)
70+ .transition(.move(edge: .bottom).combined(with: .opacity))
74 } 71 }
75− .padding(.trailing, 16)
76− .padding(.bottom, 64)
77− .accessibilityLabel("Ouvrir KA Agent, l'assistant de l'écosystème")
78 } 72 }
73+ .animation(.spring(response: 0.4, dampingFraction: 0.8), value: trajet.navigating)
74+ .onChange(of: tab) { _, t in loaded.insert(t) }
75+ .task { await recents.refreshAlerts() }
79 .sheet(isPresented: $showAgent) { 76 .sheet(isPresented: $showAgent) {
80 AgentChatView() 77 AgentChatView()
81 .presentationDetents([.large]) 78 .presentationDetents([.large])
82 } 79 }
83 } 80 }
81+
82+ @ViewBuilder
83+ private func pane(_ i: Int, @ViewBuilder content: () -> some View) -> some View {
84+ if loaded.contains(i) {
85+ content()
86+ .opacity(tab == i ? 1 : 0)
87+ .allowsHitTesting(tab == i)
88+ .accessibilityHidden(tab != i)
89+ }
90+ }
84 } 91 }
85 92
86 /// Onglet Carte : la carte unifiée directement (pas de fermeture — c'est un onglet) 93 /// Onglet Carte : la carte unifiée directement (pas de fermeture — c'est un onglet)
modified KA/App/OnboardingView.swift +8 −1
@@ -26,7 +26,13 @@ struct OnboardingView: View {
26 actions 26 actions
27 } 27 }
28 .padding(24) 28 .padding(24)
29− .background(KATheme.paper(scheme)) 29+ .background {
30+ ZStack {
31+ KATheme.paper(scheme)
32+ KAAurora()
33+ }
34+ .ignoresSafeArea()
35+ }
30 .onAppear { 36 .onAppear {
31 guard !reduceMotion else { logoRotation = -4; logoScale = 1; glow = true; return } 37 guard !reduceMotion else { logoRotation = -4; logoScale = 1; glow = true; return }
32 withAnimation(.spring(response: 0.9, dampingFraction: 0.55).delay(0.15)) { 38 withAnimation(.spring(response: 0.9, dampingFraction: 0.55).delay(0.15)) {
@@ -62,6 +68,7 @@ struct OnboardingView: View {
62 pitchRow("magnifyingglass", "Une recherche, tout le Québec", "Logements, propriétés, autos, emplois, restos, sorties — interrogés d'un coup.") 68 pitchRow("magnifyingglass", "Une recherche, tout le Québec", "Logements, propriétés, autos, emplois, restos, sorties — interrogés d'un coup.")
63 pitchRow("heart.fill", "Des favoris qui traversent les univers", "Un 4½, une auto et un resto dans une même collection.") 69 pitchRow("heart.fill", "Des favoris qui traversent les univers", "Un 4½, une auto et un resto dans une même collection.")
64 pitchRow("sparkles", "KA Agent, votre IA d'ici", "Posez une question, il fouille les vraies données de l'écosystème.") 70 pitchRow("sparkles", "KA Agent, votre IA d'ici", "Posez une question, il fouille les vraies données de l'écosystème.")
71+ pitchRow("location.north.fill", "Ka Trajet, le GPS maison", "Itinéraires, conduite 3D et les adresses de l'écosystème sur la carte.")
65 } 72 }
66 .padding(.bottom, 12) 73 .padding(.bottom, 12)
67 } 74 }
added KA/Design/KAMotion.swift +253 −0
@@ -0,0 +1,253 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// KAMotion.swift — le langage de mouvement v3 « légendaire » de la super-app :
3+// dock flottant signature (encre + lime, ombre dure), ticker encre en marquee
4+// (comme les héros des sites), compteurs à chiffres roulants, fond aurora
5+// discret, transitions de défilement. Reduce Motion respecté partout.
6+import SwiftUI
7+
8+// MARK: - Dock flottant (remplace la tab bar système)
9+
10+struct KADock: View {
11+ @Binding var tab: Int
12+ var alertCount: Int
13+ var onAgent: () -> Void
14+ @Namespace private var ns
15+ @Environment(\.colorScheme) private var scheme
16+
17+ static let items: [(icon: String, label: String)] = [
18+ ("house.fill", "Accueil"),
19+ ("magnifyingglass", "Recherche"),
20+ ("circle.grid.3x3.fill", "Explorer"),
21+ ("map.fill", "Carte"),
22+ ("location.north.fill", "Trajet"),
23+ ("person.crop.circle.fill", "Profil"),
24+ ]
25+
26+ var body: some View {
27+ HStack(spacing: 2) {
28+ ForEach(Array(Self.items.enumerated()), id: \.offset) { i, item in
29+ Button {
30+ guard tab != i else { return }
31+ Haptics.tap()
32+ withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { tab = i }
33+ } label: {
34+ ZStack(alignment: .topTrailing) {
35+ Image(systemName: item.icon)
36+ .font(.system(size: 17, weight: .semibold))
37+ .foregroundStyle(tab == i ? KATheme.inkLight : KATheme.inkDark.opacity(0.72))
38+ .frame(width: 43, height: 43)
39+ .background {
40+ if tab == i {
41+ Circle().fill(KATheme.lime)
42+ .matchedGeometryEffect(id: "ka.dock.active", in: ns)
43+ }
44+ }
45+ if i == Self.items.count - 1 && alertCount > 0 {
46+ Circle().fill(.red).frame(width: 8, height: 8)
47+ .offset(x: -5, y: 5)
48+ .accessibilityHidden(true)
49+ }
50+ }
51+ }
52+ .accessibilityLabel(item.label + (i == Self.items.count - 1 && alertCount > 0 ? ", \(alertCount) alertes" : ""))
53+ .accessibilityAddTraits(tab == i ? [.isSelected] : [])
54+ }
55+ Rectangle().fill(KATheme.inkDark.opacity(0.25))
56+ .frame(width: 1, height: 24)
57+ .padding(.horizontal, 4)
58+ .accessibilityHidden(true)
59+ Button {
60+ Haptics.rigid()
61+ onAgent()
62+ } label: {
63+ Text("Ka")
64+ .font(KAFont.display(15))
65+ .foregroundStyle(KATheme.lime)
66+ .frame(width: 43, height: 43)
67+ .background(Circle().strokeBorder(KATheme.lime, lineWidth: 1.5))
68+ }
69+ .accessibilityLabel("Ouvrir KA Agent, l'assistant de l'écosystème")
70+ }
71+ .padding(6)
72+ .background(KATheme.inkLight, in: Capsule())
73+ .overlay(Capsule().strokeBorder(.white.opacity(0.10), lineWidth: 1))
74+ // signature Ka : ombre décalée DURE, lime en clair, feutrée en sombre
75+ .background(
76+ Capsule()
77+ .fill(scheme == .dark ? KATheme.lime.opacity(0.30) : KATheme.lime)
78+ .offset(x: 5, y: 5)
79+ )
80+ .shadow(color: .black.opacity(0.22), radius: 14, y: 8)
81+ }
82+}
83+
84+// MARK: - Ticker encre (marquee des héros de sites : mono lime qui défile)
85+
86+struct KATicker: View {
87+ let items: [String]
88+ @State private var textWidth: CGFloat = 0
89+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
90+
91+ private var line: String {
92+ let parts = items.isEmpty ? ["GROUPE KA — LE QUÉBEC EN DIRECT"] : items
93+ return parts.map { $0.uppercased() }.joined(separator: " ✦ ") + " ✦ "
94+ }
95+
96+ var body: some View {
97+ Group {
98+ if reduceMotion {
99+ Text(line)
100+ .font(KAFont.mono(11))
101+ .lineLimit(1)
102+ .padding(.horizontal, 10)
103+ .frame(maxWidth: .infinity, alignment: .leading)
104+ } else {
105+ // le marquee vit en OVERLAY d'un espace fixe : sa largeur réelle
106+ // (des milliers de points) ne se propage jamais à la mise en page
107+ Color.clear
108+ .frame(height: 15)
109+ .overlay(alignment: .leading) {
110+ TimelineView(.animation(minimumInterval: 1.0 / 30)) { tl in
111+ let t = tl.date.timeIntervalSinceReferenceDate
112+ let x = textWidth > 0
113+ ? -CGFloat((t * 42).truncatingRemainder(dividingBy: textWidth))
114+ : 0
115+ HStack(spacing: 0) {
116+ tickerText
117+ .background(GeometryReader { g in
118+ Color.clear
119+ .onAppear { textWidth = g.size.width }
120+ .onChange(of: g.size.width) { _, w in textWidth = w }
121+ })
122+ tickerText
123+ }
124+ .fixedSize()
125+ .offset(x: x)
126+ }
127+ }
128+ .clipped()
129+ }
130+ }
131+ .padding(.vertical, 9)
132+ .foregroundStyle(KATheme.lime)
133+ .background(KATheme.inkLight)
134+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
135+ .background(
136+ RoundedRectangle(cornerRadius: 8, style: .continuous)
137+ .fill(KATheme.green.opacity(0.9))
138+ .offset(x: 4, y: 4)
139+ )
140+ .accessibilityElement(children: .ignore)
141+ .accessibilityLabel("Le pouls de l'écosystème : " + items.joined(separator: ", "))
142+ }
143+
144+ private var tickerText: some View {
145+ Text(line)
146+ .font(KAFont.mono(11))
147+ .fixedSize()
148+ .padding(.leading, 2)
149+ }
150+}
151+
152+// MARK: - Compteur à chiffres roulants (pouls, stats héros)
153+
154+struct KACountUp: View {
155+ let value: Int
156+ var font: Font = .system(.headline, design: .rounded).weight(.bold)
157+ var color: Color = .primary
158+ @State private var shown = 0
159+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
160+
161+ var body: some View {
162+ Text(shown.formatted(.number.locale(Locale(identifier: "fr_CA"))))
163+ .font(font)
164+ .foregroundStyle(color)
165+ .contentTransition(.numericText(value: Double(shown)))
166+ .onAppear {
167+ if reduceMotion { shown = value }
168+ else { withAnimation(.easeOut(duration: 1.1)) { shown = value } }
169+ }
170+ .onChange(of: value) { _, v in
171+ withAnimation(.easeOut(duration: 0.8)) { shown = v }
172+ }
173+ }
174+}
175+
176+// MARK: - Surligneur lime animé (le « titre surligné » des sites)
177+
178+struct KAHighlighted: ViewModifier {
179+ @State private var on = false
180+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
181+
182+ func body(content: Content) -> some View {
183+ content
184+ .padding(.horizontal, 7)
185+ .padding(.vertical, 1)
186+ .background(
187+ RoundedRectangle(cornerRadius: 6, style: .continuous)
188+ .fill(KATheme.lime)
189+ .rotationEffect(.degrees(-1.4))
190+ .scaleEffect(x: on ? 1 : 0.02, anchor: .leading)
191+ )
192+ .foregroundStyle(KATheme.inkLight)
193+ .onAppear {
194+ if reduceMotion { on = true }
195+ else { withAnimation(.spring(response: 0.7, dampingFraction: 0.8).delay(0.25)) { on = true } }
196+ }
197+ }
198+}
199+
200+extension View {
201+ /// Surligne le texte d'un marqueur lime qui se déploie (signature des sites).
202+ func kaHighlighted() -> some View { modifier(KAHighlighted()) }
203+
204+ /// Pop de défilement : les cartes entrent/sortent avec échelle + fondu.
205+ func kaScrollPop(axis: Axis = .horizontal) -> some View {
206+ scrollTransition(.interactive, axis: axis) { content, phase in
207+ content
208+ .scaleEffect(phase.isIdentity ? 1 : 0.93)
209+ .opacity(phase.isIdentity ? 1 : 0.55)
210+ }
211+ }
212+}
213+
214+// MARK: - Aurora — deux lueurs lime/vert qui dérivent, très discrètes
215+
216+struct KAAurora: View {
217+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
218+ @Environment(\.colorScheme) private var scheme
219+
220+ var body: some View {
221+ GeometryReader { geo in
222+ let w = geo.size.width, h = geo.size.height
223+ let strength: Double = scheme == .dark ? 0.14 : 0.20
224+ if reduceMotion {
225+ blobs(w: w, h: h, t: 0, strength: strength)
226+ } else {
227+ TimelineView(.animation(minimumInterval: 1.0 / 20)) { tl in
228+ let t = tl.date.timeIntervalSinceReferenceDate / 14
229+ blobs(w: w, h: h, t: t, strength: strength)
230+ }
231+ }
232+ }
233+ .allowsHitTesting(false)
234+ .accessibilityHidden(true)
235+ }
236+
237+ private func blobs(w: CGFloat, h: CGFloat, t: Double, strength: Double) -> some View {
238+ ZStack {
239+ Circle()
240+ .fill(KATheme.lime.opacity(strength))
241+ .frame(width: w * 0.9)
242+ .blur(radius: 70)
243+ .position(x: w * (0.75 + 0.18 * CGFloat(sin(t))),
244+ y: h * (0.10 + 0.08 * CGFloat(cos(t * 1.3))))
245+ Circle()
246+ .fill(KATheme.green.opacity(strength * 0.7))
247+ .frame(width: w * 0.8)
248+ .blur(radius: 80)
249+ .position(x: w * (0.12 + 0.15 * CGFloat(cos(t * 0.8))),
250+ y: h * (0.45 + 0.10 * CGFloat(sin(t * 1.1))))
251+ }
252+ }
253+}
modified KA/Features/HomeView.swift +40 −11
@@ -1,7 +1,8 @@
1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2−// HomeView.swift — l'accueil VIVANT : salutation selon l'heure, suggestions 2+// HomeView.swift — l'accueil LÉGENDAIRE : héros éditorial (kicker mono,
3−// contextuelles (restos le soir, sorties le week-end, emplois le matin…) avec 3+// salutation surlignée lime, ticker encre en marquee du pouls live), pouls à
4−// du VRAI contenu en direct, et le pouls de l'écosystème (compteurs live). 4+// chiffres roulants, suggestions contextuelles avec du VRAI contenu en direct,
5+// cartes qui « poppent » au défilement, fond aurora discret.
5 import SwiftUI 6 import SwiftUI
6 7
7 struct HomeView: View { 8 struct HomeView: View {
@@ -19,6 +20,7 @@ struct HomeView: View {
19 ScrollView { 20 ScrollView {
20 VStack(alignment: .leading, spacing: 22) { 21 VStack(alignment: .leading, spacing: 22) {
21 header 22 header
23+ tickerStrip
22 pulseStrip 24 pulseStrip
23 resumeStrip 25 resumeStrip
24 ForEach(featured, id: \.universe.id) { section in 26 ForEach(featured, id: \.universe.id) { section in
@@ -33,7 +35,13 @@ struct HomeView: View {
33 } 35 }
34 .padding(16) 36 .padding(16)
35 } 37 }
36− .background(KATheme.paper(scheme)) 38+ .background {
39+ ZStack {
40+ KATheme.paper(scheme)
41+ KAAurora()
42+ }
43+ .ignoresSafeArea()
44+ }
37 .navigationTitle("") 45 .navigationTitle("")
38 .toolbar { 46 .toolbar {
39 ToolbarItem(placement: .topBarLeading) { 47 ToolbarItem(placement: .topBarLeading) {
@@ -63,33 +71,52 @@ struct HomeView: View {
63 } 71 }
64 72
65 private var header: some View { 73 private var header: some View {
66− VStack(alignment: .leading, spacing: 5) { 74+ VStack(alignment: .leading, spacing: 8) {
67− Text(greeting.title).font(KAFont.display(32)) 75+ // kicker mono — la micro-étiquette signature des sites
76+ HStack(spacing: 6) {
77+ Rectangle().fill(KATheme.lime).frame(width: 8, height: 8)
78+ Text("GROUPE KA — L'ÉCOSYSTÈME QUÉBÉCOIS")
79+ .font(KAFont.mono(10))
80+ .foregroundStyle(KATheme.ink2(scheme))
81+ }
82+ .accessibilityHidden(true)
83+ Text(greeting.title)
84+ .font(KAFont.display(34))
85+ .kaHighlighted()
68 Text(greeting.sub).font(.subheadline).foregroundStyle(KATheme.ink2(scheme)) 86 Text(greeting.sub).font(.subheadline).foregroundStyle(KATheme.ink2(scheme))
69 } 87 }
70 } 88 }
71 89
90+ /// Ticker encre : le pouls live de l'écosystème qui défile, comme les héros des sites
91+ private var tickerStrip: some View {
92+ KATicker(items: pulse.prefix(8).map {
93+ "\($0.1.formatted(.number.locale(Locale(identifier: "fr_CA")))) \($0.0.unit)"
94+ })
95+ }
96+
72 private var pulseStrip: some View { 97 private var pulseStrip: some View {
73 ScrollView(.horizontal, showsIndicators: false) { 98 ScrollView(.horizontal, showsIndicators: false) {
74 HStack(spacing: 10) { 99 HStack(spacing: 10) {
75 ForEach(pulse, id: \.0.id) { (u, n) in 100 ForEach(pulse, id: \.0.id) { (u, n) in
76 NavigationLink(value: u.id) { 101 NavigationLink(value: u.id) {
77 VStack(alignment: .leading, spacing: 3) { 102 VStack(alignment: .leading, spacing: 3) {
78− Text(n.formatted(.number.locale(Locale(identifier: "fr_CA")))) 103+ KACountUp(value: n,
79− .font(.system(.headline, design: .rounded).weight(.bold)) 104+ font: .system(.headline, design: .rounded).weight(.bold),
80− .foregroundStyle(u.accent) 105+ color: u.accent)
81− .contentTransition(.numericText()) 106+ Text(u.unit).font(KAFont.mono(9)).textCase(.uppercase)
82− Text(u.unit).font(.caption2).foregroundStyle(.secondary) 107+ .foregroundStyle(.secondary)
83 } 108 }
84 .padding(.horizontal, 13).padding(.vertical, 9) 109 .padding(.horizontal, 13).padding(.vertical, 9)
85 .kaCard(accent: u.accent) 110 .kaCard(accent: u.accent)
86 } 111 }
87 .buttonStyle(.plain) 112 .buttonStyle(.plain)
113+ .kaScrollPop()
88 } 114 }
89 if pulse.isEmpty { 115 if pulse.isEmpty {
90 Text("Le pouls de l'écosystème…").font(.caption).foregroundStyle(.tertiary).padding(10) 116 Text("Le pouls de l'écosystème…").font(.caption).foregroundStyle(.tertiary).padding(10)
91 } 117 }
92 } 118 }
119+ .padding(.vertical, 4)
93 } 120 }
94 .accessibilityLabel("Le pouls de l'écosystème en direct") 121 .accessibilityLabel("Le pouls de l'écosystème en direct")
95 } 122 }
@@ -134,6 +161,7 @@ struct HomeView: View {
134 .kaCard(accent: u.accent) 161 .kaCard(accent: u.accent)
135 } 162 }
136 .buttonStyle(KAPressStyle()) 163 .buttonStyle(KAPressStyle())
164+ .kaScrollPop()
137 } 165 }
138 } 166 }
139 .scrollTargetLayout() 167 .scrollTargetLayout()
@@ -232,6 +260,7 @@ struct HomeView: View {
232 .kaCard(accent: u.accent) 260 .kaCard(accent: u.accent)
233 } 261 }
234 .buttonStyle(KAPressStyle()) 262 .buttonStyle(KAPressStyle())
263+ .kaScrollPop()
235 } 264 }
236 } 265 }
237 .scrollTargetLayout() 266 .scrollTargetLayout()
added KA/Features/Trajet/KaTrajetTheme.swift +25 −0
@@ -0,0 +1,25 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// KaTrajetTheme.swift — alias `Ka` de la palette pour le module Trajet
3+// (code hérité de l'app Ka Trajet, fusionnée dans la super-app KA v3).
4+// Color(hex:) vient d'Ecosystem.swift ; KATheme reste la source de vérité.
5+import SwiftUI
6+import UIKit
7+
8+enum Ka {
9+ static let paperLight = Color(hex: "#f5f3ee")
10+ static let paperDark = Color(hex: "#101410")
11+ static let inkLight = Color(hex: "#141814")
12+ static let inkDark = Color(hex: "#f0efe8")
13+ static let lime = Color(hex: "#d9f26b")
14+ static let green = Color(hex: "#1c5c41")
15+
16+ static func paper(_ s: ColorScheme) -> Color { s == .dark ? paperDark : paperLight }
17+ static func ink(_ s: ColorScheme) -> Color { s == .dark ? inkDark : inkLight }
18+ static func surface(_ s: ColorScheme) -> Color { s == .dark ? Color(hex: "#1a1f1a") : .white }
19+ static func ink2(_ s: ColorScheme) -> Color { s == .dark ? Color(hex: "#a9b0a9") : Color(hex: "#4d5551") }
20+
21+ // couleurs UIKit pour les couches de la carte (tracés d'itinéraire)
22+ static let uiGreen = UIColor(red: 0x1c / 255, green: 0x5c / 255, blue: 0x41 / 255, alpha: 1)
23+ static let uiLime = UIColor(red: 0xd9 / 255, green: 0xf2 / 255, blue: 0x6b / 255, alpha: 1)
24+ static let uiAlt = UIColor(red: 0.55, green: 0.58, blue: 0.56, alpha: 1)
25+}
added KA/Features/Trajet/LocationManager.swift +49 −0
@@ -0,0 +1,49 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// LocationManager.swift — position de l'utilisateur (demande « pendant
3+// l'utilisation », mises à jour continues pour le point bleu et l'origine
4+// des itinéraires).
5+import Foundation
6+import CoreLocation
7+
8+@MainActor
9+final class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
10+ @Published var location: CLLocationCoordinate2D?
11+ @Published var speedKmh: Double? // vitesse GPS en km/h (nil si inconnue)
12+ @Published var authorized = false
13+
14+ private let manager = CLLocationManager()
15+
16+ override init() {
17+ super.init()
18+ manager.delegate = self
19+ manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
20+ }
21+
22+ func request() {
23+ switch manager.authorizationStatus {
24+ case .notDetermined: manager.requestWhenInUseAuthorization()
25+ case .authorizedWhenInUse, .authorizedAlways: manager.startUpdatingLocation()
26+ default: break
27+ }
28+ }
29+
30+ nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
31+ let status = manager.authorizationStatus
32+ Task { @MainActor in
33+ self.authorized = (status == .authorizedWhenInUse || status == .authorizedAlways)
34+ if self.authorized { self.manager.startUpdatingLocation() }
35+ }
36+ }
37+
38+ nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
39+ guard let loc = locations.last else { return }
40+ let c = loc.coordinate
41+ let kmh = loc.speed >= 0 ? loc.speed * 3.6 : nil
42+ Task { @MainActor in
43+ self.location = c
44+ self.speedKmh = kmh
45+ }
46+ }
47+
48+ nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {}
49+}
added KA/Features/Trajet/MapboxAPI.swift +239 −0
@@ -0,0 +1,239 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// MapboxAPI.swift — les services Mapbox de Ka Trajet : Geocoding (recherche
3+// de lieux, autocomplétion, géocodage inverse) et Directions (itinéraires
4+// auto/vélo/marche avec alternatives et étapes turn-by-turn).
5+// Jeton PUBLIC Mapbox (pk.…) partagé avec Lou-Ka (kamaps/config.ts) — conçu
6+// pour être exposé côté client ; restrictions gérées au tableau de bord Mapbox.
7+import Foundation
8+import CoreLocation
9+
10+enum MapboxConfig {
11+ static let token = "pk.eyJ1Ijoic3Bib3VjaGVyIiwiYSI6ImNtc3Fyb3k4djAwOTgyenB3dWt6NHBjc2kifQ.poqLf0ADy3lIh28O-pFI2Q"
12+}
13+
14+// MARK: - Modèles
15+
16+struct Place: Identifiable, Equatable {
17+ let id: String
18+ let name: String // ex. « Château Frontenac »
19+ let address: String // ex. « 1 rue des Carrières, Québec »
20+ let coordinate: CLLocationCoordinate2D
21+
22+ static func == (l: Place, r: Place) -> Bool { l.id == r.id }
23+}
24+
25+enum TransportMode: String, CaseIterable, Identifiable {
26+ case auto, velo, marche
27+ var id: String { rawValue }
28+ var profile: String {
29+ switch self {
30+ case .auto: return "driving-traffic"
31+ case .velo: return "cycling"
32+ case .marche: return "walking"
33+ }
34+ }
35+ var icon: String {
36+ switch self {
37+ case .auto: return "car.fill"
38+ case .velo: return "bicycle"
39+ case .marche: return "figure.walk"
40+ }
41+ }
42+ var label: String {
43+ switch self {
44+ case .auto: return "Auto"
45+ case .velo: return "Vélo"
46+ case .marche: return "Marche"
47+ }
48+ }
49+}
50+
51+struct RouteStep: Identifiable {
52+ let id = UUID()
53+ let instruction: String
54+ let distance: Double // mètres
55+ let icon: String // symbole SF selon la manœuvre
56+ let coordinate: CLLocationCoordinate2D // où se fait la manœuvre
57+}
58+
59+struct Route: Identifiable {
60+ let id = UUID()
61+ let coordinates: [CLLocationCoordinate2D]
62+ let distance: Double // mètres
63+ let duration: Double // secondes
64+ let steps: [RouteStep]
65+
66+ var durationText: String {
67+ let m = Int((duration / 60).rounded())
68+ return m >= 60 ? "\(m / 60) h \(m % 60 == 0 ? "" : "\(m % 60) min")".trimmingCharacters(in: .whitespaces)
69+ : "\(max(m, 1)) min"
70+ }
71+ var distanceText: String { Route.format(meters: distance) }
72+
73+ static func format(meters: Double) -> String {
74+ meters >= 1000 ? String(format: "%.1f km", meters / 1000) : "\(Int(meters.rounded())) m"
75+ }
76+}
77+
78+// MARK: - Client HTTP
79+
80+enum MapboxAPI {
81+ // Recherche de lieux (autocomplétion), en français, biaisée vers la
82+ // position de l'utilisateur quand elle est connue.
83+ static func search(_ query: String, near: CLLocationCoordinate2D?) async throws -> [Place] {
84+ let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
85+ guard !q.isEmpty else { return [] }
86+ var comps = URLComponents(string: "https://api.mapbox.com/geocoding/v5/mapbox.places/\(q.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? q).json")!
87+ var items = [
88+ URLQueryItem(name: "access_token", value: MapboxConfig.token),
89+ URLQueryItem(name: "language", value: "fr"),
90+ URLQueryItem(name: "limit", value: "7"),
91+ URLQueryItem(name: "autocomplete", value: "true"),
92+ ]
93+ if let near {
94+ items.append(URLQueryItem(name: "proximity", value: "\(near.longitude),\(near.latitude)"))
95+ }
96+ comps.queryItems = items
97+ let (data, _) = try await URLSession.shared.data(from: comps.url!)
98+ let decoded = try JSONDecoder().decode(GeocodeResponse.self, from: data)
99+ return decoded.features.map { f in
100+ Place(id: f.id,
101+ name: f.text ?? f.place_name,
102+ address: f.place_name,
103+ coordinate: .init(latitude: f.center[1], longitude: f.center[0]))
104+ }
105+ }
106+
107+ // Géocodage inverse : nomme un point déposé sur la carte.
108+ static func reverse(_ c: CLLocationCoordinate2D) async -> Place {
109+ let fallback = Place(id: "pin-\(c.latitude)-\(c.longitude)",
110+ name: "Repère sur la carte",
111+ address: String(format: "%.5f, %.5f", c.latitude, c.longitude),
112+ coordinate: c)
113+ var comps = URLComponents(string: "https://api.mapbox.com/geocoding/v5/mapbox.places/\(c.longitude),\(c.latitude).json")!
114+ comps.queryItems = [
115+ URLQueryItem(name: "access_token", value: MapboxConfig.token),
116+ URLQueryItem(name: "language", value: "fr"),
117+ URLQueryItem(name: "limit", value: "1"),
118+ ]
119+ guard let (data, _) = try? await URLSession.shared.data(from: comps.url!),
120+ let decoded = try? JSONDecoder().decode(GeocodeResponse.self, from: data),
121+ let f = decoded.features.first else { return fallback }
122+ return Place(id: fallback.id, name: f.text ?? f.place_name, address: f.place_name, coordinate: c)
123+ }
124+
125+ // Ville au point donné (pour les APIs Ka filtrées par ville).
126+ static func cityName(at c: CLLocationCoordinate2D) async -> String? {
127+ var comps = URLComponents(string: "https://api.mapbox.com/geocoding/v5/mapbox.places/\(c.longitude),\(c.latitude).json")!
128+ comps.queryItems = [
129+ URLQueryItem(name: "access_token", value: MapboxConfig.token),
130+ URLQueryItem(name: "types", value: "place"),
131+ URLQueryItem(name: "language", value: "fr"),
132+ URLQueryItem(name: "limit", value: "1"),
133+ ]
134+ guard let (data, _) = try? await URLSession.shared.data(from: comps.url!),
135+ let decoded = try? JSONDecoder().decode(GeocodeResponse.self, from: data)
136+ else { return nil }
137+ return decoded.features.first?.text
138+ }
139+
140+ // Coordonnées d'une ville québécoise (géocodage direct, pour les sources
141+ // Ka sans lat/lng comme les boutiques Fabri-Ka).
142+ static func geocodeCity(_ name: String) async -> CLLocationCoordinate2D? {
143+ let q = "\(name), Québec, Canada"
144+ var comps = URLComponents(string: "https://api.mapbox.com/geocoding/v5/mapbox.places/\(q.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? q).json")!
145+ comps.queryItems = [
146+ URLQueryItem(name: "access_token", value: MapboxConfig.token),
147+ URLQueryItem(name: "types", value: "place,locality,neighborhood"),
148+ URLQueryItem(name: "language", value: "fr"),
149+ URLQueryItem(name: "limit", value: "1"),
150+ ]
151+ guard let (data, _) = try? await URLSession.shared.data(from: comps.url!),
152+ let decoded = try? JSONDecoder().decode(GeocodeResponse.self, from: data),
153+ let f = decoded.features.first, f.center.count >= 2 else { return nil }
154+ return .init(latitude: f.center[1], longitude: f.center[0])
155+ }
156+
157+ // Itinéraires avec alternatives + étapes, en français.
158+ static func directions(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D,
159+ mode: TransportMode) async throws -> [Route] {
160+ let path = "\(from.longitude),\(from.latitude);\(to.longitude),\(to.latitude)"
161+ var comps = URLComponents(string: "https://api.mapbox.com/directions/v5/mapbox/\(mode.profile)/\(path)")!
162+ comps.queryItems = [
163+ URLQueryItem(name: "access_token", value: MapboxConfig.token),
164+ URLQueryItem(name: "alternatives", value: "true"),
165+ URLQueryItem(name: "geometries", value: "geojson"),
166+ URLQueryItem(name: "overview", value: "full"),
167+ URLQueryItem(name: "steps", value: "true"),
168+ URLQueryItem(name: "language", value: "fr"),
169+ ]
170+ let (data, _) = try await URLSession.shared.data(from: comps.url!)
171+ let decoded = try JSONDecoder().decode(DirectionsResponse.self, from: data)
172+ guard decoded.code == "Ok" else { throw MapboxError.api(decoded.message ?? decoded.code) }
173+ return decoded.routes.map { r in
174+ Route(coordinates: r.geometry.coordinates.map { .init(latitude: $0[1], longitude: $0[0]) },
175+ distance: r.distance,
176+ duration: r.duration,
177+ steps: r.legs.flatMap(\.steps).map { s in
178+ RouteStep(instruction: s.maneuver.instruction,
179+ distance: s.distance,
180+ icon: Self.icon(for: s.maneuver),
181+ coordinate: .init(latitude: s.maneuver.location[1],
182+ longitude: s.maneuver.location[0]))
183+ })
184+ }
185+ }
186+
187+ private static func icon(for m: DirectionsResponse.Maneuver) -> String {
188+ switch m.type {
189+ case "depart": return "location.fill"
190+ case "arrive": return "mappin.circle.fill"
191+ case "roundabout", "rotary": return "arrow.triangle.2.circlepath"
192+ case "merge": return "arrow.triangle.merge"
193+ case "on ramp", "off ramp": return "arrow.up.right"
194+ default:
195+ if let mod = m.modifier {
196+ if mod.contains("left") { return "arrow.turn.up.left" }
197+ if mod.contains("right") { return "arrow.turn.up.right" }
198+ if mod.contains("uturn") { return "arrow.uturn.down" }
199+ }
200+ return "arrow.up"
201+ }
202+ }
203+}
204+
205+enum MapboxError: LocalizedError {
206+ case api(String)
207+ var errorDescription: String? {
208+ if case .api(let m) = self { return "Mapbox : \(m)" }
209+ return nil
210+ }
211+}
212+
213+// MARK: - DTO
214+
215+private struct GeocodeResponse: Decodable {
216+ struct Feature: Decodable {
217+ let id: String
218+ let text: String?
219+ let place_name: String
220+ let center: [Double]
221+ }
222+ let features: [Feature]
223+}
224+
225+private struct DirectionsResponse: Decodable {
226+ struct Geometry: Decodable { let coordinates: [[Double]] }
227+ struct Maneuver: Decodable { let type: String; let modifier: String?; let instruction: String; let location: [Double] }
228+ struct Step: Decodable { let distance: Double; let maneuver: Maneuver }
229+ struct Leg: Decodable { let steps: [Step] }
230+ struct RouteDTO: Decodable {
231+ let geometry: Geometry
232+ let distance: Double
233+ let duration: Double
234+ let legs: [Leg]
235+ }
236+ let code: String
237+ let message: String?
238+ let routes: [RouteDTO]
239+}
added KA/Features/Trajet/POI.swift +361 −0
@@ -0,0 +1,361 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// POI.swift — les couches de la carte Ka Trajet : stations-service et
3+// commerces (Mapbox Search Box par catégorie, jeton public Lou-Ka) +
4+// annonces EN DIRECT de Lou-Ka et Immo-Ka (endpoints /api/listings.geojson
5+// avec bbox réel, les mêmes que la carte unifiée de la super-app KA).
6+import Foundation
7+import CoreLocation
8+import MapKit
9+
10+enum POIKind: String, CaseIterable, Identifiable {
11+ case stations, commerces, louka, immoka, restoka, sortika, jobka, autoka, fabrika
12+ var id: String { rawValue }
13+
14+ var label: String {
15+ switch self {
16+ case .stations: return "Stations"
17+ case .commerces: return "Commerces"
18+ case .louka: return "Lou-Ka"
19+ case .immoka: return "Immo-Ka"
20+ case .restoka: return "Resto-Ka"
21+ case .sortika: return "Sorti-Ka"
22+ case .jobka: return "Job-Ka"
23+ case .autoka: return "Auto-Ka"
24+ case .fabrika: return "Fabri-Ka"
25+ }
26+ }
27+ var icon: String {
28+ switch self {
29+ case .stations: return "fuelpump.fill"
30+ case .commerces: return "bag.fill"
31+ case .louka: return "key.fill"
32+ case .immoka: return "house.fill"
33+ case .restoka: return "fork.knife"
34+ case .sortika: return "ticket.fill"
35+ case .jobka: return "briefcase.fill"
36+ case .autoka: return "car.fill"
37+ case .fabrika: return "shippingbox.fill"
38+ }
39+ }
40+}
41+
42+struct POIItem: Identifiable {
43+ let id: String
44+ let kind: POIKind
45+ let name: String
46+ let address: String
47+ let priceLabel: String? // annonces Ka / prix de l'essence ordinaire
48+ let coordinate: CLLocationCoordinate2D
49+ let url: URL? // fiche de l'annonce sur le site
50+ let extra: String? // détail (ex. ordinaire/super/diesel + source)
51+
52+ init(id: String, kind: POIKind, name: String, address: String,
53+ priceLabel: String?, coordinate: CLLocationCoordinate2D,
54+ url: URL?, extra: String? = nil) {
55+ self.id = id; self.kind = kind; self.name = name; self.address = address
56+ self.priceLabel = priceLabel; self.coordinate = coordinate
57+ self.url = url; self.extra = extra
58+ }
59+}
60+
61+/// Cache de géocodage des villes + annuaire des boutiques Fabri-Ka
62+/// (chargé une fois par session).
63+actor CityGeoCache {
64+ private var coords: [String: CLLocationCoordinate2D] = [:]
65+ private var failed: Set<String> = []
66+ private var storeList: [[String: Any]]?
67+
68+ func stores() async -> [[String: Any]]? {
69+ if let storeList { return storeList }
70+ var req = URLRequest(url: URL(string: "https://www.fabri-ka.com/api/stores?limit=300")!)
71+ req.setValue("KaTrajet/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")
72+ guard let (data, _) = try? await URLSession.shared.data(for: req),
73+ let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
74+ let items = root["items"] as? [[String: Any]] else { return nil }
75+ storeList = items
76+ return items
77+ }
78+
79+ func coordinate(for city: String) async -> CLLocationCoordinate2D? {
80+ if let c = coords[city] { return c }
81+ if failed.contains(city) { return nil }
82+ guard let c = await MapboxAPI.geocodeCity(city) else {
83+ failed.insert(city); return nil
84+ }
85+ coords[city] = c
86+ return c
87+ }
88+}
89+
90+enum POIService {
91+ // au-delà de cette étendue, on ne charge pas (trop de territoire)
92+ static let maxSpan = 0.35
93+
94+ static func fetch(_ kind: POIKind, region: MKCoordinateRegion) async -> [POIItem] {
95+ let west = region.center.longitude - region.span.longitudeDelta / 2
96+ let east = region.center.longitude + region.span.longitudeDelta / 2
97+ let south = region.center.latitude - region.span.latitudeDelta / 2
98+ let north = region.center.latitude + region.span.latitudeDelta / 2
99+ switch kind {
100+ case .stations: return await gasStations(region: region)
101+ case .commerces: return await searchbox("shopping", kind: kind, west, south, east, north)
102+ case .louka:
103+ return await kaListings(kind: kind, host: "www.lou-ka.com", detail: "/logement/",
104+ west, south, east, north)
105+ case .immoka:
106+ return await kaListings(kind: kind, host: "www.immo-ka.com", detail: "/propriete/",
107+ west, south, east, north)
108+ case .restoka: return await restoKa(region: region, west, south, east, north)
109+ case .sortika: return await sortiKa(region: region, west, south, east, north)
110+ case .jobka:
111+ return await kaListings(kind: kind, host: "www.job-ka.com", detail: "/emploi/",
112+ west, south, east, north, path: "/api/jobs.geojson")
113+ case .autoka: return await autoKa(region: region, west, south, east, north)
114+ case .fabrika: return await fabriKa(west, south, east, north)
115+ }
116+ }
117+
118+ // MARK: Auto-Ka — véhicules géolocalisés chez leur concessionnaire
119+
120+ private static func autoKa(region: MKCoordinateRegion,
121+ _ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] {
122+ guard let city = await MapboxAPI.cityName(at: region.center),
123+ let root = await cityJSON(host: "www.auto-ka.com", path: "/api/vehicles", city: city),
124+ let items = root["vehicles"] as? [[String: Any]] else { return [] }
125+ return items.compactMap { v -> POIItem? in
126+ guard let lat = v["lat"] as? Double, let lng = v["lng"] as? Double,
127+ inBBox(lat, lng, w, s, e, n),
128+ let uid = v["uid"] as? String, let title = v["title"] as? String else { return nil }
129+ let dealer = (v["dealer_name"] as? String).flatMap { $0.isEmpty ? nil : $0 }
130+ let km = v["mileage_label"] as? String
131+ return POIItem(id: "autoka:\(uid)",
132+ kind: .autoka,
133+ name: title,
134+ address: [dealer.map { "Concessionnaire : \($0)" },
135+ v["city"] as? String]
136+ .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "),
137+ priceLabel: v["price_label"] as? String,
138+ coordinate: .init(latitude: lat, longitude: lng),
139+ url: URL(string: "https://www.auto-ka.com/vehicule/\(uid.replacingOccurrences(of: "/", with: "%2F"))"),
140+ extra: km.map { "\($0) · \(v["fuel"] as? String ?? "")" })
141+ }.prefix(40).map { $0 }
142+ }
143+
144+ // MARK: Fabri-Ka — boutiques québécoises, localisées par géocodage de
145+ // leur ville (aucune coordonnée dans l'API ; cache de géocodage partagé)
146+
147+ private static let fabriGeo = CityGeoCache()
148+
149+ private static func fabriKa(_ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] {
150+ guard let stores = await fabriGeo.stores() else { return [] }
151+ var out: [POIItem] = []
152+ for st in stores {
153+ guard out.count < 30,
154+ let id = st["id"] as? String,
155+ let name = st["name"] as? String,
156+ let city = (st["city"] as? String), !city.isEmpty,
157+ let coord = await fabriGeo.coordinate(for: city) else { continue }
158+ // léger décalage déterministe pour séparer les boutiques d'une même ville
159+ let h = Double(id.unicodeScalars.reduce(0) { ($0 &+ UInt32($1.value)) % 997 })
160+ let lat = coord.latitude + (h.truncatingRemainder(dividingBy: 31) - 15) * 0.0004
161+ let lng = coord.longitude + (h.truncatingRemainder(dividingBy: 29) - 14) * 0.0005
162+ guard inBBox(lat, lng, w, s, e, n) else { continue }
163+ let count = (st["product_count"] as? Double).map(Int.init) ?? (st["product_count"] as? Int) ?? 0
164+ out.append(POIItem(id: "fabrika:\(id)",
165+ kind: .fabrika,
166+ name: name,
167+ address: city,
168+ priceLabel: nil,
169+ coordinate: .init(latitude: lat, longitude: lng),
170+ url: (st["url"] as? String).flatMap(URL.init(string:)),
171+ extra: count > 0 ? "\(count) produits fabriqués au Québec" : nil))
172+ }
173+ return out
174+ }
175+
176+ // MARK: Resto-Ka / Sorti-Ka — filtre par ville (pas de bbox côté API) :
177+ // la ville visible est résolue par géocodage inverse Mapbox, puis les
178+ // items sont refiltrés client sur la région (même approche que la
179+ // super-app KA).
180+
181+ private static func cityJSON(host: String, path: String, city: String,
182+ extraQuery: [URLQueryItem] = []) async -> [String: Any]? {
183+ var comps = URLComponents(string: "https://\(host)\(path)")!
184+ comps.queryItems = [
185+ .init(name: "city", value: city),
186+ .init(name: "limit", value: "120"),
187+ ] + extraQuery
188+ var req = URLRequest(url: comps.url!)
189+ req.setValue("KaTrajet/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")
190+ guard let (data, _) = try? await URLSession.shared.data(for: req) else { return nil }
191+ return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
192+ }
193+
194+ private static func inBBox(_ lat: Double, _ lng: Double,
195+ _ w: Double, _ s: Double, _ e: Double, _ n: Double) -> Bool {
196+ lat >= s && lat <= n && lng >= w && lng <= e
197+ }
198+
199+ private static func restoKa(region: MKCoordinateRegion,
200+ _ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] {
201+ guard let city = await MapboxAPI.cityName(at: region.center),
202+ let root = await cityJSON(host: "www.resto-ka.com", path: "/api/restaurants", city: city),
203+ let items = root["restaurants"] as? [[String: Any]] else { return [] }
204+ return items.compactMap { r -> POIItem? in
205+ guard let lat = r["lat"] as? Double, let lng = r["lng"] as? Double,
206+ inBBox(lat, lng, w, s, e, n),
207+ let uid = r["uid"] as? String, let name = r["name"] as? String else { return nil }
208+ let cuisines = (r["cuisines"] as? [String] ?? []).prefix(3).joined(separator: " · ")
209+ return POIItem(id: "restoka:\(uid)",
210+ kind: .restoka,
211+ name: name,
212+ address: [r["address"] as? String, r["city"] as? String]
213+ .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "),
214+ priceLabel: (r["price_range"] as? String).flatMap { $0.isEmpty ? nil : $0 },
215+ coordinate: .init(latitude: lat, longitude: lng),
216+ url: URL(string: "https://www.resto-ka.com/resto/\(uid.replacingOccurrences(of: "/", with: "%2F"))"),
217+ extra: cuisines.isEmpty ? nil : cuisines)
218+ }.prefix(40).map { $0 }
219+ }
220+
221+ private static func sortiKa(region: MKCoordinateRegion,
222+ _ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] {
223+ guard let city = await MapboxAPI.cityName(at: region.center),
224+ let root = await cityJSON(host: "www.sorti-ka.com", path: "/api/events", city: city,
225+ extraQuery: [.init(name: "upcoming", value: "true")]),
226+ let items = root["events"] as? [[String: Any]] else { return [] }
227+ return items.compactMap { ev -> POIItem? in
228+ guard let lat = ev["lat"] as? Double, let lng = ev["lng"] as? Double,
229+ inBBox(lat, lng, w, s, e, n),
230+ let uid = ev["uid"] as? String, let title = ev["title"] as? String else { return nil }
231+ var price: String?
232+ if (ev["is_free"] as? Bool) == true { price = "Gratuit" }
233+ else if let m = ev["price_min"] as? Double, m > 1 { price = "dès \(Int(m)) $" }
234+ let date = ((ev["starts_at"] as? String)?.prefix(10)).map(String.init)
235+ return POIItem(id: "sortika:\(uid)",
236+ kind: .sortika,
237+ name: title,
238+ address: [ev["venue_name"] as? String, date]
239+ .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · "),
240+ priceLabel: price,
241+ coordinate: .init(latitude: lat, longitude: lng),
242+ url: URL(string: "https://www.sorti-ka.com/evenement/\(uid.replacingOccurrences(of: "/", with: "%2F"))"),
243+ extra: nil)
244+ }.prefix(40).map { $0 }
245+ }
246+
247+ // MARK: stations-service avec PRIX RÉELS (Gas Québec / Régie de l'énergie)
248+
249+ private static func gasStations(region: MKCoordinateRegion) async -> [POIItem] {
250+ // rayon ≈ demi-étendue visible, borné 1–15 km (limite de l'API)
251+ let radiusKm = min(15.0, max(1.0, region.span.latitudeDelta * 111.0 / 2))
252+ var comps = URLComponents(string: "https://www.gasquebec.ca/api/stations/nearby")!
253+ comps.queryItems = [
254+ .init(name: "lat", value: "\(region.center.latitude)"),
255+ .init(name: "lng", value: "\(region.center.longitude)"),
256+ .init(name: "radius", value: String(format: "%.1f", radiusKm)),
257+ .init(name: "limit", value: "30"),
258+ ]
259+ var req = URLRequest(url: comps.url!)
260+ req.setValue("KaTrajet/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")
261+ guard let (data, _) = try? await URLSession.shared.data(for: req),
262+ let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
263+ let stations = root["stations"] as? [[String: Any]] else { return [] }
264+
265+ func cents(_ v: Any?) -> String? {
266+ guard let n = v as? Double else { return nil }
267+ return String(format: "%.1f", n).replacingOccurrences(of: ".", with: ",")
268+ }
269+ return stations.compactMap { s in
270+ guard let lat = s["lat"] as? Double, let lng = s["lng"] as? Double,
271+ let name = s["name"] as? String else { return nil }
272+ let sid = (s["stationId"] as? String) ?? "\(lat),\(lng)"
273+ let details = [
274+ cents(s["prixOrdinaire"]).map { "Ordinaire \($0)" },
275+ cents(s["prixSuper"]).map { "Super \($0)" },
276+ cents(s["prixDiesel"]).map { "Diesel \($0)" },
277+ ].compactMap { $0 }
278+ return POIItem(id: "stations:\(sid)",
279+ kind: .stations,
280+ name: name,
281+ address: (s["address"] as? String) ?? "",
282+ priceLabel: cents(s["prixOrdinaire"]).map { "\($0) ¢" },
283+ coordinate: .init(latitude: lat, longitude: lng),
284+ url: nil,
285+ extra: details.isEmpty ? nil
286+ : details.joined(separator: " · ") + " ¢/L — Régie de l'énergie")
287+ }
288+ }
289+
290+ // MARK: Mapbox Search Box — POI par catégorie
291+
292+ private static func searchbox(_ category: String, kind: POIKind,
293+ _ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] {
294+ var comps = URLComponents(string: "https://api.mapbox.com/search/searchbox/v1/category/\(category)")!
295+ comps.queryItems = [
296+ .init(name: "access_token", value: MapboxConfig.token),
297+ .init(name: "bbox", value: "\(w),\(s),\(e),\(n)"),
298+ .init(name: "limit", value: "25"),
299+ .init(name: "language", value: "fr"),
300+ ]
301+ guard let (data, _) = try? await URLSession.shared.data(from: comps.url!),
302+ let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
303+ let features = root["features"] as? [[String: Any]] else { return [] }
304+ return features.compactMap { f in
305+ guard let geo = f["geometry"] as? [String: Any],
306+ let coords = geo["coordinates"] as? [Double], coords.count >= 2,
307+ let props = f["properties"] as? [String: Any],
308+ let name = props["name"] as? String else { return nil }
309+ let mid = (props["mapbox_id"] as? String) ?? "\(coords[0]),\(coords[1])"
310+ return POIItem(id: "\(kind.rawValue):\(mid)",
311+ kind: kind,
312+ name: name,
313+ address: (props["full_address"] as? String)
314+ ?? (props["place_formatted"] as? String) ?? "",
315+ priceLabel: nil,
316+ coordinate: .init(latitude: coords[1], longitude: coords[0]),
317+ url: nil)
318+ }
319+ }
320+
321+ // MARK: annonces Lou-Ka / Immo-Ka (geojson bbox en direct)
322+
323+ private static func kaListings(kind: POIKind, host: String, detail: String,
324+ _ w: Double, _ s: Double, _ e: Double, _ n: Double,
325+ path: String = "/api/listings.geojson") async -> [POIItem] {
326+ var comps = URLComponents(string: "https://\(host)\(path)")!
327+ comps.queryItems = [
328+ .init(name: "bbox", value: "\(w),\(s),\(e),\(n)"),
329+ .init(name: "limit", value: "40"),
330+ ]
331+ guard let (data, _) = try? await URLSession.shared.data(from: comps.url!),
332+ let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
333+ let features = root["features"] as? [[String: Any]] else { return [] }
334+ return features.compactMap { f in
335+ guard let geo = f["geometry"] as? [String: Any],
336+ let coords = geo["coordinates"] as? [Double], coords.count >= 2,
337+ let props = f["properties"] as? [String: Any],
338+ let uid = props["uid"] as? String else { return nil }
339+ let title = (props["title"] as? String).flatMap { $0.isEmpty ? nil : $0 }
340+ ?? (props["property_type"] as? String)
341+ ?? (props["unit_type"] as? String)
342+ ?? (kind == .louka ? "Logement" : kind == .jobka ? "Offre d'emploi" : "Propriété")
343+ // Job-Ka : salaire en pilule quand il est connu, employeur en adresse
344+ var price = props["price_label"] as? String
345+ if kind == .jobka, price == nil, let m = props["salary_min"] as? Double {
346+ let unit = (props["salary_unit"] as? String) ?? "h"
347+ price = "dès \(Int(m)) $/\(unit)"
348+ }
349+ let addr = kind == .jobka
350+ ? [props["employer"] as? String, props["city"] as? String]
351+ : [props["address"] as? String, props["city"] as? String]
352+ return POIItem(id: "\(kind.rawValue):\(uid)",
353+ kind: kind,
354+ name: title,
355+ address: addr.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "),
356+ priceLabel: price,
357+ coordinate: .init(latitude: coords[1], longitude: coords[0]),
358+ url: URL(string: "https://\(host)\(detail)\(uid)"))
359+ }
360+ }
361+}
added KA/Features/Trajet/TrajetMapView.swift +452 −0
@@ -0,0 +1,452 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// TrajetMapView.swift — la carte de Ka Trajet rendue par MapLibre Native
3+// (même moteur que la carte de l'app iOS KA / Lou-Ka) : style vectoriel
4+// Liberty, bâtiments 3D extrudés, tracés d'itinéraires (sélectionné en vert
5+// Ka + alternatives grises), marqueur de destination, appui long → repère.
6+import SwiftUI
7+import MapLibre
8+import MapKit
9+
10+// KaMapStyle est déclaré dans KaMapLibre.swift (même style Liberty partagé).
11+
12+/// Caméra de conduite : derrière la voiture, inclinée, orientée selon la route.
13+struct NavCamera: Equatable {
14+ var center: CLLocationCoordinate2D
15+ var heading: Double
16+ var epoch: Int
17+ static func == (l: NavCamera, r: NavCamera) -> Bool { l.epoch == r.epoch }
18+}
19+
20+final class DestinationAnnotation: NSObject, MLNAnnotation {
21+ let place: Place
22+ var coordinate: CLLocationCoordinate2D { place.coordinate }
23+ var title: String? { place.name }
24+ init(_ p: Place) { place = p }
25+}
26+
27+final class POIAnnotation: NSObject, MLNAnnotation {
28+ let poi: POIItem
29+ var coordinate: CLLocationCoordinate2D { poi.coordinate }
30+ var title: String? { poi.name }
31+ init(_ p: POIItem) { poi = p }
32+}
33+
34+struct TrajetMapView: UIViewRepresentable {
35+ var destination: Place?
36+ var routes: [Route]
37+ var routeIndex: Int
38+ var programRegion: MKCoordinateRegion
39+ var programEpoch: Int
40+ var pitch3D: Bool
41+ var navCamera: NavCamera?
42+ var pois: [POIItem]
43+ var selectedPOIID: String?
44+ var onLongPress: (CLLocationCoordinate2D) -> Void
45+ var onRegionChange: (MKCoordinateRegion) -> Void
46+ var onSelectPOI: (POIItem) -> Void
47+
48+ private static let selSourceID = "ka-route-sel"
49+ private static let altSourceID = "ka-route-alt"
50+
51+ func makeUIView(context: Context) -> MLNMapView {
52+ let map = MLNMapView(frame: .zero, styleURL: KaMapStyle.url)
53+ map.delegate = context.coordinator
54+ map.logoView.isHidden = false
55+ map.attributionButtonPosition = .bottomLeft
56+ map.setCenter(programRegion.center, zoomLevel: zoom(from: programRegion), animated: false)
57+ map.allowsRotating = true
58+ map.allowsTilting = true
59+ let auth = CLLocationManager().authorizationStatus
60+ map.showsUserLocation = (auth == .authorizedWhenInUse || auth == .authorizedAlways)
61+ let long = UILongPressGestureRecognizer(target: context.coordinator,
62+ action: #selector(Coordinator.longPressed(_:)))
63+ map.addGestureRecognizer(long)
64+ return map
65+ }
66+
67+ func updateUIView(_ map: MLNMapView, context: Context) {
68+ context.coordinator.parent = self
69+
70+ // point bleu dès que la permission est accordée
71+ let auth = CLLocationManager().authorizationStatus
72+ let allowed = (auth == .authorizedWhenInUse || auth == .authorizedAlways)
73+ if map.showsUserLocation != allowed { map.showsUserLocation = allowed }
74+
75+ // caméra de conduite : suit la position réelle de la voiture,
76+ // vue 3D inclinée orientée dans le sens de la route
77+ if let nav = navCamera {
78+ if context.coordinator.lastNavEpoch != nav.epoch {
79+ context.coordinator.lastNavEpoch = nav.epoch
80+ let cam = MLNMapCamera(lookingAtCenter: nav.center,
81+ altitude: 260,
82+ pitch: 62,
83+ heading: nav.heading)
84+ map.setCamera(cam, withDuration: 0.9,
85+ animationTimingFunction: CAMediaTimingFunction(name: .linear))
86+ }
87+ return // en navigation, la caméra de conduite a priorité sur tout
88+ }
89+ context.coordinator.lastNavEpoch = 0
90+
91+ // caméra programmée (pattern epoch de la super-app KA)
92+ if context.coordinator.lastEpoch != programEpoch {
93+ context.coordinator.lastEpoch = programEpoch
94+ let cam = map.camera
95+ cam.centerCoordinate = programRegion.center
96+ cam.pitch = pitch3D ? 58 : 0
97+ map.setCamera(cam, animated: false)
98+ map.setZoomLevel(zoom(from: programRegion), animated: true)
99+ }
100+
101+ // bascule 2D / 3D
102+ if context.coordinator.lastPitch3D != pitch3D {
103+ context.coordinator.lastPitch3D = pitch3D
104+ let cam = map.camera
105+ cam.pitch = pitch3D ? 58 : 0
106+ map.fly(to: cam, withDuration: 0.7, completionHandler: nil)
107+ }
108+
109+ // marqueur de destination
110+ let destID = destination?.id
111+ if context.coordinator.lastDestID != destID {
112+ context.coordinator.lastDestID = destID
113+ let old = (map.annotations ?? []).compactMap { $0 as? DestinationAnnotation }
114+ map.removeAnnotations(old)
115+ if let d = destination { map.addAnnotation(DestinationAnnotation(d)) }
116+ }
117+
118+ // tracés d'itinéraires
119+ let signature = routes.map(\.id.uuidString).joined() + "#\(routeIndex)"
120+ if context.coordinator.lastRouteSignature != signature {
121+ context.coordinator.lastRouteSignature = signature
122+ context.coordinator.syncRoutes(on: map)
123+ }
124+
125+ // marqueurs des couches POI (stations, commerces, annonces, restos, sorties)
126+ let poiIDs = Set(pois.map(\.id))
127+ if context.coordinator.lastPOIIDs != poiIDs
128+ || context.coordinator.lastPOISelectedID != selectedPOIID {
129+ context.coordinator.lastPOIIDs = poiIDs
130+ context.coordinator.lastPOISelectedID = selectedPOIID
131+ let old = (map.annotations ?? []).compactMap { $0 as? POIAnnotation }
132+ map.removeAnnotations(old)
133+ map.addAnnotations(pois.map(POIAnnotation.init))
134+ }
135+ }
136+
137+ func makeCoordinator() -> Coordinator { Coordinator(self) }
138+
139+ private func zoom(from region: MKCoordinateRegion) -> Double {
140+ let span = max(region.span.longitudeDelta, 0.0005)
141+ return max(1, min(18, log2(360 / span)))
142+ }
143+
144+ final class Coordinator: NSObject, MLNMapViewDelegate {
145+ var parent: TrajetMapView
146+ var lastEpoch = Int.min
147+ var lastPitch3D = false
148+ var lastNavEpoch = 0
149+ var lastDestID: String?
150+ var lastRouteSignature = ""
151+ var lastPOIIDs = Set<String>()
152+ var lastPOISelectedID: String?
153+ private var buildingsAdded = false
154+ private var styleReady = false
155+ private weak var mapView: MLNMapView?
156+
157+ init(_ p: TrajetMapView) { parent = p }
158+
159+ @objc func longPressed(_ g: UILongPressGestureRecognizer) {
160+ guard g.state == .began, let map = g.view as? MLNMapView else { return }
161+ let coord = map.convert(g.location(in: map), toCoordinateFrom: map)
162+ parent.onLongPress(coord)
163+ }
164+
165+ // ---- style chargé : palette Ka + bâtiments 3D + couches d'itinéraire ----
166+ func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) {
167+ self.mapView = mapView
168+ styleReady = true
169+ applyKaCartography(style)
170+ addBuildings(style)
171+ ensureRouteLayers(style)
172+ syncRoutes(on: mapView)
173+ }
174+
175+ // La carte SIGNATURE Groupe Ka : papier crème, eau vert forêt,
176+ // parcs lime pâle, autoroutes lime cerclées de vert, étiquettes encre.
177+ private func applyKaCartography(_ style: MLNStyle) {
178+ let paper = UIColor(red: 0.961, green: 0.953, blue: 0.933, alpha: 1) // #f5f3ee
179+ let paper2 = UIColor(red: 0.937, green: 0.925, blue: 0.894, alpha: 1) // #efece4
180+ let ink = UIColor(red: 0.078, green: 0.094, blue: 0.078, alpha: 1) // #141814
181+ let water = UIColor(red: 0x2e / 255, green: 0x6f / 255, blue: 0x58 / 255, alpha: 1) // vert forêt
182+ let park = UIColor(red: 0xe2 / 255, green: 0xee / 255, blue: 0xc4 / 255, alpha: 1) // lime pâle
183+ let building = UIColor(red: 0.898, green: 0.882, blue: 0.843, alpha: 1)
184+
185+ for layer in style.layers {
186+ let id = layer.identifier.lowercased()
187+ if let bg = layer as? MLNBackgroundStyleLayer {
188+ bg.backgroundColor = NSExpression(forConstantValue: paper)
189+ } else if let fill = layer as? MLNFillStyleLayer {
190+ if id.contains("water") {
191+ fill.fillColor = NSExpression(forConstantValue: water)
192+ fill.fillOpacity = NSExpression(forConstantValue: 1)
193+ } else if id.contains("park") || id.contains("grass") || id.contains("wood")
194+ || id.contains("landcover") || id.contains("cemetery") || id.contains("golf")
195+ || id.contains("pitch") {
196+ fill.fillColor = NSExpression(forConstantValue: park)
197+ } else if id.contains("building") {
198+ fill.fillColor = NSExpression(forConstantValue: building)
199+ } else if id.contains("residential") || id.contains("landuse")
200+ || id.contains("sand") || id.contains("aeroway") {
201+ fill.fillColor = NSExpression(forConstantValue: paper2)
202+ }
203+ } else if let line = layer as? MLNLineStyleLayer {
204+ if id.contains("motorway") || id.contains("trunk") {
205+ line.lineColor = NSExpression(forConstantValue:
206+ id.contains("casing") ? Ka.uiGreen : Ka.uiLime)
207+ } else if id.contains("water") || id.contains("river") || id.contains("stream") {
208+ line.lineColor = NSExpression(forConstantValue: water)
209+ }
210+ } else if let sym = layer as? MLNSymbolStyleLayer {
211+ if sym.text != nil {
212+ sym.textColor = NSExpression(forConstantValue: ink)
213+ sym.textHaloColor = NSExpression(forConstantValue: paper.withAlphaComponent(0.92))
214+ }
215+ }
216+ }
217+ }
218+
219+ private func addBuildings(_ style: MLNStyle) {
220+ guard !buildingsAdded else { return }
221+ let source = style.source(withIdentifier: "openmaptiles")
222+ ?? style.source(withIdentifier: "composite")
223+ ?? style.sources.first
224+ guard let composite = source else { return }
225+ buildingsAdded = true
226+ let layer = MLNFillExtrusionStyleLayer(identifier: "ka-3d-buildings", source: composite)
227+ layer.sourceLayerIdentifier = "building"
228+ layer.fillExtrusionHeight = NSExpression(forKeyPath: "render_height")
229+ layer.fillExtrusionBase = NSExpression(forKeyPath: "render_min_height")
230+ layer.fillExtrusionColor = NSExpression(forConstantValue: UIColor(red: 0.82, green: 0.83, blue: 0.80, alpha: 1))
231+ layer.fillExtrusionOpacity = NSExpression(forConstantValue: 0.75)
232+ if let firstSymbol = style.layers.first(where: { $0 is MLNSymbolStyleLayer }) {
233+ style.insertLayer(layer, below: firstSymbol)
234+ } else {
235+ style.addLayer(layer)
236+ }
237+ }
238+
239+ // sources + couches (créées une fois, shapes remplacées ensuite)
240+ private func ensureRouteLayers(_ style: MLNStyle) {
241+ guard style.source(withIdentifier: TrajetMapView.selSourceID) == nil else { return }
242+ let altSource = MLNShapeSource(identifier: TrajetMapView.altSourceID,
243+ shape: MLNShapeCollectionFeature(shapes: []), options: nil)
244+ let selSource = MLNShapeSource(identifier: TrajetMapView.selSourceID,
245+ shape: MLNShapeCollectionFeature(shapes: []), options: nil)
246+ style.addSource(altSource)
247+ style.addSource(selSource)
248+
249+ let alt = MLNLineStyleLayer(identifier: "ka-route-alt-line", source: altSource)
250+ alt.lineColor = NSExpression(forConstantValue: Ka.uiAlt)
251+ alt.lineWidth = NSExpression(forConstantValue: 5)
252+ alt.lineOpacity = NSExpression(forConstantValue: 0.65)
253+ alt.lineCap = NSExpression(forConstantValue: "round")
254+ alt.lineJoin = NSExpression(forConstantValue: "round")
255+
256+ let casing = MLNLineStyleLayer(identifier: "ka-route-sel-casing", source: selSource)
257+ casing.lineColor = NSExpression(forConstantValue: UIColor.white)
258+ casing.lineWidth = NSExpression(forConstantValue: 9)
259+ casing.lineCap = NSExpression(forConstantValue: "round")
260+ casing.lineJoin = NSExpression(forConstantValue: "round")
261+
262+ let sel = MLNLineStyleLayer(identifier: "ka-route-sel-line", source: selSource)
263+ sel.lineColor = NSExpression(forConstantValue: Ka.uiGreen)
264+ sel.lineWidth = NSExpression(forConstantValue: 6)
265+ sel.lineCap = NSExpression(forConstantValue: "round")
266+ sel.lineJoin = NSExpression(forConstantValue: "round")
267+
268+ // au-dessus des routes mais sous les étiquettes
269+ if let firstSymbol = style.layers.first(where: { $0 is MLNSymbolStyleLayer }) {
270+ style.insertLayer(alt, below: firstSymbol)
271+ style.insertLayer(casing, above: alt)
272+ style.insertLayer(sel, above: casing)
273+ } else {
274+ style.addLayer(alt); style.addLayer(casing); style.addLayer(sel)
275+ }
276+ }
277+
278+ func syncRoutes(on map: MLNMapView) {
279+ guard styleReady, let style = map.style else { return }
280+ ensureRouteLayers(style)
281+ guard let altSource = style.source(withIdentifier: TrajetMapView.altSourceID) as? MLNShapeSource,
282+ let selSource = style.source(withIdentifier: TrajetMapView.selSourceID) as? MLNShapeSource
283+ else { return }
284+
285+ func line(_ r: Route) -> MLNPolylineFeature {
286+ var coords = r.coordinates
287+ return MLNPolylineFeature(coordinates: &coords, count: UInt(coords.count))
288+ }
289+ let routes = parent.routes
290+ let sel = parent.routeIndex
291+ let altShapes = routes.enumerated().filter { $0.offset != sel }.map { line($0.element) }
292+ let selShapes = routes.indices.contains(sel) ? [line(routes[sel])] : []
293+ altSource.shape = MLNShapeCollectionFeature(shapes: altShapes)
294+ selSource.shape = MLNShapeCollectionFeature(shapes: selShapes)
295+ }
296+
297+ // ---- marqueurs : épinglette de destination + POI des couches ----
298+ func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? {
299+ if annotation is DestinationAnnotation {
300+ let view = MLNAnnotationView(reuseIdentifier: nil)
301+ let host = UIHostingController(rootView: DestinationPin())
302+ host.view.backgroundColor = .clear
303+ let size = host.sizeThatFits(in: CGSize(width: 60, height: 60))
304+ host.view.frame = CGRect(origin: .zero, size: size)
305+ view.frame = host.view.frame
306+ // ancrer la pointe de l'épinglette sur la coordonnée
307+ view.centerOffset = CGVector(dx: 0, dy: -size.height / 2)
308+ view.addSubview(host.view)
309+ return view
310+ }
311+ if let ann = annotation as? POIAnnotation {
312+ let view = MLNAnnotationView(reuseIdentifier: nil)
313+ let host = UIHostingController(rootView:
314+ POIMarker(poi: ann.poi, selected: parent.selectedPOIID == ann.poi.id))
315+ host.view.backgroundColor = .clear
316+ let size = host.sizeThatFits(in: CGSize(width: 170, height: 44))
317+ host.view.frame = CGRect(origin: .zero, size: size)
318+ view.frame = host.view.frame
319+ view.addSubview(host.view)
320+ view.isUserInteractionEnabled = true
321+ let tap = UITapGestureRecognizer(target: self, action: #selector(poiTapped(_:)))
322+ view.addGestureRecognizer(tap)
323+ view.accessibilityLabel = ann.poi.name
324+ objc_setAssociatedObject(view, &AssocKeys.poi, ann.poi, .OBJC_ASSOCIATION_RETAIN)
325+ return view
326+ }
327+ return nil
328+ }
329+
330+ @objc private func poiTapped(_ g: UITapGestureRecognizer) {
331+ guard let v = g.view,
332+ let poi = objc_getAssociatedObject(v, &AssocKeys.poi) as? POIItem else { return }
333+ parent.onSelectPOI(poi)
334+ }
335+
336+ // ---- région visible → modèle (rechargement des couches) ----
337+ func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) {
338+ let bounds = mapView.visibleCoordinateBounds
339+ let region = MKCoordinateRegion(
340+ center: mapView.centerCoordinate,
341+ span: .init(latitudeDelta: abs(bounds.ne.latitude - bounds.sw.latitude),
342+ longitudeDelta: abs(bounds.ne.longitude - bounds.sw.longitude)))
343+ parent.onRegionChange(region)
344+ }
345+ }
346+}
347+
348+private enum AssocKeys { static var poi: UInt8 = 0 }
349+
350+/// Marqueur d'une couche POI : pastille icône (stations, commerces) ou
351+/// pilule de prix (annonces Lou-Ka / Immo-Ka), aux couleurs Ka.
352+struct POIMarker: View {
353+ let poi: POIItem
354+ var selected = false
355+
356+ // accents des univers Ka (ecosystem.json)
357+ private static let restoOrange = Color(hex: "#f08c00")
358+ private static let sortiRose = Color(hex: "#d6336c")
359+ private static let jobTeal = Color(hex: "#0c8599")
360+ private static let autoOrange = Color(hex: "#ff5a2a")
361+ private static let fabriBrun = Color(hex: "#c4532e")
362+
363+ var body: some View {
364+ switch poi.kind {
365+ case .stations:
366+ // prix de l'essence ordinaire en pilule (source Régie de l'énergie)
367+ if poi.priceLabel != nil { pill(bg: Ka.inkLight, fg: Ka.lime) }
368+ else { badge(bg: Ka.inkLight, fg: Ka.lime) }
369+ case .commerces:
370+ badge(bg: Ka.green, fg: .white)
371+ case .louka:
372+ pill(bg: Ka.lime, fg: Ka.inkLight)
373+ case .immoka:
374+ pill(bg: Ka.green, fg: Ka.lime)
375+ case .restoka:
376+ if poi.priceLabel != nil { pill(bg: Self.restoOrange, fg: .white) }
377+ else { badge(bg: Self.restoOrange, fg: .white) }
378+ case .sortika:
379+ if poi.priceLabel != nil { pill(bg: Self.sortiRose, fg: .white) }
380+ else { badge(bg: Self.sortiRose, fg: .white) }
381+ case .jobka:
382+ if poi.priceLabel != nil { pill(bg: Self.jobTeal, fg: .white) }
383+ else { badge(bg: Self.jobTeal, fg: .white) }
384+ case .autoka:
385+ if poi.priceLabel != nil { pill(bg: Self.autoOrange, fg: .white) }
386+ else { badge(bg: Self.autoOrange, fg: .white) }
387+ case .fabrika:
388+ badge(bg: Self.fabriBrun, fg: .white)
389+ }
390+ }
391+
392+ private func badge(bg: Color, fg: Color) -> some View {
393+ ZStack {
394+ Circle().fill(bg)
395+ .frame(width: selected ? 36 : 30, height: selected ? 36 : 30)
396+ .shadow(color: .black.opacity(0.3), radius: 3, y: 1)
397+ Image(systemName: poi.kind.icon)
398+ .font(.system(size: selected ? 15 : 13, weight: .bold))
399+ .foregroundStyle(fg)
400+ }
401+ .overlay(Circle().strokeBorder(selected ? Ka.lime : .white, lineWidth: selected ? 2.5 : 1.5))
402+ .padding(3)
403+ }
404+
405+ private func pill(bg: Color, fg: Color) -> some View {
406+ HStack(spacing: 4) {
407+ Image(systemName: poi.kind.icon)
408+ .font(.system(size: selected ? 12 : 10, weight: .bold))
409+ Text(poi.priceLabel ?? poi.name)
410+ .font(.system(size: selected ? 14 : 12, weight: .bold, design: .rounded))
411+ .lineLimit(1)
412+ }
413+ .padding(.horizontal, selected ? 11 : 9)
414+ .padding(.vertical, selected ? 7 : 5)
415+ .background(bg, in: Capsule())
416+ .overlay(Capsule().strokeBorder(selected ? Ka.lime : .white, lineWidth: selected ? 2.5 : 1.5))
417+ .foregroundStyle(fg)
418+ .shadow(color: .black.opacity(selected ? 0.45 : 0.3), radius: selected ? 5 : 3, y: 1)
419+ .padding(2)
420+ }
421+}
422+
423+/// Épinglette de destination aux couleurs Ka.
424+struct DestinationPin: View {
425+ var body: some View {
426+ VStack(spacing: -2) {
427+ ZStack {
428+ Circle().fill(Ka.green)
429+ .frame(width: 34, height: 34)
430+ .shadow(color: .black.opacity(0.35), radius: 4, y: 2)
431+ Image(systemName: "mappin")
432+ .font(.system(size: 16, weight: .bold))
433+ .foregroundStyle(Ka.lime)
434+ }
435+ Triangle()
436+ .fill(Ka.green)
437+ .frame(width: 12, height: 10)
438+ }
439+ .padding(.bottom, 2)
440+ }
441+}
442+
443+struct Triangle: Shape {
444+ func path(in r: CGRect) -> Path {
445+ var p = Path()
446+ p.move(to: .init(x: r.midX, y: r.maxY))
447+ p.addLine(to: .init(x: r.minX, y: r.minY))
448+ p.addLine(to: .init(x: r.maxX, y: r.minY))
449+ p.closeSubpath()
450+ return p
451+ }
452+}
added KA/Features/Trajet/TrajetModel.swift +298 −0
Large diff (298 lines) is not loaded by default to keep the page fast.Load diff
added KA/Features/Trajet/TrajetView.swift +496 −0
Large diff (496 lines) is not loaded by default to keep the page fast.Load diff
modified README.md +17 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
modified project.yml +2 −2
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme