v1.0.0 (7) — CARTE MAPBOX-CLASS 2D/3D (MapLibre + OpenFreeMap Liberty vectoriel : pilules de prix par univers, clusters, bascule 3D avec bâtiments extrudés, localisation sur geste HIG, recherche par zone bbox), PLAYGROUND API-Ka natif (openapi.json live, formulaires générés, JSON colorisé, copier-curl), FIX Trouve-Ka (recherche d abord — l API exige q), ICÔNE liquid glass v2 (verre noir liseré lime sur blanc, perles des univers)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
14 changed files +574 −43
modified
KA/App/KAApp.swift
+2 −1
@@ -35,7 +35,8 @@ struct KAApp: App { | ||
| 35 | 35 | |
| 36 | 36 | struct RootView: View { |
| 37 | 37 | @State private var showAgent = false |
| 38 | − @State private var tab = 0 | |
| 38 | + // onglet initial pilotable pour les tests (defaults write com.groupeka.ka ka.debug.tab -int 3) | |
| 39 | + @State private var tab = UserDefaults.standard.integer(forKey: "ka.debug.tab") | |
| 39 | 40 | @StateObject private var recents = RecentsStore.shared |
| 40 | 41 | |
| 41 | 42 | var body: some View { |
modified
KA/Core/Services.swift
+2 −0
@@ -81,6 +81,8 @@ enum UniverseService { | ||
| 81 | 81 | } |
| 82 | 82 | |
| 83 | 83 | static func fetch(_ u: Universe, query: String? = nil, city: String? = nil, limit: Int = 30, params: [String: String] = [:]) async throws -> [KAItem] { |
| 84 | + // Trouve·Ka : /api/search EXIGE q (422 sinon) — jamais d'appel à vide | |
| 85 | + if u.id == "trouve-ka", (query ?? "").isEmpty { return [] } | |
| 84 | 86 | guard let url = listURL(u, query: query, city: city, limit: limit, params: params), let map = u.map else { return [] } |
| 85 | 87 | let root = try await APIClient.shared.json(url) |
| 86 | 88 | let obj = root.object ?? [:] |
added
KA/Features/APIPlaygroundView.swift
+252 −0
@@ -0,0 +1,252 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// APIPlaygroundView.swift — le PLAYGROUND API·Ka natif : la spec OpenAPI est | |
| 3 | +// chargée EN DIRECT (openapi.json), les endpoints GET sont listés, le | |
| 4 | +// formulaire de paramètres est généré, on envoie la vraie requête et on lit | |
| 5 | +// la réponse JSON colorisée avec statut + latence + « Copier en curl ». | |
| 6 | +import SwiftUI | |
| 7 | + | |
| 8 | +struct APIEndpoint: Identifiable, Hashable { | |
| 9 | + let id: String // chemin | |
| 10 | + let path: String | |
| 11 | + let summary: String? | |
| 12 | + let params: [Param] | |
| 13 | + struct Param: Identifiable, Hashable { | |
| 14 | + var id: String { name } | |
| 15 | + let name: String | |
| 16 | + let required: Bool | |
| 17 | + let inPath: Bool // {service} dans le chemin | |
| 18 | + let description: String? | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +struct APIPlaygroundView: View { | |
| 23 | + let universe: Universe | |
| 24 | + @State private var endpoints: [APIEndpoint] = [] | |
| 25 | + @State private var selected: APIEndpoint? | |
| 26 | + @State private var values: [String: String] = [:] | |
| 27 | + @State private var response: String = "" | |
| 28 | + @State private var status: Int? | |
| 29 | + @State private var latencyMs: Int? | |
| 30 | + @State private var sending = false | |
| 31 | + @State private var loadFailed = false | |
| 32 | + @Environment(\.colorScheme) private var scheme | |
| 33 | + | |
| 34 | + private var builtURL: URL? { | |
| 35 | + guard let e = selected else { return nil } | |
| 36 | + var path = e.path | |
| 37 | + for p in e.params where p.inPath { | |
| 38 | + let v = values[p.name] ?? "" | |
| 39 | + guard !v.isEmpty else { return nil } | |
| 40 | + path = path.replacingOccurrences(of: "{\(p.name)}", with: v) | |
| 41 | + } | |
| 42 | + var comps = URLComponents(string: "https://www.api-ka.com\(path)")! | |
| 43 | + let qs = e.params.filter { !$0.inPath } | |
| 44 | + .compactMap { p -> URLQueryItem? in | |
| 45 | + guard let v = values[p.name], !v.isEmpty else { return nil } | |
| 46 | + return URLQueryItem(name: p.name, value: v) | |
| 47 | + } | |
| 48 | + comps.queryItems = qs.isEmpty ? nil : qs | |
| 49 | + return comps.url | |
| 50 | + } | |
| 51 | + | |
| 52 | + var body: some View { | |
| 53 | + ScrollView { | |
| 54 | + VStack(alignment: .leading, spacing: 16) { | |
| 55 | + VStack(alignment: .leading, spacing: 4) { | |
| 56 | + Text("PLAYGROUND · SPEC OPENAPI EN DIRECT") | |
| 57 | + .font(.system(size: 10, design: .monospaced).weight(.bold)) | |
| 58 | + .foregroundStyle(universe.accent) | |
| 59 | + Text("Essayez l'API de l'écosystème") | |
| 60 | + .font(.title3.weight(.bold)) | |
| 61 | + Text("Les données quotidiennes des services Ka, ouvertes et documentées.") | |
| 62 | + .font(.caption).foregroundStyle(.secondary) | |
| 63 | + } | |
| 64 | + | |
| 65 | + if endpoints.isEmpty && !loadFailed { | |
| 66 | + ForEach(0..<4, id: \.self) { _ in KASkeletonRow() } | |
| 67 | + } else if loadFailed { | |
| 68 | + KAEmptyState(symbol: "wifi.exclamationmark", title: "Spec inaccessible", | |
| 69 | + message: "Impossible de charger openapi.json — réessayez.") | |
| 70 | + } | |
| 71 | + | |
| 72 | + // endpoints | |
| 73 | + VStack(spacing: 8) { | |
| 74 | + ForEach(endpoints) { e in | |
| 75 | + Button { | |
| 76 | + Haptics.tap() | |
| 77 | + withAnimation(.snappy) { | |
| 78 | + selected = e | |
| 79 | + values = [:] | |
| 80 | + response = ""; status = nil; latencyMs = nil | |
| 81 | + } | |
| 82 | + } label: { | |
| 83 | + HStack(spacing: 10) { | |
| 84 | + Text("GET") | |
| 85 | + .font(.system(size: 10, design: .monospaced).weight(.bold)) | |
| 86 | + .padding(.horizontal, 7).padding(.vertical, 3) | |
| 87 | + .background(universe.accent.opacity(selected?.id == e.id ? 1 : 0.15), in: RoundedRectangle(cornerRadius: 6)) | |
| 88 | + .foregroundStyle(selected?.id == e.id ? .white : universe.accent) | |
| 89 | + VStack(alignment: .leading, spacing: 1) { | |
| 90 | + Text(e.path).font(.system(.caption, design: .monospaced).weight(.bold)) | |
| 91 | + .lineLimit(1).minimumScaleFactor(0.7) | |
| 92 | + if let s = e.summary { | |
| 93 | + Text(s).font(.caption2).foregroundStyle(.secondary).lineLimit(1) | |
| 94 | + } | |
| 95 | + } | |
| 96 | + Spacer() | |
| 97 | + Image(systemName: selected?.id == e.id ? "chevron.down" : "chevron.right") | |
| 98 | + .font(.caption2).foregroundStyle(.tertiary) | |
| 99 | + } | |
| 100 | + .padding(11) | |
| 101 | + .kaCard(accent: selected?.id == e.id ? universe.accent : nil) | |
| 102 | + } | |
| 103 | + .buttonStyle(KAPressStyle()) | |
| 104 | + | |
| 105 | + if selected?.id == e.id { | |
| 106 | + requestPanel(e) | |
| 107 | + } | |
| 108 | + } | |
| 109 | + } | |
| 110 | + } | |
| 111 | + .padding(16) | |
| 112 | + } | |
| 113 | + .background(KATheme.paper(scheme)) | |
| 114 | + .task { await loadSpec() } | |
| 115 | + } | |
| 116 | + | |
| 117 | + // MARK: panneau de requête | |
| 118 | + | |
| 119 | + @ViewBuilder | |
| 120 | + private func requestPanel(_ e: APIEndpoint) -> some View { | |
| 121 | + VStack(alignment: .leading, spacing: 10) { | |
| 122 | + ForEach(e.params) { p in | |
| 123 | + HStack(spacing: 8) { | |
| 124 | + Text(p.name) | |
| 125 | + .font(.system(.caption, design: .monospaced).weight(.bold)) | |
| 126 | + .frame(width: 86, alignment: .leading) | |
| 127 | + TextField(p.inPath ? "requis (chemin)" : (p.required ? "requis" : "optionnel"), | |
| 128 | + text: Binding(get: { values[p.name] ?? "" }, | |
| 129 | + set: { values[p.name] = $0 })) | |
| 130 | + .font(.system(.caption, design: .monospaced)) | |
| 131 | + .textInputAutocapitalization(.never) | |
| 132 | + .autocorrectionDisabled() | |
| 133 | + .padding(8) | |
| 134 | + .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8)) | |
| 135 | + .overlay(RoundedRectangle(cornerRadius: 8) | |
| 136 | + .strokeBorder(p.required && (values[p.name] ?? "").isEmpty ? universe.accent.opacity(0.6) : Color.primary.opacity(0.25), lineWidth: 1)) | |
| 137 | + } | |
| 138 | + } | |
| 139 | + if let url = builtURL { | |
| 140 | + Text(url.absoluteString) | |
| 141 | + .font(.system(size: 10, design: .monospaced)) | |
| 142 | + .foregroundStyle(.secondary) | |
| 143 | + .lineLimit(2) | |
| 144 | + .textSelection(.enabled) | |
| 145 | + } | |
| 146 | + HStack(spacing: 10) { | |
| 147 | + Button { | |
| 148 | + Task { await send() } | |
| 149 | + } label: { | |
| 150 | + HStack { | |
| 151 | + if sending { ProgressView().tint(.white) } | |
| 152 | + Text(sending ? "Envoi…" : "Envoyer la requête ➤") | |
| 153 | + .font(.subheadline.weight(.bold)) | |
| 154 | + } | |
| 155 | + .padding(.horizontal, 16).padding(.vertical, 11) | |
| 156 | + .background(builtURL == nil ? Color.gray : universe.accent, in: Capsule()) | |
| 157 | + .foregroundStyle(.white) | |
| 158 | + } | |
| 159 | + .disabled(builtURL == nil || sending) | |
| 160 | + if let url = builtURL { | |
| 161 | + Button { | |
| 162 | + UIPasteboard.general.string = "curl -s '\(url.absoluteString)'" | |
| 163 | + Haptics.success() | |
| 164 | + } label: { | |
| 165 | + Label("curl", systemImage: "doc.on.doc") | |
| 166 | + .font(.caption.weight(.bold)) | |
| 167 | + .padding(.horizontal, 12).padding(.vertical, 11) | |
| 168 | + .background(KATheme.inkLight, in: Capsule()) | |
| 169 | + .foregroundStyle(KATheme.lime) | |
| 170 | + } | |
| 171 | + .accessibilityLabel("Copier la commande curl") | |
| 172 | + } | |
| 173 | + Spacer() | |
| 174 | + if let s = status { | |
| 175 | + HStack(spacing: 6) { | |
| 176 | + Circle().fill(s == 200 ? .green : .red).frame(width: 8, height: 8) | |
| 177 | + Text("\(s)").font(.system(.caption, design: .monospaced).weight(.bold)) | |
| 178 | + if let ms = latencyMs { | |
| 179 | + Text("· \(ms) ms").font(.system(.caption2, design: .monospaced)).foregroundStyle(.secondary) | |
| 180 | + } | |
| 181 | + } | |
| 182 | + } | |
| 183 | + } | |
| 184 | + if !response.isEmpty { | |
| 185 | + ScrollView([.vertical, .horizontal]) { | |
| 186 | + Text(response) | |
| 187 | + .font(.system(size: 11, design: .monospaced)) | |
| 188 | + .textSelection(.enabled) | |
| 189 | + .padding(12) | |
| 190 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 191 | + } | |
| 192 | + .frame(maxHeight: 340) | |
| 193 | + .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) | |
| 194 | + .foregroundStyle(Color(hex: "#d9f2c9")) | |
| 195 | + } | |
| 196 | + } | |
| 197 | + .padding(12) | |
| 198 | + .background(universe.accent.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) | |
| 199 | + } | |
| 200 | + | |
| 201 | + // MARK: réseau | |
| 202 | + | |
| 203 | + private func loadSpec() async { | |
| 204 | + guard endpoints.isEmpty else { return } | |
| 205 | + guard let url = URL(string: "https://www.api-ka.com/openapi.json"), | |
| 206 | + let root = try? await APIClient.shared.json(url, ttl: 600), | |
| 207 | + let paths = root.object?["paths"]?.object else { loadFailed = true; return } | |
| 208 | + var eps: [APIEndpoint] = [] | |
| 209 | + for (path, methods) in paths.sorted(by: { $0.key < $1.key }) { | |
| 210 | + guard let get = methods.object?["get"]?.object else { continue } | |
| 211 | + let params: [APIEndpoint.Param] = (get["parameters"]?.array ?? []).compactMap { p in | |
| 212 | + guard let po = p.object, let name = po.str("name") else { return nil } | |
| 213 | + let inPath = po.str("in") == "path" | |
| 214 | + let required: Bool = { if case .bool(true) = po["required"] ?? .null { return true }; return inPath }() | |
| 215 | + return .init(name: name, required: required, inPath: inPath, | |
| 216 | + description: po.str("description")) | |
| 217 | + } | |
| 218 | + eps.append(APIEndpoint(id: path, path: path, | |
| 219 | + summary: get.str("summary") ?? get.str("description")?.prefix(70).description, | |
| 220 | + params: params)) | |
| 221 | + } | |
| 222 | + withAnimation(.snappy) { endpoints = eps } | |
| 223 | + } | |
| 224 | + | |
| 225 | + private func send() async { | |
| 226 | + guard let url = builtURL else { return } | |
| 227 | + sending = true | |
| 228 | + Haptics.rigid() | |
| 229 | + defer { sending = false } | |
| 230 | + let start = Date() | |
| 231 | + do { | |
| 232 | + var req = URLRequest(url: url) | |
| 233 | + req.setValue("application/json", forHTTPHeaderField: "Accept") | |
| 234 | + let (data, resp) = try await URLSession.shared.data(for: req) | |
| 235 | + latencyMs = Int(Date().timeIntervalSince(start) * 1000) | |
| 236 | + status = (resp as? HTTPURLResponse)?.statusCode | |
| 237 | + if let obj = try? JSONSerialization.jsonObject(with: data), | |
| 238 | + let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]) { | |
| 239 | + var text = String(decoding: pretty, as: UTF8.self) | |
| 240 | + if text.count > 12000 { text = String(text.prefix(12000)) + "\n… (tronqué)" } | |
| 241 | + response = text | |
| 242 | + } else { | |
| 243 | + response = String(decoding: data.prefix(8000), as: UTF8.self) | |
| 244 | + } | |
| 245 | + Haptics.success() | |
| 246 | + } catch { | |
| 247 | + status = nil | |
| 248 | + latencyMs = nil | |
| 249 | + response = "Erreur réseau : \(error.localizedDescription)" | |
| 250 | + } | |
| 251 | + } | |
| 252 | +} | |
added
KA/Features/KaMapLibre.swift
+160 −0
@@ -0,0 +1,160 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact @spboucher.ai | |
| 2 | +// KaMapLibre.swift — la carte MAPBOX 2D/3D de l'écosystème (comme Ka Maps sur | |
| 3 | +// lou-ka.com) rendue par MapLibre Native : style Mapbox Streets v12 (jeton | |
| 4 | +// public du web), BASCULE 2D ↔ 3D (inclinaison + bâtiments extrudés), | |
| 5 | +// marqueurs-pilules SwiftUI hébergés dans des MLNAnnotationView. | |
| 6 | +import SwiftUI | |
| 7 | +import MapLibre | |
| 8 | +import MapKit | |
| 9 | + | |
| 10 | +enum KaMapStyle { | |
| 11 | + // Tuiles vectorielles OpenFreeMap (style Liberty) : libres, sans clé, rendu | |
| 12 | + // vectoriel 2D/3D de classe Mapbox — les styles Mapbox natifs utilisent des | |
| 13 | + // sources mapbox:// que MapLibre ne résout pas. | |
| 14 | + static var url: URL { URL(string: "https://tiles.openfreemap.org/styles/liberty")! } | |
| 15 | +} | |
| 16 | + | |
| 17 | +/// Annotation portant un cluster (1 item = pilule de prix ; n = badge). | |
| 18 | +final class ClusterAnnotation: NSObject, MLNAnnotation { | |
| 19 | + let cluster: MapCluster | |
| 20 | + var coordinate: CLLocationCoordinate2D { | |
| 21 | + .init(latitude: cluster.latitude, longitude: cluster.longitude) | |
| 22 | + } | |
| 23 | + var title: String? { nil } | |
| 24 | + init(_ c: MapCluster) { cluster = c } | |
| 25 | +} | |
| 26 | + | |
| 27 | +struct KaMapLibreView: UIViewRepresentable { | |
| 28 | + var clusters: [MapCluster] | |
| 29 | + var programRegion: MKCoordinateRegion | |
| 30 | + var programEpoch: Int | |
| 31 | + var pitch3D: Bool | |
| 32 | + var selectedID: String? | |
| 33 | + var onRegionChange: (MKCoordinateRegion) -> Void | |
| 34 | + var onSelect: (MapCluster) -> Void | |
| 35 | + | |
| 36 | + func makeUIView(context: Context) -> MLNMapView { | |
| 37 | + let map = MLNMapView(frame: .zero, styleURL: KaMapStyle.url) | |
| 38 | + map.delegate = context.coordinator | |
| 39 | + map.logoView.isHidden = false // attribution Mapbox requise | |
| 40 | + map.attributionButtonPosition = .bottomLeft | |
| 41 | + map.setCenter(programRegion.center, zoomLevel: zoom(from: programRegion), animated: false) | |
| 42 | + map.allowsRotating = true | |
| 43 | + map.allowsTilting = true | |
| 44 | + // n'affiche le point bleu (et ne déclenche la permission) que si déjà accordée | |
| 45 | + let auth = CLLocationManager().authorizationStatus | |
| 46 | + map.showsUserLocation = (auth == .authorizedWhenInUse || auth == .authorizedAlways) | |
| 47 | + return map | |
| 48 | + } | |
| 49 | + | |
| 50 | + func updateUIView(_ map: MLNMapView, context: Context) { | |
| 51 | + context.coordinator.parent = self | |
| 52 | + // recentrage programmé (localisation, dégroupage, liste) — piloté par epoch | |
| 53 | + if context.coordinator.lastEpoch != programEpoch { | |
| 54 | + context.coordinator.lastEpoch = programEpoch | |
| 55 | + let cam = map.camera | |
| 56 | + cam.centerCoordinate = programRegion.center | |
| 57 | + cam.pitch = pitch3D ? 58 : 0 | |
| 58 | + map.setCamera(cam, animated: false) | |
| 59 | + map.setZoomLevel(zoom(from: programRegion), animated: true) | |
| 60 | + } | |
| 61 | + // bascule 2D/3D | |
| 62 | + if context.coordinator.lastPitch3D != pitch3D { | |
| 63 | + context.coordinator.lastPitch3D = pitch3D | |
| 64 | + let cam = map.camera | |
| 65 | + cam.pitch = pitch3D ? 58 : 0 | |
| 66 | + map.fly(to: cam, withDuration: 0.7, completionHandler: nil) | |
| 67 | + } | |
| 68 | + // annotations : resynchroniser (jeu de clusters OU sélection changée) | |
| 69 | + let currentIDs = Set(clusters.map(\.id)) | |
| 70 | + let existing = (map.annotations ?? []).compactMap { $0 as? ClusterAnnotation } | |
| 71 | + let existingIDs = Set(existing.map(\.cluster.id)) | |
| 72 | + if currentIDs != existingIDs || context.coordinator.lastSelectedID != selectedID { | |
| 73 | + context.coordinator.lastSelectedID = selectedID | |
| 74 | + map.removeAnnotations(existing) | |
| 75 | + map.addAnnotations(clusters.map(ClusterAnnotation.init)) | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + func makeCoordinator() -> Coordinator { Coordinator(self) } | |
| 80 | + | |
| 81 | + private func zoom(from region: MKCoordinateRegion) -> Double { | |
| 82 | + // approximation classique span → niveau de zoom Web Mercator | |
| 83 | + let span = max(region.span.longitudeDelta, 0.0005) | |
| 84 | + return max(1, min(18, log2(360 / span) )) | |
| 85 | + } | |
| 86 | + | |
| 87 | + final class Coordinator: NSObject, MLNMapViewDelegate { | |
| 88 | + var parent: KaMapLibreView | |
| 89 | + var lastPitch3D = false | |
| 90 | + var lastEpoch = Int.min | |
| 91 | + var lastSelectedID: String? | |
| 92 | + private var buildingsAdded = false | |
| 93 | + | |
| 94 | + init(_ p: KaMapLibreView) { parent = p } | |
| 95 | + | |
| 96 | + // ---- style chargé : ajouter les BÂTIMENTS 3D (fill-extrusion) ---- | |
| 97 | + func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) { | |
| 98 | + guard !buildingsAdded else { return } | |
| 99 | + // source vectorielle openmaptiles (Liberty) — repli : première source dispo | |
| 100 | + let source = style.source(withIdentifier: "openmaptiles") | |
| 101 | + ?? style.source(withIdentifier: "composite") | |
| 102 | + ?? style.sources.first | |
| 103 | + guard let composite = source else { return } | |
| 104 | + buildingsAdded = true | |
| 105 | + let layer = MLNFillExtrusionStyleLayer(identifier: "ka-3d-buildings", source: composite) | |
| 106 | + layer.sourceLayerIdentifier = "building" | |
| 107 | + // OpenMapTiles : render_height / render_min_height | |
| 108 | + layer.fillExtrusionHeight = NSExpression(forKeyPath: "render_height") | |
| 109 | + layer.fillExtrusionBase = NSExpression(forKeyPath: "render_min_height") | |
| 110 | + layer.fillExtrusionColor = NSExpression(forConstantValue: UIColor(red: 0.82, green: 0.83, blue: 0.80, alpha: 1)) | |
| 111 | + layer.fillExtrusionOpacity = NSExpression(forConstantValue: 0.75) | |
| 112 | + // insérer sous les étiquettes pour garder les noms lisibles | |
| 113 | + if let firstSymbol = style.layers.first(where: { $0 is MLNSymbolStyleLayer }) { | |
| 114 | + style.insertLayer(layer, below: firstSymbol) | |
| 115 | + } else { | |
| 116 | + style.addLayer(layer) | |
| 117 | + } | |
| 118 | + } | |
| 119 | + | |
| 120 | + // ---- pilules SwiftUI comme vues d'annotation ---- | |
| 121 | + func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? { | |
| 122 | + guard let ann = annotation as? ClusterAnnotation else { return nil } | |
| 123 | + let view = MLNAnnotationView(reuseIdentifier: nil) | |
| 124 | + let cluster = ann.cluster | |
| 125 | + let selected = parent.selectedID == cluster.id | |
| 126 | + let content: AnyView = cluster.isSingle | |
| 127 | + ? AnyView(PricePill(item: cluster.item, selected: selected)) | |
| 128 | + : AnyView(ClusterBadge(cluster: cluster)) | |
| 129 | + let host = UIHostingController(rootView: content) | |
| 130 | + host.view.backgroundColor = .clear | |
| 131 | + let size = host.sizeThatFits(in: CGSize(width: 200, height: 60)) | |
| 132 | + host.view.frame = CGRect(origin: .zero, size: size) | |
| 133 | + view.frame = host.view.frame | |
| 134 | + view.addSubview(host.view) | |
| 135 | + view.isUserInteractionEnabled = true | |
| 136 | + let tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:))) | |
| 137 | + view.addGestureRecognizer(tap) | |
| 138 | + view.accessibilityLabel = cluster.isSingle ? cluster.item.title : "Groupe de \(cluster.items.count) résultats" | |
| 139 | + objc_setAssociatedObject(view, &AssocKeys.cluster, cluster, .OBJC_ASSOCIATION_RETAIN) | |
| 140 | + return view | |
| 141 | + } | |
| 142 | + | |
| 143 | + @objc private func tapped(_ g: UITapGestureRecognizer) { | |
| 144 | + guard let v = g.view, | |
| 145 | + let cluster = objc_getAssociatedObject(v, &AssocKeys.cluster) as? MapCluster else { return } | |
| 146 | + parent.onSelect(cluster) | |
| 147 | + } | |
| 148 | + | |
| 149 | + func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) { | |
| 150 | + let bounds = mapView.visibleCoordinateBounds | |
| 151 | + let region = MKCoordinateRegion( | |
| 152 | + center: mapView.centerCoordinate, | |
| 153 | + span: .init(latitudeDelta: abs(bounds.ne.latitude - bounds.sw.latitude), | |
| 154 | + longitudeDelta: abs(bounds.ne.longitude - bounds.sw.longitude))) | |
| 155 | + parent.onRegionChange(region) | |
| 156 | + } | |
| 157 | + } | |
| 158 | +} | |
| 159 | + | |
| 160 | +private enum AssocKeys { static var cluster: UInt8 = 0 } | |
modified
KA/Features/MapView.swift
+74 −39
@@ -7,6 +7,7 @@ | ||
| 7 | 7 | import SwiftUI |
| 8 | 8 | import MapKit |
| 9 | 9 | import CoreLocation |
| 10 | +// Rendu : MapLibre (style Mapbox streets v12 + bâtiments 3D) — voir KaMapLibre.swift | |
| 10 | 11 | |
| 11 | 12 | // MARK: - Clustering par grille (pur, testé dans KATests) |
| 12 | 13 | |
@@ -47,9 +48,11 @@ enum ClusterEngine { | ||
| 47 | 48 | struct UnifiedMapView: View { |
| 48 | 49 | /// true quand la carte est un ONGLET (pas de bouton fermer) |
| 49 | 50 | var embedded: Bool = false |
| 50 | − @State private var camera: MapCameraPosition = .region( | |
| 51 | − MKCoordinateRegion(center: .init(latitude: 46.81, longitude: -71.21), | |
| 52 | − span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3))) | |
| 51 | + @State private var programRegion = MKCoordinateRegion( | |
| 52 | + center: .init(latitude: 46.81, longitude: -71.21), | |
| 53 | + span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3)) | |
| 54 | + @State private var programEpoch = 0 | |
| 55 | + @State private var pitch3D = false | |
| 53 | 56 | @State private var visibleRegion = MKCoordinateRegion( |
| 54 | 57 | center: .init(latitude: 46.81, longitude: -71.21), |
| 55 | 58 | span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3)) |
@@ -108,12 +111,12 @@ struct UnifiedMapView: View { | ||
| 108 | 111 | .presentationBackgroundInteraction(.enabled(upThrough: .medium)) |
| 109 | 112 | } |
| 110 | 113 | .task { |
| 111 | − location.request() | |
| 114 | + location.requestIfAuthorized() // HIG : pas de dialogue à l'ouverture | |
| 112 | 115 | await loadZone() |
| 113 | 116 | } |
| 114 | 117 | .onChange(of: location.coordinate != nil) { |
| 115 | 118 | if let c = location.coordinate { |
| 116 | − withAnimation { camera = .region(.init(center: c, span: .init(latitudeDelta: 0.18, longitudeDelta: 0.18))) } | |
| 119 | + recenter(.init(center: c, span: .init(latitudeDelta: 0.18, longitudeDelta: 0.18))) | |
| 117 | 120 | Task { await loadZone() } |
| 118 | 121 | } |
| 119 | 122 | } |
@@ -123,39 +126,35 @@ struct UnifiedMapView: View { | ||
| 123 | 126 | // MARK: couches |
| 124 | 127 | |
| 125 | 128 | private var mapLayer: some View { |
| 126 | − Map(position: $camera) { | |
| 127 | − UserAnnotation() | |
| 128 | − ForEach(clusters) { cluster in | |
| 129 | − Annotation("", coordinate: .init(latitude: cluster.latitude, longitude: cluster.longitude)) { | |
| 130 | − if cluster.isSingle { | |
| 131 | − PricePill(item: cluster.item, | |
| 132 | − selected: selectedCluster?.id == cluster.id) | |
| 133 | − .onTapGesture { | |
| 134 | − Haptics.tap() | |
| 135 | − withAnimation(.snappy) { selectedCluster = cluster } | |
| 136 | − } | |
| 137 | − } else { | |
| 138 | − ClusterBadge(cluster: cluster) | |
| 139 | − .onTapGesture { | |
| 140 | − Haptics.rigid() | |
| 141 | − // dégroupage : zoom sur le groupe | |
| 142 | − withAnimation(.easeInOut(duration: 0.4)) { | |
| 143 | − camera = .region(.init( | |
| 144 | − center: .init(latitude: cluster.latitude, longitude: cluster.longitude), | |
| 145 | − span: .init(latitudeDelta: visibleRegion.span.latitudeDelta / 3.2, | |
| 146 | − longitudeDelta: visibleRegion.span.longitudeDelta / 3.2))) | |
| 147 | − } | |
| 148 | − } | |
| 149 | − } | |
| 129 | + KaMapLibreView( | |
| 130 | + clusters: clusters, | |
| 131 | + programRegion: programRegion, | |
| 132 | + programEpoch: programEpoch, | |
| 133 | + pitch3D: pitch3D, | |
| 134 | + selectedID: selectedCluster?.id, | |
| 135 | + onRegionChange: { region in | |
| 136 | + visibleRegion = region | |
| 137 | + zoneDirty = true | |
| 138 | + }, | |
| 139 | + onSelect: { cluster in | |
| 140 | + if cluster.isSingle { | |
| 141 | + Haptics.tap() | |
| 142 | + withAnimation(.snappy) { selectedCluster = cluster } | |
| 143 | + } else { | |
| 144 | + Haptics.rigid() | |
| 145 | + // dégroupage : zoom sur le groupe | |
| 146 | + recenter(.init(center: .init(latitude: cluster.latitude, longitude: cluster.longitude), | |
| 147 | + span: .init(latitudeDelta: visibleRegion.span.latitudeDelta / 3.2, | |
| 148 | + longitudeDelta: visibleRegion.span.longitudeDelta / 3.2))) | |
| 150 | 149 | } |
| 151 | − .annotationTitles(.hidden) | |
| 152 | − } | |
| 153 | − } | |
| 154 | − .mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll)) | |
| 155 | − .onMapCameraChange(frequency: .onEnd) { ctx in | |
| 156 | − visibleRegion = ctx.region | |
| 157 | − zoneDirty = true | |
| 158 | − } | |
| 150 | + }) | |
| 151 | + .ignoresSafeArea(edges: .bottom) | |
| 152 | + } | |
| 153 | + | |
| 154 | + /// Recentrage programmé de la carte | |
| 155 | + private func recenter(_ region: MKCoordinateRegion) { | |
| 156 | + programRegion = region | |
| 157 | + programEpoch += 1 | |
| 159 | 158 | } |
| 160 | 159 | |
| 161 | 160 | private var chips: some View { |
@@ -186,6 +185,34 @@ struct UnifiedMapView: View { | ||
| 186 | 185 | |
| 187 | 186 | private var bottomBar: some View { |
| 188 | 187 | HStack(spacing: 10) { |
| 188 | + Button { | |
| 189 | + Haptics.rigid() | |
| 190 | + pitch3D.toggle() | |
| 191 | + } label: { | |
| 192 | + Text(pitch3D ? "2D" : "3D") | |
| 193 | + .font(.system(.caption, design: .rounded).weight(.bold)) | |
| 194 | + .frame(width: 40, height: 36) | |
| 195 | + .background(pitch3D ? KATheme.inkLight : .clear) | |
| 196 | + .background(.ultraThinMaterial, in: Capsule()) | |
| 197 | + .foregroundStyle(pitch3D ? KATheme.lime : .primary) | |
| 198 | + .overlay(Capsule().strokeBorder(.primary.opacity(0.3), lineWidth: 1)) | |
| 199 | + } | |
| 200 | + .accessibilityLabel(pitch3D ? "Repasser en vue 2D" : "Passer en vue 3D avec bâtiments") | |
| 201 | + Button { | |
| 202 | + Haptics.tap() | |
| 203 | + location.request() // la permission n'est demandée QU'ICI, sur geste | |
| 204 | + if let c = location.coordinate { | |
| 205 | + recenter(.init(center: c, span: .init(latitudeDelta: 0.12, longitudeDelta: 0.12))) | |
| 206 | + Task { await loadZone() } | |
| 207 | + } | |
| 208 | + } label: { | |
| 209 | + Image(systemName: "location.fill") | |
| 210 | + .font(.caption.weight(.bold)) | |
| 211 | + .frame(width: 36, height: 36) | |
| 212 | + .background(.ultraThinMaterial, in: Circle()) | |
| 213 | + .overlay(Circle().strokeBorder(.primary.opacity(0.3), lineWidth: 1)) | |
| 214 | + } | |
| 215 | + .accessibilityLabel("Autour de moi") | |
| 189 | 216 | Text(loading ? "Chargement…" : "\(visibleItems.count) résultats") |
| 190 | 217 | .font(.system(.caption, design: .monospaced).weight(.bold)) |
| 191 | 218 | .padding(.horizontal, 11).padding(.vertical, 9) |
@@ -257,8 +284,8 @@ struct UnifiedMapView: View { | ||
| 257 | 284 | withAnimation(.snappy) { |
| 258 | 285 | showList = false |
| 259 | 286 | if let la = item.latitude, let lo = item.longitude { |
| 260 | − camera = .region(.init(center: .init(latitude: la, longitude: lo), | |
| 261 | − span: .init(latitudeDelta: 0.02, longitudeDelta: 0.02))) | |
| 287 | + recenter(.init(center: .init(latitude: la, longitude: lo), | |
| 288 | + span: .init(latitudeDelta: 0.02, longitudeDelta: 0.02))) | |
| 262 | 289 | } |
| 263 | 290 | selectedCluster = MapCluster(id: item.id, latitude: item.latitude ?? 0, |
| 264 | 291 | longitude: item.longitude ?? 0, items: [item]) |
@@ -358,6 +385,14 @@ final class LocationOnce: NSObject, ObservableObject, CLLocationManagerDelegate | ||
| 358 | 385 | manager.requestLocation() |
| 359 | 386 | } |
| 360 | 387 | } |
| 388 | + | |
| 389 | + /// Ne demande JAMAIS la permission — n'agit que si elle est déjà accordée. | |
| 390 | + func requestIfAuthorized() { | |
| 391 | + manager.delegate = self | |
| 392 | + if manager.authorizationStatus == .authorizedWhenInUse || manager.authorizationStatus == .authorizedAlways { | |
| 393 | + manager.requestLocation() | |
| 394 | + } | |
| 395 | + } | |
| 361 | 396 | func locationManagerDidChangeAuthorization(_ m: CLLocationManager) { |
| 362 | 397 | if m.authorizationStatus == .authorizedWhenInUse || m.authorizationStatus == .authorizedAlways { |
| 363 | 398 | m.requestLocation() |
modified
KA/Features/UniversesView.swift
+10 −2
@@ -147,6 +147,8 @@ struct UniverseHomeView: View { | ||
| 147 | 147 | Group { |
| 148 | 148 | if universe.id == "vrai-prix" { |
| 149 | 149 | VraiPrixView(universe: universe) |
| 150 | + } else if universe.id == "api-ka" { | |
| 151 | + APIPlaygroundView(universe: universe) | |
| 150 | 152 | } else if universe.listPath == nil { |
| 151 | 153 | webUniverse |
| 152 | 154 | } else { |
@@ -214,8 +216,14 @@ struct UniverseHomeView: View { | ||
| 214 | 216 | KAEmptyState(symbol: "wifi.exclamationmark", title: "Impossible de charger", |
| 215 | 217 | message: e + "\nTirez pour réessayer.") |
| 216 | 218 | } else if items.isEmpty { |
| 217 | − KAEmptyState(symbol: "tray", title: "Aucun résultat", | |
| 218 | − message: "Essayez d'autres mots-clés.") | |
| 219 | + if universe.id == "trouve-ka" && query.isEmpty { | |
| 220 | + KAEmptyState(symbol: "magnifyingglass", | |
| 221 | + title: "Cherchez le web québécois", | |
| 222 | + message: "1,2 M de pages d'ici indexées — tapez un mot dans la barre de recherche ci-dessus.") | |
| 223 | + } else { | |
| 224 | + KAEmptyState(symbol: "tray", title: "Aucun résultat", | |
| 225 | + message: "Essayez d'autres mots-clés.") | |
| 226 | + } | |
| 219 | 227 | } else { |
| 220 | 228 | ForEach(items) { item in |
| 221 | 229 | NavigationLink(value: item) { KAItemRow(item: item) } |
modified
KA/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png
+0 −0
Binary file not shown.
added
docs/icon-glass-1024.png
+0 −0
Binary file not shown.
added
docs/icon-glass.svg
+67 −0
@@ -0,0 +1,67 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"> | |
| 2 | + <!-- Icône KA « liquid glass » v2 — verre fumé encre TRÈS lisible sur blanc --> | |
| 3 | + <defs> | |
| 4 | + <radialGradient id="bg" cx="35%" cy="22%" r="95%"> | |
| 5 | + <stop offset="0%" stop-color="#ffffff"/> | |
| 6 | + <stop offset="65%" stop-color="#f6f8f2"/> | |
| 7 | + <stop offset="100%" stop-color="#e9eee0"/> | |
| 8 | + </radialGradient> | |
| 9 | + <linearGradient id="inkGlass" x1="0%" y1="0%" x2="12%" y2="100%"> | |
| 10 | + <stop offset="0%" stop-color="#3a463a"/> | |
| 11 | + <stop offset="50%" stop-color="#161c16"/> | |
| 12 | + <stop offset="100%" stop-color="#060906"/> | |
| 13 | + </linearGradient> | |
| 14 | + <linearGradient id="limeRim" x1="0%" y1="0%" x2="0%" y2="100%"> | |
| 15 | + <stop offset="0%" stop-color="#eaffa3"/> | |
| 16 | + <stop offset="100%" stop-color="#b8d94e"/> | |
| 17 | + </linearGradient> | |
| 18 | + <linearGradient id="blobA" x1="0%" y1="0%" x2="100%" y2="100%"> | |
| 19 | + <stop offset="0%" stop-color="#d9f26b" stop-opacity="0.5"/> | |
| 20 | + <stop offset="100%" stop-color="#1c5c41" stop-opacity="0.15"/> | |
| 21 | + </linearGradient> | |
| 22 | + <filter id="softShadow" x="-40%" y="-40%" width="180%" height="180%"> | |
| 23 | + <feDropShadow dx="0" dy="24" stdDeviation="30" flood-color="#12240e" flood-opacity="0.35"/> | |
| 24 | + </filter> | |
| 25 | + <filter id="blur16"><feGaussianBlur stdDeviation="16"/></filter> | |
| 26 | + <filter id="blur6"><feGaussianBlur stdDeviation="6"/></filter> | |
| 27 | + </defs> | |
| 28 | + | |
| 29 | + <rect width="1024" height="1024" fill="url(#bg)"/> | |
| 30 | + | |
| 31 | + <!-- gouttes liquides lime --> | |
| 32 | + <ellipse cx="822" cy="180" rx="150" ry="115" fill="url(#blobA)" filter="url(#blur16)"/> | |
| 33 | + <ellipse cx="170" cy="820" rx="165" ry="125" fill="url(#blobA)" filter="url(#blur16)"/> | |
| 34 | + | |
| 35 | + <!-- KA en verre fumé encre : liseré lime + ombre portée + reflets --> | |
| 36 | + <g transform="rotate(-4 512 512)"> | |
| 37 | + <text x="512" y="678" text-anchor="middle" filter="url(#softShadow)" | |
| 38 | + font-family="-apple-system,'SF Pro Rounded','Arial Rounded MT Bold',sans-serif" | |
| 39 | + font-size="560" font-weight="800" letter-spacing="-22" | |
| 40 | + fill="url(#inkGlass)" stroke="url(#limeRim)" stroke-width="12" | |
| 41 | + paint-order="stroke fill">KA</text> | |
| 42 | + <!-- reflet spéculaire dans le verre --> | |
| 43 | + <path d="M 200 300 Q 512 214 824 300 L 824 372 Q 512 292 200 388 Z" | |
| 44 | + fill="#ffffff" opacity="0.30" filter="url(#blur6)"/> | |
| 45 | + <!-- goutte de lumière --> | |
| 46 | + <ellipse cx="352" cy="330" rx="46" ry="20" fill="#ffffff" opacity="0.75" | |
| 47 | + filter="url(#blur6)" transform="rotate(-12 352 330)"/> | |
| 48 | + </g> | |
| 49 | + | |
| 50 | + <!-- perles de verre des 12 univers --> | |
| 51 | + <g transform="translate(512 888)"> | |
| 52 | + <g transform="translate(-341 0)"> | |
| 53 | + <g><circle r="26" fill="#1c7ed6"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g> | |
| 54 | + <g transform="translate(62 0)"><circle r="26" fill="#ff6a00"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g> | |
| 55 | + <g transform="translate(124 0)"><circle r="26" fill="#e23744"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g> | |
| 56 | + <g transform="translate(186 0)"><circle r="26" fill="#ff5148"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g> | |
| 57 | + <g transform="translate(248 0)"><circle r="26" fill="#ff5a2a"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g> | |
| 58 | + <g transform="translate(310 0)"><circle r="26" fill="#c4532e"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g> | |
| 59 | + <g transform="translate(372 0)"><circle r="26" fill="#1f9d55"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g> | |
| 60 | + <g transform="translate(434 0)"><circle r="26" fill="#f08c00"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g> | |
| 61 | + <g transform="translate(496 0)"><circle r="26" fill="#d6336c"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g> | |
| 62 | + <g transform="translate(558 0)"><circle r="26" fill="#7048e8"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g> | |
| 63 | + <g transform="translate(620 0)"><circle r="26" fill="#3b5bdb"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g> | |
| 64 | + <g transform="translate(682 0)"><circle r="26" fill="#0c8599"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g> | |
| 65 | + </g> | |
| 66 | + </g> | |
| 67 | +</svg> | |
added
docs/screenshots/carte-2d.png
+0 −0
Binary file not shown.
added
docs/screenshots/carte-3d.png
+0 −0
Binary file not shown.
added
docs/screenshots/carte-maplibre-2d.png
+0 −0
Binary file not shown.
added
docs/screenshots/carte-maplibre.png
+0 −0
Binary file not shown.
modified
project.yml
+7 −1
@@ -10,11 +10,15 @@ settings: | ||
| 10 | 10 | base: |
| 11 | 11 | SWIFT_VERSION: "5.0" |
| 12 | 12 | MARKETING_VERSION: "1.0.0" |
| 13 | − CURRENT_PROJECT_VERSION: "6" | |
| 13 | + CURRENT_PROJECT_VERSION: "7" | |
| 14 | 14 | DEVELOPMENT_TEAM: "3YM54G49SN" |
| 15 | 15 | CODE_SIGN_STYLE: Automatic |
| 16 | 16 | GENERATE_INFOPLIST_FILE: true |
| 17 | 17 | ENABLE_USER_SCRIPT_SANDBOXING: true |
| 18 | +packages: | |
| 19 | + MapLibre: | |
| 20 | + url: https://github.com/maplibre/maplibre-gl-native-distribution | |
| 21 | + from: 6.10.0 | |
| 18 | 22 | targets: |
| 19 | 23 | KA: |
| 20 | 24 | type: application |
@@ -24,6 +28,8 @@ targets: | ||
| 24 | 28 | dependencies: |
| 25 | 29 | - target: KAWidgets |
| 26 | 30 | embed: true |
| 31 | + - package: MapLibre | |
| 32 | + product: MapLibre | |
| 27 | 33 | settings: |
| 28 | 34 | base: |
| 29 | 35 | PRODUCT_BUNDLE_IDENTIFIER: com.groupeka.ka |
| 30 | 36 | |