364 lines
12 KiB
Dart
364 lines
12 KiB
Dart
import 'dart:math' as math;
|
|
import 'package:flutter/material.dart';
|
|
import '../../../core/theme/app_typography.dart';
|
|
import '../../../core/theme/theme_extensions.dart';
|
|
import '../models/route_curvature_analysis.dart';
|
|
|
|
/// Interactive cross-section elevation profile chart showing mountain grades and elevation changes.
|
|
class RouteElevationChart extends StatefulWidget {
|
|
final RouteCurvatureReport report;
|
|
final MotorSignalThemeColors colors;
|
|
final bool isDark;
|
|
|
|
const RouteElevationChart({
|
|
super.key,
|
|
required this.report,
|
|
required this.colors,
|
|
this.isDark = true,
|
|
});
|
|
|
|
@override
|
|
State<RouteElevationChart> createState() => _RouteElevationChartState();
|
|
}
|
|
|
|
class _RouteElevationChartState extends State<RouteElevationChart> {
|
|
double? _scrubFraction;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final profile = widget.report.elevationProfile;
|
|
if (profile.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final totalDistMiles = profile.last.distanceFromStartMiles;
|
|
final minElevFt = widget.report.minElevationFeet;
|
|
final maxElevFt = widget.report.maxElevationFeet;
|
|
|
|
RouteElevationPoint? scrubbedPoint;
|
|
if (_scrubFraction != null && profile.isNotEmpty) {
|
|
final scrubbedDist = _scrubFraction! * profile.last.distanceFromStartMeters;
|
|
scrubbedPoint = profile.reduce((prev, curr) {
|
|
final prevDiff = (prev.distanceFromStartMeters - scrubbedDist).abs();
|
|
final currDiff = (curr.distanceFromStartMeters - scrubbedDist).abs();
|
|
return currDiff < prevDiff ? curr : prev;
|
|
});
|
|
}
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: widget.colors.surfaceContainer,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: widget.colors.outlineVariant, width: 1.0),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Header Row
|
|
Wrap(
|
|
alignment: WrapAlignment.spaceBetween,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
spacing: 8,
|
|
runSpacing: 6,
|
|
children: [
|
|
FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
alignment: Alignment.centerLeft,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.landscape_rounded,
|
|
color: widget.colors.launchGreen,
|
|
size: 18,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'ELEVATION PROFILE',
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontHeadline,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 1.1,
|
|
color: widget.isDark ? Colors.white : Colors.black87,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 2,
|
|
children: [
|
|
Text(
|
|
'Gain: ${widget.report.formattedElevationGain}',
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontTelemetry,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
color: widget.colors.launchGreen,
|
|
),
|
|
),
|
|
Text(
|
|
'Loss: ${widget.report.formattedElevationDescent}',
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontTelemetry,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
color: widget.colors.raceRed,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
|
|
// Subheader / Scrub readout
|
|
Wrap(
|
|
alignment: WrapAlignment.spaceBetween,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
spacing: 8,
|
|
runSpacing: 4,
|
|
children: [
|
|
Text(
|
|
'Min: ${minElevFt.round()} ft • Max: ${maxElevFt.round()} ft',
|
|
style: const TextStyle(
|
|
fontFamily: AppTypography.fontBody,
|
|
fontSize: 11,
|
|
color: Colors.white54,
|
|
),
|
|
),
|
|
if (scrubbedPoint != null)
|
|
Text(
|
|
'${scrubbedPoint.distanceFromStartMiles.toStringAsFixed(1)} mi: ${scrubbedPoint.elevationFeet.round()} ft',
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontTelemetry,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
color: widget.colors.launchGreen,
|
|
),
|
|
)
|
|
else
|
|
const Text(
|
|
'Touch chart to scrub distance',
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontBody,
|
|
fontSize: 10,
|
|
color: Colors.white38,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Elevation Chart Canvas
|
|
SizedBox(
|
|
height: 130,
|
|
child: GestureDetector(
|
|
onHorizontalDragStart: (details) => _updateScrub(details.localPosition.dx, context),
|
|
onHorizontalDragUpdate: (details) => _updateScrub(details.localPosition.dx, context),
|
|
onHorizontalDragEnd: (_) => setState(() => _scrubFraction = null),
|
|
onTapDown: (details) => _updateScrub(details.localPosition.dx, context),
|
|
onTapUp: (_) => setState(() => _scrubFraction = null),
|
|
child: CustomPaint(
|
|
painter: _ElevationChartPainter(
|
|
profile: profile,
|
|
minElev: widget.report.minElevationMeters,
|
|
maxElev: widget.report.maxElevationMeters,
|
|
scrubFraction: _scrubFraction,
|
|
colors: widget.colors,
|
|
isDark: widget.isDark,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
|
|
// Distance labels
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Flexible(
|
|
child: Text(
|
|
'0.0 mi (Start)',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontTelemetry,
|
|
fontSize: 9,
|
|
color: Colors.white38,
|
|
),
|
|
),
|
|
),
|
|
Flexible(
|
|
child: Text(
|
|
'${(totalDistMiles / 2).toStringAsFixed(1)} mi',
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontFamily: AppTypography.fontTelemetry,
|
|
fontSize: 9,
|
|
color: Colors.white38,
|
|
),
|
|
),
|
|
),
|
|
Flexible(
|
|
child: Text(
|
|
'${totalDistMiles.toStringAsFixed(1)} mi (End)',
|
|
textAlign: TextAlign.right,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontFamily: AppTypography.fontTelemetry,
|
|
fontSize: 9,
|
|
color: Colors.white38,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _updateScrub(double localX, BuildContext context) {
|
|
final box = context.findRenderObject() as RenderBox?;
|
|
if (box == null) return;
|
|
const paddingLeft = 24.0;
|
|
final chartWidth = box.size.width - 48.0;
|
|
if (chartWidth <= 0) return;
|
|
|
|
final frac = ((localX - paddingLeft) / chartWidth).clamp(0.0, 1.0);
|
|
setState(() => _scrubFraction = frac);
|
|
}
|
|
}
|
|
|
|
class _ElevationChartPainter extends CustomPainter {
|
|
final List<RouteElevationPoint> profile;
|
|
final double minElev;
|
|
final double maxElev;
|
|
final double? scrubFraction;
|
|
final MotorSignalThemeColors colors;
|
|
final bool isDark;
|
|
|
|
_ElevationChartPainter({
|
|
required this.profile,
|
|
required this.minElev,
|
|
required this.maxElev,
|
|
this.scrubFraction,
|
|
required this.colors,
|
|
required this.isDark,
|
|
});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
const leftMargin = 20.0;
|
|
const rightMargin = 20.0;
|
|
const topMargin = 8.0;
|
|
const bottomMargin = 16.0;
|
|
|
|
final plotWidth = size.width - leftMargin - rightMargin;
|
|
final plotHeight = size.height - topMargin - bottomMargin;
|
|
|
|
if (plotWidth <= 0 || plotHeight <= 0 || profile.isEmpty) return;
|
|
|
|
final elevRange = math.max(10.0, maxElev - minElev);
|
|
final totalDist = profile.last.distanceFromStartMeters > 0
|
|
? profile.last.distanceFromStartMeters
|
|
: 1.0;
|
|
|
|
// Grid lines
|
|
final gridPaint = Paint()
|
|
..color = (isDark ? Colors.white : Colors.black).withValues(alpha: 0.08)
|
|
..strokeWidth = 1.0;
|
|
|
|
for (int i = 0; i <= 3; i++) {
|
|
final y = topMargin + (plotHeight * (i / 3.0));
|
|
canvas.drawLine(
|
|
Offset(leftMargin, y),
|
|
Offset(leftMargin + plotWidth, y),
|
|
gridPaint,
|
|
);
|
|
}
|
|
|
|
final path = Path();
|
|
final areaPath = Path();
|
|
|
|
for (int i = 0; i < profile.length; i++) {
|
|
final p = profile[i];
|
|
final xFrac = (p.distanceFromStartMeters / totalDist).clamp(0.0, 1.0);
|
|
final yFrac = ((p.elevationMeters - minElev) / elevRange).clamp(0.0, 1.0);
|
|
|
|
final x = leftMargin + (xFrac * plotWidth);
|
|
final y = topMargin + plotHeight * (1.0 - yFrac);
|
|
|
|
if (i == 0) {
|
|
path.moveTo(x, y);
|
|
areaPath.moveTo(x, topMargin + plotHeight);
|
|
areaPath.lineTo(x, y);
|
|
} else {
|
|
path.lineTo(x, y);
|
|
areaPath.lineTo(x, y);
|
|
}
|
|
}
|
|
|
|
areaPath.lineTo(leftMargin + plotWidth, topMargin + plotHeight);
|
|
areaPath.close();
|
|
|
|
// Area Gradient Fill
|
|
final areaGradient = LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
colors.launchGreen.withValues(alpha: 0.35),
|
|
colors.launchGreen.withValues(alpha: 0.02),
|
|
],
|
|
);
|
|
final areaPaint = Paint()
|
|
..shader = areaGradient.createShader(
|
|
Rect.fromLTWH(leftMargin, topMargin, plotWidth, plotHeight),
|
|
)
|
|
..style = PaintingStyle.fill;
|
|
canvas.drawPath(areaPath, areaPaint);
|
|
|
|
// Profile Stroke
|
|
final linePaint = Paint()
|
|
..color = colors.launchGreen
|
|
..strokeWidth = 2.5
|
|
..style = PaintingStyle.stroke;
|
|
canvas.drawPath(path, linePaint);
|
|
|
|
// Scrubber Line
|
|
if (scrubFraction != null) {
|
|
final scrubX = leftMargin + (scrubFraction! * plotWidth);
|
|
final scrubPaint = Paint()
|
|
..color = Colors.white
|
|
..strokeWidth = 1.5;
|
|
|
|
canvas.drawLine(
|
|
Offset(scrubX, topMargin),
|
|
Offset(scrubX, topMargin + plotHeight),
|
|
scrubPaint,
|
|
);
|
|
|
|
canvas.drawCircle(
|
|
Offset(scrubX, topMargin + 4),
|
|
3.5,
|
|
Paint()..color = colors.launchGreen,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _ElevationChartPainter oldDelegate) {
|
|
return oldDelegate.scrubFraction != scrubFraction ||
|
|
oldDelegate.profile != profile ||
|
|
oldDelegate.minElev != minElev ||
|
|
oldDelegate.maxElev != maxElev;
|
|
}
|
|
}
|