pi ui accelerometer widget
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
"bright": {
|
"bright": {
|
||||||
"background": "#fda052",
|
"background": "#fda052",
|
||||||
"foreground": "#202020",
|
"foreground": "#202020",
|
||||||
"highlight": "#FB2E0A",
|
"highlight": "#df2100",
|
||||||
"subdued": "#EAEAEA"
|
"subdued": "#EAEAEA"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../widgets/stat_box_main.dart';
|
|||||||
import '../widgets/system_bar.dart';
|
import '../widgets/system_bar.dart';
|
||||||
import '../widgets/debug_console.dart';
|
import '../widgets/debug_console.dart';
|
||||||
import '../widgets/whiskey_mark.dart';
|
import '../widgets/whiskey_mark.dart';
|
||||||
|
import '../widgets/accel_graph.dart';
|
||||||
|
|
||||||
// test service for triggers
|
// test service for triggers
|
||||||
import '../services/test_flipflop_service.dart';
|
import '../services/test_flipflop_service.dart';
|
||||||
@@ -44,6 +45,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
int? _gear;
|
int? _gear;
|
||||||
double? _roll;
|
double? _roll;
|
||||||
double? _pitch;
|
double? _pitch;
|
||||||
|
double? _ax;
|
||||||
|
double? _ay;
|
||||||
|
|
||||||
// From backend - GPS data
|
// From backend - GPS data
|
||||||
double? _gpsSpeed;
|
double? _gpsSpeed;
|
||||||
@@ -71,6 +74,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
_gear = data.gear;
|
_gear = data.gear;
|
||||||
_roll = data.roll;
|
_roll = data.roll;
|
||||||
_pitch = data.pitch;
|
_pitch = data.pitch;
|
||||||
|
_ax = data.ax;
|
||||||
|
_ay = data.ay;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,6 +111,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
_gear = cachedArduino.gear;
|
_gear = cachedArduino.gear;
|
||||||
_roll = cachedArduino.roll;
|
_roll = cachedArduino.roll;
|
||||||
_pitch = cachedArduino.pitch;
|
_pitch = cachedArduino.pitch;
|
||||||
|
_ax = cachedArduino.ax;
|
||||||
|
_ay = cachedArduino.ay;
|
||||||
}
|
}
|
||||||
|
|
||||||
final cachedGps = WebSocketService.instance.latestGps;
|
final cachedGps = WebSocketService.instance.latestGps;
|
||||||
@@ -181,11 +188,6 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
flex: 8,
|
flex: 8,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// RPM from Arduino
|
|
||||||
// StatBoxMain(
|
|
||||||
// value: _formatInt(_rpm),
|
|
||||||
// label: 'RPM',
|
|
||||||
// ),
|
|
||||||
// Attitude indicator (whiskey mark)
|
// Attitude indicator (whiskey mark)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: WhiskeyMark(
|
child: WhiskeyMark(
|
||||||
@@ -193,6 +195,14 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
pitch: _pitch,
|
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<DashboardScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000),
|
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'),
|
StatBox(value: _formatGear(_gear), label: 'GEAR'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ class ArduinoData {
|
|||||||
final int? gear; // 0 = neutral, 1-6 = gear
|
final int? gear; // 0 = neutral, 1-6 = gear
|
||||||
final double? roll; // Euler angle in degrees (negative = left, positive = right)
|
final double? roll; // Euler angle in degrees (negative = left, positive = right)
|
||||||
final double? pitch; // Euler angle in degrees (negative = nose down)
|
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<String, dynamic> json) {
|
factory ArduinoData.fromJson(Map<String, dynamic> json) {
|
||||||
return ArduinoData(
|
return ArduinoData(
|
||||||
@@ -21,6 +24,9 @@ class ArduinoData {
|
|||||||
gear: (json['gear'] as num?)?.toInt(),
|
gear: (json['gear'] as num?)?.toInt(),
|
||||||
roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped
|
roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped
|
||||||
pitch: (json['pitch'] as num?)?.toDouble(),
|
pitch: (json['pitch'] as num?)?.toDouble(),
|
||||||
|
ax: (json['ax'] as num?)?.toDouble(),
|
||||||
|
ay: (json['ay'] as num?)?.toDouble(),
|
||||||
|
az: (json['az'] as num?)?.toDouble(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ class TestFlipFlopService {
|
|||||||
// ThemeService.instance.toggle();
|
// ThemeService.instance.toggle();
|
||||||
|
|
||||||
// Surprise the navigator
|
// Surprise the navigator
|
||||||
if (navigatorKey.currentState?.emotion == 'surprise') {
|
// if (navigatorKey.currentState?.emotion == 'surprise') {
|
||||||
navigatorKey.currentState?.reset();
|
// navigatorKey.currentState?.reset();
|
||||||
} else {
|
// } else {
|
||||||
navigatorKey.currentState?.setEmotion('surprise');
|
// navigatorKey.currentState?.setEmotion('surprise');
|
||||||
}
|
// }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
276
pi/ui/lib/widgets/accel_graph.dart
Normal file
276
pi/ui/lib/widgets/accel_graph.dart
Normal file
@@ -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<AccelGraph> createState() => _AccelGraphState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccelGraphState extends State<AccelGraph> {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ class WhiskeyMark extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'Roll: ${_formatAngle(roll)}',
|
'Roll: ${_formatAngle(roll)}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: fontSize * 0.8,
|
fontSize: fontSize * 0.5,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
color: theme.foreground,
|
color: theme.foreground,
|
||||||
@@ -74,7 +74,7 @@ class WhiskeyMark extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'P: ${_formatAngle(pitch)}',
|
'P: ${_formatAngle(pitch)}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: fontSize * 0.8,
|
fontSize: fontSize * 0.5,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
color: theme.subdued,
|
color: theme.subdued,
|
||||||
@@ -101,7 +101,9 @@ class WhiskeyMark extends StatelessWidget {
|
|||||||
|
|
||||||
String _formatAngle(double? angle) {
|
String _formatAngle(double? angle) {
|
||||||
if (angle == null) return '—°';
|
if (angle == null) return '—°';
|
||||||
return '${angle.round()}°';
|
return '${
|
||||||
|
angle.round() > 180 ? angle.round() - 360 : angle.round()
|
||||||
|
}°';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user