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