Merge pull request 'feat(webui): battery history chart (#280)' (#285) from sl-webui/issue-280-battery-chart into main

This commit is contained in:
sl-jetson 2026-03-02 20:44:16 -05:00
commit d3806094ee
2 changed files with 368 additions and 0 deletions

View File

@ -32,6 +32,7 @@ import { AudioMeter } from './components/AudioMeter.jsx';
// Telemetry panels
import PoseViewer from './components/PoseViewer.jsx';
import { BatteryPanel } from './components/BatteryPanel.jsx';
import { BatteryChart } from './components/BatteryChart.jsx';
import { MotorPanel } from './components/MotorPanel.jsx';
import { MapViewer } from './components/MapViewer.jsx';
import { ControlMode } from './components/ControlMode.jsx';
@ -81,6 +82,7 @@ const TAB_GROUPS = [
tabs: [
{ id: 'imu', label: 'IMU', },
{ id: 'battery', label: 'Battery', },
{ id: 'battery-chart', label: 'Battery History', },
{ id: 'motors', label: 'Motors', },
{ id: 'map', label: 'Map', },
{ id: 'control', label: 'Control', },
@ -245,6 +247,7 @@ export default function App() {
{activeTab === 'imu' && <PoseViewer subscribe={subscribe} />}
{activeTab === 'battery' && <BatteryPanel subscribe={subscribe} />}
{activeTab === 'battery-chart' && <BatteryChart subscribe={subscribe} />}
{activeTab === 'motors' && <MotorPanel subscribe={subscribe} />}
{activeTab === 'map' && <MapViewer subscribe={subscribe} />}
{activeTab === 'control' && (

View File

@ -0,0 +1,365 @@
/**
* BatteryChart.jsx Battery voltage and percentage history chart
*
* Features:
* - Subscribes to /saltybot/battery_state for real-time battery data
* - Maintains rolling 30-minute history of readings
* - Dual-axis line chart: voltage (left) and percentage (right)
* - Canvas-based rendering for performance
* - Automatic rate calculation: charge/discharge in mV/min and %/min
* - Grid overlay, axis labels, and legend
* - Responsive sizing
*/
import { useEffect, useRef, useState } from 'react';
const MAX_HISTORY_MINUTES = 30;
const DATA_POINTS_PER_MINUTE = 2; // Sample every 30 seconds
const MAX_DATA_POINTS = MAX_HISTORY_MINUTES * DATA_POINTS_PER_MINUTE;
function formatTime(minutes) {
if (minutes === 0) return 'now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}h ${mins}m ago`;
}
function calculateRate(data, field, minutes = 5) {
// Calculate rate of change over the last N minutes
if (data.length < 2) return 0;
const recentMinutes = minutes;
const now = data[data.length - 1].timestamp;
const startTime = now - recentMinutes * 60 * 1000;
let startValue = null;
let startIdx = -1;
for (let i = 0; i < data.length; i++) {
if (data[i].timestamp >= startTime) {
startIdx = i;
break;
}
}
if (startIdx === -1 || startIdx === data.length - 1) {
return 0;
}
startValue = data[startIdx][field];
const endValue = data[data.length - 1][field];
const elapsedMinutes = (now - data[startIdx].timestamp) / (60 * 1000);
return (endValue - startValue) / elapsedMinutes;
}
export function BatteryChart({ subscribe }) {
const canvasRef = useRef(null);
const [data, setData] = useState([]);
const dataRef = useRef([]);
const [stats, setStats] = useState({
voltage: 0,
percentage: 0,
voltageRate: 0,
percentageRate: 0,
});
// Subscribe to battery state
useEffect(() => {
const unsubscribe = subscribe(
'/saltybot/battery_state',
'std_msgs/Float32MultiArray',
(msg) => {
try {
// Expect array with [voltage_mv, percentage]
// or individual fields
let voltage = 0;
let percentage = 0;
if (msg.data && msg.data.length >= 2) {
voltage = msg.data[0]; // mV
percentage = msg.data[1]; // 0-100
} else if (msg.voltage !== undefined && msg.percentage !== undefined) {
voltage = msg.voltage;
percentage = msg.percentage;
}
const timestamp = Date.now();
const newPoint = { timestamp, voltage, percentage };
setData((prev) => {
const updated = [...prev, newPoint];
// Keep only last 30 minutes of data
const thirtyMinutesAgo = timestamp - MAX_HISTORY_MINUTES * 60 * 1000;
const filtered = updated.filter((p) => p.timestamp >= thirtyMinutesAgo);
dataRef.current = filtered;
// Calculate rates
if (filtered.length >= 2) {
setStats({
voltage: voltage.toFixed(2),
percentage: percentage.toFixed(1),
voltageRate: calculateRate(filtered, 'voltage', 5),
percentageRate: calculateRate(filtered, 'percentage', 5),
});
}
return filtered;
});
} catch (e) {
console.error('Error parsing battery state:', 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 minVoltage = Infinity;
let maxVoltage = -Infinity;
let minPercentage = Infinity;
let maxPercentage = -Infinity;
data.forEach((point) => {
minVoltage = Math.min(minVoltage, point.voltage);
maxVoltage = Math.max(maxVoltage, point.voltage);
minPercentage = Math.min(minPercentage, point.percentage);
maxPercentage = Math.max(maxPercentage, point.percentage);
});
// Add 5% padding to ranges
const voltageRange = maxVoltage - minVoltage || 1;
const percentageRange = maxPercentage - minPercentage || 1;
minVoltage -= voltageRange * 0.05;
maxVoltage += voltageRange * 0.05;
minPercentage -= percentageRange * 0.05;
maxPercentage += percentageRange * 0.05;
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();
// Voltage line (cyan)
ctx.strokeStyle = '#06b6d4';
ctx.lineWidth = 2;
ctx.globalAlpha = 0.8;
ctx.beginPath();
let firstPoint = true;
data.forEach((point) => {
const x = padding.left + ((point.timestamp - startTime) / timeRange) * chartWidth;
const y =
padding.top +
chartHeight -
((point.voltage - minVoltage) / (maxVoltage - minVoltage)) * chartHeight;
if (firstPoint) {
ctx.moveTo(x, y);
firstPoint = false;
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// Percentage line (amber)
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 2;
ctx.globalAlpha = 0.8;
ctx.beginPath();
firstPoint = true;
data.forEach((point) => {
const x = padding.left + ((point.timestamp - startTime) / timeRange) * chartWidth;
const y =
padding.top +
chartHeight -
((point.percentage - minPercentage) / (maxPercentage - minPercentage)) * chartHeight;
if (firstPoint) {
ctx.moveTo(x, y);
firstPoint = false;
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
ctx.globalAlpha = 1.0;
// Left axis labels (voltage)
ctx.fillStyle = '#06b6d4';
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'right';
for (let i = 0; i <= 5; i++) {
const value = minVoltage + (i * (maxVoltage - minVoltage)) / 5;
const y = padding.top + (5 - i) * (chartHeight / 5);
ctx.fillText(value.toFixed(0), padding.left - 10, y + 4);
}
// Right axis labels (percentage)
ctx.fillStyle = '#f59e0b';
ctx.textAlign = 'left';
for (let i = 0; i <= 5; i++) {
const value = minPercentage + (i * (maxPercentage - minPercentage)) / 5;
const y = padding.top + (5 - i) * (chartHeight / 5);
ctx.fillText(value.toFixed(0) + '%', width - padding.right + 10, y + 4);
}
// Time axis labels
ctx.fillStyle = '#9ca3af';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
for (let i = 0; i <= 4; i++) {
const minutesAgo = MAX_HISTORY_MINUTES - (i * MAX_HISTORY_MINUTES) / 4;
const label = formatTime(Math.floor(minutesAgo));
const x = padding.left + (i * chartWidth) / 4;
ctx.fillText(label, x, height - padding.bottom + 20);
}
// Legend
const legendY = 10;
ctx.font = 'bold 12px monospace';
ctx.fillStyle = '#06b6d4';
ctx.fillRect(width - 200, legendY, 10, 10);
ctx.fillStyle = '#06b6d4';
ctx.font = '11px monospace';
ctx.textAlign = 'left';
ctx.fillText('Voltage (mV)', width - 185, legendY + 10);
ctx.fillStyle = '#f59e0b';
ctx.fillRect(width - 200, legendY + 15, 10, 10);
ctx.fillStyle = '#f59e0b';
ctx.fillText('Percentage (%)', width - 185, legendY + 25);
}, []);
const isCharging = stats.voltageRate > 0.1;
const isDischaging = stats.voltageRate < -0.1;
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">
BATTERY HISTORY (30 MIN)
</div>
<div className="text-gray-600 text-xs">
{data.length} samples
</div>
</div>
{/* Current stats */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-gray-900 rounded border border-gray-800 p-2 space-y-1">
<div className="text-gray-700 text-xs font-bold">VOLTAGE</div>
<div className="flex items-end gap-1">
<span className="text-lg font-mono text-cyan-400">{stats.voltage}</span>
<span className="text-xs text-gray-600 mb-0.5">mV</span>
</div>
<div className={`text-xs font-mono ${
isCharging ? 'text-green-500' : isDischaging ? 'text-red-500' : 'text-gray-600'
}`}>
{isCharging ? '↑' : isDischaging ? '↓' : '→'} {Math.abs(stats.voltageRate).toFixed(2)} mV/min
</div>
</div>
<div className="bg-gray-900 rounded border border-gray-800 p-2 space-y-1">
<div className="text-gray-700 text-xs font-bold">PERCENTAGE</div>
<div className="flex items-end gap-1">
<span className="text-lg font-mono text-amber-400">{stats.percentage}</span>
<span className="text-xs text-gray-600 mb-0.5">%</span>
</div>
<div className={`text-xs font-mono ${
stats.percentageRate > 0.1 ? 'text-green-500' : stats.percentageRate < -0.1 ? 'text-red-500' : 'text-gray-600'
}`}>
{stats.percentageRate > 0.1 ? '↑' : stats.percentageRate < -0.1 ? '↓' : '→'} {Math.abs(stats.percentageRate).toFixed(3)} %/min
</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/battery_state</span>
</div>
<div className="flex justify-between">
<span>History:</span>
<span className="text-gray-500">{MAX_HISTORY_MINUTES} minutes rolling window</span>
</div>
<div className="flex justify-between">
<span>Rate Calculation:</span>
<span className="text-gray-500">Last 5 minutes average</span>
</div>
</div>
</div>
);
}