MotorSignal-App/lib/features/routes/models/route_curvature_analysis.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;
}
}