Files
maps-saas/packages/tactical_app/lib/services/valhalla_offline_engine.dart
T

241 lines
8.7 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:intaleq_maps/intaleq_maps.dart';
import 'offline_routing_engine.dart';
/// [ValhallaOfflineEngine] - محرك التوجيه المحلي السيادي (100% على الجهاز)
///
/// English:
/// Dart bridge to the native valhalla-mobile routing engine (Android/iOS).
/// Routes are calculated against a downloaded Jordan tile extract
/// (valhalla_tiles.tar built from the same OSM data as the server GraphHopper,
/// with SRTM elevation baked into every edge). Zero network usage.
///
/// العربية:
/// جسر Dart إلى محرك Valhalla الأصلي على الجهاز. يحسب المسارات على شبكة الطرق
/// الحقيقية للأردن من حزمة التوجيه المنزّلة (بنفس بيانات OSM وارتفاعات SRTM
/// المستخدمة في GraphHopper على السيرفر) بدون أي اتصال شبكي.
class ValhallaOfflineEngine {
static const MethodChannel _channel = MethodChannel('intaleq_tactical/valhalla');
/// Warm up / (re)build the native engine against [regionDir] tiles.
/// Returns true when the native library answered (engine usable).
static Future<bool> ensureReady({String? regionDir}) async {
try {
// فحص مسبق محلي: لا نزعج الجسر الأصلي إذا لم تُثبَّت الحزمة أصلاً
final dir = regionDir;
if (dir != null && !Directory(dir).existsSync()) {
debugPrint('ValhallaOfflineEngine: package not installed at $dir');
return false;
}
await _channel.invokeMethod<bool>('ensureReady', {'regionDir': regionDir});
return true;
} on MissingPluginException {
debugPrint('ValhallaOfflineEngine: native bridge not available on this platform');
return false;
} on PlatformException catch (e) {
debugPrint('ValhallaOfflineEngine ensureReady failed: ${e.code} ${e.message}');
return false;
} catch (e) {
debugPrint('ValhallaOfflineEngine ensureReady error: $e');
return false;
}
}
static Future<void> release() async {
try {
await _channel.invokeMethod<void>('release');
} catch (_) {}
}
/// Calculate a fully offline road-following route.
///
/// Returns null when the engine/package is unavailable so callers can fall
/// back to the legacy synthetic graph.
static Future<OfflineRoutePlan?> calculateOfflineRoute({
required LatLng start,
required LatLng destination,
TacticalVehicleProfile profile = TacticalVehicleProfile.convoy,
String? regionDir,
}) async {
try {
final ready = await ensureReady(regionDir: regionDir);
if (!ready) return null;
final request = jsonEncode({
'locations': [
{'lat': start.latitude, 'lon': start.longitude, 'type': 'break'},
{'lat': destination.latitude, 'lon': destination.longitude, 'type': 'break'},
],
'costing': 'auto',
'alternatives': false,
'directions_options': {
'units': 'kilometers',
'language': 'ar',
'directions_type': 'instructions',
},
'id': 'intaleq_tactical',
});
final raw = await _channel.invokeMethod<String>('route', {'request': request});
if (raw == null || raw.isEmpty) return null;
final data = jsonDecode(raw) as Map<String, dynamic>;
return _parseTripResponse(data, profile);
} on PlatformException catch (e) {
debugPrint('ValhallaOfflineEngine route failed: ${e.code} ${e.message}');
return null;
} catch (e) {
debugPrint('ValhallaOfflineEngine route error: $e');
return null;
}
}
// ── Response Parsing ─────────────────────────────────────────────────────
static OfflineRoutePlan? _parseTripResponse(Map<String, dynamic> data, TacticalVehicleProfile profile) {
final trip = data['trip'];
if (trip is! Map<String, dynamic>) return null;
final status = (trip['status'] as num?)?.toInt() ?? -1;
if (status != 0) {
debugPrint('Valhalla trip status $status: ${trip['status_message']}');
return null;
}
final legs = trip['legs'];
if (legs is! List || legs.isEmpty) return null;
final allPoints = <LatLng>[];
final maneuvers = <RouteManeuver>[];
for (final legDynamic in legs) {
if (legDynamic is! Map<String, dynamic>) continue;
// Shape: Valhalla encoded polyline, precision 6, includes altitude when
// the tiles were built with SRTM elevation.
final shapeStr = legDynamic['shape'] as String?;
if (shapeStr == null) continue;
final legPoints = decodePolyline6(shapeStr);
final legOffset = allPoints.isEmpty ? 0 : allPoints.length - 1; // join at shared point
allPoints.addAll(legOffset == 0 ? legPoints : legPoints.skip(1));
// Maneuvers with real street names + Arabic narrative instructions.
final legManeuvers = legDynamic['maneuvers'];
if (legManeuvers is List) {
for (final m in legManeuvers) {
if (m is! Map<String, dynamic>) continue;
final beginIdx = (m['begin_shape_index'] as num?)?.toInt() ?? 0;
final globalIdx = (legOffset + beginIdx).clamp(0, allPoints.length - 1);
final streetNames = <String>[];
final rawStreets = m['street_names'];
if (rawStreets is List) {
for (final s in rawStreets) {
if (s is String && s.isNotEmpty) streetNames.add(s);
}
}
maneuvers.add(RouteManeuver(
instructionAr: (m['instruction'] as String?) ?? 'واصل السير',
type: (m['type'] as num?)?.toInt() ?? 0,
streetNames: streetNames,
lengthKm: (m['length'] as num?)?.toDouble() ?? 0.0,
location: allPoints[globalIdx],
));
}
}
}
if (allPoints.length < 2) return null;
final summary = trip['summary'];
double totalKm = 0.0;
double totalTimeSec = 0.0;
double ascend = 0.0;
double descend = 0.0;
if (summary is Map<String, dynamic>) {
totalKm = (summary['length'] as num?)?.toDouble() ?? 0.0;
totalTimeSec = (summary['time'] as num?)?.toDouble() ?? 0.0;
ascend = (summary['ascend'] as num?)?.toDouble() ?? 0.0;
descend = (summary['descend'] as num?)?.toDouble() ?? 0.0;
}
if (totalKm <= 0) {
totalKm = _haversineKm(allPoints.first, allPoints.last);
}
// نفس معاملات التعديل التكتيكي المستخدمة في مسار السيرفر للحفاظ على الاتساق
double profileFactor;
switch (profile) {
case TacticalVehicleProfile.convoy:
profileFactor = 1.45;
break;
case TacticalVehicleProfile.armored:
profileFactor = 1.75;
break;
case TacticalVehicleProfile.offroad4x4:
profileFactor = 1.1;
break;
case TacticalVehicleProfile.rapidResponse:
profileFactor = 0.85;
break;
}
return OfflineRoutePlan(
polylinePoints: allPoints,
totalDistanceKm: totalKm,
estimatedDurationMinutes: (totalTimeSec / 60.0) * profileFactor,
profile: profile,
tacticalWaypoints: const ['موقع الانطلاق', 'الهدف التكتيكي المحدد'],
isOffline: true,
elevationGainMeters: ascend.round(),
elevationLossMeters: descend.round(),
usesRealRoadNetwork: true,
maneuvers: maneuvers,
);
}
/// Decode Valhalla encoded polyline (precision 1e6, optional 3rd dim = altitude cm).
static List<LatLng> decodePolyline6(String encoded) {
final points = <LatLng>[];
int index = 0;
int lat = 0;
int lng = 0;
int decodeChunk() {
int result = 0;
int shift = 0;
int b;
do {
b = encoded.codeUnitAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
} while (b >= 0x20 && index < encoded.length);
return (result & 1) != 0 ? ~(result >> 1) : (result >> 1);
}
while (index < encoded.length) {
lat += decodeChunk();
lng += decodeChunk();
points.add(LatLng(lat / 1e6, lng / 1e6));
}
return points;
}
static double _haversineKm(LatLng a, LatLng b) {
const r = 6371.0;
final dLat = (b.latitude - a.latitude) * (math.pi / 180.0);
final dLng = (b.longitude - a.longitude) * (math.pi / 180.0);
final la1 = a.latitude * (math.pi / 180.0);
final la2 = b.latitude * (math.pi / 180.0);
final h = math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(la1) * math.cos(la2) * math.sin(dLng / 2) * math.sin(dLng / 2);
return 2 * r * math.asin(math.sqrt(h));
}
}