From 8044bbde94b7dc26d9efe140dcecb8c0529e8e4b Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Wed, 4 Feb 2026 23:19:24 +0900 Subject: [PATCH] pi ui accelerometer widget --- extra/themes/rei.json | 2 +- pi/ui/lib/screens/dashboard_screen.dart | 21 +- pi/ui/lib/services/backend_service.dart | 8 +- pi/ui/lib/services/test_flipflop_service.dart | 10 +- pi/ui/lib/widgets/accel_graph.dart | 276 ++++++++++++++++++ pi/ui/lib/widgets/whiskey_mark.dart | 8 +- 6 files changed, 309 insertions(+), 16 deletions(-) create mode 100644 pi/ui/lib/widgets/accel_graph.dart diff --git a/extra/themes/rei.json b/extra/themes/rei.json index 062fff4..bd2deaa 100644 --- a/extra/themes/rei.json +++ b/extra/themes/rei.json @@ -8,7 +8,7 @@ "bright": { "background": "#fda052", "foreground": "#202020", - "highlight": "#FB2E0A", + "highlight": "#df2100", "subdued": "#EAEAEA" } } diff --git a/pi/ui/lib/screens/dashboard_screen.dart b/pi/ui/lib/screens/dashboard_screen.dart index 3e944a8..65653d6 100644 --- a/pi/ui/lib/screens/dashboard_screen.dart +++ b/pi/ui/lib/screens/dashboard_screen.dart @@ -11,6 +11,7 @@ import '../widgets/stat_box_main.dart'; import '../widgets/system_bar.dart'; import '../widgets/debug_console.dart'; import '../widgets/whiskey_mark.dart'; +import '../widgets/accel_graph.dart'; // test service for triggers import '../services/test_flipflop_service.dart'; @@ -44,6 +45,8 @@ class _DashboardScreenState extends State { int? _gear; double? _roll; double? _pitch; + double? _ax; + double? _ay; // From backend - GPS data double? _gpsSpeed; @@ -71,6 +74,8 @@ class _DashboardScreenState extends State { _gear = data.gear; _roll = data.roll; _pitch = data.pitch; + _ax = data.ax; + _ay = data.ay; }); }); @@ -106,6 +111,8 @@ class _DashboardScreenState extends State { _gear = cachedArduino.gear; _roll = cachedArduino.roll; _pitch = cachedArduino.pitch; + _ax = cachedArduino.ax; + _ay = cachedArduino.ay; } final cachedGps = WebSocketService.instance.latestGps; @@ -181,11 +188,6 @@ class _DashboardScreenState extends State { flex: 8, child: Row( children: [ - // RPM from Arduino - // StatBoxMain( - // value: _formatInt(_rpm), - // label: 'RPM', - // ), // Attitude indicator (whiskey mark) Expanded( child: WhiskeyMark( @@ -193,6 +195,14 @@ class _DashboardScreenState extends State { pitch: _pitch, ), ), + Expanded( + child: AccelGraph( + ax: _ay, // Swapped: IMU Y → screen X (lateral) + ay: _ax, // Swapped: IMU X → screen Y (longitudinal) + maxG: 1.0, + ghostTrackPeriod: const Duration(seconds: 3), + ), + ) ], ), ), @@ -204,7 +214,6 @@ class _DashboardScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000), - StatBox(value: _formatInt(_engineTemp), unit: '°C', label: 'ENG', isWarning: () => (_engineTemp ?? 0) > 120), StatBox(value: _formatGear(_gear), label: 'GEAR'), ], ), diff --git a/pi/ui/lib/services/backend_service.dart b/pi/ui/lib/services/backend_service.dart index 2df14a3..f6672f3 100644 --- a/pi/ui/lib/services/backend_service.dart +++ b/pi/ui/lib/services/backend_service.dart @@ -10,8 +10,11 @@ class ArduinoData { final int? gear; // 0 = neutral, 1-6 = gear final double? roll; // Euler angle in degrees (negative = left, positive = right) final double? pitch; // Euler angle in degrees (negative = nose down) + final double? ax; // Lateral acceleration (g) + final double? ay; // Longitudinal acceleration (g) + final double? az; // Vertical acceleration (g) - ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear, this.roll, this.pitch}); + ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear, this.roll, this.pitch, this.ax, this.ay, this.az}); factory ArduinoData.fromJson(Map json) { return ArduinoData( @@ -21,6 +24,9 @@ class ArduinoData { gear: (json['gear'] as num?)?.toInt(), roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped pitch: (json['pitch'] as num?)?.toDouble(), + ax: (json['ax'] as num?)?.toDouble(), + ay: (json['ay'] as num?)?.toDouble(), + az: (json['az'] as num?)?.toDouble(), ); } } diff --git a/pi/ui/lib/services/test_flipflop_service.dart b/pi/ui/lib/services/test_flipflop_service.dart index c68f6cb..4f6dd66 100644 --- a/pi/ui/lib/services/test_flipflop_service.dart +++ b/pi/ui/lib/services/test_flipflop_service.dart @@ -33,11 +33,11 @@ class TestFlipFlopService { // ThemeService.instance.toggle(); // Surprise the navigator - if (navigatorKey.currentState?.emotion == 'surprise') { - navigatorKey.currentState?.reset(); - } else { - navigatorKey.currentState?.setEmotion('surprise'); - } + // if (navigatorKey.currentState?.emotion == 'surprise') { + // navigatorKey.currentState?.reset(); + // } else { + // navigatorKey.currentState?.setEmotion('surprise'); + // } }); } diff --git a/pi/ui/lib/widgets/accel_graph.dart b/pi/ui/lib/widgets/accel_graph.dart new file mode 100644 index 0000000..54e7ff8 --- /dev/null +++ b/pi/ui/lib/widgets/accel_graph.dart @@ -0,0 +1,276 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +/// 2D lateral G-meter showing acceleration as a dot on a cartesian grid. +/// +/// Visual: square grid with dot position = (ax, ay) scaled by maxG +/// Optional ghost dot tracks peak magnitude within ghostTrackPeriod window. +class AccelGraph extends StatefulWidget { + /// X-axis acceleration in g (lateral: negative = left, positive = right) + final double? ax; + + /// Y-axis acceleration in g (longitudinal: negative = back, positive = forward) + final double? ay; + + /// Maximum G range for the grid (default 2.0 = ±2G) + final double maxG; + + /// If set, shows a ghost dot at peak magnitude position, resetting after this duration + final Duration? ghostTrackPeriod; + + const AccelGraph({ + super.key, + this.ax, + this.ay, + this.maxG = 2.0, + this.ghostTrackPeriod, + }); + + @override + State createState() => _AccelGraphState(); +} + +class _AccelGraphState extends State { + // Ghost dot tracking + double _ghostAx = 0; + double _ghostAy = 0; + double _ghostMagnitude = 0; + Timer? _ghostResetTimer; + + @override + void initState() { + super.initState(); + _setupGhostTimer(); + } + + @override + void didUpdateWidget(AccelGraph oldWidget) { + super.didUpdateWidget(oldWidget); + + // Update ghost position if current magnitude exceeds previous peak + final currentAx = widget.ax ?? 0; + final currentAy = widget.ay ?? 0; + final currentMag = math.sqrt(currentAx * currentAx + currentAy * currentAy); + + if (currentMag > _ghostMagnitude) { + _ghostAx = currentAx; + _ghostAy = currentAy; + _ghostMagnitude = currentMag; + } + + // Restart timer if period changed + if (oldWidget.ghostTrackPeriod != widget.ghostTrackPeriod) { + _setupGhostTimer(); + } + } + + void _setupGhostTimer() { + _ghostResetTimer?.cancel(); + if (widget.ghostTrackPeriod != null) { + _ghostResetTimer = Timer.periodic(widget.ghostTrackPeriod!, (_) { + setState(() { + // Reset ghost to current position + _ghostAx = widget.ax ?? 0; + _ghostAy = widget.ay ?? 0; + _ghostMagnitude = math.sqrt(_ghostAx * _ghostAx + _ghostAy * _ghostAy); + }); + }); + } + } + + @override + void dispose() { + _ghostResetTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + + return LayoutBuilder( + builder: (context, constraints) { + final size = math.min(constraints.maxWidth, constraints.maxHeight); + final gridSize = size * 0.75; + final fontSize = size * 0.12; + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // G-meter grid + SizedBox( + width: gridSize, + height: gridSize, + child: CustomPaint( + painter: _AccelGraphPainter( + ax: widget.ax ?? 0, + ay: widget.ay ?? 0, + ghostAx: _ghostAx, + ghostAy: _ghostAy, + showGhost: widget.ghostTrackPeriod != null && _ghostMagnitude > 0, + maxG: widget.maxG, + foreground: theme.foreground, + subdued: theme.subdued, + background: theme.background, + ), + ), + ), + + SizedBox(height: size * 0.03), + + // Label + Text( + 'ACCEL', + style: TextStyle( + fontSize: fontSize * 0.8, + fontWeight: FontWeight.w400, + color: theme.subdued, + letterSpacing: 1, + ), + ), + ], + ); + }, + ); + } +} + +/// Custom painter for the G-meter grid and dots +class _AccelGraphPainter extends CustomPainter { + final double ax; + final double ay; + final double ghostAx; + final double ghostAy; + final bool showGhost; + final double maxG; + final Color foreground; + final Color subdued; + final Color background; + + _AccelGraphPainter({ + required this.ax, + required this.ay, + required this.ghostAx, + required this.ghostAy, + required this.showGhost, + required this.maxG, + required this.foreground, + required this.subdued, + required this.background, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final halfSize = size.width / 2; + + // No rectangular border + + // Grid lines at 0.5G intervals + final gridPaint = Paint() + ..color = subdued + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + + final gStep = 0.5; + for (double g = gStep; g < maxG; g += gStep) { + final offset = (g / maxG) * halfSize; + + // Vertical lines (left and right of center) + canvas.drawLine( + Offset(center.dx - offset, 0), + Offset(center.dx - offset, size.height), + gridPaint, + ); + canvas.drawLine( + Offset(center.dx + offset, 0), + Offset(center.dx + offset, size.height), + gridPaint, + ); + + // Horizontal lines (above and below center) + canvas.drawLine( + Offset(0, center.dy - offset), + Offset(size.width, center.dy - offset), + gridPaint, + ); + canvas.drawLine( + Offset(0, center.dy + offset), + Offset(size.width, center.dy + offset), + gridPaint, + ); + } + + // Center axis lines (heavier) + final axisPaint = Paint() + ..color = subdued.withValues(alpha: 0.6) + ..strokeWidth = 3 + ..style = PaintingStyle.stroke; + + // Horizontal axis + canvas.drawLine( + Offset(0, center.dy), + Offset(size.width, center.dy), + axisPaint, + ); + // Vertical axis + canvas.drawLine( + Offset(center.dx, 0), + Offset(center.dx, size.height), + axisPaint, + ); + + // G-ring markers (circles at 1G and 2G for quick reference) + final ringPaint = Paint() + ..color = subdued + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke; + + for (double g = 1.0; g <= maxG; g += 1.0) { + final radius = (g / maxG) * halfSize; + canvas.drawCircle(center, radius, ringPaint); + } + + // Ghost dot (if enabled and has data) + if (showGhost) { + final ghostX = center.dx + (ghostAx / maxG) * halfSize; + final ghostY = center.dy - (ghostAy / maxG) * halfSize; // Y inverted (up = positive) + final ghostRadius = halfSize * 0.08; + + final ghostPaint = Paint() + ..color = subdued.withValues(alpha: 0.5) + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + + canvas.drawCircle(Offset(ghostX, ghostY), ghostRadius, ghostPaint); + } + + // Main dot - clamp to grid bounds + final clampedAx = ax.clamp(-maxG, maxG); + final clampedAy = ay.clamp(-maxG, maxG); + final dotX = center.dx + (clampedAx / maxG) * halfSize; + final dotY = center.dy - (clampedAy / maxG) * halfSize; // Y inverted (up = positive) + final dotRadius = halfSize * 0.1; + + final dotPaint = Paint() + ..color = foreground + ..style = PaintingStyle.fill; + + canvas.drawCircle(Offset(dotX, dotY), dotRadius, dotPaint); + } + + @override + bool shouldRepaint(_AccelGraphPainter oldDelegate) { + return ax != oldDelegate.ax || + ay != oldDelegate.ay || + ghostAx != oldDelegate.ghostAx || + ghostAy != oldDelegate.ghostAy || + showGhost != oldDelegate.showGhost || + maxG != oldDelegate.maxG || + foreground != oldDelegate.foreground || + subdued != oldDelegate.subdued; + } +} diff --git a/pi/ui/lib/widgets/whiskey_mark.dart b/pi/ui/lib/widgets/whiskey_mark.dart index 6817bdd..1aadd08 100644 --- a/pi/ui/lib/widgets/whiskey_mark.dart +++ b/pi/ui/lib/widgets/whiskey_mark.dart @@ -64,7 +64,7 @@ class WhiskeyMark extends StatelessWidget { Text( 'Roll: ${_formatAngle(roll)}', style: TextStyle( - fontSize: fontSize * 0.8, + fontSize: fontSize * 0.5, fontWeight: FontWeight.w400, fontFeatures: const [FontFeature.tabularFigures()], color: theme.foreground, @@ -74,7 +74,7 @@ class WhiskeyMark extends StatelessWidget { Text( 'P: ${_formatAngle(pitch)}', style: TextStyle( - fontSize: fontSize * 0.8, + fontSize: fontSize * 0.5, fontWeight: FontWeight.w400, fontFeatures: const [FontFeature.tabularFigures()], color: theme.subdued, @@ -101,7 +101,9 @@ class WhiskeyMark extends StatelessWidget { String _formatAngle(double? angle) { if (angle == null) return '—°'; - return '${angle.round()}°'; + return '${ + angle.round() > 180 ? angle.round() - 360 : angle.round() + }°'; } }