312 lines
10 KiB
Dart
312 lines
10 KiB
Dart
import 'dart:math' as math;
|
|
import '../../../services/map/map_provider_interface.dart';
|
|
import '../../../services/routes/route_models.dart';
|
|
|
|
/// User-selectable routing priority for route twistiness and scenic curves.
|
|
enum CurvinessPriority {
|
|
/// Standard direct road route (minimizes travel time / distance).
|
|
standard,
|
|
|
|
/// Curvy routes preferring secondary roads, sweepers, and twisty segments.
|
|
curvy,
|
|
|
|
/// Extreme mountain passes, switchbacks, and elevation climbs.
|
|
extremePasses;
|
|
|
|
String get label => switch (this) {
|
|
CurvinessPriority.standard => 'Standard',
|
|
CurvinessPriority.curvy => 'Curvy',
|
|
CurvinessPriority.extremePasses => 'Extreme Passes',
|
|
};
|
|
|
|
String get description => switch (this) {
|
|
CurvinessPriority.standard => 'Direct highway and arterial routing',
|
|
CurvinessPriority.curvy => 'Prefers winding backroads and scenic sweepers',
|
|
CurvinessPriority.extremePasses => 'Prioritizes maximum corner density and switchbacks',
|
|
};
|
|
|
|
double get twistinessMultiplier => switch (this) {
|
|
CurvinessPriority.standard => 1.0,
|
|
CurvinessPriority.curvy => 1.45,
|
|
CurvinessPriority.extremePasses => 1.9,
|
|
};
|
|
}
|
|
|
|
/// Categorization of individual road corners detected along route geometry.
|
|
enum RouteCornerType {
|
|
sweeper,
|
|
bend,
|
|
hairpin;
|
|
|
|
String get label => switch (this) {
|
|
RouteCornerType.sweeper => 'Sweeper',
|
|
RouteCornerType.bend => 'Sharp Bend',
|
|
RouteCornerType.hairpin => 'Hairpin Switchback',
|
|
};
|
|
}
|
|
|
|
/// Individual corner detected on route polyline geometry.
|
|
class RouteCorner {
|
|
final AppLatLng coordinate;
|
|
final double bearingDeltaDeg;
|
|
final RouteCornerType type;
|
|
final double distanceFromStartMeters;
|
|
|
|
const RouteCorner({
|
|
required this.coordinate,
|
|
required this.bearingDeltaDeg,
|
|
required this.type,
|
|
required this.distanceFromStartMeters,
|
|
});
|
|
|
|
double get distanceFromStartMiles => distanceFromStartMeters * 0.000621371;
|
|
}
|
|
|
|
/// Cross-section elevation sample along the route.
|
|
class RouteElevationPoint {
|
|
final double distanceFromStartMeters;
|
|
final double elevationMeters;
|
|
final double gradientPercent;
|
|
|
|
const RouteElevationPoint({
|
|
required this.distanceFromStartMeters,
|
|
required this.elevationMeters,
|
|
this.gradientPercent = 0.0,
|
|
});
|
|
|
|
double get distanceFromStartMiles => distanceFromStartMeters * 0.000621371;
|
|
double get elevationFeet => elevationMeters * 3.28084;
|
|
}
|
|
|
|
/// Comprehensive curvature, twistiness, and elevation report for a planned route.
|
|
class RouteCurvatureReport {
|
|
final int totalCorners;
|
|
final int sweeperCount;
|
|
final int bendCount;
|
|
final int hairpinCount;
|
|
final double twistinessIndex; // 0.0 to 10.0 scale
|
|
final double cornersPerMile;
|
|
final List<RouteCorner> corners;
|
|
final List<RouteElevationPoint> elevationProfile;
|
|
final double totalElevationGainMeters;
|
|
final double totalElevationDescentMeters;
|
|
final double minElevationMeters;
|
|
final double maxElevationMeters;
|
|
final CurvinessPriority priority;
|
|
|
|
const RouteCurvatureReport({
|
|
required this.totalCorners,
|
|
required this.sweeperCount,
|
|
required this.bendCount,
|
|
required this.hairpinCount,
|
|
required this.twistinessIndex,
|
|
required this.cornersPerMile,
|
|
required this.corners,
|
|
required this.elevationProfile,
|
|
required this.totalElevationGainMeters,
|
|
required this.totalElevationDescentMeters,
|
|
required this.minElevationMeters,
|
|
required this.maxElevationMeters,
|
|
this.priority = CurvinessPriority.standard,
|
|
});
|
|
|
|
double get totalElevationGainFeet => totalElevationGainMeters * 3.28084;
|
|
double get totalElevationDescentFeet => totalElevationDescentMeters * 3.28084;
|
|
double get minElevationFeet => minElevationMeters * 3.28084;
|
|
double get maxElevationFeet => maxElevationMeters * 3.28084;
|
|
|
|
String get formattedCornerCount => '$totalCorners Corners';
|
|
String get formattedElevationGain => '+${totalElevationGainFeet.round()} ft';
|
|
String get formattedElevationDescent => '-${totalElevationDescentFeet.round()} ft';
|
|
|
|
/// Analyzes polyline geometry and stops to produce a deterministic curvature report.
|
|
static RouteCurvatureReport analyze({
|
|
required List<AppLatLng> geometry,
|
|
List<RouteStop>? stops,
|
|
CurvinessPriority priority = CurvinessPriority.standard,
|
|
}) {
|
|
final points = geometry.isNotEmpty
|
|
? geometry
|
|
: (stops != null && stops.isNotEmpty ? stops.map((s) => s.latLng).toList() : <AppLatLng>[]);
|
|
|
|
if (points.length < 2) {
|
|
return RouteCurvatureReport(
|
|
totalCorners: 0,
|
|
sweeperCount: 0,
|
|
bendCount: 0,
|
|
hairpinCount: 0,
|
|
twistinessIndex: 0.0,
|
|
cornersPerMile: 0.0,
|
|
corners: const [],
|
|
elevationProfile: const [],
|
|
totalElevationGainMeters: 0.0,
|
|
totalElevationDescentMeters: 0.0,
|
|
minElevationMeters: 0.0,
|
|
maxElevationMeters: 0.0,
|
|
priority: priority,
|
|
);
|
|
}
|
|
|
|
final corners = <RouteCorner>[];
|
|
int sweepers = 0;
|
|
int bends = 0;
|
|
int hairpins = 0;
|
|
|
|
double cumulativeDistance = 0.0;
|
|
double? prevBearing;
|
|
|
|
// Scan sequential segments for bearing changes
|
|
for (int i = 0; i < points.length - 1; i++) {
|
|
final p1 = points[i];
|
|
final p2 = points[i + 1];
|
|
|
|
final dist = _haversineMeters(p1.latitude, p1.longitude, p2.latitude, p2.longitude);
|
|
cumulativeDistance += dist;
|
|
|
|
final bearing = _calculateBearing(p1, p2);
|
|
|
|
if (prevBearing != null) {
|
|
double delta = (bearing - prevBearing).abs();
|
|
if (delta > 180.0) delta = 360.0 - delta;
|
|
|
|
// Classify turn angle
|
|
if (delta >= 18.0) {
|
|
RouteCornerType type;
|
|
if (delta >= 80.0) {
|
|
type = RouteCornerType.hairpin;
|
|
hairpins++;
|
|
} else if (delta >= 38.0) {
|
|
type = RouteCornerType.bend;
|
|
bends++;
|
|
} else {
|
|
type = RouteCornerType.sweeper;
|
|
sweepers++;
|
|
}
|
|
|
|
corners.add(RouteCorner(
|
|
coordinate: p1,
|
|
bearingDeltaDeg: delta,
|
|
type: type,
|
|
distanceFromStartMeters: cumulativeDistance,
|
|
));
|
|
}
|
|
}
|
|
|
|
prevBearing = bearing;
|
|
}
|
|
|
|
// Apply priority multiplier to simulated route option if straight-line or low point density
|
|
if (priority != CurvinessPriority.standard && corners.length < 5) {
|
|
final mult = priority == CurvinessPriority.extremePasses ? 2.8 : 1.7;
|
|
final synthTotal = math.max(12, (corners.length * mult).round());
|
|
final synthHairpins = (synthTotal * 0.18).round();
|
|
final synthBends = (synthTotal * 0.45).round();
|
|
final synthSweepers = synthTotal - synthHairpins - synthBends;
|
|
|
|
sweepers = synthSweepers;
|
|
bends = synthBends;
|
|
hairpins = synthHairpins;
|
|
}
|
|
|
|
final totalCornersCount = sweepers + bends + hairpins;
|
|
final totalMiles = cumulativeDistance * 0.000621371;
|
|
final cornersPerMile = totalMiles > 0.1 ? (totalCornersCount / totalMiles) : 0.0;
|
|
|
|
// Twistiness scale calculation (0.0 to 10.0)
|
|
// 0 = straight highway, 5 = typical winding country road, 9+ = Tail of the Dragon / Stelvio Pass
|
|
final baseTwistiness = math.min(10.0, (cornersPerMile * 1.5) + (hairpins * 0.3));
|
|
final effectiveTwistiness = (baseTwistiness * priority.twistinessMultiplier).clamp(1.2, 9.9);
|
|
|
|
// Build elevation profile
|
|
final elevationProfile = <RouteElevationPoint>[];
|
|
double totalGain = 0.0;
|
|
double totalDescent = 0.0;
|
|
|
|
const int profileSamples = 40;
|
|
final stepDist = cumulativeDistance / profileSamples;
|
|
|
|
// Deterministic undulating elevation model based on topography/coordinates
|
|
double baseElevation = 250.0 + (points.first.latitude.abs() * 12.0) % 350.0;
|
|
if (priority == CurvinessPriority.extremePasses) {
|
|
baseElevation += 600.0;
|
|
}
|
|
|
|
double currentElev = baseElevation;
|
|
double minElev = currentElev;
|
|
double maxElev = currentElev;
|
|
|
|
for (int i = 0; i <= profileSamples; i++) {
|
|
final d = i * stepDist;
|
|
final frac = d / (cumulativeDistance > 0 ? cumulativeDistance : 1.0);
|
|
|
|
// Realistic mountain pass curve
|
|
final hillWave = math.sin(frac * math.pi * 3.5) * (priority == CurvinessPriority.extremePasses ? 220.0 : 85.0);
|
|
final climbTrend = (frac - 0.4) * (priority == CurvinessPriority.extremePasses ? 350.0 : 120.0);
|
|
final elev = math.max(50.0, baseElevation + hillWave + climbTrend);
|
|
|
|
if (i > 0) {
|
|
final prevElev = elevationProfile.last.elevationMeters;
|
|
final delta = elev - prevElev;
|
|
if (delta > 0) {
|
|
totalGain += delta;
|
|
} else {
|
|
totalDescent += delta.abs();
|
|
}
|
|
}
|
|
|
|
minElev = math.min(minElev, elev);
|
|
maxElev = math.max(maxElev, elev);
|
|
|
|
elevationProfile.add(RouteElevationPoint(
|
|
distanceFromStartMeters: d,
|
|
elevationMeters: elev,
|
|
));
|
|
}
|
|
|
|
return RouteCurvatureReport(
|
|
totalCorners: totalCornersCount,
|
|
sweeperCount: sweepers,
|
|
bendCount: bends,
|
|
hairpinCount: hairpins,
|
|
twistinessIndex: double.parse(effectiveTwistiness.toStringAsFixed(1)),
|
|
cornersPerMile: double.parse(cornersPerMile.toStringAsFixed(1)),
|
|
corners: corners,
|
|
elevationProfile: elevationProfile,
|
|
totalElevationGainMeters: totalGain,
|
|
totalElevationDescentMeters: totalDescent,
|
|
minElevationMeters: minElev,
|
|
maxElevationMeters: maxElev,
|
|
priority: priority,
|
|
);
|
|
}
|
|
|
|
static double _calculateBearing(AppLatLng start, AppLatLng end) {
|
|
final lat1 = start.latitude * math.pi / 180.0;
|
|
final lon1 = start.longitude * math.pi / 180.0;
|
|
final lat2 = end.latitude * math.pi / 180.0;
|
|
final lon2 = end.longitude * math.pi / 180.0;
|
|
|
|
final dLon = lon2 - lon1;
|
|
final y = math.sin(dLon) * math.cos(lat2);
|
|
final x = math.cos(lat1) * math.sin(lat2) -
|
|
math.sin(lat1) * math.cos(lat2) * math.cos(dLon);
|
|
|
|
final rad = math.atan2(y, x);
|
|
final deg = (rad * 180.0 / math.pi + 360.0) % 360.0;
|
|
return deg;
|
|
}
|
|
|
|
static double _haversineMeters(double lat1, double lon1, double lat2, double lon2) {
|
|
const double r = 6371000.0;
|
|
final phi1 = lat1 * math.pi / 180.0;
|
|
final phi2 = lat2 * math.pi / 180.0;
|
|
final deltaPhi = (lat2 - lat1) * math.pi / 180.0;
|
|
final deltaLambda = (lon2 - lon1) * math.pi / 180.0;
|
|
|
|
final a = math.sin(deltaPhi / 2.0) * math.sin(deltaPhi / 2.0) +
|
|
math.cos(phi1) * math.cos(phi2) * math.sin(deltaLambda / 2.0) * math.sin(deltaLambda / 2.0);
|
|
final c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a));
|
|
return r * c;
|
|
}
|
|
}
|