diff --git a/StikDebug/Services/CoordinateConverter.swift b/StikDebug/Services/CoordinateConverter.swift new file mode 100644 index 0000000..b046c1b --- /dev/null +++ b/StikDebug/Services/CoordinateConverter.swift @@ -0,0 +1,113 @@ +// +// CoordinateConverter.swift +// StikDebug +// + +import CoreLocation + +/// WGS‑84 ↔ GCJ‑02 (Mars coordinate) bi‑directional conversion. +/// +/// MapKit in mainland China renders on GCJ‑02 tiles provided by Amap +/// (高德). Points picked on the map are therefore in GCJ‑02, but +/// `simulate_location()` always treats its input as WGS‑84. This utility +/// converts GCJ‑02 back to WGS‑84 before simulation so the simulated +/// location lands on the correct spot on all map providers. +enum CoordinateConverter { + + // MARK: - Constants (from the public domain GCJ‑02 algorithm) + + private static let a = 6378245.0 // semi‑major axis + private static let ee = 0.00669342162296594323 // eccentricity squared + + // MARK: - Public API + + /// WGS‑84 → GCJ‑02 (forward). + static func wgs84ToGCJ02(_ coordinate: CLLocationCoordinate2D) -> CLLocationCoordinate2D { + let (lat, lon) = transform(coordinate.latitude, coordinate.longitude, delta: 1) + return CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + + /// GCJ‑02 → WGS‑84 (inverse, via fixed‑point iteration). + static func gcj02ToWGS84(_ coordinate: CLLocationCoordinate2D) -> CLLocationCoordinate2D { + let lat = coordinate.latitude + let lon = coordinate.longitude + + // 1st pass + let d1 = delta(lat, lon) + var wgsLat = lat - d1.lat + var wgsLon = lon - d1.lon + + // 2nd pass (refined) + let d2 = delta(wgsLat, wgsLon) + wgsLat = lat - d2.lat + wgsLon = lon - d2.lon + + return CLLocationCoordinate2D(latitude: wgsLat, longitude: wgsLon) + } + + // MARK: - Private helpers + + private static func transform(_ lat: Double, _ lon: Double, delta: Int) -> (Double, Double) { + let d = self.delta(lat, lon) + return (lat + Double(delta) * d.lat, lon + Double(delta) * d.lon) + } + + private static func delta(_ lat: Double, _ lon: Double) -> (lat: Double, lon: Double) { + var dLat = 0.0, dLon = 0.0 + + if isOutOfChina(lat, lon) { + return (dLat, dLon) + } + + dLat = transformLat(lon - 105.0, lat - 35.0) + dLon = transformLon(lon - 105.0, lat - 35.0) + let radLat = lat / 180.0 * .pi + var magic = sin(radLat) + magic = 1 - ee * magic * magic + let sqrtMagic = sqrt(magic) + dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * .pi) + dLon = (dLon * 180.0) / (a / sqrtMagic * cos(radLat) * .pi) + return (dLat, dLon) + } + + private static func transformLat(_ x: Double, _ y: Double) -> Double { + var ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + + 0.1 * x * y + 0.2 * sqrt(abs(x)) + ret += (20.0 * sin(6.0 * x * .pi) + 20.0 * sin(2.0 * x * .pi)) * 2.0 / 3.0 + ret += (20.0 * sin(y * .pi) + 40.0 * sin(y / 3.0 * .pi)) * 2.0 / 3.0 + ret += (160.0 * sin(y / 12.0 * .pi) + 320.0 * sin(y * .pi / 30.0)) * 2.0 / 3.0 + return ret + } + + private static func transformLon(_ x: Double, _ y: Double) -> Double { + var ret = 300.0 + x + 2.0 * y + 0.1 * x * x + + 0.1 * x * y + 0.1 * sqrt(abs(x)) + ret += (20.0 * sin(6.0 * x * .pi) + 20.0 * sin(2.0 * x * .pi)) * 2.0 / 3.0 + ret += (20.0 * sin(x * .pi) + 40.0 * sin(x / 3.0 * .pi)) * 2.0 / 3.0 + ret += (150.0 * sin(x / 12.0 * .pi) + 300.0 * sin(x / 30.0 * .pi)) * 2.0 / 3.0 + return ret + } + + /// Rough bounding‑box check — avoids unnecessary iteration for + /// coordinates clearly outside mainland China. + private static func isOutOfChina(_ lat: Double, _ lon: Double) -> Bool { + // Hong Kong, Macau, and Taiwan are excluded because their + // MapKit tiles are *not* GCJ‑02. + if lat < 0.8293 || lat > 55.8271 || lon < 72.004 || lon > 137.8347 { + return true + } + // Exclude Hong Kong + if lat >= 22.15 && lat <= 22.58 && lon >= 113.82 && lon <= 114.42 { + return true + } + // Exclude Macau + if lat >= 22.06 && lat <= 22.22 && lon >= 113.52 && lon <= 113.62 { + return true + } + // Exclude Taiwan + if lat >= 21.9 && lat <= 25.4 && lon >= 120.0 && lon <= 122.1 { + return true + } + return false + } +} diff --git a/StikDebug/Views/MainTabView.swift b/StikDebug/Views/MainTabView.swift index 17fbb16..19783ba 100644 --- a/StikDebug/Views/MainTabView.swift +++ b/StikDebug/Views/MainTabView.swift @@ -7,6 +7,7 @@ import SwiftUI import Foundation +import CoreLocation private enum ExternalLocationAction: Identifiable { case simulate(URL, Double, Double) @@ -54,6 +55,7 @@ struct MainTabView: View { @State private var detachedFeature: AppFeature? @State private var didSetInitialHome = false @State private var pendingLocationAction: ExternalLocationAction? + @AppStorage("cnCoordinateCorrection") private var cnCoordinateCorrection = false var body: some View { ZStack { @@ -213,11 +215,24 @@ struct MainTabView: View { return } - LocationSimulationCommandQueue.shared.async { + let simLat: Double + let simLon: Double + if cnCoordinateCorrection { + let corrected = CoordinateConverter.gcj02ToWGS84( + CLLocationCoordinate2D(latitude: coordinate.latitude, longitude: coordinate.longitude) + ) + simLat = corrected.latitude + simLon = corrected.longitude + } else { + simLat = coordinate.latitude + simLon = coordinate.longitude + } + + let workItem = DispatchWorkItem { let code = simulate_location( DeviceConnectionContext.targetIPAddress, - coordinate.latitude, - coordinate.longitude, + simLat, + simLon, pairingFile.path ) @@ -236,10 +251,11 @@ struct MainTabView: View { } } } + LocationSimulationCommandQueue.shared.async(execute: workItem) } private func clearSimulatedLocation() { - LocationSimulationCommandQueue.shared.async { + let workItem = DispatchWorkItem { let code = clear_simulated_location() DispatchQueue.main.async { if code == 0 { @@ -254,6 +270,7 @@ struct MainTabView: View { } } } + LocationSimulationCommandQueue.shared.async(execute: workItem) } private func coordinate(from url: URL) -> (latitude: Double, longitude: Double)? { diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index 037f9f4..e0b796e 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -767,6 +767,8 @@ struct LocationSimulationView: View { @State private var showSaveBookmark = false @State private var newBookmarkName = "" + @AppStorage("cnCoordinateCorrection") private var cnCoordinateCorrection = false + private var pairingFilePath: String { PairingFileStore.prepareURL().path } @@ -1568,7 +1570,13 @@ struct LocationSimulationView: View { } private func locationUpdateCode(for coordinate: CLLocationCoordinate2D) -> Int32 { - simulate_location(deviceIP, coordinate.latitude, coordinate.longitude, pairingFilePath) + let adjusted: CLLocationCoordinate2D + if cnCoordinateCorrection { + adjusted = CoordinateConverter.gcj02ToWGS84(coordinate) + } else { + adjusted = coordinate + } + return simulate_location(deviceIP, adjusted.latitude, adjusted.longitude, pairingFilePath) } } diff --git a/StikDebug/Views/SettingsView.swift b/StikDebug/Views/SettingsView.swift index c876037..b52c4ab 100644 --- a/StikDebug/Views/SettingsView.swift +++ b/StikDebug/Views/SettingsView.swift @@ -18,6 +18,7 @@ struct SettingsView: View { @AppStorage(UserDefaults.Keys.confirmExternalJITRequests) private var confirmExternalJITRequests = true @AppStorage("keepAliveAudio") private var keepAliveAudio = true @AppStorage("keepAliveLocation") private var keepAliveLocation = true + @AppStorage("cnCoordinateCorrection") private var cnCoordinateCorrection = false @AppStorage(UserDefaults.Keys.targetDeviceIP) private var targetDeviceIP = DeviceConnectionContext.defaultTargetIPAddress @State private var isShowingPairingFilePicker = false @@ -131,6 +132,18 @@ struct SettingsView: View { } } + Section { + Toggle(isOn: $cnCoordinateCorrection) { + VStack(alignment: .leading, spacing: 2) { + Text("China Coordinate Correction") + Text("Fix offset issue of location simulation in China region") + .font(.caption).foregroundStyle(.secondary) + } + } + } header: { + Text("Location") + } + Section("Advanced") { HStack { Text("Target Device IP")