feat(webui): battery voltage and percentage history chart (Issue #280)
Real-time battery history visualization with: - Subscribes to /saltybot/battery_state for continuous battery data - Rolling 30-minute history window with automatic data culling - Dual-axis line chart: voltage (left, cyan) and percentage (right, amber) - Canvas-based rendering for performance - Charge/discharge rate calculation (last 5-minute average): * Voltage rate in mV/min with up/down/stable indicator * Percentage rate in %/min with up/down/stable indicator - Grid overlay, axis labels, time labels, and legend - Current stats display: voltage, percentage, rates - Responsive canvas sizing Integrated into TELEMETRY tab group as 'Battery History' tab. Follows established canvas rendering and data subscription patterns. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
54bc37926b
commit
fc26226368
@ -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' && (
|
||||
|
||||
365
ui/social-bot/src/components/BatteryChart.jsx
Normal file
365
ui/social-bot/src/components/BatteryChart.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user