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 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 isDatabaseAvailable() async { final file = await _getDbFile(); return file != null && await file.exists(); } static Future _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 _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 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> 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 = >{}; final nodeIdsNeeded = {}; 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 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 = {}; 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?> _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> adjacency, required Map 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 = {startNodeId: 0.0}; final cameFrom = {}; final cameFromNode = {}; final visited = {}; // Min-heap using a sorted structure: (fScore, nodeId) final pq = SplayTreeMap>(); 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 cameFrom, required Map cameFromNode, required int destNodeId, required LatLng startUserPos, required LatLng destUserPos, required TacticalVehicleProfile profile, }) { final polyline = []; final maneuvers = []; final waypoints = []; 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 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; } }