Skip to content

Commit 90739fc

Browse files
committed
feat(site-planner): frame the map to the coverage bounds with padding on import
After a coverage estimate imports, fit the map to the coverage geometry's bounding box (computed from the returned GeoJSON) with ~15% margin, so the whole overlay is visible with breathing room instead of a fixed span centered on the transmitter. Falls back to the previous fixed span when no bounds can be derived.
1 parent 13e40e6 commit 90739fc

2 files changed

Lines changed: 62 additions & 6 deletions

File tree

Meshtastic/Helpers/SitePlanner/CoverageEstimateRunner.swift

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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`.
3442
struct 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

Meshtastic/Views/Nodes/MeshMapMK.swift

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -615,12 +615,31 @@ struct MeshMapMK: View {
615615
rebuildGeoJSONOverlays()
616616
cameraCommand = ClusterMapCameraCommand(
617617
id: UUID(),
618-
region: MKCoordinateRegion(
618+
region: coverageRegion(for: result),
619+
animated: true
620+
)
621+
}
622+
623+
/// Frame the map on the imported coverage: fit its bounding box with padding so the whole
624+
/// overlay is visible with breathing room. Falls back to a fixed span around the transmitter
625+
/// when the GeoJSON yielded no bounds.
626+
private func coverageRegion(for result: CoverageEstimateResult) -> MKCoordinateRegion {
627+
guard let box = result.boundingBox else {
628+
return MKCoordinateRegion(
619629
center: result.coordinate,
620630
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
621-
),
622-
animated: true
631+
)
632+
}
633+
let padding = 1.3 // ~15% margin on each side
634+
let center = CLLocationCoordinate2D(
635+
latitude: (box.minLatitude + box.maxLatitude) / 2,
636+
longitude: (box.minLongitude + box.maxLongitude) / 2
637+
)
638+
let span = MKCoordinateSpan(
639+
latitudeDelta: min(max((box.maxLatitude - box.minLatitude) * padding, 0.01), 180),
640+
longitudeDelta: min(max((box.maxLongitude - box.minLongitude) * padding, 0.01), 360)
623641
)
642+
return MKCoordinateRegion(center: center, span: span)
624643
}
625644

626645
/// The connected radio's LoRa config, used to prefill frequency / power / sensitivity.

0 commit comments

Comments
 (0)