@@ -28,13 +28,23 @@ enum CoverageEstimateState: Equatable {
2828 case failed( String )
2929}
3030
31- /// The result of a successful run: the imported overlay's metadata plus the
32- /// transmitter coordinate to recenter the map on. `Equatable` (by a per-result
31+ /// Geographic bounding box (degrees) of an imported coverage overlay, used to frame the map on it.
32+ struct CoverageBounds {
33+ let minLatitude : Double
34+ let maxLatitude : Double
35+ let minLongitude : Double
36+ let maxLongitude : Double
37+ }
38+
39+ /// The result of a successful run: the imported overlay's metadata, the transmitter coordinate, and
40+ /// the coverage geometry's bounding box (to frame the map on it). `Equatable` (by a per-result
3341/// token) so it can drive a SwiftUI `onChange`.
3442struct CoverageEstimateResult : Equatable {
3543 let token = UUID ( )
3644 let metadata : MapDataMetadata
3745 let coordinate : CLLocationCoordinate2D
46+ /// Bounding box of the imported coverage geometry, when derivable from the GeoJSON.
47+ let boundingBox : CoverageBounds ?
3848
3949 static func == ( lhs: CoverageEstimateResult , rhs: CoverageEstimateResult ) -> Bool {
4050 lhs. token == rhs. token
@@ -166,21 +176,48 @@ final class CoverageEstimateRunner: NSObject, ObservableObject {
166176 let coordinate = CLLocationCoordinate2D ( latitude: params. latitude, longitude: params. longitude)
167177 let layerName = params. name. trimmingCharacters ( in: . whitespacesAndNewlines)
168178 let importName = layerName. isEmpty ? " Coverage " : layerName
179+ let boundingBox = Self . boundingBox ( fromGeoJSON: geoJSON)
169180
170181 // This method is `@MainActor`, so the Task inherits MainActor isolation — after each
171182 // `await` we're back on the main actor and can assign published state directly.
172183 Task { [ weak self] in
173184 do {
174185 let metadata = try await MapDataManager . shared. importFromString ( geoJSON, name: importName)
175186 guard let self else { return }
176- self . importedResult = CoverageEstimateResult ( metadata: metadata, coordinate: coordinate)
187+ self . importedResult = CoverageEstimateResult ( metadata: metadata, coordinate: coordinate, boundingBox : boundingBox )
177188 self . state = . imported
178189 } catch {
179190 self ? . state = . failed( error. localizedDescription)
180191 }
181192 }
182193 }
183194
195+ /// Compute the bounding box of every position in a GeoJSON string. Walks the parsed JSON
196+ /// generically: any array whose first element is a number is a `[lon, lat, …]` position, and
197+ /// everything else is a container to recurse into — so it works for Point / LineString / Polygon /
198+ /// MultiPolygon without a schema. Returns nil when no positions are found.
199+ static func boundingBox( fromGeoJSON geoJSON: String ) -> CoverageBounds ? {
200+ guard let data = geoJSON. data ( using: . utf8) ,
201+ let root = try ? JSONSerialization . jsonObject ( with: data) else { return nil }
202+ var minLat = Double . infinity, maxLat = - Double. infinity
203+ var minLon = Double . infinity, maxLon = - Double. infinity
204+ func walk( _ node: Any ) {
205+ if let array = node as? [ Any ] {
206+ if let lon = array. first as? Double , array. count >= 2 , let lat = array [ 1 ] as? Double {
207+ minLat = min ( minLat, lat) ; maxLat = max ( maxLat, lat)
208+ minLon = min ( minLon, lon) ; maxLon = max ( maxLon, lon)
209+ } else {
210+ array. forEach ( walk)
211+ }
212+ } else if let dict = node as? [ String : Any ] {
213+ dict. values. forEach ( walk)
214+ }
215+ }
216+ walk ( root)
217+ guard minLat <= maxLat, minLon <= maxLon else { return nil }
218+ return CoverageBounds ( minLatitude: minLat, maxLatitude: maxLat, minLongitude: minLon, maxLongitude: maxLon)
219+ }
220+
184221 /// The foreground key window to host the headless WebView.
185222 private static func keyWindow( ) -> UIWindow ? {
186223 UIApplication . shared. connectedScenes
0 commit comments