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 createState() => _RouteElevationChartState(); } class _RouteElevationChartState extends State { 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 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; } }