From aa2b9f131f67c906b199b27d9f3ce3a1ebcb762d Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Mon, 14 Sep 2026 14:21:30 +0300 Subject: [PATCH] fix(tactical): implement dynamic MGRS grid conversion, triangle of error resection accuracy, clean surveyed IDW elevation, and robust offline sync validation --- .../lib/services/military_grid_utils.dart | 95 +++++++++++++- .../lib/services/offline_los_engine.dart | 8 +- .../lib/services/offline_package_manager.dart | 71 ++++++++--- .../lib/services/resection_calculator.dart | 120 ++++++++++++++++-- 4 files changed, 253 insertions(+), 41 deletions(-) diff --git a/packages/tactical_app/lib/services/military_grid_utils.dart b/packages/tactical_app/lib/services/military_grid_utils.dart index b9d66a4..270eafa 100644 --- a/packages/tactical_app/lib/services/military_grid_utils.dart +++ b/packages/tactical_app/lib/services/military_grid_utils.dart @@ -53,14 +53,97 @@ class MilitaryGridUtils { return 'شمال غرب (NW)'; } - /// Convert LatLng to MGRS String representation - static String latLngToMgrs(double lat, double lng) { - final coords = fromLatLng(LatLng(lat, lng)); + /// Convert LatLng to standard MGRS String representation (Zone + Band + 100k Square ID + Easting/Northing) + static String latLngToMgrs(double lat, double lng, {int accuracyDigits = 4}) { + if (lat < -80.0 || lat > 84.0) { + return 'OUT_OF_BOUNDS'; + } + + final zone = ((lng + 180.0) / 6.0).floor() + 1; + final coords = fromLatLng(LatLng(lat, lng), zone: zone); + + final bandLetter = _getLatitudeBandLetter(lat); + final squareId = _get100kSquareId(coords.easting, coords.northing, zone); + final eInt = coords.easting.round() % 100000; final nInt = coords.northing.round() % 100000; - final eStr = (eInt ~/ 10).toString().padLeft(4, '0'); - final nStr = (nInt ~/ 10).toString().padLeft(4, '0'); - return '${coords.zone}R YU $eStr $nStr'; + + // Pad to 5 digits then trim to desired accuracy + final e5 = eInt.toString().padLeft(5, '0'); + final n5 = nInt.toString().padLeft(5, '0'); + + final eStr = e5.substring(0, math.min(5, accuracyDigits)); + final nStr = n5.substring(0, math.min(5, accuracyDigits)); + + return '$zone$bandLetter $squareId $eStr $nStr'; + } + + /// Latitude band letter (8-degree bands from -80° to +84°: C to X, skipping I and O) + static String _getLatitudeBandLetter(double lat) { + const bands = 'CDEFGHJKLMNPQRSTUVWX'; + if (lat >= 84.0) return 'X'; + if (lat < -80.0) return 'C'; + int index = ((lat + 80.0) / 8.0).floor(); + if (index < 0) index = 0; + if (index >= bands.length) index = bands.length - 1; + return bands[index]; + } + + /// 100,000-meter Square Identification (2 letters) per US DMA TM 8358.1 / WGS84 standard + static String _get100kSquareId(double easting, double northing, int zone) { + const setColOrigin = 'AJSAJS'; + const setRowOrigin = 'AFAFAF'; + const aCode = 65; // 'A' + const iCode = 73; // 'I' + const oCode = 79; // 'O' + const vCode = 86; // 'V' + const zCode = 90; // 'Z' + + final setParm = (zone % 6 == 0) ? 6 : (zone % 6); + final setIndex = setParm - 1; + + final colOrigin = setColOrigin.codeUnitAt(setIndex); + final rowOrigin = setRowOrigin.codeUnitAt(setIndex); + + final col = (easting / 100000.0).floor(); + final row = (northing / 100000.0).floor() % 20; + + var colInt = colOrigin + col - 1; + var rowInt = rowOrigin + row; + var colRollover = false; + + if (colInt > zCode) { + colInt = colInt - zCode + aCode - 1; + colRollover = true; + } + if (colInt == iCode || (colOrigin < iCode && colInt > iCode) || ((colInt > iCode || colOrigin < iCode) && colRollover)) { + colInt++; + } + if (colInt == oCode || (colOrigin < oCode && colInt > oCode) || ((colInt > oCode || colOrigin < oCode) && colRollover)) { + colInt++; + if (colInt == iCode) colInt++; + } + if (colInt > zCode) { + colInt = colInt - zCode + aCode - 1; + } + + var rowRollover = false; + if (rowInt > vCode) { + rowInt = rowInt - vCode + aCode - 1; + rowRollover = true; + } + if (rowInt == iCode || (rowOrigin < iCode && rowInt > iCode) || ((rowInt > iCode || rowOrigin < iCode) && rowRollover)) { + rowInt++; + } + if (rowInt == oCode || (rowOrigin < oCode && rowInt > oCode) || ((rowInt > oCode || rowOrigin < oCode) && rowRollover)) { + rowInt++; + if (rowInt == iCode) rowInt++; + } + if (rowInt > vCode) { + rowInt = rowInt - vCode + aCode - 1; + } + + return String.fromCharCode(colInt) + String.fromCharCode(rowInt); } /// Convert WGS84 Lat/Lng to UTM Zone 36N Easting (شرقيات) and Northing (شماليات) diff --git a/packages/tactical_app/lib/services/offline_los_engine.dart b/packages/tactical_app/lib/services/offline_los_engine.dart index a8e9cf2..bd52d60 100644 --- a/packages/tactical_app/lib/services/offline_los_engine.dart +++ b/packages/tactical_app/lib/services/offline_los_engine.dart @@ -155,12 +155,8 @@ class JordanDemSurface { } final baseElev = den > 0 ? num / den : 750.0; - // 3. Server-matching topographical relief ridges & wadis - final ridge = 65.0 * math.sin(lat * 85.0 + lng * 65.0) + - 40.0 * math.cos(lat * 140.0 - lng * 110.0) + - 15.0 * math.sin(lat * 310.0 + lng * 270.0); - - return (baseElev + ridge).roundToDouble(); + // Authentic IDW elevation based on surveyed benchmarks across Jordan + return baseElev.roundToDouble(); } } diff --git a/packages/tactical_app/lib/services/offline_package_manager.dart b/packages/tactical_app/lib/services/offline_package_manager.dart index f41c3a0..fb5e559 100644 --- a/packages/tactical_app/lib/services/offline_package_manager.dart +++ b/packages/tactical_app/lib/services/offline_package_manager.dart @@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../config/app_config.dart'; import 'landmark_database.dart'; import 'offline_routing_engine.dart'; +import 'offline_routing_package_service.dart'; enum PackageSyncStatus { notInstalled, @@ -24,6 +25,9 @@ class OfflinePackageInfo { final bool canSyncNow; final int daysUntilNextSync; + /// هل غراف الطرق الحقيقي (Valhalla) مثبت على الجهاز؟ + final bool realRoadRoutingReady; + const OfflinePackageInfo({ required this.packageId, required this.name, @@ -35,12 +39,13 @@ class OfflinePackageInfo { this.status = PackageSyncStatus.notInstalled, this.canSyncNow = true, this.daysUntilNextSync = 0, + this.realRoadRoutingReady = false, }); } class OfflinePackageManager { static const String _pkgKey = 'offline_jordan_pkg_v2'; - static const int syncCooldownDays = 14; // 14-day server protection rule + static const int syncCooldownDays = 14; // Cooldown enabled: updates allowed every 14 days static String get defaultServerUrl => AppConfig.serverUrl; static String get defaultApiKey => AppConfig.apiKey; @@ -48,6 +53,13 @@ class OfflinePackageManager { static ValueNotifier syncStatusMessage = ValueNotifier('جاهز للمزامنة'); static ValueNotifier packageStatus = ValueNotifier(PackageSyncStatus.installed); + /// Reset sync lock and preferences / تصفير قفل المزامنة بالكامل + static Future resetSyncLock() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('${_pkgKey}_date'); + await prefs.remove('${_pkgKey}_synced'); + } + static Future getPackageInfo() async { final prefs = await SharedPreferences.getInstance(); final syncDateStr = prefs.getString('${_pkgKey}_date'); @@ -58,24 +70,31 @@ class OfflinePackageManager { int remainingDays = 0; if (lastDate != null) { - final daysSinceLastSync = DateTime.now().difference(lastDate).inDays; - if (daysSinceLastSync < syncCooldownDays) { + final daysSinceSync = DateTime.now().difference(lastDate).inDays; + if (daysSinceSync < syncCooldownDays) { canSync = false; - remainingDays = syncCooldownDays - daysSinceLastSync; + remainingDays = syncCooldownDays - daysSinceSync; } } + // حالة حزمة التوجيه الحقيقية (شبكة الطرق الكاملة) + final routingInstalled = await OfflineRoutingPackageService.isInstalled(); + final routingVersion = await OfflineRoutingPackageService.installedVersion(); + return OfflinePackageInfo( packageId: 'jordan-tactical-offline-v2', name: 'حزمة الأردن التكتيكية الميدانية الكاملة (Jordan Tactical Pack)', - version: '2.4.0', + version: routingInstalled ? (routingVersion ?? '2.4.0') : '2.4.0', totalLandmarks: LandmarkDatabase.allLandmarks.length, - totalRoadNodes: 36, - sizeFormatted: '3.5 MB (شبكة الطرق) + 25 MB (المعالم)', + totalRoadNodes: routingInstalled ? 450000 : 36, + sizeFormatted: routingInstalled + ? 'غراف طرق الأردن الحقيقي + SRTM مثبت محلياً' + : '3.5 MB (شبكة الطرق) + 25 MB (المعالم)', lastSyncTime: lastDate, status: isSynced ? PackageSyncStatus.installed : PackageSyncStatus.notInstalled, canSyncNow: canSync, daysUntilNextSync: remainingDays, + realRoadRoutingReady: routingInstalled, ); } @@ -85,18 +104,17 @@ class OfflinePackageManager { String? apiKey, bool force = false, }) async { - final activeUrl = serverUrl ?? defaultServerUrl; - final activeKey = apiKey ?? defaultApiKey; - - // Check 14-day cooldown unless forced if (!force) { final info = await getPackageInfo(); - if (!info.canSyncNow && info.lastSyncTime != null) { - syncStatusMessage.value = 'الحزمة محدثة بالفعل • المزامنة القادمة بعد ${info.daysUntilNextSync} يوم لحماية السيرفر.'; - return false; + if (!info.canSyncNow && info.status == PackageSyncStatus.installed) { + debugPrint('Package sync skipped (14-day cooldown active. ${info.daysUntilNextSync} days left).'); + return true; // Already synced and in cooldown } } + final activeUrl = serverUrl ?? defaultServerUrl; + final activeKey = apiKey ?? defaultApiKey; + try { packageStatus.value = PackageSyncStatus.downloading; syncProgress.value = 0.1; @@ -122,9 +140,30 @@ class OfflinePackageManager { debugPrint('Synced $count landmarks to local storage.'); syncProgress.value = 0.75; - syncStatusMessage.value = 'جاري بناء وتحديث شبكة التوجيه الطوبولوجية...'; + syncStatusMessage.value = 'جاري تنزيل شبكة الطرق الحقيقية للأردن (غراف التوجيه + الارتفاعات)...'; - // 3. Initialize & warm up on-device routing engine + // 3. Download & install the real Jordan road graph routing package + // (Valhalla tiles built from the same OSM data as server GraphHopper) + final routingOk = await OfflineRoutingPackageService.downloadAndInstall( + serverUrl: activeUrl, + apiKey: activeKey, + ); + if (!routingOk) { + final hasLocal = await OfflineRoutingPackageService.isInstalled(); + if (!hasLocal) { + debugPrint('Routing package failed to download and no local offline cache exists.'); + syncStatusMessage.value = 'خطأ: تعذر تنزيل شبكة الطرق الأوفلاين. يرجى التحقق من الاتصال بالخادم.'; + packageStatus.value = PackageSyncStatus.error; + return false; + } else { + debugPrint('Using previously installed local routing package.'); + } + } + + syncProgress.value = 0.90; + syncStatusMessage.value = 'جاري بناء وتحديث محرك التوجيه المحلي...'; + + // 4. Initialize & warm up on-device routing engine OfflineRoutingEngine.initialize(); syncProgress.value = 0.95; diff --git a/packages/tactical_app/lib/services/resection_calculator.dart b/packages/tactical_app/lib/services/resection_calculator.dart index 7bc7c21..982a9cc 100644 --- a/packages/tactical_app/lib/services/resection_calculator.dart +++ b/packages/tactical_app/lib/services/resection_calculator.dart @@ -3,11 +3,13 @@ import '../models/landmark.dart'; class ResectionCalculator { static const double earthRadiusKm = 6371.0; - static const double magneticDeclinationJordan = 4.8; // +4.8° East avg in Jordan + static const double magneticDeclinationJordan = 5.5; // +5.5° East avg in Jordan /// Calculates true azimuth from magnetic compass heading. - static double getTrueAzimuth(double magneticHeadingDeg) { - return (magneticHeadingDeg + magneticDeclinationJordan) % 360.0; + /// When Magnetic North is East of True North (Jordan ~ +5.5° East), + /// True Grid Azimuth = (Magnetic Heading - Declination). + static double getTrueAzimuth(double magneticHeadingDeg, [double customOffset = 5.5]) { + return (magneticHeadingDeg - customOffset + 360.0) % 360.0; } /// Calculates observer position from 2 or more landmark observations using triangulation resection. @@ -55,8 +57,14 @@ class ResectionCalculator { var calcLng = xIntersect / cosLat; var calcLat = yIntersect; - // If 3rd landmark observation exists, calculate centroid / weighted least-squares refinement - double estimatedAccuracy = 50.0; // Base 50m accuracy for 2 landmarks + // Base angular GDOP for 2 landmarks based on cut angle (sin(det)) and landmark distances + final cutAngleSin = det.abs(); + final distADeg = math.sqrt(math.pow((xIntersect - xA), 2) + math.pow((yIntersect - yA), 2)); + final distBDeg = math.sqrt(math.pow((xIntersect - xB), 2) + math.pow((yIntersect - yB), 2)); + final approxDistMeters = ((distADeg + distBDeg) / 2.0) * 111320.0; + + // Compass measurement uncertainty (~1.5 deg = ~0.026 rad) scaled by GDOP (1 / sin(cutAngle)) + double estimatedAccuracy = math.max(10.0, (approxDistMeters * 0.026) / math.max(0.2, cutAngleSin)); if (observations.length >= 3) { final obsC = observations[2]; @@ -70,19 +78,37 @@ class ResectionCalculator { // Solve intersection of B and C final detBC = sinB * cosC - cosB * sinC; - if (detBC.abs() >= 0.0001) { + // Solve intersection of A and C + final detAC = sinA * cosC - cosA * sinC; + + if (detBC.abs() >= 0.0001 && detAC.abs() >= 0.0001) { final dxBC = xC - xB; final dyBC = yC - yB; final tB = (dxBC * cosC - dyBC * sinC) / detBC; - final xIntersectBC = xB + tB * sinB; - final yIntersectBC = yB + tB * cosB; + final xBC = xB + tB * sinB; + final yBC = yB + tB * cosB; - // Weighted centroid average of the triangle of error - calcLng = ((xIntersect + xIntersectBC) / 2.0) / cosLat; - calcLat = (yIntersect + yIntersectBC) / 2.0; + final dxAC = xC - xA; + final dyAC = yC - yA; + final tAc = (dxAC * cosC - dyAC * sinC) / detAC; + final xAC = xA + tAc * sinA; + final yAC = yA + tAc * cosA; - // 3 sightings tighten the error ellipse to ~20-30 meters - estimatedAccuracy = 25.0; + // Centroid of the classic military "Triangle of Error" (مثلث الخطأ) + final xCentroid = (xIntersect + xBC + xAC) / 3.0; + final yCentroid = (yIntersect + yBC + yAC) / 3.0; + + calcLng = xCentroid / cosLat; + calcLat = yCentroid; + + // Compute actual radius of Triangle of Error in meters + final d1 = math.sqrt(math.pow(xIntersect - xCentroid, 2) + math.pow(yIntersect - yCentroid, 2)); + final d2 = math.sqrt(math.pow(xBC - xCentroid, 2) + math.pow(yBC - yCentroid, 2)); + final d3 = math.sqrt(math.pow(xAC - xCentroid, 2) + math.pow(yAC - yCentroid, 2)); + final triangleRadiusMeters = ((d1 + d2 + d3) / 3.0) * 111320.0; + + // Estimated accuracy is bound by triangle size + residual optical error + estimatedAccuracy = math.max(5.0, double.parse(triangleRadiusMeters.toStringAsFixed(1))); } } @@ -131,4 +157,72 @@ class ResectionCalculator { final bearingRad = math.atan2(y, x); return (bearingRad * (180.0 / math.pi) + 360.0) % 360.0; } + + /// Single Landmark Rangefinder (Parallax Triangulation from baseline step-off) + /// حساب المسافة والإحداثيات من معلم جغرافي وحيد بالتحرك على خط أساس + /// + /// [landmark]: المعلم الجغرافي المرصود + /// [azimuth1Deg]: زاوية الرصد الأولى (بالدرجات) + /// [azimuth2Deg]: زاوية الرصد الثانية بعد التحرك (بالدرجات) + /// [baselineMeters]: مسافة التحرك العمودي على خط النظر (مثلاً 10م، 11م، 20م، 50م) + static ResectionResult? calculateSingleLandmarkPolar({ + required TacticalLandmark landmark, + required double azimuth1Deg, + required double azimuth2Deg, + required double baselineMeters, + }) { + final trueAzimuth1 = getTrueAzimuth(azimuth1Deg); + final trueAzimuth2 = getTrueAzimuth(azimuth2Deg); + + // Angular parallax difference in degrees + var deltaAngleDeg = (trueAzimuth2 - trueAzimuth1).abs(); + if (deltaAngleDeg > 180.0) { + deltaAngleDeg = 360.0 - deltaAngleDeg; + } + + if (deltaAngleDeg < 0.05) { + return null; + } + + final deltaAngleRad = deltaAngleDeg * (math.pi / 180.0); + + // Exact trigonometric range: D = Baseline / tan(deltaAngle) + final distanceMeters = baselineMeters / math.tan(deltaAngleRad); + final distanceKm = distanceMeters / 1000.0; + + // Project observer position from Landmark along Back-Azimuth + final backBearingDeg = (trueAzimuth1 + 180.0) % 360.0; + final backBearingRad = backBearingDeg * (math.pi / 180.0); + + final latRad = landmark.lat * (math.pi / 180.0); + final angularDist = distanceKm / earthRadiusKm; + + final obsLatRad = math.asin( + math.sin(latRad) * math.cos(angularDist) + + math.cos(latRad) * math.sin(angularDist) * math.cos(backBearingRad) + ); + + final obsLngRad = (landmark.lng * (math.pi / 180.0)) + + math.atan2( + math.sin(backBearingRad) * math.sin(angularDist) * math.cos(latRad), + math.cos(angularDist) - math.sin(latRad) * math.sin(obsLatRad) + ); + + final calcLat = obsLatRad * (180.0 / math.pi); + final calcLng = obsLngRad * (180.0 / math.pi); + + final obs = ResectionObservation( + landmark: landmark, + observedAzimuthDeg: azimuth1Deg, + trueAzimuthDeg: trueAzimuth1, + ); + + return ResectionResult( + lat: double.parse(calcLat.toStringAsFixed(6)), + lng: double.parse(calcLng.toStringAsFixed(6)), + estimatedAccuracyMeters: math.max(15.0, distanceMeters * 0.03), + observations: [obs], + distanceToLandmarksKm: {landmark.id: distanceKm}, + ); + } }