241 lines
8.7 KiB
Dart
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));
|
|
}
|
|
}
|