545 lines
20 KiB
Dart
545 lines
20 KiB
Dart
import 'dart:convert';
|
|
import 'dart:collection';
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:intaleq_maps/intaleq_maps.dart';
|
|
import 'package:sqflite/sqflite.dart';
|
|
import 'offline_routing_engine.dart';
|
|
import 'offline_routing_package_service.dart';
|
|
|
|
class _GraphEdge {
|
|
final int toNode;
|
|
final String name;
|
|
final String highway;
|
|
final double speedKmh;
|
|
final double lengthM;
|
|
final List<LatLng> coords;
|
|
|
|
const _GraphEdge({
|
|
required this.toNode,
|
|
required this.name,
|
|
required this.highway,
|
|
required this.speedKmh,
|
|
required this.lengthM,
|
|
required this.coords,
|
|
});
|
|
}
|
|
|
|
/// ============================================================================
|
|
/// [OfflineRoadGraphEngine] - محرك التوجيه السيادي الميداني على شبكة الطرق الحقيقية
|
|
/// ============================================================================
|
|
/// English:
|
|
/// 100% On-Device high-performance routing engine powered by local SQLite road network
|
|
/// (jordan_roads.db). Contains 1.99M real OSM nodes and 667K routable edges with real
|
|
/// street names, speed limits, oneway constraints, and exact road curvature geometry.
|
|
///
|
|
/// Key improvements over previous version:
|
|
/// - Snaps to CONNECTED nodes only (nodes that are actual edge endpoints)
|
|
/// - Hierarchical routing: major highways for long distance + local streets near endpoints
|
|
/// - Priority queue (min-heap) A* instead of linear scan openSet
|
|
/// - Proper turn-by-turn maneuver generation with Arabic instructions
|
|
///
|
|
/// العربية:
|
|
/// محرك التوجيه والملاحة السيادي المحلي 100% المعتمد على شبكة طرق الأردن الحقيقية (SQLite).
|
|
/// يعمل بدون أي اتصال بالإنترنت، يقرأ بيانات الشوارع والتقاطعات والسرعات والانحناءات الفعلية.
|
|
/// يحتوي على 1.99 مليون عقدة و667 ألف حافة طريق حقيقية مع أسماء شوارع عربية وسرعات فعلية.
|
|
/// ============================================================================
|
|
class OfflineRoadGraphEngine {
|
|
static Database? _db;
|
|
static String? _loadedDbPath;
|
|
|
|
/// Check if the SQLite road network database is installed locally
|
|
static Future<bool> isDatabaseAvailable() async {
|
|
final file = await _getDbFile();
|
|
return file != null && await file.exists();
|
|
}
|
|
|
|
static Future<File?> _getDbFile() async {
|
|
final dir = await OfflineRoutingPackageService.installDir();
|
|
final dbFile = File('${dir.path}/jordan_roads.db');
|
|
if (await dbFile.exists()) return dbFile;
|
|
|
|
// Check parent or subfolders
|
|
if (await dir.exists()) {
|
|
for (final entity in dir.listSync(recursive: true)) {
|
|
if (entity is File && entity.path.endsWith('jordan_roads.db')) {
|
|
return entity;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static Future<Database?> _getDatabase() async {
|
|
if (_db != null && _db!.isOpen) return _db;
|
|
|
|
final dbFile = await _getDbFile();
|
|
if (dbFile == null || !await dbFile.exists()) return null;
|
|
|
|
try {
|
|
_db = await openDatabase(dbFile.path);
|
|
_loadedDbPath = dbFile.path;
|
|
debugPrint('OfflineRoadGraphEngine: Connected to local road network database at $_loadedDbPath');
|
|
return _db;
|
|
} catch (e) {
|
|
debugPrint('OfflineRoadGraphEngine: Error opening SQLite roads database: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Calculate 100% Offline Route following real road network curves
|
|
static Future<OfflineRoutePlan?> calculateRoute({
|
|
required LatLng start,
|
|
required LatLng destination,
|
|
TacticalVehicleProfile profile = TacticalVehicleProfile.convoy,
|
|
}) async {
|
|
final db = await _getDatabase();
|
|
if (db == null) return null;
|
|
|
|
try {
|
|
final stopwatch = Stopwatch()..start();
|
|
|
|
// 1. Find nearest CONNECTED road nodes (nodes that are edge endpoints)
|
|
final startSnap = await _snapToConnectedNode(db, start);
|
|
final destSnap = await _snapToConnectedNode(db, destination);
|
|
|
|
if (startSnap == null || destSnap == null) {
|
|
debugPrint('OfflineRoadGraphEngine: Failed to snap start or destination to road network');
|
|
return null;
|
|
}
|
|
|
|
final startNodeId = startSnap['id'] as int;
|
|
final destNodeId = destSnap['id'] as int;
|
|
final startNodePos = LatLng(startSnap['lat'] as double, startSnap['lng'] as double);
|
|
final destNodePos = LatLng(destSnap['lat'] as double, destSnap['lng'] as double);
|
|
|
|
// 2. Determine distance and choose routing strategy
|
|
final straightLineKm = _haversineKm(startNodePos, destNodePos);
|
|
final isLongDistance = straightLineKm > 15.0; // >15km = hierarchical
|
|
|
|
// 3. Query subgraph with appropriate strategy
|
|
final bboxMargin = isLongDistance ? 0.20 : 0.08;
|
|
final localDelta = isLongDistance ? 0.08 : 0.0; // 8km around endpoints for local streets
|
|
final minLat = math.min(start.latitude, destination.latitude) - bboxMargin;
|
|
final maxLat = math.max(start.latitude, destination.latitude) + bboxMargin;
|
|
final minLng = math.min(start.longitude, destination.longitude) - bboxMargin;
|
|
final maxLng = math.max(start.longitude, destination.longitude) + bboxMargin;
|
|
|
|
List<Map<String, Object?>> rows;
|
|
|
|
if (isLongDistance) {
|
|
// Hierarchical: major highways across entire bbox + local streets near start/end
|
|
rows = await db.rawQuery('''
|
|
SELECT from_node, to_node, name, highway, speed_kmh, length_m, geom_json
|
|
FROM edges
|
|
WHERE max_lat >= ? AND min_lat <= ? AND max_lng >= ? AND min_lng <= ?
|
|
AND (
|
|
highway IN ('motorway','motorway_link','trunk','trunk_link','primary','primary_link','secondary','secondary_link')
|
|
OR (min_lat >= ? AND max_lat <= ? AND min_lng >= ? AND max_lng <= ?)
|
|
OR (min_lat >= ? AND max_lat <= ? AND min_lng >= ? AND max_lng <= ?)
|
|
)
|
|
''', [
|
|
minLat, maxLat, minLng, maxLng,
|
|
start.latitude - localDelta, start.latitude + localDelta,
|
|
start.longitude - localDelta, start.longitude + localDelta,
|
|
destination.latitude - localDelta, destination.latitude + localDelta,
|
|
destination.longitude - localDelta, destination.longitude + localDelta,
|
|
]);
|
|
} else {
|
|
// Short distance: load ALL edges in bounding box
|
|
rows = await db.rawQuery('''
|
|
SELECT from_node, to_node, name, highway, speed_kmh, length_m, geom_json
|
|
FROM edges
|
|
WHERE max_lat >= ? AND min_lat <= ? AND max_lng >= ? AND min_lng <= ?
|
|
''', [minLat, maxLat, minLng, maxLng]);
|
|
}
|
|
|
|
if (rows.isEmpty) {
|
|
debugPrint('OfflineRoadGraphEngine: No road edges found in bounding box');
|
|
return null;
|
|
}
|
|
|
|
// 4. Build in-memory adjacency list for fast A*
|
|
final adjacency = <int, List<_GraphEdge>>{};
|
|
final nodeIdsNeeded = <int>{};
|
|
for (final r in rows) {
|
|
final u = r['from_node'] as int;
|
|
final v = r['to_node'] as int;
|
|
final name = (r['name'] as String?) ?? '';
|
|
final highway = (r['highway'] as String?) ?? 'unclassified';
|
|
final speed = (r['speed_kmh'] as num?)?.toDouble() ?? 40.0;
|
|
final lengthM = (r['length_m'] as num?)?.toDouble() ?? 100.0;
|
|
|
|
List<LatLng> coords = [];
|
|
final geomStr = r['geom_json'] as String?;
|
|
if (geomStr != null && geomStr.isNotEmpty) {
|
|
try {
|
|
final list = jsonDecode(geomStr) as List;
|
|
coords = list.map((pt) => LatLng((pt[0] as num).toDouble(), (pt[1] as num).toDouble())).toList();
|
|
} catch (_) {}
|
|
}
|
|
|
|
adjacency.putIfAbsent(u, () => []).add(_GraphEdge(
|
|
toNode: v,
|
|
name: name,
|
|
highway: highway,
|
|
speedKmh: speed,
|
|
lengthM: lengthM,
|
|
coords: coords,
|
|
));
|
|
|
|
nodeIdsNeeded.add(u);
|
|
nodeIdsNeeded.add(v);
|
|
}
|
|
|
|
// 5. Batch-query node positions for A* heuristic
|
|
final nodePositions = <int, LatLng>{};
|
|
final nodeIdList = nodeIdsNeeded.toList();
|
|
for (int i = 0; i < nodeIdList.length; i += 500) {
|
|
final chunk = nodeIdList.sublist(i, math.min(i + 500, nodeIdList.length));
|
|
final placeholders = List.filled(chunk.length, '?').join(',');
|
|
final nodeRows = await db.rawQuery(
|
|
'SELECT id, lat, lng FROM nodes WHERE id IN ($placeholders)',
|
|
chunk,
|
|
);
|
|
for (final nr in nodeRows) {
|
|
nodePositions[nr['id'] as int] = LatLng(
|
|
(nr['lat'] as num).toDouble(),
|
|
(nr['lng'] as num).toDouble(),
|
|
);
|
|
}
|
|
}
|
|
|
|
debugPrint('OfflineRoadGraphEngine: Loaded ${rows.length} edges, ${nodePositions.length} nodes (${isLongDistance ? "hierarchical" : "local"} mode, ${straightLineKm.toStringAsFixed(1)}km straight-line)');
|
|
|
|
// 6. Run A* Search with priority queue
|
|
final plan = _runAStarHeap(
|
|
adjacency: adjacency,
|
|
nodePositions: nodePositions,
|
|
startNodeId: startNodeId,
|
|
destNodeId: destNodeId,
|
|
startUserPos: start,
|
|
destUserPos: destination,
|
|
startNodePos: startNodePos,
|
|
destNodePos: destNodePos,
|
|
profile: profile,
|
|
);
|
|
|
|
stopwatch.stop();
|
|
if (plan != null) {
|
|
debugPrint('OfflineRoadGraphEngine: Route computed in ${stopwatch.elapsedMilliseconds}ms '
|
|
'(${plan.totalDistanceKm}km, ${plan.polylinePoints.length} points, '
|
|
'${plan.maneuvers.length} maneuvers)');
|
|
} else {
|
|
debugPrint('OfflineRoadGraphEngine: No route found after ${stopwatch.elapsedMilliseconds}ms');
|
|
}
|
|
return plan;
|
|
} catch (e, st) {
|
|
debugPrint('OfflineRoadGraphEngine: Route computation error: $e\n$st');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Snap to nearest node that is an actual edge endpoint (not an orphan node).
|
|
/// This is critical — the old method snapped to ANY node which often had no edges.
|
|
static Future<Map<String, dynamic>?> _snapToConnectedNode(Database db, LatLng pos) async {
|
|
// Search nearby connected nodes (edge endpoints) with spatial bounding box
|
|
const delta = 0.02; // ~2km initial search radius
|
|
var rows = await db.rawQuery('''
|
|
SELECT DISTINCT e.from_node AS id, n.lat, n.lng,
|
|
((n.lat - ?) * (n.lat - ?) + (n.lng - ?) * (n.lng - ?)) AS dist_sq
|
|
FROM edges e
|
|
JOIN nodes n ON e.from_node = n.id
|
|
WHERE n.lat BETWEEN ? AND ? AND n.lng BETWEEN ? AND ?
|
|
ORDER BY dist_sq ASC
|
|
LIMIT 1;
|
|
''', [
|
|
pos.latitude, pos.latitude, pos.longitude, pos.longitude,
|
|
pos.latitude - delta, pos.latitude + delta,
|
|
pos.longitude - delta, pos.longitude + delta,
|
|
]);
|
|
|
|
if (rows.isNotEmpty) return rows.first;
|
|
|
|
// Wider fallback: 10km radius
|
|
const widerDelta = 0.1;
|
|
rows = await db.rawQuery('''
|
|
SELECT DISTINCT e.from_node AS id, n.lat, n.lng,
|
|
((n.lat - ?) * (n.lat - ?) + (n.lng - ?) * (n.lng - ?)) AS dist_sq
|
|
FROM edges e
|
|
JOIN nodes n ON e.from_node = n.id
|
|
WHERE n.lat BETWEEN ? AND ? AND n.lng BETWEEN ? AND ?
|
|
ORDER BY dist_sq ASC
|
|
LIMIT 1;
|
|
''', [
|
|
pos.latitude, pos.latitude, pos.longitude, pos.longitude,
|
|
pos.latitude - widerDelta, pos.latitude + widerDelta,
|
|
pos.longitude - widerDelta, pos.longitude + widerDelta,
|
|
]);
|
|
|
|
return rows.isNotEmpty ? rows.first : null;
|
|
}
|
|
|
|
/// A* with binary-heap priority queue for O(log n) extraction.
|
|
/// The old implementation used a Set with linear scan — O(n) per step.
|
|
static OfflineRoutePlan? _runAStarHeap({
|
|
required Map<int, List<_GraphEdge>> adjacency,
|
|
required Map<int, LatLng> nodePositions,
|
|
required int startNodeId,
|
|
required int destNodeId,
|
|
required LatLng startUserPos,
|
|
required LatLng destUserPos,
|
|
required LatLng startNodePos,
|
|
required LatLng destNodePos,
|
|
required TacticalVehicleProfile profile,
|
|
}) {
|
|
if (startNodeId == destNodeId) {
|
|
final dist = _haversineKm(startUserPos, destUserPos);
|
|
return OfflineRoutePlan(
|
|
polylinePoints: [startUserPos, startNodePos, destUserPos],
|
|
totalDistanceKm: double.parse(dist.toStringAsFixed(1)),
|
|
estimatedDurationMinutes: math.max(1.0, (dist / 40.0) * 60.0),
|
|
profile: profile,
|
|
tacticalWaypoints: const ['نقطة الانطلاق', 'نقطة الوصول'],
|
|
usesRealRoadNetwork: true,
|
|
isOffline: true,
|
|
);
|
|
}
|
|
|
|
// Priority queue entries: [fScore, nodeId]
|
|
// Using a list-based heap via SplayTreeMap for efficient min extraction
|
|
final gScore = <int, double>{startNodeId: 0.0};
|
|
final cameFrom = <int, _GraphEdge>{};
|
|
final cameFromNode = <int, int>{};
|
|
final visited = <int>{};
|
|
|
|
// Min-heap using a sorted structure: (fScore, nodeId)
|
|
final pq = SplayTreeMap<double, List<int>>();
|
|
final initialH = _haversineMeters(startNodePos, destNodePos);
|
|
pq.putIfAbsent(initialH, () => []).add(startNodeId);
|
|
|
|
int explored = 0;
|
|
const maxExplored = 200000; // Safety limit
|
|
|
|
while (pq.isNotEmpty && explored < maxExplored) {
|
|
// Extract minimum fScore node
|
|
final minEntry = pq.entries.first;
|
|
final fVal = minEntry.key;
|
|
final nodeList = minEntry.value;
|
|
final current = nodeList.removeLast();
|
|
if (nodeList.isEmpty) pq.remove(fVal);
|
|
|
|
if (visited.contains(current)) continue;
|
|
visited.add(current);
|
|
explored++;
|
|
|
|
if (current == destNodeId) {
|
|
debugPrint('OfflineRoadGraphEngine: A* explored $explored nodes');
|
|
return _reconstructPlan(
|
|
cameFrom: cameFrom,
|
|
cameFromNode: cameFromNode,
|
|
destNodeId: destNodeId,
|
|
startUserPos: startUserPos,
|
|
destUserPos: destUserPos,
|
|
profile: profile,
|
|
);
|
|
}
|
|
|
|
final currentG = gScore[current] ?? double.infinity;
|
|
final neighbors = adjacency[current] ?? [];
|
|
|
|
for (final edge in neighbors) {
|
|
final neighbor = edge.toNode;
|
|
if (visited.contains(neighbor)) continue;
|
|
|
|
final tentativeG = currentG + edge.lengthM;
|
|
|
|
if (tentativeG < (gScore[neighbor] ?? double.infinity)) {
|
|
cameFrom[neighbor] = edge;
|
|
cameFromNode[neighbor] = current;
|
|
gScore[neighbor] = tentativeG;
|
|
|
|
final neighborPos = nodePositions[neighbor] ?? destNodePos;
|
|
final h = _haversineMeters(neighborPos, destNodePos);
|
|
final f = tentativeG + h;
|
|
pq.putIfAbsent(f, () => []).add(neighbor);
|
|
}
|
|
}
|
|
}
|
|
|
|
debugPrint('OfflineRoadGraphEngine: A* exhausted after $explored nodes');
|
|
return null;
|
|
}
|
|
|
|
static OfflineRoutePlan _reconstructPlan({
|
|
required Map<int, _GraphEdge> cameFrom,
|
|
required Map<int, int> cameFromNode,
|
|
required int destNodeId,
|
|
required LatLng startUserPos,
|
|
required LatLng destUserPos,
|
|
required TacticalVehicleProfile profile,
|
|
}) {
|
|
final polyline = <LatLng>[];
|
|
final maneuvers = <RouteManeuver>[];
|
|
final waypoints = <String>[];
|
|
double totalMeters = 0.0;
|
|
double totalSeconds = 0.0;
|
|
|
|
int curr = destNodeId;
|
|
final edgeChain = <_GraphEdge>[];
|
|
|
|
while (cameFrom.containsKey(curr)) {
|
|
final edge = cameFrom[curr]!;
|
|
edgeChain.add(edge);
|
|
curr = cameFromNode[curr]!;
|
|
}
|
|
|
|
final forwardEdges = edgeChain.reversed.toList();
|
|
polyline.add(startUserPos);
|
|
|
|
// Apply tactical profile speed multiplier
|
|
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;
|
|
}
|
|
|
|
String lastStreetName = '';
|
|
double segmentLengthM = 0.0;
|
|
|
|
for (final edge in forwardEdges) {
|
|
totalMeters += edge.lengthM;
|
|
final speedMs = (edge.speedKmh * 1000.0) / 3600.0;
|
|
totalSeconds += (edge.lengthM / (speedMs > 0 ? speedMs : 10.0)) * profileFactor;
|
|
|
|
if (edge.coords.isNotEmpty) {
|
|
polyline.addAll(edge.coords);
|
|
}
|
|
|
|
if (edge.name.isNotEmpty && edge.name != lastStreetName) {
|
|
// Generate a maneuver for the street change
|
|
if (lastStreetName.isNotEmpty && segmentLengthM > 0) {
|
|
// Determine turn direction heuristic from polyline geometry
|
|
final turnType = _detectTurnType(polyline);
|
|
final turnInstr = _arabicTurnInstruction(turnType, edge.name);
|
|
if (edge.coords.isNotEmpty) {
|
|
maneuvers.add(RouteManeuver(
|
|
type: turnType,
|
|
instructionAr: turnInstr,
|
|
streetNames: [edge.name],
|
|
lengthKm: edge.lengthM / 1000.0,
|
|
location: edge.coords.first,
|
|
));
|
|
}
|
|
} else if (edge.coords.isNotEmpty) {
|
|
// First street segment
|
|
maneuvers.add(RouteManeuver(
|
|
type: 0, // depart
|
|
instructionAr: 'انطلق على ${edge.name}',
|
|
streetNames: [edge.name],
|
|
lengthKm: edge.lengthM / 1000.0,
|
|
location: edge.coords.first,
|
|
));
|
|
}
|
|
lastStreetName = edge.name;
|
|
waypoints.add(edge.name);
|
|
segmentLengthM = edge.lengthM;
|
|
} else {
|
|
segmentLengthM += edge.lengthM;
|
|
}
|
|
}
|
|
|
|
// Final arrival maneuver
|
|
maneuvers.add(RouteManeuver(
|
|
type: 4, // arrive
|
|
instructionAr: 'وصلت إلى وجهتك',
|
|
streetNames: const [],
|
|
lengthKm: 0.0,
|
|
location: destUserPos,
|
|
));
|
|
|
|
polyline.add(destUserPos);
|
|
|
|
final totalKm = totalMeters / 1000.0;
|
|
final durationMin = math.max(1.0, totalSeconds / 60.0);
|
|
|
|
return OfflineRoutePlan(
|
|
polylinePoints: polyline,
|
|
totalDistanceKm: double.parse(totalKm.toStringAsFixed(1)),
|
|
estimatedDurationMinutes: double.parse(durationMin.toStringAsFixed(0)),
|
|
profile: profile,
|
|
tacticalWaypoints: waypoints.take(8).toList(),
|
|
usesRealRoadNetwork: true,
|
|
isOffline: true,
|
|
maneuvers: maneuvers,
|
|
);
|
|
}
|
|
|
|
/// Simple turn type detection from the last 3 polyline points
|
|
static int _detectTurnType(List<LatLng> polyline) {
|
|
if (polyline.length < 3) return 1; // continue
|
|
final p1 = polyline[polyline.length - 3];
|
|
final p2 = polyline[polyline.length - 2];
|
|
final p3 = polyline[polyline.length - 1];
|
|
|
|
final bearing1 = math.atan2(p2.longitude - p1.longitude, p2.latitude - p1.latitude);
|
|
final bearing2 = math.atan2(p3.longitude - p2.longitude, p3.latitude - p2.latitude);
|
|
var angleDiff = (bearing2 - bearing1) * 180.0 / math.pi;
|
|
while (angleDiff > 180) {
|
|
angleDiff -= 360;
|
|
}
|
|
while (angleDiff < -180) {
|
|
angleDiff += 360;
|
|
}
|
|
|
|
if (angleDiff.abs() < 20) return 1; // continue straight
|
|
if (angleDiff > 20 && angleDiff < 160) return 3; // turn right
|
|
if (angleDiff < -20 && angleDiff > -160) return 2; // turn left
|
|
return 1; // continue
|
|
}
|
|
|
|
/// Generate Arabic turn-by-turn instruction
|
|
static String _arabicTurnInstruction(int turnType, String streetName) {
|
|
switch (turnType) {
|
|
case 0:
|
|
return 'انطلق على $streetName';
|
|
case 2:
|
|
return 'انعطف يساراً إلى $streetName';
|
|
case 3:
|
|
return 'انعطف يميناً إلى $streetName';
|
|
case 4:
|
|
return 'وصلت إلى وجهتك';
|
|
default:
|
|
return 'تابع السير على $streetName';
|
|
}
|
|
}
|
|
|
|
static double _haversineMeters(LatLng p1, LatLng p2) {
|
|
return _haversineKm(p1, p2) * 1000.0;
|
|
}
|
|
|
|
static double _haversineKm(LatLng p1, LatLng p2) {
|
|
const R = 6371.0;
|
|
final dLat = (p2.latitude - p1.latitude) * (math.pi / 180.0);
|
|
final dLon = (p2.longitude - p1.longitude) * (math.pi / 180.0);
|
|
final a = math.sin(dLat / 2) * math.sin(dLat / 2) +
|
|
math.cos(p1.latitude * (math.pi / 180.0)) *
|
|
math.cos(p2.latitude * (math.pi / 180.0)) *
|
|
math.sin(dLon / 2) *
|
|
math.sin(dLon / 2);
|
|
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
|
|
return R * c;
|
|
}
|
|
}
|