saltylab-firmware/ui/social-bot/src/components/MotorCurrentGraph.jsx
sl-webui f12f0bdc2b feat(webui): motor current live graph (Issue #297)
Real-time motor current visualization with:
- Subscribes to /saltybot/motor_currents for dual-motor current data
- Rolling 60-second history window with automatic data culling
- Dual-axis line chart for left (cyan) and right (amber) motor amps
- Canvas-based rendering for performance
- Thermal warning threshold line (25A, configurable)
- Real-time statistics:
  * Current draw for left and right motors
  * Peak current tracking over 60-second window
  * Average current calculation
  * Thermal status indicator with warning badge
- Color-coded thermal alerts:
  * Red background when threshold exceeded
  * Warning indicator and message
- Grid overlay, axis labels, time labels, legend
- Takes absolute value of currents (handles reverse direction)

Integrated into TELEMETRY tab group as 'Motor Current' tab.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-02 21:09:17 -05:00

399 lines
13 KiB
JavaScript

/**
* MotorCurrentGraph.jsx — Live motor current visualization
*
* Features:
* - Subscribes to /saltybot/motor_currents for real-time motor current data
* - Maintains rolling 60-second history of readings
* - Dual-axis line chart: left motor (cyan) and right motor (amber)
* - Canvas-based rendering for performance
* - Real-time peak current tracking
* - Average current calculation
* - Thermal warning threshold line (configurable)
* - Current stats and alerts
*/
import { useEffect, useRef, useState } from 'react';
const MAX_HISTORY_SECONDS = 60;
const SAMPLE_RATE = 10; // Hz
const MAX_DATA_POINTS = MAX_HISTORY_SECONDS * SAMPLE_RATE;
const THERMAL_WARNING_THRESHOLD = 25; // Amps
function calculateStats(data, field) {
if (data.length === 0) return { current: 0, peak: 0, average: 0 };
const values = data.map((d) => d[field]);
const current = values[values.length - 1];
const peak = Math.max(...values);
const average = values.reduce((a, b) => a + b, 0) / values.length;
return { current, peak, average };
}
export function MotorCurrentGraph({ subscribe }) {
const canvasRef = useRef(null);
const [data, setData] = useState([]);
const dataRef = useRef([]);
const [stats, setStats] = useState({
left: { current: 0, peak: 0, average: 0 },
right: { current: 0, peak: 0, average: 0 },
});
const [alerts, setAlerts] = useState({
leftThermal: false,
rightThermal: false,
});
// Subscribe to motor currents
useEffect(() => {
const unsubscribe = subscribe(
'/saltybot/motor_currents',
'std_msgs/Float32MultiArray',
(msg) => {
try {
let leftAmps = 0;
let rightAmps = 0;
if (msg.data && msg.data.length >= 2) {
leftAmps = Math.abs(msg.data[0]);
rightAmps = Math.abs(msg.data[1]);
}
const timestamp = Date.now();
const newPoint = { timestamp, leftAmps, rightAmps };
setData((prev) => {
const updated = [...prev, newPoint];
// Keep only last 60 seconds of data
const sixtySecondsAgo = timestamp - MAX_HISTORY_SECONDS * 1000;
const filtered = updated.filter((p) => p.timestamp >= sixtySecondsAgo);
dataRef.current = filtered;
// Calculate stats
if (filtered.length > 0) {
const leftStats = calculateStats(filtered, 'leftAmps');
const rightStats = calculateStats(filtered, 'rightAmps');
setStats({
left: leftStats,
right: rightStats,
});
// Check thermal warnings
setAlerts({
leftThermal: leftStats.current > THERMAL_WARNING_THRESHOLD,
rightThermal: rightStats.current > THERMAL_WARNING_THRESHOLD,
});
}
return filtered;
});
} catch (e) {
console.error('Error parsing motor currents:', e);
}
}
);
return unsubscribe;
}, [subscribe]);
// Canvas rendering
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || dataRef.current.length === 0) return;
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
// Clear canvas
ctx.fillStyle = '#1f2937';
ctx.fillRect(0, 0, width, height);
const data = dataRef.current;
const padding = { top: 30, right: 60, bottom: 40, left: 60 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
// Find min/max values for scaling
let maxCurrent = 0;
data.forEach((point) => {
maxCurrent = Math.max(maxCurrent, point.leftAmps, point.rightAmps);
});
maxCurrent = maxCurrent * 1.1;
const minCurrent = 0;
const startTime = data[0].timestamp;
const endTime = data[data.length - 1].timestamp;
const timeRange = endTime - startTime || 1;
// Grid
ctx.strokeStyle = '#374151';
ctx.lineWidth = 0.5;
ctx.globalAlpha = 0.3;
for (let i = 0; i <= 5; i++) {
const y = padding.top + (i * chartHeight) / 5;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
}
for (let i = 0; i <= 4; i++) {
const x = padding.left + (i * chartWidth) / 4;
ctx.beginPath();
ctx.moveTo(x, padding.top);
ctx.lineTo(x, height - padding.bottom);
ctx.stroke();
}
ctx.globalAlpha = 1.0;
// Draw axes
ctx.strokeStyle = '#6b7280';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding.left, padding.top);
ctx.lineTo(padding.left, height - padding.bottom);
ctx.lineTo(width - padding.right, height - padding.bottom);
ctx.stroke();
// Draw thermal warning threshold line
const thresholdY =
padding.top +
chartHeight -
((THERMAL_WARNING_THRESHOLD - minCurrent) / (maxCurrent - minCurrent)) * chartHeight;
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.globalAlpha = 0.6;
ctx.beginPath();
ctx.moveTo(padding.left, thresholdY);
ctx.lineTo(width - padding.right, thresholdY);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1.0;
// Left motor line (cyan)
ctx.strokeStyle = '#06b6d4';
ctx.lineWidth = 2.5;
ctx.globalAlpha = 0.85;
ctx.beginPath();
let firstPoint = true;
data.forEach((point) => {
const x = padding.left + ((point.timestamp - startTime) / timeRange) * chartWidth;
const y =
padding.top +
chartHeight -
((point.leftAmps - minCurrent) / (maxCurrent - minCurrent)) * chartHeight;
if (firstPoint) {
ctx.moveTo(x, y);
firstPoint = false;
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// Right motor line (amber)
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 2.5;
ctx.globalAlpha = 0.85;
ctx.beginPath();
firstPoint = true;
data.forEach((point) => {
const x = padding.left + ((point.timestamp - startTime) / timeRange) * chartWidth;
const y =
padding.top +
chartHeight -
((point.rightAmps - minCurrent) / (maxCurrent - minCurrent)) * chartHeight;
if (firstPoint) {
ctx.moveTo(x, y);
firstPoint = false;
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
ctx.globalAlpha = 1.0;
// Y-axis labels (current in amps)
ctx.fillStyle = '#9ca3af';
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'right';
for (let i = 0; i <= 5; i++) {
const value = minCurrent + (i * (maxCurrent - minCurrent)) / 5;
const y = padding.top + (5 - i) * (chartHeight / 5);
ctx.fillText(value.toFixed(1), padding.left - 10, y + 4);
}
// X-axis time labels
ctx.fillStyle = '#9ca3af';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
for (let i = 0; i <= 4; i++) {
const secondsAgo = MAX_HISTORY_SECONDS - (i * MAX_HISTORY_SECONDS) / 4;
const label = secondsAgo === 0 ? 'now' : `${Math.floor(secondsAgo)}s ago`;
const x = padding.left + (i * chartWidth) / 4;
ctx.fillText(label, x, height - padding.bottom + 20);
}
// Legend
const legendY = 10;
ctx.fillStyle = '#06b6d4';
ctx.fillRect(width - 200, legendY, 10, 10);
ctx.fillStyle = '#06b6d4';
ctx.font = '11px monospace';
ctx.textAlign = 'left';
ctx.fillText('Left Motor', width - 185, legendY + 10);
ctx.fillStyle = '#f59e0b';
ctx.fillRect(width - 200, legendY + 15, 10, 10);
ctx.fillStyle = '#f59e0b';
ctx.fillText('Right Motor', width - 185, legendY + 25);
ctx.fillStyle = '#ef4444';
ctx.setLineDash([4, 4]);
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(width - 200, legendY + 37);
ctx.lineTo(width - 190, legendY + 37);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#ef4444';
ctx.font = '11px monospace';
ctx.fillText('Thermal Limit', width - 185, legendY + 42);
}, []);
const leftThermalStatus = alerts.leftThermal ? 'THERMAL WARNING' : 'Normal';
const rightThermalStatus = alerts.rightThermal ? 'THERMAL WARNING' : 'Normal';
return (
<div className="flex flex-col h-full space-y-3">
{/* Controls */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 space-y-3">
<div className="flex justify-between items-center flex-wrap gap-2">
<div className="text-cyan-700 text-xs font-bold tracking-widest">
MOTOR CURRENT (60 SEC)
</div>
<div className="text-gray-600 text-xs">
{data.length} samples
</div>
</div>
{/* Current stats */}
<div className="grid grid-cols-2 gap-3">
{/* Left motor */}
<div
className={`rounded border p-2 space-y-1 ${
alerts.leftThermal
? 'bg-red-950 border-red-800'
: 'bg-gray-900 border-gray-800'
}`}
>
<div className={`text-xs font-bold ${
alerts.leftThermal ? 'text-red-400' : 'text-gray-700'
}`}>
LEFT MOTOR
</div>
<div className="flex items-end gap-1">
<span className={`text-lg font-mono ${
alerts.leftThermal ? 'text-red-400' : 'text-cyan-400'
}`}>
{stats.left.current.toFixed(2)}
</span>
<span className="text-xs text-gray-600 mb-0.5">A</span>
</div>
<div className="text-xs space-y-0.5">
<div className="flex justify-between text-gray-500">
<span>Peak:</span>
<span className="text-cyan-300">{stats.left.peak.toFixed(2)}A</span>
</div>
<div className="flex justify-between text-gray-500">
<span>Avg:</span>
<span className="text-cyan-300">{stats.left.average.toFixed(2)}A</span>
</div>
</div>
{alerts.leftThermal && (
<div className="text-xs text-red-400 font-bold mt-1"> {leftThermalStatus}</div>
)}
</div>
{/* Right motor */}
<div
className={`rounded border p-2 space-y-1 ${
alerts.rightThermal
? 'bg-red-950 border-red-800'
: 'bg-gray-900 border-gray-800'
}`}
>
<div className={`text-xs font-bold ${
alerts.rightThermal ? 'text-red-400' : 'text-gray-700'
}`}>
RIGHT MOTOR
</div>
<div className="flex items-end gap-1">
<span className={`text-lg font-mono ${
alerts.rightThermal ? 'text-red-400' : 'text-amber-400'
}`}>
{stats.right.current.toFixed(2)}
</span>
<span className="text-xs text-gray-600 mb-0.5">A</span>
</div>
<div className="text-xs space-y-0.5">
<div className="flex justify-between text-gray-500">
<span>Peak:</span>
<span className="text-amber-300">{stats.right.peak.toFixed(2)}A</span>
</div>
<div className="flex justify-between text-gray-500">
<span>Avg:</span>
<span className="text-amber-300">{stats.right.average.toFixed(2)}A</span>
</div>
</div>
{alerts.rightThermal && (
<div className="text-xs text-red-400 font-bold mt-1"> {rightThermalStatus}</div>
)}
</div>
</div>
</div>
{/* Chart canvas */}
<div className="flex-1 bg-gray-950 rounded-lg border border-cyan-950 overflow-hidden">
<canvas
ref={canvasRef}
width={800}
height={400}
className="w-full h-full"
style={{ userSelect: 'none' }}
/>
</div>
{/* Info panel */}
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 space-y-1">
<div className="flex justify-between">
<span>Topic:</span>
<span className="text-gray-500">/saltybot/motor_currents</span>
</div>
<div className="flex justify-between">
<span>History:</span>
<span className="text-gray-500">{MAX_HISTORY_SECONDS} seconds rolling window</span>
</div>
<div className="flex justify-between">
<span>Thermal Threshold:</span>
<span className="text-gray-500">{THERMAL_WARNING_THRESHOLD}A (warning only)</span>
</div>
</div>
</div>
);
}