From fb36edd3d6bf1bd69e3ca97f5343a012e9c83335 Mon Sep 17 00:00:00 2001 From: bruschill <621389+bruschill@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:48:54 -0500 Subject: [PATCH 1/3] feat(map): in-app Site Planner coverage estimate round-trip (#2058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the outbound half of the Site Planner integration: a user can kick off a coverage run from inside the app and get the styled GeoJSON back on the map without manually round-tripping through the browser + share sheet. - SitePlannerParameters: the flat query-contract model + URL builder (run=1, bridge=1), validation bounds, and radio prefill — device dBm→W, preset→rx sensitivity table, and firmware-accurate frequency via LoRaChannelCalculator. - CoverageEstimateRunner: a headless WKWebView + WKUserScript __meshtasticNative shim forwarding to a WKScriptMessageHandler (WKWebView has no object injection), attached full-size/alpha-0 so WebGL/WASM gets a GL context; 45s timeout, deterministic teardown, publishes state + importedResult. - CoverageEstimateForm: SwiftUI sheet mirroring the planner's collapsible panels (Transmitter / Receiver / Simulation / Display) with device/node/map-center location shortcuts, progress + cancel, and error handling. - MapDataManager.importFromString: import an in-memory GeoJSON string via the existing processUploadedFile pipeline (reuses #2056/#2037 validation + styling). - Entry points: map-toolbar button (MeshMapMK) and an "Estimate Coverage" action on node detail (Logs section) via a new .coverageEstimate(nodeNum) map state, consumed from onChange(mapState) / onChange(selectedTab) / onAppear so a cold Map tab handoff isn't dropped. On import: enable the overlay, add its config, and recenter on the transmitter. Tests: query-URL builder, validation, prefill math, and importFromString. Docs: docs/user/map.md (+nodes.md) regenerated into the bundled docs. --- Meshtastic.xcodeproj/project.pbxproj | 16 ++ Meshtastic/Helpers/MapDataManager.swift | 30 +++ .../SitePlanner/CoverageEstimateRunner.swift | 222 +++++++++++++++++ .../SitePlanner/SitePlannerParameters.swift | 225 +++++++++++++++++ Meshtastic/Resources/docs/index.json | 40 +-- .../Resources/docs/markdown/user/map.md | 31 +++ .../Resources/docs/markdown/user/nodes.md | 2 + Meshtastic/Resources/docs/user/map.html | 41 +++ Meshtastic/Resources/docs/user/nodes.html | 1 + Meshtastic/Router/NavigationState.swift | 2 + .../Helpers/Map/CoverageEstimateForm.swift | 234 ++++++++++++++++++ .../Views/Nodes/Helpers/NodeDetail.swift | 13 + Meshtastic/Views/Nodes/MeshMapMK.swift | 112 +++++++++ MeshtasticTests/MapDataManagerTests.swift | 54 ++++ .../SitePlannerParametersTests.swift | 187 ++++++++++++++ .../SwiftUIViewSnapshotTests.swift | 1 + docs/user/map.md | 31 +++ docs/user/nodes.md | 2 + 18 files changed, 1224 insertions(+), 20 deletions(-) create mode 100644 Meshtastic/Helpers/SitePlanner/CoverageEstimateRunner.swift create mode 100644 Meshtastic/Helpers/SitePlanner/SitePlannerParameters.swift create mode 100644 Meshtastic/Views/Nodes/Helpers/Map/CoverageEstimateForm.swift create mode 100644 MeshtasticTests/SitePlannerParametersTests.swift diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 7626f1fed..bfa55498b 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -223,6 +223,10 @@ BB0001000000000000000000 /* MBTilesArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001000000000000000000 /* MBTilesArchive.swift */; }; BB0003000000000000000000 /* PMTilesMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0003000000000000000000 /* PMTilesMapView.swift */; }; BB0004000000000000000000 /* ClusterMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0004000000000000000000 /* ClusterMapView.swift */; }; + 517E000000000000000B0001 /* SitePlannerParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517E000000000000000A0001 /* SitePlannerParameters.swift */; }; + 517E000000000000000B0002 /* CoverageEstimateRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517E000000000000000A0002 /* CoverageEstimateRunner.swift */; }; + 517E000000000000000B0003 /* CoverageEstimateForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517E000000000000000A0003 /* CoverageEstimateForm.swift */; }; + 517E000000000000000B0004 /* SitePlannerParametersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517E000000000000000A0004 /* SitePlannerParametersTests.swift */; }; BB0005000000000000000000 /* MeshMapMK.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005000000000000000000 /* MeshMapMK.swift */; }; BB0F1900000000000000F190 /* TraceRouteFlyover.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0F1900000000000000F190 /* TraceRouteFlyover.swift */; }; BBFF00000000000000000001 /* OfflineMapRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFF00000000000000000001 /* OfflineMapRegion.swift */; }; @@ -766,6 +770,10 @@ AA0003000000000000000000 /* PMTilesMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PMTilesMapView.swift; path = Meshtastic/Views/Nodes/Helpers/Map/PMTilesMapView.swift; sourceTree = SOURCE_ROOT; }; AA000301CPTST000000000001 /* CarPlayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayTests.swift; sourceTree = ""; }; AA0004000000000000000000 /* ClusterMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ClusterMapView.swift; path = Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift; sourceTree = SOURCE_ROOT; }; + 517E000000000000000A0001 /* SitePlannerParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SitePlannerParameters.swift; path = Meshtastic/Helpers/SitePlanner/SitePlannerParameters.swift; sourceTree = SOURCE_ROOT; }; + 517E000000000000000A0002 /* CoverageEstimateRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CoverageEstimateRunner.swift; path = Meshtastic/Helpers/SitePlanner/CoverageEstimateRunner.swift; sourceTree = SOURCE_ROOT; }; + 517E000000000000000A0003 /* CoverageEstimateForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CoverageEstimateForm.swift; path = Meshtastic/Views/Nodes/Helpers/Map/CoverageEstimateForm.swift; sourceTree = SOURCE_ROOT; }; + 517E000000000000000A0004 /* SitePlannerParametersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitePlannerParametersTests.swift; sourceTree = ""; }; AA000401SIRI000000000010 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = en; path = en.lproj/AppIntentVocabulary.plist; sourceTree = ""; }; AA000401SIRI000000000011 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = de; path = de.lproj/AppIntentVocabulary.plist; sourceTree = ""; }; AA000401SIRI000000000012 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = es; path = es.lproj/AppIntentVocabulary.plist; sourceTree = ""; }; @@ -1668,6 +1676,7 @@ DD00001BINTCVRTST0000000 /* IntentConverterTests.swift */, DD00001CMAPDATTST0000000 /* MapDataModelTests.swift */, DD00001CMAPDMGRTST000000 /* MapDataManagerTests.swift */, + 517E000000000000000A0004 /* SitePlannerParametersTests.swift */, DD00001DCDENTTST00000000 /* EntityTests.swift */, DD00001EURLDETTST0000000 /* URLExtensionDetailedTests.swift */, DD00001FFWVMERRTST000000 /* FirmwareViewModelErrorTests.swift */, @@ -2147,6 +2156,7 @@ AA0003000000000000000000 /* PMTilesMapView.swift */, AA0005000000000000000000 /* MeshMapMK.swift */, AA0004000000000000000000 /* ClusterMapView.swift */, + 517E000000000000000A0003 /* CoverageEstimateForm.swift */, AA0F1900000000000000F190 /* TraceRouteFlyover.swift */, DDD3A2B12D5127B40045EB48 /* ci_scripts */, DDDBC87A2BC62E4E001E8DF7 /* Settings.bundle */, @@ -2347,6 +2357,8 @@ isa = PBXGroup; children = ( 3D3417D12E2DC260006A988B /* MapDataManager.swift */, + 517E000000000000000A0001 /* SitePlannerParameters.swift */, + 517E000000000000000A0002 /* CoverageEstimateRunner.swift */, 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */, BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */, BEEF00102F00000100000001 /* MeshtasticChannelURL.swift */, @@ -2820,6 +2832,7 @@ DD00001BINTCVRTST0000001 /* IntentConverterTests.swift in Sources */, DD00001CMAPDATTST0000001 /* MapDataModelTests.swift in Sources */, DD00001CMAPDMGRTST000001 /* MapDataManagerTests.swift in Sources */, + 517E000000000000000B0004 /* SitePlannerParametersTests.swift in Sources */, BBFF00000000000000000009 /* PMTilesExtractorTests.swift in Sources */, DD00001DCDENTTST00000001 /* EntityTests.swift in Sources */, DD00001EURLDETTST0000001 /* URLExtensionDetailedTests.swift in Sources */, @@ -2878,6 +2891,9 @@ BB0003000000000000000000 /* PMTilesMapView.swift in Sources */, BB0005000000000000000000 /* MeshMapMK.swift in Sources */, BB0004000000000000000000 /* ClusterMapView.swift in Sources */, + 517E000000000000000B0001 /* SitePlannerParameters.swift in Sources */, + 517E000000000000000B0002 /* CoverageEstimateRunner.swift in Sources */, + 517E000000000000000B0003 /* CoverageEstimateForm.swift in Sources */, BB0F1900000000000000F190 /* TraceRouteFlyover.swift in Sources */, 86CCA3EEDD7E1036893B2663 /* DiscoverySessionEntity.swift in Sources */, 82F6BD3326F1BFEAF0F16723 /* DiscoveryPresetResultEntity.swift in Sources */, diff --git a/Meshtastic/Helpers/MapDataManager.swift b/Meshtastic/Helpers/MapDataManager.swift index 9944a81d0..51dcb80b6 100644 --- a/Meshtastic/Helpers/MapDataManager.swift +++ b/Meshtastic/Helpers/MapDataManager.swift @@ -134,6 +134,36 @@ class MapDataManager: ObservableObject { return try await processUploadedFile(from: tempURL) } + /// Imports an in-memory GeoJSON string (e.g. the coverage `FeatureCollection` handed back by + /// the Site Planner's native bridge) through the same pipeline as `processUploadedFile`, so it + /// reuses the exact validation + render + styling path with no round-trip to the share sheet. + /// `name` becomes the on-disk file / layer name; a `.geojson` extension is enforced. + func importFromString(_ geoJSON: String, name: String) async throws -> MapDataMetadata { + guard let data = geoJSON.data(using: .utf8) else { + throw MapDataError.invalidContent + } + guard data.count <= maxFileSize else { + throw MapDataError.fileTooLarge + } + + // Sanitise the caller-supplied name into a safe single path component and enforce `.geojson`. + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + let base = trimmed.isEmpty ? "coverage" : trimmed + let safeBase = base + .components(separatedBy: CharacterSet(charactersIn: "/\\:\n\r\t")) + .joined(separator: "-") + let fileName = safeBase.lowercased().hasSuffix(".geojson") ? safeBase : "\(safeBase).geojson" + + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent(fileName) + try FileManager.default.createDirectory(at: tempURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: tempURL) + defer { try? FileManager.default.removeItem(at: tempURL) } + + return try await processUploadedFile(from: tempURL) + } + /// Validate uploaded file private func validateFile(at url: URL) throws { let fileAttributes = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) diff --git a/Meshtastic/Helpers/SitePlanner/CoverageEstimateRunner.swift b/Meshtastic/Helpers/SitePlanner/CoverageEstimateRunner.swift new file mode 100644 index 000000000..e320bc8e0 --- /dev/null +++ b/Meshtastic/Helpers/SitePlanner/CoverageEstimateRunner.swift @@ -0,0 +1,222 @@ +// +// CoverageEstimateRunner.swift +// Meshtastic +// +// Drives the hosted Meshtastic Site Planner headlessly to compute a coverage +// estimate and hand the styled GeoJSON straight back to the app — no visible +// browser hop or manual share-sheet tap. +// +// There is no headless params→GeoJSON HTTP API; the planner is a client-side +// WASM SPLAT!/ITM simulator, so coverage is only ever computed in a WebView. +// We load the planner with `run=1&bridge=1`, inject a tiny `__meshtasticNative` +// shim (the object-injection contract the planner expects, since WKWebView has +// no `addJavascriptInterface`), and receive the result via a script message. +// +// See meshtastic/Meshtastic-Apple#2058. +// + +import Foundation +import CoreLocation +import WebKit +import OSLog + +/// Progress of a single headless coverage run. +enum CoverageEstimateState: Equatable { + case idle + case running + case imported + case failed(String) +} + +/// The result of a successful run: the imported overlay's metadata plus the +/// transmitter coordinate to recenter the map on. `Equatable` (by a per-result +/// token) so it can drive a SwiftUI `onChange`. +struct CoverageEstimateResult: Equatable { + let token = UUID() + let metadata: MapDataMetadata + let coordinate: CLLocationCoordinate2D + + static func == (lhs: CoverageEstimateResult, rhs: CoverageEstimateResult) -> Bool { + lhs.token == rhs.token + } +} + +@MainActor +final class CoverageEstimateRunner: NSObject, ObservableObject { + + /// The message-handler name the injected shim forwards coverage GeoJSON to. + private static let bridgeName = "coverageBridge" + /// Matches the Android reference timeout for a headless run. + private static let timeout: TimeInterval = 45 + + /// JS injected at `.atDocumentStart` so `__meshtasticNative` exists before the + /// planner's success handler (`postCoverageToBridge`) runs. It defines the + /// object-shaped contract the planner checks for and forwards to the WKWebView + /// message handler. + private static let shimJS = """ + window.__meshtasticNative = { + onCoverage: function (geojson) { + window.webkit.messageHandlers.\(bridgeName).postMessage(geojson); + } + }; + """ + + @Published private(set) var state: CoverageEstimateState = .idle + + /// The most recent successful import (overlay metadata + transmitter coordinate). + /// Observe via `onChange` to recenter the map and enable the overlay. + @Published private(set) var importedResult: CoverageEstimateResult? + + private var webView: WKWebView? + private var timeoutTask: Task? + private var params: SitePlannerParameters? + private var didFinish = false + + /// Whether a run is currently in flight. + var isRunning: Bool { state == .running } + + /// Kick off a headless coverage run for `params`. Requires a valid coordinate. + func start(params: SitePlannerParameters) { + cancel() // tear down any prior run first + guard params.isValid, let url = params.queryURL(autorun: true, bridge: true) else { + state = .failed(String(localized: "Invalid coverage parameters.")) + return + } + guard let hostWindow = Self.keyWindow() else { + state = .failed(String(localized: "Could not start the coverage estimate.")) + return + } + + self.params = params + self.didFinish = false + state = .running + + let config = WKWebViewConfiguration() + let controller = WKUserContentController() + controller.add(self, name: Self.bridgeName) + controller.addUserScript(WKUserScript(source: Self.shimJS, injectionTime: .atDocumentStart, forMainFrameOnly: true)) + config.userContentController = controller + + // The WebView must be attached to a window and non-zero size, or WebGL/WASM + // never gets a GL context and the planner's autorun stalls on map-load. Keep + // it full-size but invisible and non-interactive behind the progress UI. + let webView = WKWebView(frame: hostWindow.bounds, configuration: config) + webView.navigationDelegate = self + webView.isUserInteractionEnabled = false + webView.alpha = 0 + webView.isHidden = false + hostWindow.addSubview(webView) + self.webView = webView + + Logger.services.info("🗺️ [SitePlanner] Starting headless coverage run: \(url.absoluteString, privacy: .public)") + webView.load(URLRequest(url: url)) + + timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(Self.timeout * 1_000_000_000)) + guard !Task.isCancelled else { return } + await self?.fail(String(localized: "Coverage estimate timed out.")) + } + } + + /// Cancel any in-flight run and tear down the WebView. + func cancel() { + timeoutTask?.cancel() + timeoutTask = nil + teardownWebView() + if state == .running { state = .idle } + } + + /// Reset back to idle (e.g. when the form is dismissed after success/failure). + func reset() { + cancel() + state = .idle + } + + // MARK: - Private + + private func teardownWebView() { + if let webView { + webView.stopLoading() + webView.navigationDelegate = nil + webView.configuration.userContentController.removeAllUserScripts() + webView.configuration.userContentController.removeScriptMessageHandler(forName: Self.bridgeName) + webView.removeFromSuperview() + } + webView = nil + } + + private func fail(_ message: String) { + guard !didFinish else { return } + didFinish = true + Logger.services.error("🗺️ [SitePlanner] Coverage run failed: \(message, privacy: .public)") + teardownWebView() + timeoutTask?.cancel() + timeoutTask = nil + state = .failed(message) + } + + private func handleCoverage(_ geoJSON: String) { + guard !didFinish, let params else { return } + didFinish = true + timeoutTask?.cancel() + timeoutTask = nil + teardownWebView() + + let coordinate = CLLocationCoordinate2D(latitude: params.latitude, longitude: params.longitude) + let layerName = params.name.trimmingCharacters(in: .whitespacesAndNewlines) + let importName = layerName.isEmpty ? "Coverage" : layerName + + // This method is `@MainActor`, so the Task inherits MainActor isolation — after each + // `await` we're back on the main actor and can assign published state directly. + Task { [weak self] in + do { + let metadata = try await MapDataManager.shared.importFromString(geoJSON, name: importName) + guard let self else { return } + self.importedResult = CoverageEstimateResult(metadata: metadata, coordinate: coordinate) + self.state = .imported + } catch { + self?.state = .failed(error.localizedDescription) + } + } + } + + /// The foreground key window to host the headless WebView. + private static func keyWindow() -> UIWindow? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive || $0.activationState == .foregroundInactive } + .flatMap { $0.windows } + .first { $0.isKeyWindow } ?? + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first + } +} + +// MARK: - WKScriptMessageHandler + +extension CoverageEstimateRunner: WKScriptMessageHandler { + nonisolated func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard message.name == Self.bridgeName, let body = message.body as? String else { return } + Task { @MainActor [weak self] in + self?.handleCoverage(body) + } + } +} + +// MARK: - WKNavigationDelegate + +extension CoverageEstimateRunner: WKNavigationDelegate { + nonisolated func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + Task { @MainActor [weak self] in + self?.fail(error.localizedDescription) + } + } + + nonisolated func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + Task { @MainActor [weak self] in + self?.fail(error.localizedDescription) + } + } +} diff --git a/Meshtastic/Helpers/SitePlanner/SitePlannerParameters.swift b/Meshtastic/Helpers/SitePlanner/SitePlannerParameters.swift new file mode 100644 index 000000000..e322449bf --- /dev/null +++ b/Meshtastic/Helpers/SitePlanner/SitePlannerParameters.swift @@ -0,0 +1,225 @@ +// +// SitePlannerParameters.swift +// Meshtastic +// +// The flat query contract used to drive the hosted Meshtastic Site Planner +// (https://site.meshtastic.org) for an in-app coverage estimate. A partial +// transmitter is merged over the planner's own defaults, so the app never needs +// the planner's internal schema — it only sends the keys the user can edit. +// +// Contract reference: meshtastic/Meshtastic-Apple#2058 and +// meshtastic/meshtastic-site-planner#73 / #74. +// + +import Foundation +import CoreLocation + +/// The coverage-map colour palette (`color_scale` query key). Raw values are the +/// exact planner tokens; an unknown token falls back to `plasma` planner-side. +enum SitePlannerColorScale: String, CaseIterable, Identifiable, Hashable { + case plasma + case viridis + case cmrmap = "CMRmap" + case cool + case turbo + case jet + + var id: String { rawValue } + + /// Human-facing palette name for the picker. + var displayName: String { + switch self { + case .plasma: return "Plasma" + case .viridis: return "Viridis" + case .cmrmap: return "CMRmap" + case .cool: return "Cool" + case .turbo: return "Turbo" + case .jet: return "Jet" + } + } +} + +/// An in-app coverage-estimate request, mirroring the planner's `Site Parameters` +/// panels. Defaults equal a fresh planner session so an untouched form is a no-op +/// merge (see the Default column in the issue's flat-query contract table). +struct SitePlannerParameters: Equatable { + + // MARK: Site / Transmitter + var name: String = "" + var latitude: Double = 0 + var longitude: Double = 0 + /// Transmit power in **watts** (wire unit). Planner default 0.1 W (30 dBm). + var txPowerWatts: Double = 0.1 + /// Centre frequency in MHz. Planner default 907. + var txFrequencyMHz: Double = 907 + /// Antenna height above ground in metres. Planner default 2. + var txHeightMeters: Double = 2 + /// Antenna gain in dBi. Planner default 2. + var txGainDBi: Double = 2 + + // MARK: Receiver + /// Receiver sensitivity / coverage threshold in dBm. Planner default -130. + var rxSensitivityDBm: Double = -130 + + // MARK: Simulation Options + /// Maximum simulation range in km. Planner default 30; ≤150, or ≤70 with high-res. + var maxRangeKm: Double = 30 + /// High-resolution terrain (30 m). Caps `maxRangeKm` at 70 when enabled. + var highResolution: Bool = false + + // MARK: Display + var colorScale: SitePlannerColorScale = .plasma + + // MARK: - Validation bounds (mirror the planner's `store.ts` / input ranges) + static let frequencyRange: ClosedRange = 20...20_000 + static let rxSensitivityRange: ClosedRange = -150...(-30) + static let latitudeRange: ClosedRange = -90...90 + static let longitudeRange: ClosedRange = -180...180 + static let maxRangeStandard: ClosedRange = 1...150 + static let maxRangeHighRes: ClosedRange = 1...70 + + /// The allowed max-range span for the current high-res selection. + var maxRangeBounds: ClosedRange { + highResolution ? Self.maxRangeHighRes : Self.maxRangeStandard + } + + /// A `0,0` fix is the firmware's "no position" sentinel — never a valid transmitter. + var hasValidCoordinate: Bool { + guard Self.latitudeRange.contains(latitude), Self.longitudeRange.contains(longitude) else { return false } + return !(latitude == 0 && longitude == 0) + } + + /// Whether every field is inside the planner's accepted ranges and coordinates are usable. + var isValid: Bool { + hasValidCoordinate + && txPowerWatts > 0 + && Self.frequencyRange.contains(txFrequencyMHz) + && txHeightMeters >= 0 + && Self.rxSensitivityRange.contains(rxSensitivityDBm) + && maxRangeBounds.contains(maxRangeKm) + } + + // MARK: - Query URL + + /// The hosted planner base URL. + static let plannerBaseURL = "https://site.meshtastic.org/" + + /// Builds `https://site.meshtastic.org/?`. + /// - Parameters: + /// - autorun: append `run=1` so the planner computes on load (no click). + /// - bridge: append `bridge=1` so a native embed delivers via the JS bridge + /// instead of the share sheet. + func queryURL(autorun: Bool = true, bridge: Bool = false) -> URL? { + guard var components = URLComponents(string: Self.plannerBaseURL) else { return nil } + + var items: [URLQueryItem] = [ + URLQueryItem(name: "lat", value: Self.wire(latitude)), + URLQueryItem(name: "lon", value: Self.wire(longitude)), + URLQueryItem(name: "tx_power", value: Self.wire(txPowerWatts)), + URLQueryItem(name: "tx_freq", value: Self.wire(txFrequencyMHz)), + URLQueryItem(name: "tx_height", value: Self.wire(txHeightMeters)), + URLQueryItem(name: "tx_gain", value: Self.wire(txGainDBi)), + URLQueryItem(name: "rx_sensitivity", value: Self.wire(rxSensitivityDBm)), + URLQueryItem(name: "max_range", value: Self.wire(maxRangeKm)), + URLQueryItem(name: "color_scale", value: colorScale.rawValue) + ] + + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedName.isEmpty { + // URLQueryItem percent-encodes the value for us. + items.append(URLQueryItem(name: "name", value: trimmedName)) + } + if highResolution { + items.append(URLQueryItem(name: "high_res", value: "1")) + } + if autorun { + items.append(URLQueryItem(name: "run", value: "1")) + } + if bridge { + items.append(URLQueryItem(name: "bridge", value: "1")) + } + + components.queryItems = items + // URLComponents encodes `+` in a query value as a literal `+`, which the + // planner would read as a space. None of our numeric values contain `+`, + // and names are handled by URLQueryItem, so no extra escaping is needed. + return components.url + } + + /// Locale-independent numeric formatting for the wire (always a `.` decimal + /// separator; integers render without a trailing `.0`). + private static func wire(_ value: Double) -> String { + if value == value.rounded() && abs(value) < 1e15 { + return String(Int(value)) + } + return String(value) + } +} + +// MARK: - Radio prefill + +extension SitePlannerParameters { + + /// Watts from device transmit power in dBm: `W = 10^((dBm − 30) / 10)`. + static func watts(fromDBm dBm: Double) -> Double { + pow(10, (dBm - 30) / 10) + } + + /// Receiver sensitivity (dBm) for a modem preset, per the planner's `parameters.md` + /// per-preset table. Unmapped presets fall back to the planner default (-130). + static func rxSensitivity(for preset: ModemPresets) -> Double { + switch preset { + case .shortTurbo: return -126 + case .shortFast: return -129 + case .shortSlow: return -131.5 + case .medFast: return -134 + case .medSlow: return -136.5 + case .longFast: return -139 + case .longModerate: return -142 + case .longSlow: return -144.5 + default: return -130 + } + } + + /// Build parameters prefilled from the connected radio where possible: + /// transmit frequency (computed primary-channel MHz), transmit power + /// (device dBm → W, guarding the firmware "0 = region max" case) and a + /// receiver sensitivity mapped from the modem preset. Antenna gain/height + /// aren't in device config, so they keep the planner defaults. + static func prefilled( + name: String, + coordinate: CLLocationCoordinate2D?, + loRaConfig: LoRaConfigEntity?, + primaryChannelName: String + ) -> SitePlannerParameters { + var params = SitePlannerParameters() + params.name = name + if let coordinate { + params.latitude = coordinate.latitude + params.longitude = coordinate.longitude + } + + guard let loRaConfig else { return params } + + // Frequency — reuse the firmware-accurate slot math. + let calculator = LoRaChannelCalculator(config: loRaConfig) + let slot = calculator.effectiveChannelSlot(primaryName: primaryChannelName) + let frequency = calculator.radioFrequencyMHz(slot: slot) + if frequency > 0 { + params.txFrequencyMHz = frequency + } + + // Transmit power — device stores dBm; 0 means "region max", which we can't + // resolve here, so leave the planner default in that case. + if loRaConfig.txPower > 0 { + params.txPowerWatts = watts(fromDBm: Double(loRaConfig.txPower)) + } + + // Receiver sensitivity — from the modem preset when using a preset. + if loRaConfig.usePreset, let preset = ModemPresets(rawValue: Int(loRaConfig.modemPreset)) { + params.rxSensitivityDBm = rxSensitivity(for: preset) + } + + return params + } +} diff --git a/Meshtastic/Resources/docs/index.json b/Meshtastic/Resources/docs/index.json index 3193db963..a1544fc1e 100644 --- a/Meshtastic/Resources/docs/index.json +++ b/Meshtastic/Resources/docs/index.json @@ -240,37 +240,37 @@ "navOrder": 6, "keywords": [ "map", - "nodes", "tap", + "nodes", + "node", + "from", "waypoint", "show", - "node", "only", + "coverage", "position", "mesh", + "site", + "radio", + "open", "lora", + "location", + "estimate", + "planner", "path", + "options", "line", + "layer", "geofence", - "from", + "device", "description", "box", "bounding", "waypoints", "via", - "route", - "location", - "icon", - "filter", - "favorites", - "circle", - "trace", - "right", - "radius", - "over", - "open" + "route" ], - "charCount": 5717 + "charCount": 8086 }, { "id": "messages", @@ -378,16 +378,16 @@ "direct", "client", "supported", + "section", + "position", "mesh", + "map", "long", "local", "links", - "hops", - "heard", - "filter", - "display" + "hops" ], - "charCount": 8333 + "charCount": 8605 }, { "id": "settings", diff --git a/Meshtastic/Resources/docs/markdown/user/map.md b/Meshtastic/Resources/docs/markdown/user/map.md index 2b6dce978..2081f2ccb 100644 --- a/Meshtastic/Resources/docs/markdown/user/map.md +++ b/Meshtastic/Resources/docs/markdown/user/map.md @@ -54,6 +54,37 @@ Tap the **info button** (`info.circle`) in the bottom-right toolbar to open the | Traffic | Show Apple Maps live traffic | | Points of Interest | Show Apple Maps points of interest | +## Coverage Estimate (Site Planner) + +Estimate the radio coverage of a transmitter site without leaving the app. The app drives the hosted [Meshtastic Site Planner](https://site.meshtastic.org) — a SPLAT!/ITM propagation simulator — and imports the resulting coverage map as a styled GeoJSON overlay. + +### Starting an estimate + +You can open the estimate form two ways: + +- **From the map** — tap the coverage button (`cellularbars`) in the bottom-right toolbar. The form is prefilled from the connected radio and located at the current map centre. +- **From a node** — open a node with a known position, scroll to the **Logs** section, and tap **Estimate Coverage**. The map recenters on that node and opens the form prefilled from it. + +### The estimate form + +The form mirrors the Site Planner's own panels. The **Transmitter** and **Display** sections are open by default; **Receiver** and **Simulation Options** are collapsed. + +| Section | Fields | +|---------|--------| +| Site / Transmitter | Site name, latitude/longitude, transmit power (W), frequency (MHz), antenna height (m), antenna gain (dBi). Location shortcuts fill the coordinates from **My Location**, the selected **Node**, or the **Map Center**. | +| Receiver | Receiver sensitivity (dBm) — the coverage threshold. | +| Simulation Options | Maximum range (km) and a high-resolution terrain toggle (which caps the range at 70 km). | +| Display | Colour palette for the coverage map (Plasma, Viridis, CMRmap, Cool, Turbo, Jet). | + +**Prefill from the connected radio:** frequency is computed from the radio's region, modem preset, and channel; transmit power is converted from the device's dBm setting; and receiver sensitivity is mapped from the modem preset (for example LongFast → −139 dBm). Antenna gain and height aren't part of the device config, so they keep the planner's defaults. You can edit any value before running. + +### Running it + +Tap **Estimate**. A progress indicator appears while the planner computes the coverage; you can **Cancel** at any time. When it finishes, the styled coverage map is added as a map overlay (in its dBm colours), the GeoJSON overlays layer is enabled, and the map recenters on the transmitter. The imported layer is managed like any other GeoJSON overlay in **Map Options**. + +> **Note — Requires a network connection** +> The coverage simulation runs in the hosted Site Planner, so an internet connection is needed. Estimates time out after 45 seconds; on failure the flow ends cleanly without leaving a stuck spinner. + ## Waypoints Waypoints are named points of interest you can share across the mesh. diff --git a/Meshtastic/Resources/docs/markdown/user/nodes.md b/Meshtastic/Resources/docs/markdown/user/nodes.md index 61474bd8c..ec3765a8e 100644 --- a/Meshtastic/Resources/docs/markdown/user/nodes.md +++ b/Meshtastic/Resources/docs/markdown/user/nodes.md @@ -142,6 +142,8 @@ Tap a node and scroll to the Logs section for detailed metrics: | ![Detection Sensor](../assets/screenshots/logDetectionSensor.png) | Motion or door open/close alerts from the node. | | ![Trace Routes](../assets/screenshots/logTraceRoutes.png) | Recorded trace route paths showing the hops a message took through the mesh. | +When a node has a known position, the Logs section also offers **Estimate Coverage** (`cellularbars`). It switches to the Map tab and opens the Site Planner coverage-estimate form prefilled from that node. See [Coverage Estimate (Site Planner)](map.md) on the Map page for the full flow. + ## Local Stats and Noise Floor Local Stats show radio diagnostics reported by a node, including packets received, packets transmitted, duplicate packets, relayed packets, bad receives, canceled packets, online node count, total node count, and noise floor. diff --git a/Meshtastic/Resources/docs/user/map.html b/Meshtastic/Resources/docs/user/map.html index fd96efd3b..ee3cc5098 100644 --- a/Meshtastic/Resources/docs/user/map.html +++ b/Meshtastic/Resources/docs/user/map.html @@ -114,6 +114,47 @@

Map Options

+

Coverage Estimate (Site Planner)

+

Estimate the radio coverage of a transmitter site without leaving the app. The app drives the hosted Meshtastic Site Planner — a SPLAT!/ITM propagation simulator — and imports the resulting coverage map as a styled GeoJSON overlay.

+

Starting an estimate

+

You can open the estimate form two ways:

+
    +
  • From the map — tap the coverage button (cellularbars) in the bottom-right toolbar. The form is prefilled from the connected radio and located at the current map centre.
  • +
  • From a node — open a node with a known position, scroll to the Logs section, and tap Estimate Coverage. The map recenters on that node and opens the form prefilled from it.
  • +
+

The estimate form

+

The form mirrors the Site Planner's own panels. The Transmitter and Display sections are open by default; Receiver and Simulation Options are collapsed.

+ + + + + + + + + + + + + + + + + + + + + + + + + +
SectionFields
Site / TransmitterSite name, latitude/longitude, transmit power (W), frequency (MHz), antenna height (m), antenna gain (dBi). Location shortcuts fill the coordinates from My Location, the selected Node, or the Map Center.
ReceiverReceiver sensitivity (dBm) — the coverage threshold.
Simulation OptionsMaximum range (km) and a high-resolution terrain toggle (which caps the range at 70 km).
DisplayColour palette for the coverage map (Plasma, Viridis, CMRmap, Cool, Turbo, Jet).
+

Prefill from the connected radio: frequency is computed from the radio's region, modem preset, and channel; transmit power is converted from the device's dBm setting; and receiver sensitivity is mapped from the modem preset (for example LongFast → −139 dBm). Antenna gain and height aren't part of the device config, so they keep the planner's defaults. You can edit any value before running.

+

Running it

+

Tap Estimate. A progress indicator appears while the planner computes the coverage; you can Cancel at any time. When it finishes, the styled coverage map is added as a map overlay (in its dBm colours), the GeoJSON overlays layer is enabled, and the map recenters on the transmitter. The imported layer is managed like any other GeoJSON overlay in Map Options.

+
Note — Requires a network connection +The coverage simulation runs in the hosted Site Planner, so an internet connection is needed. Estimates time out after 45 seconds; on failure the flow ends cleanly without leaving a stuck spinner.

Waypoints

Waypoints are named points of interest you can share across the mesh.

Creating a Waypoint

diff --git a/Meshtastic/Resources/docs/user/nodes.html b/Meshtastic/Resources/docs/user/nodes.html index 4a876018a..f241e6340 100644 --- a/Meshtastic/Resources/docs/user/nodes.html +++ b/Meshtastic/Resources/docs/user/nodes.html @@ -263,6 +263,7 @@

Additional Icons

+

When a node has a known position, the Logs section also offers Estimate Coverage (cellularbars). It switches to the Map tab and opens the Site Planner coverage-estimate form prefilled from that node. See Coverage Estimate (Site Planner) on the Map page for the full flow.

Local Stats and Noise Floor

Local Stats show radio diagnostics reported by a node, including packets received, packets transmitted, duplicate packets, relayed packets, bad receives, canceled packets, online node count, total node count, and noise floor.

Noise floor is displayed in dBm when the node reports it. Treat it as a directional diagnostic instead of an absolute site score: readings can vary quickly, and external filters can lower or skew the displayed value because of insertion loss or in-band interference.

diff --git a/Meshtastic/Router/NavigationState.swift b/Meshtastic/Router/NavigationState.swift index 2263bdabf..4df7bf7b6 100644 --- a/Meshtastic/Router/NavigationState.swift +++ b/Meshtastic/Router/NavigationState.swift @@ -37,6 +37,8 @@ enum MapNavigationState: Hashable { case waypoint(Int64) /// Show a specific trace route (by its request id) drawn on the map. case traceRoute(Int64) + /// Open the Site Planner coverage-estimate flow prefilled from a node (by its node number). + case coverageEstimate(Int64) } // MARK: Settings diff --git a/Meshtastic/Views/Nodes/Helpers/Map/CoverageEstimateForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/CoverageEstimateForm.swift new file mode 100644 index 000000000..b2177665c --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/Map/CoverageEstimateForm.swift @@ -0,0 +1,234 @@ +// +// CoverageEstimateForm.swift +// Meshtastic +// +// The estimate form for an in-app Site Planner coverage run. Mirrors the +// planner's `Site Parameters` panels — Site / Transmitter → Receiver → +// Simulation Options → Display, with Transmitter + Display open and the rest +// collapsed. Submitting drives `CoverageEstimateRunner` (headless WebView + +// bridge) and, on success, imports the styled GeoJSON as a map overlay. +// +// See meshtastic/Meshtastic-Apple#2058. +// + +import SwiftUI +import CoreLocation + +/// Seed for presenting the estimate form: the prefilled parameters plus the +/// coordinates available as one-tap location shortcuts. +struct CoverageEstimateSeed: Identifiable { + let id = UUID() + var parameters: SitePlannerParameters + /// The selected node's coordinate, when launched from a node. + var nodeCoordinate: CLLocationCoordinate2D? + /// The current map centre, when launched from the map toolbar. + var mapCenter: CLLocationCoordinate2D? +} + +struct CoverageEstimateForm: View { + + let seed: CoverageEstimateSeed + @ObservedObject var runner: CoverageEstimateRunner + + @Environment(\.dismiss) private var dismiss + + @State private var params: SitePlannerParameters + @State private var transmitterExpanded = true + @State private var receiverExpanded = false + @State private var simulationExpanded = false + @State private var displayExpanded = true + @State private var errorMessage: String? + + init(seed: CoverageEstimateSeed, runner: CoverageEstimateRunner) { + self.seed = seed + self.runner = runner + _params = State(initialValue: seed.parameters) + } + + private var deviceLocation: CLLocationCoordinate2D? { + LocationsHandler.currentLocation + } + + var body: some View { + NavigationStack { + Form { + transmitterSection + receiverSection + simulationSection + displaySection + } + .navigationTitle("Estimate Coverage") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + runner.reset() + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Estimate") { + runner.start(params: params) + } + .disabled(!params.isValid || runner.isRunning) + } + } + .overlay { + if runner.isRunning { + estimatingOverlay + } + } + .onDisappear { + // Tear down a still-running headless run if the sheet is swipe-dismissed. Safe on the + // success path too — the import already published its result before `dismiss()`. + runner.reset() + } + .onChange(of: runner.state) { _, newState in + switch newState { + case .imported: + dismiss() + case let .failed(message): + errorMessage = message + default: + break + } + } + .alert("Coverage estimate failed", isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { + Button("OK", role: .cancel) { runner.reset() } + } message: { + Text(errorMessage ?? "") + } + } + } + + // MARK: - Sections + + private var transmitterSection: some View { + Section { + DisclosureGroup(isExpanded: $transmitterExpanded) { + TextField("Site name", text: $params.name) + .accessibilityLabel("Site name") + + locationShortcuts + + labeledNumber("Latitude", value: $params.latitude) + labeledNumber("Longitude", value: $params.longitude) + labeledNumber("Transmit power (W)", value: $params.txPowerWatts) + labeledNumber("Frequency (MHz)", value: $params.txFrequencyMHz) + labeledNumber("Antenna height (m)", value: $params.txHeightMeters) + labeledNumber("Antenna gain (dBi)", value: $params.txGainDBi) + } label: { + Label("Site / Transmitter", systemImage: "antenna.radiowaves.left.and.right") + } + } + } + + private var receiverSection: some View { + Section { + DisclosureGroup(isExpanded: $receiverExpanded) { + labeledNumber("Sensitivity (dBm)", value: $params.rxSensitivityDBm) + } label: { + Label("Receiver", systemImage: "dot.radiowaves.left.and.right") + } + } + } + + private var simulationSection: some View { + Section { + DisclosureGroup(isExpanded: $simulationExpanded) { + labeledNumber("Max range (km)", value: $params.maxRangeKm) + Toggle("High-resolution terrain", isOn: $params.highResolution) + .onChange(of: params.highResolution) { _, _ in + // High-res caps the range at 70 km — clamp so the value stays valid. + let bounds = params.maxRangeBounds + params.maxRangeKm = min(max(params.maxRangeKm, bounds.lowerBound), bounds.upperBound) + } + } label: { + Label("Simulation Options", systemImage: "slider.horizontal.3") + } + } + } + + private var displaySection: some View { + Section { + DisclosureGroup(isExpanded: $displayExpanded) { + Picker("Palette", selection: $params.colorScale) { + ForEach(SitePlannerColorScale.allCases) { scale in + Text(scale.displayName).tag(scale) + } + } + } label: { + Label("Display", systemImage: "paintpalette") + } + } + } + + // MARK: - Location shortcuts + + @ViewBuilder private var locationShortcuts: some View { + let hasShortcuts = deviceLocation != nil || seed.nodeCoordinate != nil || seed.mapCenter != nil + if hasShortcuts { + HStack(spacing: 12) { + if let device = deviceLocation { + shortcutButton("My Location", systemImage: "location.fill") { apply(device) } + } + if let node = seed.nodeCoordinate { + shortcutButton("Node", systemImage: "flipphone") { apply(node) } + } + if let center = seed.mapCenter { + shortcutButton("Map Center", systemImage: "scope") { apply(center) } + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + private func shortcutButton(_ title: LocalizedStringKey, systemImage: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Label(title, systemImage: systemImage) + } + .accessibilityLabel(Text(title)) + .accessibilityHint("Fills the transmitter coordinates.") + } + + private func apply(_ coordinate: CLLocationCoordinate2D) { + params.latitude = coordinate.latitude + params.longitude = coordinate.longitude + } + + // MARK: - Helpers + + private func labeledNumber(_ title: LocalizedStringKey, value: Binding) -> some View { + HStack { + Text(title) + Spacer() + TextField("", value: value, format: .number) + .keyboardType(.numbersAndPunctuation) + .multilineTextAlignment(.trailing) + .frame(maxWidth: 140) + .accessibilityLabel(Text(title)) + } + } + + private var estimatingOverlay: some View { + ZStack { + Color(.systemBackground).opacity(0.85).ignoresSafeArea() + VStack(spacing: 16) { + ProgressView() + .controlSize(.large) + Text("Estimating coverage…") + .font(.headline) + Button("Cancel") { + runner.reset() + } + .buttonStyle(.bordered) + } + .padding(24) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) + } + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index 7139f8c7b..e98f9ccd7 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -36,6 +36,7 @@ struct NodeDetail: View { ) ?? ModemPresets.longFast @Environment(\.modelContext) private var context @EnvironmentObject var accessoryManager: AccessoryManager + @EnvironmentObject var router: Router @State private var showingShutdownConfirm: Bool = false @State private var showingRebootConfirm: Bool = false @State private var dateFormatRelative: Bool = true @@ -536,6 +537,18 @@ struct NodeDetail: View { } } .disabled(!hasPositions) + Button { + router.selectedTab = .map + router.mapState = .coverageEstimate(node.num) + } label: { + Label { + Text("Estimate Coverage") + } icon: { + Image(systemName: "cellularbars") + .symbolRenderingMode(.multicolor) + } + } + .disabled(!hasPositions) } NavigationLink { PositionLog(node: node) diff --git a/Meshtastic/Views/Nodes/MeshMapMK.swift b/Meshtastic/Views/Nodes/MeshMapMK.swift index ae7296cee..307f8a329 100644 --- a/Meshtastic/Views/Nodes/MeshMapMK.swift +++ b/Meshtastic/Views/Nodes/MeshMapMK.swift @@ -104,6 +104,9 @@ struct MeshMapMK: View { @State var newWaypointCoord: CLLocationCoordinate2D? @State var isMeshMap = true @State private var showLegend = false + /// Site Planner coverage-estimate flow: the headless WebView+bridge runner and the form seed. + @StateObject private var coverageRunner = CoverageEstimateRunner() + @State private var coverageSeed: CoverageEstimateSeed? /// Filter @ObservedObject var filters = NodeFilterParameters.shared /// Track whether a detached Mesh Map window is currently open. @@ -396,6 +399,7 @@ struct MeshMapMK: View { .onChange(of: router.mapState) { guard case .map = router.selectedTab else { return } applyTraceRouteSelection() + consumeCoverageEstimateState() // TODO: handle deep link for waypoints } .onChange(of: selectedMapLayer) { _, newMapLayer in @@ -428,9 +432,24 @@ struct MeshMapMK: View { #endif .presentationBackgroundInteraction(.enabled(upThrough: .medium)) } + .sheet(item: $coverageSeed) { seed in + CoverageEstimateForm(seed: seed, runner: coverageRunner) + .presentationDetents([.large]) + #if !targetEnvironment(macCatalyst) + .presentationDragIndicator(.visible) + #endif + } .safeAreaInset(edge: .bottom, alignment: .trailing) { HStack(spacing: 12) { Spacer() + Button(action: { + presentCoverageEstimateFromMap() + }) { + Image(systemName: "cellularbars") + } + .accessibilityLabel("Estimate coverage") + .accessibilityHint("Runs a Site Planner coverage estimate and adds it to the map.") + .glassButtonStyle() Button(action: { withAnimation { editingFilters = !editingFilters @@ -517,10 +536,15 @@ struct MeshMapMK: View { // snapshots are dropped when backgrounded and rebuilt when foregrounded. refreshVisiblePositionSnapshots(from: positionState.positions) } + .onChange(of: coverageRunner.importedResult) { _, result in + guard let result else { return } + applyImportedCoverage(result) + } .onAppear { UIApplication.shared.isIdleTimerDisabled = true syncFallbackLocation() refreshMapWindowOpenState() + consumeCoverageEstimateState() // Initialize enabled overlay configs with all active files // Migrate the legacy `.offline` base layer to the new independent offline-tiles overlay. if selectedMapLayer == .offline { @@ -561,6 +585,7 @@ struct MeshMapMK: View { UIApplication.shared.isIdleTimerDisabled = true refreshVisiblePositionSnapshots() applyTraceRouteSelection() + consumeCoverageEstimateState() } else { flyover.stop(restoreCamera: false) UIApplication.shared.isIdleTimerDisabled = false @@ -581,6 +606,93 @@ struct MeshMapMK: View { } } + /// A successful import turns on the overlay master toggle, enables the imported file's config, + /// refreshes the styled overlays, and recenters the map on the transmitter. + private func applyImportedCoverage(_ result: CoverageEstimateResult) { + GeoJSONOverlayManager.shared.clearCache() + mapOverlaysEnabled = true + enabledOverlayConfigs.insert(result.metadata.id) + rebuildGeoJSONOverlays() + cameraCommand = ClusterMapCameraCommand( + id: UUID(), + region: MKCoordinateRegion( + center: result.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) + ), + animated: true + ) + } + + /// The connected radio's LoRa config, used to prefill frequency / power / sensitivity. + private var connectedLoRaConfig: LoRaConfigEntity? { + guard let num = accessoryManager.activeDeviceNum else { return nil } + return getNodeInfo(id: num, context: context)?.loRaConfig + } + + /// The connected radio's primary channel name (for the firmware-accurate frequency-slot hash). + private var connectedPrimaryChannelName: String { + guard let num = accessoryManager.activeDeviceNum, + let node = getNodeInfo(id: num, context: context) else { return "LongFast" } + if let primary = node.myInfo?.channels.first(where: { $0.index == 0 || $0.role == 1 }), + let name = primary.name, !name.isEmpty { + return name + } + if node.loRaConfig?.usePreset == false { return "Custom" } + let preset = ModemPresets(rawValue: Int(node.loRaConfig?.modemPreset ?? 0)) ?? .longFast + return preset.androidChannelName + } + + /// Open the estimate form from the map toolbar, prefilled from the connected radio and located at + /// the current map centre (falling back to device / connected-device GPS). + private func presentCoverageEstimateFromMap() { + let center = visibleRegion?.center + let coordinate = center ?? LocationsHandler.currentLocation ?? activeDeviceCoordinate + let params = SitePlannerParameters.prefilled( + name: "", + coordinate: coordinate, + loRaConfig: connectedLoRaConfig, + primaryChannelName: connectedPrimaryChannelName + ) + coverageSeed = CoverageEstimateSeed(parameters: params, nodeCoordinate: nil, mapCenter: center) + } + + /// Consume a `.coverageEstimate` map-state hand-off. Called from `onChange(of: router.mapState)`, + /// `onChange(of: router.selectedTab)`, and `onAppear` — mirroring `applyTraceRouteSelection` — so a + /// hand-off from node detail isn't dropped when the Map tab mounts fresh and misses the change. + private func consumeCoverageEstimateState() { + guard case .map = router.selectedTab else { return } + if case let .coverageEstimate(nodeNum)? = router.mapState { + presentCoverageEstimate(forNode: nodeNum) + } + } + + /// Open the estimate form for a specific node (node-detail handoff), prefilled from the connected + /// radio and located at the node's latest position, and recenter the map on it. Consumes the + /// `.coverageEstimate` map state. + private func presentCoverageEstimate(forNode nodeNum: Int64) { + defer { router.mapState = nil } + guard let node = getNodeInfo(id: nodeNum, context: context) else { return } + let coordinate = node.latestPosition?.nodeCoordinate + let name = node.user?.displayLongName ?? node.user?.shortName ?? "" + let params = SitePlannerParameters.prefilled( + name: name, + coordinate: coordinate, + loRaConfig: connectedLoRaConfig, + primaryChannelName: connectedPrimaryChannelName + ) + coverageSeed = CoverageEstimateSeed(parameters: params, nodeCoordinate: coordinate, mapCenter: visibleRegion?.center) + if let coordinate { + cameraCommand = ClusterMapCameraCommand( + id: UUID(), + region: MKCoordinateRegion( + center: coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) + ), + animated: true + ) + } + } + private func refreshMapWindowOpenState() { // One primary app window plus one detached window means > 1 attached scenes. let attachedScenes = UIApplication.shared.connectedScenes.filter { $0.activationState != .unattached } diff --git a/MeshtasticTests/MapDataManagerTests.swift b/MeshtasticTests/MapDataManagerTests.swift index 0a6e48271..1f85a5b09 100644 --- a/MeshtasticTests/MapDataManagerTests.swift +++ b/MeshtasticTests/MapDataManagerTests.swift @@ -103,3 +103,57 @@ struct MapDataManagerImportFromRemoteTests { #expect(metadata.overlayCount == 2) } } + +/// Covers `MapDataManager.importFromString` — the in-memory GeoJSON import path used by the +/// Site Planner native bridge (meshtastic/Meshtastic-Apple#2058). +@Suite("MapDataManager.importFromString", .serialized) +struct MapDataManagerImportFromStringTests { + + private let sampleGeoJSON = """ + { "type": "FeatureCollection", "properties": { "name": "Coverage" }, "features": [ + { "type": "Feature", "geometry": { "type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]] }, + "properties": { "fill": "#0080ff" } }, + { "type": "Feature", "geometry": { "type": "Polygon", "coordinates": [[[2, 2], [3, 2], [3, 3], [2, 2]]] }, + "properties": { "fill": "#00ff80" } } + ]} + """ + + private func cleanUp(_ manager: MapDataManager, _ metadata: MapDataMetadata) async { + try? await manager.deleteFile(metadata) + } + + @Test func importsValidGeoJSONString() async throws { + let manager = MapDataManager() + let metadata = try await manager.importFromString(sampleGeoJSON, name: "Site Alpha \(UUID().uuidString)") + defer { Task { await cleanUp(manager, metadata) } } + + #expect(metadata.overlayCount == 2) + #expect(metadata.format == "geojson") + } + + @Test func enforcesGeojsonExtensionAndSanitizesName() async throws { + let manager = MapDataManager() + // A name with path separators must not escape the temp directory or lose the extension. + let metadata = try await manager.importFromString(sampleGeoJSON, name: "a/b:c \(UUID().uuidString)") + defer { Task { await cleanUp(manager, metadata) } } + + #expect(metadata.filename.hasSuffix(".geojson")) + #expect(!metadata.filename.contains("/")) + #expect(!metadata.filename.contains(":")) + } + + @Test func throwsOnInvalidGeoJSON() async throws { + let manager = MapDataManager() + await #expect(throws: Error.self) { + try await manager.importFromString("not json at all", name: "bad") + } + } + + @Test func throwsOnOversizedString() async throws { + let manager = MapDataManager() + let huge = String(repeating: "x", count: 11 * 1024 * 1024) // > 10MB cap + await #expect(throws: MapDataError.self) { + try await manager.importFromString(huge, name: "huge") + } + } +} diff --git a/MeshtasticTests/SitePlannerParametersTests.swift b/MeshtasticTests/SitePlannerParametersTests.swift new file mode 100644 index 000000000..366e16555 --- /dev/null +++ b/MeshtasticTests/SitePlannerParametersTests.swift @@ -0,0 +1,187 @@ +// SitePlannerParametersTests.swift +// MeshtasticTests +// +// Locks down the Site Planner flat-query contract (meshtastic/Meshtastic-Apple#2058): +// the query-URL builder, validation bounds, and radio prefill (dBm→W, preset→sensitivity, +// firmware-accurate frequency). + +import Testing +import Foundation +import CoreLocation +@testable import Meshtastic + +@Suite("SitePlannerParameters") +struct SitePlannerParametersTests { + + private func queryItems(_ url: URL?) -> [String: String] { + guard let url, let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return [:] } + var dict: [String: String] = [:] + for item in comps.queryItems ?? [] { + dict[item.name] = item.value + } + return dict + } + + // MARK: - Query URL + + @Test func buildsFlatQueryWithAutorunAndBridge() throws { + var params = SitePlannerParameters() + params.name = "Hill Site" + params.latitude = 47.6062 + params.longitude = -122.3321 + params.txPowerWatts = 0.1 + params.txFrequencyMHz = 906.875 + params.txHeightMeters = 2 + params.txGainDBi = 2 + params.rxSensitivityDBm = -139 + params.maxRangeKm = 30 + params.colorScale = .viridis + + let url = params.queryURL(autorun: true, bridge: true) + let items = queryItems(url) + + #expect(url?.absoluteString.hasPrefix("https://site.meshtastic.org/?") == true) + #expect(items["lat"] == "47.6062") + #expect(items["lon"] == "-122.3321") + #expect(items["name"] == "Hill Site") + #expect(items["tx_power"] == "0.1") + #expect(items["tx_freq"] == "906.875") + #expect(items["tx_height"] == "2") // integer-valued → no trailing .0 + #expect(items["tx_gain"] == "2") + #expect(items["rx_sensitivity"] == "-139") + #expect(items["max_range"] == "30") + #expect(items["color_scale"] == "viridis") + #expect(items["run"] == "1") + #expect(items["bridge"] == "1") + } + + @Test func omitsAutorunBridgeHighResAndEmptyNameWhenNotSet() throws { + var params = SitePlannerParameters() + params.latitude = 10 + params.longitude = 10 + params.name = " " // whitespace only + + let items = queryItems(params.queryURL(autorun: false, bridge: false)) + #expect(items["run"] == nil) + #expect(items["bridge"] == nil) + #expect(items["high_res"] == nil) + #expect(items["name"] == nil) + } + + @Test func includesHighResWhenEnabled() throws { + var params = SitePlannerParameters() + params.latitude = 10 + params.longitude = 10 + params.highResolution = true + params.maxRangeKm = 70 + + let items = queryItems(params.queryURL()) + #expect(items["high_res"] == "1") + } + + @Test func percentEncodesName() throws { + var params = SitePlannerParameters() + params.latitude = 1 + params.longitude = 1 + params.name = "A & B / C" + + let url = params.queryURL() + // The raw query string must not contain a bare `&` or `/` from the name. + let raw = url?.query ?? "" + #expect(raw.contains("name=A%20%26%20B%20/%20C") || raw.contains("name=A%20%26%20B%20%2F%20C")) + // And decoded round-trips back to the original. + #expect(queryItems(url)["name"] == "A & B / C") + } + + // MARK: - Validation + + @Test func zeroZeroCoordinateIsInvalid() { + var params = SitePlannerParameters() + params.latitude = 0 + params.longitude = 0 + #expect(params.hasValidCoordinate == false) + #expect(params.isValid == false) + } + + @Test func outOfRangeFieldsAreInvalid() { + var params = SitePlannerParameters() + params.latitude = 45 + params.longitude = 45 + params.txFrequencyMHz = 5 // below 20 MHz floor + #expect(params.isValid == false) + + params.txFrequencyMHz = 907 + params.rxSensitivityDBm = -200 // below -150 + #expect(params.isValid == false) + } + + @Test func highResClampsMaxRangeBounds() { + var params = SitePlannerParameters() + params.latitude = 45 + params.longitude = 45 + params.highResolution = true + params.maxRangeKm = 120 // >70 with high-res + #expect(params.isValid == false) + params.maxRangeKm = 70 + #expect(params.isValid == true) + } + + // MARK: - Prefill + + @Test func wattsFromDBmConversion() { + #expect(abs(SitePlannerParameters.watts(fromDBm: 30) - 1.0) < 1e-9) + #expect(abs(SitePlannerParameters.watts(fromDBm: 20) - 0.1) < 1e-9) + #expect(abs(SitePlannerParameters.watts(fromDBm: 27) - 0.5011872336) < 1e-6) + } + + @Test func rxSensitivityTableMatchesPlanner() { + #expect(SitePlannerParameters.rxSensitivity(for: .longFast) == -139) + #expect(SitePlannerParameters.rxSensitivity(for: .shortFast) == -129) + #expect(SitePlannerParameters.rxSensitivity(for: .longSlow) == -144.5) + // Unmapped preset falls back to the planner default. + #expect(SitePlannerParameters.rxSensitivity(for: .tinyFast) == -130) + } + + @MainActor + @Test func prefillFromRadioConfig() { + let config = LoRaConfigEntity() + config.regionCode = Int32(RegionCodes.us.rawValue) + config.modemPreset = Int32(ModemPresets.longFast.rawValue) + config.usePreset = true + config.txPower = 27 // dBm + config.channelNum = 20 // pins the frequency slot + + let coord = CLLocationCoordinate2D(latitude: 47.6, longitude: -122.3) + let params = SitePlannerParameters.prefilled( + name: "Base", + coordinate: coord, + loRaConfig: config, + primaryChannelName: "LongFast" + ) + + #expect(params.name == "Base") + #expect(params.latitude == 47.6) + #expect(params.longitude == -122.3) + #expect(abs(params.txPowerWatts - SitePlannerParameters.watts(fromDBm: 27)) < 1e-9) + #expect(params.rxSensitivityDBm == -139) + // US LongFast slot 20 → 902 + 0.125 + 19*0.25 = 906.875 MHz. + #expect(abs(params.txFrequencyMHz - 906.875) < 1e-6) + } + + @MainActor + @Test func prefillKeepsDefaultsWhenTxPowerIsRegionMax() { + let config = LoRaConfigEntity() + config.regionCode = Int32(RegionCodes.us.rawValue) + config.modemPreset = Int32(ModemPresets.longFast.rawValue) + config.usePreset = true + config.txPower = 0 // firmware "0 = region max" — can't resolve, keep default + + let params = SitePlannerParameters.prefilled( + name: "", + coordinate: nil, + loRaConfig: config, + primaryChannelName: "LongFast" + ) + #expect(params.txPowerWatts == 0.1) // planner default preserved + } +} diff --git a/MeshtasticTests/SwiftUIViewSnapshotTests.swift b/MeshtasticTests/SwiftUIViewSnapshotTests.swift index a65ea674a..58c18e67e 100644 --- a/MeshtasticTests/SwiftUIViewSnapshotTests.swift +++ b/MeshtasticTests/SwiftUIViewSnapshotTests.swift @@ -1628,6 +1628,7 @@ struct NodeDetailSnapshotTests { let view = NodeDetail(node: node) .environmentObject(AccessoryManager.shared) .environmentObject(MeshtasticAPI.shared) + .environmentObject(Router()) .modelContainer(container) await assertViewSnapshot(of: view, width: 390, height: 1800, named: "nodeDetail", forDocs: true) diff --git a/docs/user/map.md b/docs/user/map.md index 2b6dce978..2081f2ccb 100644 --- a/docs/user/map.md +++ b/docs/user/map.md @@ -54,6 +54,37 @@ Tap the **info button** (`info.circle`) in the bottom-right toolbar to open the | Traffic | Show Apple Maps live traffic | | Points of Interest | Show Apple Maps points of interest | +## Coverage Estimate (Site Planner) + +Estimate the radio coverage of a transmitter site without leaving the app. The app drives the hosted [Meshtastic Site Planner](https://site.meshtastic.org) — a SPLAT!/ITM propagation simulator — and imports the resulting coverage map as a styled GeoJSON overlay. + +### Starting an estimate + +You can open the estimate form two ways: + +- **From the map** — tap the coverage button (`cellularbars`) in the bottom-right toolbar. The form is prefilled from the connected radio and located at the current map centre. +- **From a node** — open a node with a known position, scroll to the **Logs** section, and tap **Estimate Coverage**. The map recenters on that node and opens the form prefilled from it. + +### The estimate form + +The form mirrors the Site Planner's own panels. The **Transmitter** and **Display** sections are open by default; **Receiver** and **Simulation Options** are collapsed. + +| Section | Fields | +|---------|--------| +| Site / Transmitter | Site name, latitude/longitude, transmit power (W), frequency (MHz), antenna height (m), antenna gain (dBi). Location shortcuts fill the coordinates from **My Location**, the selected **Node**, or the **Map Center**. | +| Receiver | Receiver sensitivity (dBm) — the coverage threshold. | +| Simulation Options | Maximum range (km) and a high-resolution terrain toggle (which caps the range at 70 km). | +| Display | Colour palette for the coverage map (Plasma, Viridis, CMRmap, Cool, Turbo, Jet). | + +**Prefill from the connected radio:** frequency is computed from the radio's region, modem preset, and channel; transmit power is converted from the device's dBm setting; and receiver sensitivity is mapped from the modem preset (for example LongFast → −139 dBm). Antenna gain and height aren't part of the device config, so they keep the planner's defaults. You can edit any value before running. + +### Running it + +Tap **Estimate**. A progress indicator appears while the planner computes the coverage; you can **Cancel** at any time. When it finishes, the styled coverage map is added as a map overlay (in its dBm colours), the GeoJSON overlays layer is enabled, and the map recenters on the transmitter. The imported layer is managed like any other GeoJSON overlay in **Map Options**. + +> **Note — Requires a network connection** +> The coverage simulation runs in the hosted Site Planner, so an internet connection is needed. Estimates time out after 45 seconds; on failure the flow ends cleanly without leaving a stuck spinner. + ## Waypoints Waypoints are named points of interest you can share across the mesh. diff --git a/docs/user/nodes.md b/docs/user/nodes.md index 61474bd8c..ec3765a8e 100644 --- a/docs/user/nodes.md +++ b/docs/user/nodes.md @@ -142,6 +142,8 @@ Tap a node and scroll to the Logs section for detailed metrics: | ![Detection Sensor](../assets/screenshots/logDetectionSensor.png) | Motion or door open/close alerts from the node. | | ![Trace Routes](../assets/screenshots/logTraceRoutes.png) | Recorded trace route paths showing the hops a message took through the mesh. | +When a node has a known position, the Logs section also offers **Estimate Coverage** (`cellularbars`). It switches to the Map tab and opens the Site Planner coverage-estimate form prefilled from that node. See [Coverage Estimate (Site Planner)](map.md) on the Map page for the full flow. + ## Local Stats and Noise Floor Local Stats show radio diagnostics reported by a node, including packets received, packets transmitted, duplicate packets, relayed packets, bad receives, canceled packets, online node count, total node count, and noise floor. From 2069157053976c50bebfad97f7c67d2248be6037 Mon Sep 17 00:00:00 2001 From: bruschill <621389+bruschill@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:11:20 -0500 Subject: [PATCH 2/3] fix(map): polish coverage estimate location shortcut buttons Replace the wrapping/hyphenating location shortcut labels with compact equal-width icon-over-label cells under a 'Set coordinates from' header, so 'My Location' / 'Map Center' stay on one line. --- .../Helpers/Map/CoverageEstimateForm.swift | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/Meshtastic/Views/Nodes/Helpers/Map/CoverageEstimateForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/CoverageEstimateForm.swift index b2177665c..e537680df 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/CoverageEstimateForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/CoverageEstimateForm.swift @@ -171,25 +171,40 @@ struct CoverageEstimateForm: View { @ViewBuilder private var locationShortcuts: some View { let hasShortcuts = deviceLocation != nil || seed.nodeCoordinate != nil || seed.mapCenter != nil if hasShortcuts { - HStack(spacing: 12) { - if let device = deviceLocation { - shortcutButton("My Location", systemImage: "location.fill") { apply(device) } - } - if let node = seed.nodeCoordinate { - shortcutButton("Node", systemImage: "flipphone") { apply(node) } - } - if let center = seed.mapCenter { - shortcutButton("Map Center", systemImage: "scope") { apply(center) } + VStack(alignment: .leading, spacing: 6) { + Text("Set coordinates from") + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 8) { + if let device = deviceLocation { + shortcutButton("My Location", systemImage: "location.fill") { apply(device) } + } + if let node = seed.nodeCoordinate { + shortcutButton("Node", systemImage: "flipphone") { apply(node) } + } + if let center = seed.mapCenter { + shortcutButton("Map Center", systemImage: "scope") { apply(center) } + } } + .buttonStyle(.bordered) } - .buttonStyle(.bordered) - .controlSize(.small) } } + /// A compact equal-width shortcut cell: icon over a single-line label, so long titles + /// ("My Location", "Map Center") don't wrap or hyphenate in the three-across row. private func shortcutButton(_ title: LocalizedStringKey, systemImage: String, action: @escaping () -> Void) -> some View { Button(action: action) { - Label(title, systemImage: systemImage) + VStack(spacing: 4) { + Image(systemName: systemImage) + .font(.body) + Text(title) + .font(.caption2) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 4) } .accessibilityLabel(Text(title)) .accessibilityHint("Fills the transmitter coordinates.") From 4ab4420cfa559ad463acfb3cb1a69171034feaba Mon Sep 17 00:00:00 2001 From: bruschill <621389+bruschill@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:24:03 -0500 Subject: [PATCH 3/3] fix(map): close terminal state on cancel; text-buffer negative coverage fields Address CodeRabbit feedback on #2081: - CoverageEstimateRunner.cancel() now sets didFinish so an in-flight bridge message can't import stale coverage after teardown (matches fail()/handleCoverage). - Latitude/Longitude/Sensitivity use a text-backed DecimalField so intermediate '-'/'.' input parses reliably instead of resetting the bound value to nil. --- .../SitePlanner/CoverageEstimateRunner.swift | 1 + .../Helpers/Map/CoverageEstimateForm.swift | 61 ++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/Meshtastic/Helpers/SitePlanner/CoverageEstimateRunner.swift b/Meshtastic/Helpers/SitePlanner/CoverageEstimateRunner.swift index e320bc8e0..7ecc09a28 100644 --- a/Meshtastic/Helpers/SitePlanner/CoverageEstimateRunner.swift +++ b/Meshtastic/Helpers/SitePlanner/CoverageEstimateRunner.swift @@ -120,6 +120,7 @@ final class CoverageEstimateRunner: NSObject, ObservableObject { /// Cancel any in-flight run and tear down the WebView. func cancel() { + didFinish = true timeoutTask?.cancel() timeoutTask = nil teardownWebView() diff --git a/Meshtastic/Views/Nodes/Helpers/Map/CoverageEstimateForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/CoverageEstimateForm.swift index e537680df..fca2e93e9 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/CoverageEstimateForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/CoverageEstimateForm.swift @@ -114,8 +114,8 @@ struct CoverageEstimateForm: View { locationShortcuts - labeledNumber("Latitude", value: $params.latitude) - labeledNumber("Longitude", value: $params.longitude) + DecimalField("Latitude", value: $params.latitude) + DecimalField("Longitude", value: $params.longitude) labeledNumber("Transmit power (W)", value: $params.txPowerWatts) labeledNumber("Frequency (MHz)", value: $params.txFrequencyMHz) labeledNumber("Antenna height (m)", value: $params.txHeightMeters) @@ -129,7 +129,7 @@ struct CoverageEstimateForm: View { private var receiverSection: some View { Section { DisclosureGroup(isExpanded: $receiverExpanded) { - labeledNumber("Sensitivity (dBm)", value: $params.rxSensitivityDBm) + DecimalField("Sensitivity (dBm)", value: $params.rxSensitivityDBm) } label: { Label("Receiver", systemImage: "dot.radiowaves.left.and.right") } @@ -229,6 +229,61 @@ struct CoverageEstimateForm: View { } } + /// A text-backed decimal field for values that can be negative (latitude, longitude, + /// receiver sensitivity). `TextField(value:format: .number)` resets the bound value to + /// `nil` on intermediate input like a lone `-` or a trailing `.`, which makes negative + /// values awkward to type. A local string buffer lets the user type freely and only + /// pushes a parsed value back to the model, while still reflecting external updates + /// (e.g. the location shortcut buttons) when the field isn't being edited. + private struct DecimalField: View { + let title: LocalizedStringKey + @Binding var value: Double + @State private var text: String + @FocusState private var focused: Bool + + init(_ title: LocalizedStringKey, value: Binding) { + self.title = title + _value = value + _text = State(initialValue: Self.format(value.wrappedValue)) + } + + var body: some View { + HStack { + Text(title) + Spacer() + TextField("", text: $text) + .keyboardType(.numbersAndPunctuation) + .multilineTextAlignment(.trailing) + .frame(maxWidth: 140) + .focused($focused) + .accessibilityLabel(Text(title)) + .onChange(of: text) { _, newValue in + if let parsed = Double(newValue.replacingOccurrences(of: ",", with: ".")) { + value = parsed + } + } + .onChange(of: value) { _, newValue in + // Reflect external updates (shortcut buttons), but don't fight the user mid-edit. + if !focused { + text = Self.format(newValue) + } + } + } + } + + /// Formats with a stable `.` decimal separator (matching `Double` parsing) and no + /// grouping, trimming trailing zeros while preserving coordinate precision. + private static func format(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.usesGroupingSeparator = false + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 8 + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: NSNumber(value: value)) ?? String(value) + } + } + private var estimatingOverlay: some View { ZStack { Color(.systemBackground).opacity(0.85).ignoresSafeArea()