Compare commits
No commits in common. "d3806094eec400095180bb4237d087e24f130c0c" and "d1f0e95fa20a5ea91aa8069b55661e35b2bc5f91" have entirely different histories.
d3806094ee
...
d1f0e95fa2
@ -32,7 +32,6 @@ 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';
|
||||||
@ -82,7 +81,6 @@ 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', },
|
||||||
@ -247,7 +245,6 @@ 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' && (
|
||||||
|
|||||||
@ -1,365 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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