/** * MotorCurrentGraph.jsx — Live motor current visualization * * Features: * - Subscribes to /saltybot/motor_currents for real-time motor current data * - Maintains rolling 60-second history of readings * - Dual-axis line chart: left motor (cyan) and right motor (amber) * - Canvas-based rendering for performance * - Real-time peak current tracking * - Average current calculation * - Thermal warning threshold line (configurable) * - Current stats and alerts */ import { useEffect, useRef, useState } from 'react'; const MAX_HISTORY_SECONDS = 60; const SAMPLE_RATE = 10; // Hz const MAX_DATA_POINTS = MAX_HISTORY_SECONDS * SAMPLE_RATE; const THERMAL_WARNING_THRESHOLD = 25; // Amps function calculateStats(data, field) { if (data.length === 0) return { current: 0, peak: 0, average: 0 }; const values = data.map((d) => d[field]); const current = values[values.length - 1]; const peak = Math.max(...values); const average = values.reduce((a, b) => a + b, 0) / values.length; return { current, peak, average }; } export function MotorCurrentGraph({ subscribe }) { const canvasRef = useRef(null); const [data, setData] = useState([]); const dataRef = useRef([]); const [stats, setStats] = useState({ left: { current: 0, peak: 0, average: 0 }, right: { current: 0, peak: 0, average: 0 }, }); const [alerts, setAlerts] = useState({ leftThermal: false, rightThermal: false, }); // Subscribe to motor currents useEffect(() => { const unsubscribe = subscribe( '/saltybot/motor_currents', 'std_msgs/Float32MultiArray', (msg) => { try { let leftAmps = 0; let rightAmps = 0; if (msg.data && msg.data.length >= 2) { leftAmps = Math.abs(msg.data[0]); rightAmps = Math.abs(msg.data[1]); } const timestamp = Date.now(); const newPoint = { timestamp, leftAmps, rightAmps }; setData((prev) => { const updated = [...prev, newPoint]; // Keep only last 60 seconds of data const sixtySecondsAgo = timestamp - MAX_HISTORY_SECONDS * 1000; const filtered = updated.filter((p) => p.timestamp >= sixtySecondsAgo); dataRef.current = filtered; // Calculate stats if (filtered.length > 0) { const leftStats = calculateStats(filtered, 'leftAmps'); const rightStats = calculateStats(filtered, 'rightAmps'); setStats({ left: leftStats, right: rightStats, }); // Check thermal warnings setAlerts({ leftThermal: leftStats.current > THERMAL_WARNING_THRESHOLD, rightThermal: rightStats.current > THERMAL_WARNING_THRESHOLD, }); } return filtered; }); } catch (e) { console.error('Error parsing motor currents:', 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 maxCurrent = 0; data.forEach((point) => { maxCurrent = Math.max(maxCurrent, point.leftAmps, point.rightAmps); }); maxCurrent = maxCurrent * 1.1; const minCurrent = 0; 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(); // Draw thermal warning threshold line const thresholdY = padding.top + chartHeight - ((THERMAL_WARNING_THRESHOLD - minCurrent) / (maxCurrent - minCurrent)) * chartHeight; ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 4]); ctx.globalAlpha = 0.6; ctx.beginPath(); ctx.moveTo(padding.left, thresholdY); ctx.lineTo(width - padding.right, thresholdY); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1.0; // Left motor line (cyan) ctx.strokeStyle = '#06b6d4'; ctx.lineWidth = 2.5; ctx.globalAlpha = 0.85; ctx.beginPath(); let firstPoint = true; data.forEach((point) => { const x = padding.left + ((point.timestamp - startTime) / timeRange) * chartWidth; const y = padding.top + chartHeight - ((point.leftAmps - minCurrent) / (maxCurrent - minCurrent)) * chartHeight; if (firstPoint) { ctx.moveTo(x, y); firstPoint = false; } else { ctx.lineTo(x, y); } }); ctx.stroke(); // Right motor line (amber) ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 2.5; ctx.globalAlpha = 0.85; ctx.beginPath(); firstPoint = true; data.forEach((point) => { const x = padding.left + ((point.timestamp - startTime) / timeRange) * chartWidth; const y = padding.top + chartHeight - ((point.rightAmps - minCurrent) / (maxCurrent - minCurrent)) * chartHeight; if (firstPoint) { ctx.moveTo(x, y); firstPoint = false; } else { ctx.lineTo(x, y); } }); ctx.stroke(); ctx.globalAlpha = 1.0; // Y-axis labels (current in amps) ctx.fillStyle = '#9ca3af'; ctx.font = 'bold 11px monospace'; ctx.textAlign = 'right'; for (let i = 0; i <= 5; i++) { const value = minCurrent + (i * (maxCurrent - minCurrent)) / 5; const y = padding.top + (5 - i) * (chartHeight / 5); ctx.fillText(value.toFixed(1), padding.left - 10, y + 4); } // X-axis time labels ctx.fillStyle = '#9ca3af'; ctx.font = '10px monospace'; ctx.textAlign = 'center'; for (let i = 0; i <= 4; i++) { const secondsAgo = MAX_HISTORY_SECONDS - (i * MAX_HISTORY_SECONDS) / 4; const label = secondsAgo === 0 ? 'now' : `${Math.floor(secondsAgo)}s ago`; const x = padding.left + (i * chartWidth) / 4; ctx.fillText(label, x, height - padding.bottom + 20); } // Legend const legendY = 10; ctx.fillStyle = '#06b6d4'; ctx.fillRect(width - 200, legendY, 10, 10); ctx.fillStyle = '#06b6d4'; ctx.font = '11px monospace'; ctx.textAlign = 'left'; ctx.fillText('Left Motor', width - 185, legendY + 10); ctx.fillStyle = '#f59e0b'; ctx.fillRect(width - 200, legendY + 15, 10, 10); ctx.fillStyle = '#f59e0b'; ctx.fillText('Right Motor', width - 185, legendY + 25); ctx.fillStyle = '#ef4444'; ctx.setLineDash([4, 4]); ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(width - 200, legendY + 37); ctx.lineTo(width - 190, legendY + 37); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = '#ef4444'; ctx.font = '11px monospace'; ctx.fillText('Thermal Limit', width - 185, legendY + 42); }, []); const leftThermalStatus = alerts.leftThermal ? 'THERMAL WARNING' : 'Normal'; const rightThermalStatus = alerts.rightThermal ? 'THERMAL WARNING' : 'Normal'; return (
{/* Controls */}
MOTOR CURRENT (60 SEC)
{data.length} samples
{/* Current stats */}
{/* Left motor */}
LEFT MOTOR
{stats.left.current.toFixed(2)} A
Peak: {stats.left.peak.toFixed(2)}A
Avg: {stats.left.average.toFixed(2)}A
{alerts.leftThermal && (
⚠ {leftThermalStatus}
)}
{/* Right motor */}
RIGHT MOTOR
{stats.right.current.toFixed(2)} A
Peak: {stats.right.peak.toFixed(2)}A
Avg: {stats.right.average.toFixed(2)}A
{alerts.rightThermal && (
⚠ {rightThermalStatus}
)}
{/* Chart canvas */}
{/* Info panel */}
Topic: /saltybot/motor_currents
History: {MAX_HISTORY_SECONDS} seconds rolling window
Thermal Threshold: {THERMAL_WARNING_THRESHOLD}A (warning only)
); }