diff --git a/lib/features/map/map_screen.dart b/lib/features/map/map_screen.dart index 5288d1f..288387b 100644 --- a/lib/features/map/map_screen.dart +++ b/lib/features/map/map_screen.dart @@ -35,6 +35,8 @@ class _MapScreenState extends ConsumerState { static const double _hardAccelThresholdG = 0.35; static const double _hardBrakingThresholdG = -0.35; + static const double _corneringLeanThresholdDeg = 20.0; + static const double _corneringLateralGThreshold = 0.35; static const Duration _eventMergeWindow = Duration(milliseconds: 1500); @override @@ -134,41 +136,64 @@ class _MapScreenState extends ConsumerState { List _buildTelemetryEventMarkers(List samples) { final gps = samples.where((s) => s.latitude != null && s.longitude != null && - (s.longitudinalG != null || s.accelX != null)).toList(); + (s.longitudinalG != null || s.accelX != null || s.lateralG != null || s.leanAngle != null)).toList(); if (gps.isEmpty) return []; final markers = []; DateTime? lastAccel; DateTime? lastBrake; + DateTime? lastCorner; for (final sample in gps) { - final g = sample.longitudinalG ?? sample.accelX; - if (g == null || !g.isFinite) continue; + final longitudinalG = sample.longitudinalG ?? sample.accelX; + final lateralG = sample.lateralG; + final leanAngle = sample.leanAngle; - if (g >= _hardAccelThresholdG && - (lastAccel == null || sample.timestamp.difference(lastAccel).abs() > _eventMergeWindow)) { + if (longitudinalG != null && longitudinalG.isFinite) { + if (longitudinalG >= _hardAccelThresholdG && + (lastAccel == null || sample.timestamp.difference(lastAccel).abs() > _eventMergeWindow)) { + markers.add(_eventMarker( + id: 'telemetry_accel_${sample.id}', + position: AppLatLng(sample.latitude!, sample.longitude!), + label: 'Hard Acceleration', + value: longitudinalG, + timestamp: sample.timestamp, + markerColor: const Color(0xFF76FF03), + markerIcon: Icons.arrow_upward, + )); + lastAccel = sample.timestamp; + } else if (longitudinalG <= _hardBrakingThresholdG && + (lastBrake == null || sample.timestamp.difference(lastBrake).abs() > _eventMergeWindow)) { + markers.add(_eventMarker( + id: 'telemetry_braking_${sample.id}', + position: AppLatLng(sample.latitude!, sample.longitude!), + label: 'Hard Braking', + value: longitudinalG, + timestamp: sample.timestamp, + markerColor: const Color(0xFFFF5252), + markerIcon: Icons.arrow_downward, + )); + lastBrake = sample.timestamp; + } + } + + final isCornering = (leanAngle != null && leanAngle.isFinite && leanAngle.abs() >= _corneringLeanThresholdDeg) || + (lateralG != null && lateralG.isFinite && lateralG.abs() >= _corneringLateralGThreshold); + if (isCornering && + (lastCorner == null || sample.timestamp.difference(lastCorner).abs() > _eventMergeWindow)) { + final cornerValue = leanAngle != null && leanAngle.isFinite + ? leanAngle + : lateralG!.abs(); markers.add(_eventMarker( - id: 'telemetry_accel_${sample.id}', + id: 'telemetry_corner_${sample.id}', position: AppLatLng(sample.latitude!, sample.longitude!), - label: 'Hard Acceleration', - value: g, + label: 'Cornering', + value: cornerValue, timestamp: sample.timestamp, - markerColor: const Color(0xFF76FF03), - markerIcon: Icons.arrow_upward, + markerColor: const Color(0xFF00E5FF), + markerIcon: Icons.turn_right, )); - lastAccel = sample.timestamp; - } else if (g <= _hardBrakingThresholdG && - (lastBrake == null || sample.timestamp.difference(lastBrake).abs() > _eventMergeWindow)) { - markers.add(_eventMarker( - id: 'telemetry_braking_${sample.id}', - position: AppLatLng(sample.latitude!, sample.longitude!), - label: 'Hard Braking', - value: g, - timestamp: sample.timestamp, - markerColor: const Color(0xFFFF5252), - markerIcon: Icons.arrow_downward, - )); - lastBrake = sample.timestamp; + lastCorner = sample.timestamp; } } return markers; @@ -219,7 +244,7 @@ class _MapScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildDialogRow('Timestamp:', timeStr), - _buildDialogRow('G Value:', value.toStringAsFixed(2)), + _buildDialogRow('Value:', value.toStringAsFixed(2)), _buildDialogRow('GPS Location:', '${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'), ], ), @@ -238,7 +263,7 @@ class _MapScreenState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: const TextStyle(fontSize: 11, color: Colors.white54)), - Text(value, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.white)), + Flexible(child: Text(value, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.white), textAlign: TextAlign.right)), ], ), ); @@ -299,6 +324,9 @@ class _MapScreenState extends ConsumerState { color: const Color(0xFFFF5252), )); } + if (_showCornering) { + polylines.addAll(_buildCorneringEventPolylines(validSamples)); + } return polylines; } @@ -311,10 +339,6 @@ class _MapScreenState extends ConsumerState { }) { if (samples.isEmpty) return []; - // A threshold crossing is often only one telemetry sample wide. Requiring - // two consecutive qualifying samples made isolated acceleration/braking - // events disappear from the map. Build event runs, then include one GPS - // sample on each side so every event renders as a visible route segment. final polylines = []; final eventIndices = []; @@ -338,9 +362,6 @@ class _MapScreenState extends ConsumerState { final sample = samples[i]; points.add(AppLatLng(sample.latitude!, sample.longitude!)); } - - // The surrounding point guarantees two points for an isolated event - // while preserving a single continuous segment for adjacent events. if (points.length >= 2) { polylines.add(AppPolyline( id: '${idPrefix}_$segmentIndex', @@ -354,9 +375,6 @@ class _MapScreenState extends ConsumerState { for (var i = 1; i < eventIndices.length; i++) { final index = eventIndices[i]; - // Keep nearby threshold samples in one event run. A gap of more than - // two samples starts a new visual event while allowing brief sensor - // dropouts inside a hard maneuver. if (index - runEnd <= 2) { runEnd = index; } else { @@ -365,7 +383,62 @@ class _MapScreenState extends ConsumerState { } } emitRun(runStart, runEnd); + return polylines; + } + List _buildCorneringEventPolylines(List samples) { + if (samples.isEmpty) return []; + + final eventIndices = []; + for (var i = 0; i < samples.length; i++) { + final sample = samples[i]; + final lean = sample.leanAngle; + final lateral = sample.lateralG; + final isCornering = (lean != null && lean.isFinite && lean.abs() >= _corneringLeanThresholdDeg) || + (lateral != null && lateral.isFinite && lateral.abs() >= _corneringLateralGThreshold); + if (isCornering) eventIndices.add(i); + } + if (eventIndices.isEmpty) return []; + + final polylines = []; + var runStart = eventIndices.first; + var runEnd = eventIndices.first; + var segmentIndex = 0; + + void emitRun(int start, int end) { + // Include a GPS breadcrumb before and after the detected cornering + // samples. This makes a short threshold crossing visible as a route + // segment rather than a point hidden beneath a marker. + final expandedStart = start > 0 ? start - 1 : start; + final expandedEnd = end < samples.length - 1 ? end + 1 : end; + final points = []; + for (var i = expandedStart; i <= expandedEnd; i++) { + final sample = samples[i]; + if (sample.latitude != null && sample.longitude != null) { + points.add(AppLatLng(sample.latitude!, sample.longitude!)); + } + } + if (points.length >= 2) { + polylines.add(AppPolyline( + id: 'cornering_layer_$segmentIndex', + points: points, + color: const Color(0xFF00E5FF), + width: 7.0, + )); + segmentIndex++; + } + } + + for (var i = 1; i < eventIndices.length; i++) { + final index = eventIndices[i]; + if (index - runEnd <= 2) { + runEnd = index; + } else { + emitRun(runStart, runEnd); + runStart = runEnd = index; + } + } + emitRun(runStart, runEnd); return polylines; }