diff --git a/src/components/panels/MosaicDashboard.tsx b/src/components/panels/MosaicDashboard.tsx index 0bf130c..2571027 100644 --- a/src/components/panels/MosaicDashboard.tsx +++ b/src/components/panels/MosaicDashboard.tsx @@ -30,6 +30,7 @@ import MorseTransmissionPanel from './MorseTransmissionPanel'; import RtpStats from './RtpStats'; import TopicEchoPanel from './TopicEchoPanel'; import DriveThrottlePanel from './DriveThrottlePanel'; +import MotorCurrentGraphPanel from './MotorCurrentGraphPanel'; import { ROVER_IP } from '@/constants'; @@ -70,6 +71,7 @@ const TILE_REGISTRY = { headlightControlPanel: { title: 'Headlights', render: () => }, morseTransmissionPanel: { title: 'Morse Transmission', render: () => }, echoPanel: { title: 'Topic Echo', render: () => }, + motorCurrentGraphPanel: { title: 'Motor Current', render: () => }, } satisfies Record; type TileType = keyof typeof TILE_REGISTRY; diff --git a/src/components/panels/MotorCurrentGraphPanel.tsx b/src/components/panels/MotorCurrentGraphPanel.tsx new file mode 100644 index 0000000..d62386e --- /dev/null +++ b/src/components/panels/MotorCurrentGraphPanel.tsx @@ -0,0 +1,211 @@ +'use client'; + +import React, { useEffect, useRef, useState } from 'react'; +import ROSLIB from 'roslib'; +import html2canvas from 'html2canvas'; +import { useROS } from '@/ros/ROSContext'; +import { + ResponsiveContainer, + LineChart, + Line, + CartesianGrid, + XAxis, + YAxis, + Tooltip, + Legend, +} from 'recharts'; + +const WINDOW_SEC = 60; +const DEFAULT_MAX_AMPS = 2; + +const CURRENT_MOTORS = { + fl_d: { label: 'FL Drive', topic: '/Left_front_wheel_joint/status', color: '#4da3ff', dashed: false }, + fr_d: { label: 'FR Drive', topic: '/Right_front_wheel_joint/status', color: '#ff7a45', dashed: false }, + rl_d: { label: 'BL Drive', topic: '/Left_back_wheel_joint/status', color: '#4cd964', dashed: false }, + rr_d: { label: 'BR Drive', topic: '/Right_back_wheel_joint/status', color: '#c77dff', dashed: false }, + fl_s: { label: 'FL Steer', topic: '/Left_front_wheel_arm_joint/status', color: '#4da3ff', dashed: true }, + fr_s: { label: 'FR Steer', topic: '/Right_front_wheel_arm_joint/status', color: '#ff7a45', dashed: true }, + rl_s: { label: 'BL Steer', topic: '/Left_back_wheel_arm_joint/status', color: '#4cd964', dashed: true }, + rr_s: { label: 'BR Steer', topic: '/Right_back_wheel_arm_joint/status', color: '#c77dff', dashed: true }, +} as const; + +type MotorKey = keyof typeof CURRENT_MOTORS; +const KEYS = Object.keys(CURRENT_MOTORS) as MotorKey[]; + +type Point = { time: number; value: number }; // time = Date.now() when the message arrived (ms) +type CurrentHistory = Record; + +const emptyCurrentHistory = () => Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as CurrentHistory; + +const MotorCurrentGraphPanel: React.FC = () => { + const { ros } = useROS(); + const [currentHistory, setCurrentHistory] = useState(emptyCurrentHistory); + const containerRef = useRef(null); + + useEffect(() => { + if (!ros) return; + + const unsubscribers = KEYS.map((key) => { + const topic = new ROSLIB.Topic({ + ros, + name: CURRENT_MOTORS[key].topic, + messageType: 'ros_phoenix/msg/MotorStatus', + }); + + const handler = (msg: any) => { + const value = Number(msg.output_current); + if (!Number.isFinite(value)) return; + + const now = Date.now(); + const cutoff = now - WINDOW_SEC * 1000; + + setCurrentHistory((prev) => { + const next = {} as CurrentHistory; + KEYS.forEach((k) => { + next[k] = prev[k].filter((p) => p.time >= cutoff); + }); + next[key].push({ time: now, value }); + return next; + }); + }; + + topic.subscribe(handler); + return () => topic.unsubscribe(handler); + }); + + return () => unsubscribers.forEach((unsub) => unsub()); + }, [ros]); + + const downloadPNG = async () => { + if (!containerRef.current) return; + + const canvas = await html2canvas(containerRef.current, { + backgroundColor: '#181818', + }); + + const link = document.createElement('a'); + link.download = 'motor-currents.png'; + link.href = canvas.toDataURL('image/png'); + link.click(); + }; + + return ( +
+
+

Motor Currents

+
+ +
+
+
+ + + + `${Math.round((Date.now() - t) / 1000)}s ago`} + tick={{ fill: '#aaa', fontSize: 10 }} + axisLine={{ stroke: '#444' }} + tickLine={{ stroke: '#444' }} + minTickGap={30} + /> + Number.isFinite(dataMax) ? Math.max(DEFAULT_MAX_AMPS, Math.ceil(dataMax)) : DEFAULT_MAX_AMPS, + ]} + unit="A" + tickFormatter={(c) => Number(c).toFixed(2)} + tick={{ fill: '#aaa', fontSize: 10 }} + axisLine={{ stroke: '#444' }} + tickLine={{ stroke: '#444' }} + width={55} + /> + [`${Number(value).toFixed(2)} A`, name]} + labelFormatter={(value) => `${((Date.now() - Number(value)) / 1000).toFixed(1)}s ago`} + contentStyle={{ + background: '#222', + border: '1px solid #444', + borderRadius: '8px', + color: '#fff', + }} + /> + + {KEYS.map((key) => ( + + ))} + + +
+ + +
+ ); +}; + +export default MotorCurrentGraphPanel;