333 lines
10 KiB
Dart
333 lines
10 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
|
|
|
import 'map_config.dart';
|
|
|
|
/// A road route returned by the routing backend.
|
|
class RouteResult {
|
|
const RouteResult({
|
|
required this.points,
|
|
required this.distanceMeters,
|
|
required this.durationSeconds,
|
|
});
|
|
|
|
/// Decoded geometry of the route, ready to draw as a polyline.
|
|
final List<LatLng> points;
|
|
|
|
/// Total road distance in metres.
|
|
final double distanceMeters;
|
|
|
|
/// Total travel time in seconds.
|
|
final double durationSeconds;
|
|
|
|
double get distanceKm => distanceMeters / 1000.0;
|
|
|
|
bool get isEmpty => points.isEmpty && distanceMeters == 0;
|
|
}
|
|
|
|
/// Road routing via OSRM — the Open Source Routing Machine.
|
|
///
|
|
/// Replaces the Google Directions API (and `flutter_polyline_points`). OSRM is
|
|
/// keyless and self-hostable, so there is no credential to ship and no
|
|
/// per-request billing.
|
|
///
|
|
/// Request shape:
|
|
/// GET {base}/route/v1/driving/{lon},{lat};{lon},{lat}?overview=full&geometries=polyline
|
|
///
|
|
/// Note OSRM takes coordinates as **lon,lat** — the reverse of most APIs.
|
|
class RoutingService {
|
|
const RoutingService._();
|
|
|
|
static const Duration _timeout = Duration(seconds: 10);
|
|
static const Duration _cacheTtl = Duration(minutes: 30);
|
|
static const int _maxCacheEntries = 120;
|
|
|
|
static final Map<String, _CacheEntry> _cache = {};
|
|
|
|
/// Computes a driving route. [waypoints] are visited in order between
|
|
/// [origin] and [destination] — OSRM routes through all of them in a single
|
|
/// request, so a multi-stop route costs one call, not one per leg.
|
|
///
|
|
/// Returns null when the service is unreachable or finds no route, so callers
|
|
/// can fall back to a straight line.
|
|
static Future<RouteResult?> getRoute({
|
|
required LatLng origin,
|
|
required LatLng destination,
|
|
List<LatLng> waypoints = const [],
|
|
String profile = 'driving',
|
|
}) async {
|
|
if (!_isValid(origin) || !_isValid(destination)) {
|
|
debugPrint('[ROUTING] Invalid coordinates, skipping request.');
|
|
return null;
|
|
}
|
|
|
|
final cacheKey = _cacheKey(origin, destination, waypoints, profile);
|
|
final cached = _cache[cacheKey];
|
|
if (cached != null && !cached.isExpired) return cached.result;
|
|
|
|
// OSRM wants lon,lat pairs joined by ';' in the path itself.
|
|
final coordinates = [
|
|
origin,
|
|
...waypoints,
|
|
destination,
|
|
].map((p) => '${p.longitude},${p.latitude}').join(';');
|
|
|
|
final uri = Uri.parse(
|
|
'${MapConfig.osrmBaseUrl}/route/v1/$profile/$coordinates',
|
|
).replace(
|
|
queryParameters: const {
|
|
'overview': 'full',
|
|
'geometries': 'polyline',
|
|
'alternatives': 'false',
|
|
'steps': 'false',
|
|
},
|
|
);
|
|
|
|
try {
|
|
final response = await http.get(uri).timeout(_timeout);
|
|
|
|
if (response.statusCode != 200) {
|
|
debugPrint('[ROUTING] HTTP ${response.statusCode}');
|
|
return null;
|
|
}
|
|
|
|
final result = _parse(json.decode(response.body));
|
|
if (result == null || result.isEmpty) {
|
|
debugPrint('[ROUTING] No route in response.');
|
|
return null;
|
|
}
|
|
|
|
_store(cacheKey, result);
|
|
return result;
|
|
} catch (e) {
|
|
debugPrint('[ROUTING] Request failed: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Convenience wrapper for callers that only need road distance.
|
|
static Future<double?> getRouteDistanceKm({
|
|
required LatLng origin,
|
|
required LatLng destination,
|
|
}) async {
|
|
final route = await getRoute(origin: origin, destination: destination);
|
|
return route?.distanceKm;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Response parsing
|
|
// ---------------------------------------------------------------------
|
|
|
|
/// Parses an OSRM `route` response. Written tolerantly so a self-hosted
|
|
/// build, a proxy, or a drop-in alternative (Valhalla's OSRM-compatible
|
|
/// endpoint, GraphHopper's) does not require code changes: numeric fields are
|
|
/// accepted as plain numbers or as `{"value": n}` objects, and the geometry
|
|
/// is looked for under several key names.
|
|
static RouteResult? _parse(dynamic body) {
|
|
if (body is! Map) return null;
|
|
|
|
final code = body['code'];
|
|
if (code is String && code != 'Ok') {
|
|
debugPrint('[ROUTING] Service returned code: $code');
|
|
return null;
|
|
}
|
|
|
|
final routes = body['routes'];
|
|
if (routes is! List || routes.isEmpty) return null;
|
|
|
|
final route = routes.first;
|
|
if (route is! Map) return null;
|
|
|
|
var distance = _num(route['distance']);
|
|
var duration = _num(route['duration']);
|
|
|
|
// Fall back to summing legs when totals are absent.
|
|
if (distance == 0 || duration == 0) {
|
|
final legs = route['legs'];
|
|
if (legs is List) {
|
|
var legDistance = 0.0;
|
|
var legDuration = 0.0;
|
|
for (final leg in legs) {
|
|
if (leg is! Map) continue;
|
|
legDistance += _num(leg['distance']);
|
|
legDuration += _num(leg['duration']);
|
|
}
|
|
if (distance == 0) distance = legDistance;
|
|
if (duration == 0) duration = legDuration;
|
|
}
|
|
}
|
|
|
|
final points = _extractGeometry(route);
|
|
if (points.isEmpty && distance == 0) return null;
|
|
|
|
return RouteResult(
|
|
points: points,
|
|
distanceMeters: distance,
|
|
durationSeconds: duration,
|
|
);
|
|
}
|
|
|
|
static List<LatLng> _extractGeometry(Map route) {
|
|
for (final key in const ['geometry', 'overview_polyline', 'polyline']) {
|
|
final value = route[key];
|
|
if (value is String && value.isNotEmpty) {
|
|
final decoded = decodePolyline(value);
|
|
if (decoded.isNotEmpty) return decoded;
|
|
}
|
|
// Encoded polyline nested as {"points": "<encoded>"}.
|
|
if (value is Map && value['points'] is String) {
|
|
final decoded = decodePolyline(value['points'] as String);
|
|
if (decoded.isNotEmpty) return decoded;
|
|
}
|
|
// GeoJSON LineString, if the caller ever requests geometries=geojson.
|
|
if (value is Map && value['coordinates'] is List) {
|
|
final coords = value['coordinates'] as List;
|
|
final points = <LatLng>[];
|
|
for (final c in coords) {
|
|
if (c is List && c.length >= 2) {
|
|
points.add(LatLng(_num(c[1]), _num(c[0])));
|
|
}
|
|
}
|
|
if (points.isNotEmpty) return points;
|
|
}
|
|
}
|
|
|
|
// Last resort: stitch the per-step geometries together.
|
|
final points = <LatLng>[];
|
|
final legs = route['legs'];
|
|
if (legs is List) {
|
|
for (final leg in legs) {
|
|
if (leg is! Map) continue;
|
|
final steps = leg['steps'];
|
|
if (steps is! List) continue;
|
|
for (final step in steps) {
|
|
if (step is! Map) continue;
|
|
final geometry = step['geometry'] ?? step['polyline'];
|
|
final encoded = geometry is Map ? geometry['points'] : geometry;
|
|
if (encoded is String && encoded.isNotEmpty) {
|
|
points.addAll(decodePolyline(encoded));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return points;
|
|
}
|
|
|
|
static double _num(dynamic value) {
|
|
if (value == null) return 0.0;
|
|
if (value is num) return value.toDouble();
|
|
if (value is Map) return _num(value['value']);
|
|
return double.tryParse(value.toString()) ?? 0.0;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Polyline codec (Google's Encoded Polyline Algorithm)
|
|
//
|
|
// OSRM's default `geometries=polyline` uses precision 5. Its `polyline6`
|
|
// option uses precision 6 — pass `precision: 6` if you switch to it.
|
|
// ---------------------------------------------------------------------
|
|
|
|
static List<LatLng> decodePolyline(String encoded, {int precision = 5}) {
|
|
final points = <LatLng>[];
|
|
final factor = _pow10(precision);
|
|
var index = 0;
|
|
var lat = 0;
|
|
var lng = 0;
|
|
|
|
while (index < encoded.length) {
|
|
final dLat = _decodeValue(encoded, index);
|
|
if (dLat == null) break;
|
|
index = dLat.nextIndex;
|
|
lat += dLat.value;
|
|
|
|
final dLng = _decodeValue(encoded, index);
|
|
if (dLng == null) break;
|
|
index = dLng.nextIndex;
|
|
lng += dLng.value;
|
|
|
|
points.add(LatLng(lat / factor, lng / factor));
|
|
}
|
|
return points;
|
|
}
|
|
|
|
static _Delta? _decodeValue(String encoded, int index) {
|
|
var shift = 0;
|
|
var result = 0;
|
|
int byte;
|
|
do {
|
|
if (index >= encoded.length) return null;
|
|
byte = encoded.codeUnitAt(index++) - 63;
|
|
result |= (byte & 0x1f) << shift;
|
|
shift += 5;
|
|
} while (byte >= 0x20);
|
|
final value = (result & 1) != 0 ? ~(result >> 1) : (result >> 1);
|
|
return _Delta(value, index);
|
|
}
|
|
|
|
static double _pow10(int exponent) {
|
|
var value = 1.0;
|
|
for (var i = 0; i < exponent; i++) {
|
|
value *= 10;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Cache
|
|
// ---------------------------------------------------------------------
|
|
|
|
static bool _isValid(LatLng p) =>
|
|
p.latitude != 0 &&
|
|
p.longitude != 0 &&
|
|
p.latitude.abs() <= 90 &&
|
|
p.longitude.abs() <= 180;
|
|
|
|
static String _cacheKey(
|
|
LatLng origin,
|
|
LatLng destination,
|
|
List<LatLng> waypoints,
|
|
String profile,
|
|
) {
|
|
// ~11 m resolution: identical pickup/drop pairs reuse one request.
|
|
String round(LatLng p) =>
|
|
'${p.latitude.toStringAsFixed(4)},${p.longitude.toStringAsFixed(4)}';
|
|
return [
|
|
profile,
|
|
round(origin),
|
|
round(destination),
|
|
...waypoints.map(round),
|
|
].join('>');
|
|
}
|
|
|
|
static void _store(String key, RouteResult result) {
|
|
if (_cache.length >= _maxCacheEntries) {
|
|
final oldest = _cache.entries.reduce(
|
|
(a, b) => a.value.storedAt.isBefore(b.value.storedAt) ? a : b,
|
|
);
|
|
_cache.remove(oldest.key);
|
|
}
|
|
_cache[key] = _CacheEntry(result, DateTime.now());
|
|
}
|
|
|
|
@visibleForTesting
|
|
static void clearCache() => _cache.clear();
|
|
}
|
|
|
|
class _Delta {
|
|
const _Delta(this.value, this.nextIndex);
|
|
final int value;
|
|
final int nextIndex;
|
|
}
|
|
|
|
class _CacheEntry {
|
|
const _CacheEntry(this.result, this.storedAt);
|
|
final RouteResult result;
|
|
final DateTime storedAt;
|
|
|
|
bool get isExpired =>
|
|
DateTime.now().difference(storedAt) > RoutingService._cacheTtl;
|
|
}
|