diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx
index cc16def..8b2ff7a 100644
--- a/ui/social-bot/src/App.jsx
+++ b/ui/social-bot/src/App.jsx
@@ -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' && }
{activeTab === 'battery' && }
+ {activeTab === 'battery-chart' && }
{activeTab === 'motors' && }
{activeTab === 'map' && }
{activeTab === 'control' && (
diff --git a/ui/social-bot/src/components/BatteryChart.jsx b/ui/social-bot/src/components/BatteryChart.jsx
new file mode 100644
index 0000000..45d51ce
--- /dev/null
+++ b/ui/social-bot/src/components/BatteryChart.jsx
@@ -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 (
+
+ {/* Controls */}
+
+
+
+ BATTERY HISTORY (30 MIN)
+
+
+ {data.length} samples
+
+
+
+ {/* Current stats */}
+
+
+
VOLTAGE
+
+ {stats.voltage}
+ mV
+
+
+ {isCharging ? '↑' : isDischaging ? '↓' : '→'} {Math.abs(stats.voltageRate).toFixed(2)} mV/min
+
+
+
+
+
PERCENTAGE
+
+ {stats.percentage}
+ %
+
+
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
+
+
+
+
+
+ {/* Chart canvas */}
+
+
+
+
+ {/* Info panel */}
+
+
+ Topic:
+ /saltybot/battery_state
+
+
+ History:
+ {MAX_HISTORY_MINUTES} minutes rolling window
+
+
+ Rate Calculation:
+ Last 5 minutes average
+
+
+
+ );
+}