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 +
+
+
+ ); +}