/** * FleetPanel.jsx — Multi-robot fleet management dashboard. * * Sub-views (internal tab nav): * Robots | Map | Missions | Video | Alerts | Detail * * Uses useFleet() for multi-robot WebSocket multiplexing. * Robot detail re-uses single-robot panel components via subscribeRobot adapter. * * Video streaming: * http://:/stream?topic=&type=ros_compressed * (web_video_server / mjpeg_server) */ import { useState, useRef, useCallback, useEffect } from 'react'; import { useFleet } from '../hooks/useFleet.js'; // Reusable single-robot detail panels (re-used via subscribeRobot adapter) import { ImuPanel } from './ImuPanel.jsx'; import { BatteryPanel } from './BatteryPanel.jsx'; import { MotorPanel } from './MotorPanel.jsx'; import { ControlMode } from './ControlMode.jsx'; import { SystemHealth } from './SystemHealth.jsx'; // ── Constants ─────────────────────────────────────────────────────────────── const VARIANT_COLORS = { balance: 'text-cyan-400 border-cyan-800', rover: 'text-green-400 border-green-800', tank: 'text-amber-400 border-amber-800', social: 'text-purple-400 border-purple-800', }; const FLEET_VIEWS = [ { id: 'robots', label: 'Robots' }, { id: 'map', label: 'Fleet Map'}, { id: 'missions', label: 'Missions' }, { id: 'video', label: 'Video' }, { id: 'alerts', label: 'Alerts' }, ]; // ── Add-Robot modal ────────────────────────────────────────────────────────── function AddRobotModal({ onAdd, onClose }) { const [form, setForm] = useState({ name: '', variant: 'balance', host: '', port: '9090', videoPort: '8080', videoTopic: '/camera/panoramic/compressed', }); const set = (k, v) => setForm(f => ({ ...f, [k]: v })); const handleSubmit = (e) => { e.preventDefault(); if (!form.host.trim()) return; onAdd({ ...form, port: Number(form.port), videoPort: Number(form.videoPort), }); onClose(); }; return (
ADD ROBOT
); } // ── Sub-view: Robots List ──────────────────────────────────────────────────── function SocBar({ soc }) { const color = soc < 20 ? '#ef4444' : soc < 40 ? '#f59e0b' : '#22c55e'; return (
{soc}%
); } function RobotCard({ robot, connection, data, onSelect, onRemove }) { const conn = connection ?? { connected: false, error: null }; const d = data ?? {}; const varColor = VARIANT_COLORS[robot.variant] ?? 'text-gray-400 border-gray-700'; return (
onSelect(robot.id)} > {/* Header row */}
{robot.name || robot.id} {robot.variant}
{/* Address */}
{robot.host}:{robot.port}
{/* Telemetry row */} {conn.connected ? (
Battery
State {d.state ?? '—'}
Pipeline {d.pipeline ?? '—'}
{d.pose && (
pos {d.pose.x.toFixed(1)},{d.pose.y.toFixed(1)} {' '}hdg {(d.pose.yaw * 180 / Math.PI).toFixed(0)}°
)}
) : (
{conn.error ? ⚠ {conn.error} : 'Connecting…'}
)} {d.alerts?.length > 0 && (
{d.alerts.slice(0, 3).map((a, i) => ( = 2 ? 'bg-red-950 border-red-800 text-red-400' : 'bg-amber-950 border-amber-800 text-amber-400' }`}>{a.name} ))}
)}
); } function RobotsView({ robots, connections, robotData, onSelect, onRemove, onAdd, onScan, scanning }) { return (
FLEET — {robots.length} ROBOT{robots.length !== 1 ? 'S' : ''}
{robots.length === 0 ? (
No robots configured. Add one or scan the LAN.
) : (
{robots.map(r => ( ))}
)}
); } // ── Sub-view: Fleet Map ────────────────────────────────────────────────────── const ROBOT_COLORS = ['#06b6d4','#f97316','#22c55e','#a855f7','#f59e0b','#ec4899','#84cc16','#38bdf8']; function FleetMapView({ robots, robotData, onSelect }) { const canvasRef = useRef(null); const [zoom, setZoom] = useState(30); // px per metre const [pan, setPan] = useState({ x: 0, y: 0 }); const dragging = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const W = canvas.width, H = canvas.height; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#050510'; ctx.fillRect(0, 0, W, H); // Grid ctx.strokeStyle = '#0d1b2a'; ctx.lineWidth = 1; const gridSpacing = zoom; const offX = (W / 2 + pan.x) % gridSpacing; const offY = (H / 2 + pan.y) % gridSpacing; for (let x = offX; x < W; x += gridSpacing) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); } for (let y = offY; y < H; y += gridSpacing) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); } // Origin cross const cx = W / 2 + pan.x; const cy = H / 2 + pan.y; ctx.strokeStyle = '#1e3a4a'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(cx - 10, cy); ctx.lineTo(cx + 10, cy); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx, cy - 10); ctx.lineTo(cx, cy + 10); ctx.stroke(); // Draw each robot that has pose data robots.forEach((robot, i) => { const d = robotData[robot.id]; if (!d?.pose) return; const color = ROBOT_COLORS[i % ROBOT_COLORS.length]; const rx = cx + d.pose.x * zoom; const ry = cy - d.pose.y * zoom; const arrowLen = Math.max(14, zoom * 0.5); // Heading line ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(rx, ry); ctx.lineTo(rx + Math.cos(d.pose.yaw) * arrowLen, ry - Math.sin(d.pose.yaw) * arrowLen); ctx.stroke(); // Body circle ctx.fillStyle = color; ctx.shadowBlur = 10; ctx.shadowColor = color; ctx.beginPath(); ctx.arc(rx, ry, 7, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; // Label ctx.fillStyle = color; ctx.font = '9px monospace'; ctx.textAlign = 'center'; ctx.fillText(robot.name || robot.id, rx, ry - 12); }); }, [robots, robotData, zoom, pan]); const onMouseDown = e => { dragging.current = { x: e.clientX - pan.x, y: e.clientY - pan.y }; }; const onMouseMove = e => { if (!dragging.current) return; setPan({ x: e.clientX - dragging.current.x, y: e.clientY - dragging.current.y }); }; const onMouseUp = () => { dragging.current = null; }; const botsWithPose = robots.filter(r => robotData[r.id]?.pose); return (
FLEET MAP
{zoom.toFixed(0)}px/m
{/* Legend */}
{robots.map((r, i) => ( ))}
); } // ── Sub-view: Mission Dispatch ─────────────────────────────────────────────── function MissionsView({ robots, connections, sendGoal, sendPatrol }) { const [targetId, setTargetId] = useState(''); const [mode, setMode] = useState('goal'); // 'goal' | 'patrol' const [goalX, setGoalX] = useState(''); const [goalY, setGoalY] = useState(''); const [goalYaw, setGoalYaw] = useState('0'); const [waypoints, setWaypoints] = useState(''); const [sent, setSent] = useState(null); const connected = robots.filter(r => connections[r.id]?.connected); const handleSend = () => { if (!targetId) return; if (mode === 'goal') { sendGoal(targetId, parseFloat(goalX), parseFloat(goalY), parseFloat(goalYaw) * Math.PI / 180); setSent(`Goal → (${goalX}, ${goalY}) sent to ${targetId}`); } else { try { const pts = waypoints.trim().split('\n').map(line => { const [x, y, yaw = 0] = line.split(',').map(Number); return { x, y, yaw: yaw * Math.PI / 180 }; }); sendPatrol(targetId, pts); setSent(`Patrol (${pts.length} wpts) sent to ${targetId}`); } catch { setSent('Error parsing waypoints'); } } setTimeout(() => setSent(null), 4000); }; return (
MISSION DISPATCH
{connected.length === 0 && (
No robots connected. Connect at least one robot to dispatch missions.
)}
{['goal', 'patrol'].map(m => ( ))}
{mode === 'goal' ? (
{[['X (m)', goalX, setGoalX], ['Y (m)', goalY, setGoalY], ['Yaw (°)', goalYaw, setGoalYaw]].map(([lbl, val, setter]) => ( ))}
) : (