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