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 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 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 5 import SwiftUI
5 6
6 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 37 struct RootView: View {
37 38 @State private var showAgent = false
38 39 // onglet initial pilotable en debug — JAMAIS pendant les tests (les threads
39 40 // MapLibre de la carte feraient une course avec le exit() de XCTest)
40 − @State private var tab = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
41 − ? 0 : UserDefaults.standard.integer(forKey: "ka.debug.tab")
41 + @State private var tab: Int
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 45 @StateObject private var recents = RecentsStore.shared
46 + @StateObject private var trajet = TrajetModel()
43 47
44 − var body: some View {
45 − ZStack(alignment: .bottomTrailing) {
46 − TabView(selection: $tab) {
47 − HomeView()
48 − .tabItem { Label("Accueil", systemImage: "house.fill") }.tag(0)
49 − SearchView()
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() }
48 + init() {
49 + let initial = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
50 + ? 0 : UserDefaults.standard.integer(forKey: "ka.debug.tab")
51 + _tab = State(initialValue: initial)
52 + _loaded = State(initialValue: [initial])
53 + }
61 54
62 − // Bulle KA Agent — flottante au-dessus de la tab bar, partout
63 − Button {
64 − Haptics.rigid()
65 − showAgent = true
66 − } label: {
67 − Text("Ka")
68 − .font(.system(size: 19, weight: .bold, design: .rounded))
69 − .frame(width: 54, height: 54)
70 − .background(KATheme.lime, in: Circle())
71 − .foregroundStyle(KATheme.inkLight)
72 − .overlay(Circle().strokeBorder(KATheme.inkLight, lineWidth: 2))
73 − .shadow(color: .black.opacity(0.25), radius: 1, x: 3, y: 3)
55 + var body: some View {
56 + ZStack {
57 + pane(0) { HomeView() }
58 + pane(1) { SearchView() }
59 + pane(2) { UniversesView() }
60 + pane(3) { MapTab() }
61 + pane(4) { TrajetView().environmentObject(trajet) }
62 + pane(5) { MoreTab() }
63 + }
64 + .tint(KATheme.green)
65 + .safeAreaInset(edge: .bottom) {
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 76 .sheet(isPresented: $showAgent) {
80 77 AgentChatView()
81 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 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 26 actions
27 27 }
28 28 .padding(24)
29 − .background(KATheme.paper(scheme))
29 + .background {
30 + ZStack {
31 + KATheme.paper(scheme)
32 + KAAurora()
33 + }
34 + .ignoresSafeArea()
35 + }
30 36 .onAppear {
31 37 guard !reduceMotion else { logoRotation = -4; logoScale = 1; glow = true; return }
32 38 withAnimation(.spring(response: 0.9, dampingFraction: 0.55).delay(0.15)) {
@@ -62,6 +68,7 @@ struct OnboardingView: View {
62 68 pitchRow("magnifyingglass", "Une recherche, tout le Québec", "Logements, propriétés, autos, emplois, restos, sorties — interrogés d'un coup.")
63 69 pitchRow("heart.fill", "Des favoris qui traversent les univers", "Un 4½, une auto et un resto dans une même collection.")
64 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 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 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 −// HomeView.swift — l'accueil VIVANT : salutation selon l'heure, suggestions
3 −// contextuelles (restos le soir, sorties le week-end, emplois le matin…) avec
4 −// du VRAI contenu en direct, et le pouls de l'écosystème (compteurs live).
2 +// HomeView.swift — l'accueil LÉGENDAIRE : héros éditorial (kicker mono,
3 +// salutation surlignée lime, ticker encre en marquee du pouls live), pouls à
4 +// chiffres roulants, suggestions contextuelles avec du VRAI contenu en direct,
5 +// cartes qui « poppent » au défilement, fond aurora discret.
5 6 import SwiftUI
6 7
7 8 struct HomeView: View {
@@ -19,6 +20,7 @@ struct HomeView: View {
19 20 ScrollView {
20 21 VStack(alignment: .leading, spacing: 22) {
21 22 header
23 + tickerStrip
22 24 pulseStrip
23 25 resumeStrip
24 26 ForEach(featured, id: \.universe.id) { section in
@@ -33,7 +35,13 @@ struct HomeView: View {
33 35 }
34 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 45 .navigationTitle("")
38 46 .toolbar {
39 47 ToolbarItem(placement: .topBarLeading) {
@@ -63,33 +71,52 @@ struct HomeView: View {
63 71 }
64 72
65 73 private var header: some View {
66 − VStack(alignment: .leading, spacing: 5) {
67 − Text(greeting.title).font(KAFont.display(32))
74 + VStack(alignment: .leading, spacing: 8) {
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 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 97 private var pulseStrip: some View {
73 98 ScrollView(.horizontal, showsIndicators: false) {
74 99 HStack(spacing: 10) {
75 100 ForEach(pulse, id: \.0.id) { (u, n) in
76 101 NavigationLink(value: u.id) {
77 102 VStack(alignment: .leading, spacing: 3) {
78 − Text(n.formatted(.number.locale(Locale(identifier: "fr_CA"))))
79 − .font(.system(.headline, design: .rounded).weight(.bold))
80 − .foregroundStyle(u.accent)
81 − .contentTransition(.numericText())
82 − Text(u.unit).font(.caption2).foregroundStyle(.secondary)
103 + KACountUp(value: n,
104 + font: .system(.headline, design: .rounded).weight(.bold),
105 + color: u.accent)
106 + Text(u.unit).font(KAFont.mono(9)).textCase(.uppercase)
107 + .foregroundStyle(.secondary)
83 108 }
84 109 .padding(.horizontal, 13).padding(.vertical, 9)
85 110 .kaCard(accent: u.accent)
86 111 }
87 112 .buttonStyle(.plain)
113 + .kaScrollPop()
88 114 }
89 115 if pulse.isEmpty {
90 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 121 .accessibilityLabel("Le pouls de l'écosystème en direct")
95 122 }
@@ -134,6 +161,7 @@ struct HomeView: View {
134 161 .kaCard(accent: u.accent)
135 162 }
136 163 .buttonStyle(KAPressStyle())
164 + .kaScrollPop()
137 165 }
138 166 }
139 167 .scrollTargetLayout()
@@ -232,6 +260,7 @@ struct HomeView: View {
232 260 .kaCard(accent: u.accent)
233 261 }
234 262 .buttonStyle(KAPressStyle())
263 + .kaScrollPop()
235 264 }
236 265 }
237 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