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>
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