feat(webui): teleop web interface with live camera stream (Issue #534)

Adds TeleopWebUI component — a dedicated browser-based remote control
panel combining live video and joystick teleoperation in one view:

- Live camera stream (front/rear/left/right) via rosbridge CompressedImage
- Virtual joystick (canvas-based, touch + mouse, 10% deadzone)
- WASD / arrow-key keyboard fallback, Space for quick stop
- Speed presets: SLOW (20%), NORMAL (50%), FAST (100%)
- Latching E-stop button with pulsing visual indicator
- Real-time linear/angular velocity display
- Mobile-responsive: stacks vertically on small screens, side-by-side on lg+
- Added TELEOP tab group → Drive tab in App.jsx

Topics: /camera/<name>/image_raw/compressed (subscribe)
        /cmd_vel geometry_msgs/Twist (publish)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sl-webui 2026-03-07 09:51:14 -05:00
parent e67783f313
commit 916ad36ad5
2 changed files with 493 additions and 1 deletions

View File

@ -91,7 +91,17 @@ import { SaltyFace } from './components/SaltyFace.jsx';
// Parameter server (issue #471) // Parameter server (issue #471)
import { ParameterServer } from './components/ParameterServer.jsx'; import { ParameterServer } from './components/ParameterServer.jsx';
// Teleop web interface (issue #534)
import { TeleopWebUI } from './components/TeleopWebUI.jsx';
const TAB_GROUPS = [ const TAB_GROUPS = [
{
label: 'TELEOP',
color: 'text-orange-600',
tabs: [
{ id: 'teleop-webui', label: 'Drive' },
],
},
{ {
label: 'DISPLAY', label: 'DISPLAY',
color: 'text-rose-600', color: 'text-rose-600',
@ -286,8 +296,10 @@ export default function App() {
{/* ── Content ── */} {/* ── Content ── */}
<main className={`flex-1 ${ <main className={`flex-1 ${
activeTab === 'salty-face' ? '' : activeTab === 'salty-face' ? '' :
['eventlog', 'control', 'imu'].includes(activeTab) ? 'flex flex-col' : 'overflow-y-auto' ['eventlog', 'control', 'imu', 'teleop-webui'].includes(activeTab) ? 'flex flex-col' : 'overflow-y-auto'
} ${activeTab === 'salty-face' ? '' : 'p-4'}`}> } ${activeTab === 'salty-face' ? '' : 'p-4'}`}>
{activeTab === 'teleop-webui' && <TeleopWebUI subscribe={subscribe} publish={publishFn} />}
{activeTab === 'salty-face' && <SaltyFace subscribe={subscribe} />} {activeTab === 'salty-face' && <SaltyFace subscribe={subscribe} />}
{activeTab === 'status' && <StatusPanel subscribe={subscribe} />} {activeTab === 'status' && <StatusPanel subscribe={subscribe} />}

View File

@ -0,0 +1,480 @@
/**
* TeleopWebUI.jsx Browser-based remote control with live video (Issue #534).
*
* Features:
* - Live camera stream via rosbridge (sensor_msgs/CompressedImage)
* - Camera selector: front / rear / left / right
* - Virtual joystick (touch + mouse) for mobile-friendly control
* - WASD / arrow-key keyboard fallback
* - Speed presets: SLOW (20%), NORMAL (50%), FAST (100%)
* - E-stop button (latching, prominent)
* - Real-time linear/angular velocity display
* - Mobile-responsive split layout
*
* Topics:
* /camera/<name>/image_raw/compressed sensor_msgs/CompressedImage (subscribe)
* /cmd_vel geometry_msgs/Twist (publish)
*/
import { useEffect, useRef, useState, useCallback } from 'react';
// Constants
const MAX_LINEAR = 0.5; // m/s
const MAX_ANGULAR = 1.0; // rad/s
const DEADZONE = 0.10; // 10 %
const CMD_HZ = 20; // publish rate (ms)
const SPEED_PRESETS = [
{ label: 'SLOW', value: 0.20, color: 'text-green-400', border: 'border-green-800', bg: 'bg-green-950' },
{ label: 'NORMAL', value: 0.50, color: 'text-amber-400', border: 'border-amber-800', bg: 'bg-amber-950' },
{ label: 'FAST', value: 1.00, color: 'text-red-400', border: 'border-red-800', bg: 'bg-red-950' },
];
const CAMERAS = [
{ id: 'front', label: 'Front', topic: '/camera/front/image_raw/compressed' },
{ id: 'rear', label: 'Rear', topic: '/camera/rear/image_raw/compressed' },
{ id: 'left', label: 'Left', topic: '/camera/left/image_raw/compressed' },
{ id: 'right', label: 'Right', topic: '/camera/right/image_raw/compressed' },
];
// Utilities
function applyDeadzone(value) {
const abs = Math.abs(value);
if (abs < DEADZONE) return 0;
return Math.sign(value) * ((abs - DEADZONE) / (1 - DEADZONE));
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
// Virtual joystick
function VirtualJoystick({ onMove }) {
const canvasRef = useRef(null);
const activeRef = useRef(false);
const stickRef = useRef({ x: 0, y: 0 });
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
const cx = W / 2;
const cy = H / 2;
const R = Math.min(W, H) * 0.38;
const kr = Math.min(W, H) * 0.14;
ctx.clearRect(0, 0, W, H);
// Base plate
ctx.fillStyle = 'rgba(15,23,42,0.85)';
ctx.beginPath();
ctx.arc(cx, cy, R + kr, 0, Math.PI * 2);
ctx.fill();
// Base ring
ctx.strokeStyle = '#1e3a5f';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.stroke();
// Deadzone ring
ctx.strokeStyle = '#374151';
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.arc(cx, cy, R * DEADZONE, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
// Crosshair
ctx.strokeStyle = '#1e3a5f';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx - R * 0.3, cy); ctx.lineTo(cx + R * 0.3, cy);
ctx.moveTo(cx, cy - R * 0.3); ctx.lineTo(cx, cy + R * 0.3);
ctx.stroke();
// Knob
const kx = cx + stickRef.current.x * R;
const ky = cy - stickRef.current.y * R;
const active = activeRef.current;
const grad = ctx.createRadialGradient(kx - kr * 0.3, ky - kr * 0.3, 0, kx, ky, kr);
grad.addColorStop(0, active ? '#38bdf8' : '#1d4ed8');
grad.addColorStop(1, active ? '#0369a1' : '#1e3a8a');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(kx, ky, kr, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = active ? '#7dd3fc' : '#3b82f6';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(kx, ky, kr, 0, Math.PI * 2);
ctx.stroke();
// Vector line
const { x, y } = stickRef.current;
if (Math.abs(x) > 0.02 || Math.abs(y) > 0.02) {
ctx.strokeStyle = 'rgba(56,189,248,0.5)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(kx, ky);
ctx.stroke();
}
}, []);
const getPos = (clientX, clientY) => {
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const R = Math.min(rect.width, rect.height) * 0.38;
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
let x = (clientX - cx) / R;
let y = (clientY - cy) / R;
const mag = Math.sqrt(x * x + y * y);
if (mag > 1) { x /= mag; y /= mag; }
return { x: clamp(x, -1, 1), y: clamp(-y, -1, 1) };
};
const onStart = useCallback((clientX, clientY) => {
activeRef.current = true;
const { x, y } = getPos(clientX, clientY);
stickRef.current = { x, y };
onMove({ x: applyDeadzone(x), y: applyDeadzone(y) });
draw();
}, [draw, onMove]);
const onContinue = useCallback((clientX, clientY) => {
if (!activeRef.current) return;
const { x, y } = getPos(clientX, clientY);
stickRef.current = { x, y };
onMove({ x: applyDeadzone(x), y: applyDeadzone(y) });
draw();
}, [draw, onMove]);
const onEnd = useCallback(() => {
activeRef.current = false;
stickRef.current = { x: 0, y: 0 };
onMove({ x: 0, y: 0 });
draw();
}, [draw, onMove]);
// Mount: draw once + attach pointer events
useEffect(() => {
draw();
const canvas = canvasRef.current;
if (!canvas) return;
const onPD = (e) => { e.preventDefault(); onStart(e.clientX, e.clientY); };
const onPM = (e) => { e.preventDefault(); onContinue(e.clientX, e.clientY); };
const onPU = (e) => { e.preventDefault(); onEnd(); };
const onTD = (e) => { e.preventDefault(); const t = e.touches[0]; onStart(t.clientX, t.clientY); };
const onTM = (e) => { e.preventDefault(); const t = e.touches[0]; onContinue(t.clientX, t.clientY); };
const onTE = (e) => { e.preventDefault(); onEnd(); };
canvas.addEventListener('pointerdown', onPD);
window.addEventListener('pointermove', onPM);
window.addEventListener('pointerup', onPU);
canvas.addEventListener('touchstart', onTD, { passive: false });
canvas.addEventListener('touchmove', onTM, { passive: false });
canvas.addEventListener('touchend', onTE, { passive: false });
return () => {
canvas.removeEventListener('pointerdown', onPD);
window.removeEventListener('pointermove', onPM);
window.removeEventListener('pointerup', onPU);
canvas.removeEventListener('touchstart', onTD);
canvas.removeEventListener('touchmove', onTM);
canvas.removeEventListener('touchend', onTE);
};
}, [onStart, onContinue, onEnd, draw]);
return (
<canvas
ref={canvasRef}
width={200}
height={200}
className="rounded-full cursor-grab active:cursor-grabbing select-none"
style={{ touchAction: 'none' }}
/>
);
}
// Camera feed
function CameraFeed({ subscribe, topic }) {
const [src, setSrc] = useState(null);
const [fps, setFps] = useState(0);
const frameCountRef = useRef(0);
const lastFpsTimeRef = useRef(Date.now());
useEffect(() => {
setSrc(null);
frameCountRef.current = 0;
lastFpsTimeRef.current = Date.now();
const unsub = subscribe(topic, 'sensor_msgs/CompressedImage', (msg) => {
const fmt = msg.format?.includes('png') ? 'png' : 'jpeg';
const dataUrl = `data:image/${fmt};base64,${msg.data}`;
setSrc(dataUrl);
frameCountRef.current++;
const now = Date.now();
const dt = now - lastFpsTimeRef.current;
if (dt >= 1000) {
setFps(Math.round((frameCountRef.current * 1000) / dt));
frameCountRef.current = 0;
lastFpsTimeRef.current = now;
}
});
return unsub;
}, [subscribe, topic]);
return (
<div className="relative w-full h-full flex items-center justify-center bg-black rounded-lg overflow-hidden">
{src ? (
<img
src={src}
alt="camera"
className="max-w-full max-h-full object-contain"
draggable={false}
/>
) : (
<div className="flex flex-col items-center gap-2 text-gray-700">
<div className="text-3xl">📷</div>
<div className="text-xs tracking-widest">NO SIGNAL</div>
</div>
)}
{/* FPS badge */}
{src && (
<div className="absolute top-2 right-2 bg-black bg-opacity-60 text-green-400 text-xs font-mono px-1.5 py-0.5 rounded">
{fps} fps
</div>
)}
</div>
);
}
// Main component
export function TeleopWebUI({ subscribe, publish }) {
const [selectedCam, setSelectedCam] = useState('front');
const [speedPreset, setSpeedPreset] = useState(0.50);
const [isEstopped, setIsEstopped] = useState(false);
const [linearVel, setLinearVel] = useState(0);
const [angularVel, setAngularVel] = useState(0);
// Joystick normalized input ref [-1,1] each axis
const joystickRef = useRef({ x: 0, y: 0 });
// Keyboard pressed keys
const keysRef = useRef({});
const currentCam = CAMERAS.find(c => c.id === selectedCam);
// Keyboard input
useEffect(() => {
const down = (e) => {
const k = e.key.toLowerCase();
if (['w','a','s','d','arrowup','arrowdown','arrowleft','arrowright',' '].includes(k)) {
keysRef.current[k] = true;
e.preventDefault();
}
};
const up = (e) => {
const k = e.key.toLowerCase();
keysRef.current[k] = false;
};
window.addEventListener('keydown', down);
window.addEventListener('keyup', up);
return () => {
window.removeEventListener('keydown', down);
window.removeEventListener('keyup', up);
};
}, []);
// Publish loop
useEffect(() => {
const timer = setInterval(() => {
const k = keysRef.current;
const j = joystickRef.current;
// Keyboard overrides joystick if any key pressed
let lin = j.y;
let ang = -j.x; // right stick-x negative angular (turn left on left)
const anyKey = k['w'] || k['s'] || k['a'] || k['d'] ||
k['arrowup'] || k['arrowdown'] || k['arrowleft'] || k['arrowright'];
if (anyKey) {
lin = 0;
ang = 0;
if (k['w'] || k['arrowup']) lin = 1;
if (k['s'] || k['arrowdown']) lin = -1;
if (k['a'] || k['arrowleft']) ang = 1;
if (k['d'] || k['arrowright']) ang = -1;
}
// Space = quick stop (doesn't latch e-stop)
if (k[' ']) { lin = 0; ang = 0; }
const factor = isEstopped ? 0 : speedPreset;
const finalLin = clamp(lin * MAX_LINEAR * factor, -MAX_LINEAR, MAX_LINEAR);
const finalAng = clamp(ang * MAX_ANGULAR * factor, -MAX_ANGULAR, MAX_ANGULAR);
setLinearVel(finalLin);
setAngularVel(finalAng);
if (publish) {
publish('/cmd_vel', 'geometry_msgs/Twist', {
linear: { x: finalLin, y: 0, z: 0 },
angular: { x: 0, y: 0, z: finalAng },
});
}
}, CMD_HZ);
return () => clearInterval(timer);
}, [isEstopped, speedPreset, publish]);
// E-stop
const handleEstop = () => {
const next = !isEstopped;
setIsEstopped(next);
if (publish) {
publish('/cmd_vel', 'geometry_msgs/Twist', {
linear: { x: 0, y: 0, z: 0 },
angular: { x: 0, y: 0, z: 0 },
});
}
};
// Joystick callback
const handleJoystick = useCallback((pos) => {
joystickRef.current = pos;
}, []);
// Render
return (
<div className="flex flex-col lg:flex-row h-full gap-3">
{/* ── Left: Camera feed ── */}
<div className="flex flex-col gap-2 flex-1 min-h-0">
{/* Camera selector */}
<div className="flex gap-1 shrink-0">
{CAMERAS.map((cam) => (
<button
key={cam.id}
onClick={() => setSelectedCam(cam.id)}
className={`px-3 py-1 text-xs font-bold rounded border tracking-widest transition-colors ${
selectedCam === cam.id
? 'bg-cyan-950 border-cyan-700 text-cyan-300'
: 'bg-gray-950 border-gray-800 text-gray-600 hover:text-gray-300'
}`}
>
{cam.label.toUpperCase()}
</button>
))}
<div className="flex-1" />
<div className="text-xs text-gray-700 font-mono self-center">
{currentCam?.topic}
</div>
</div>
{/* Video feed */}
<div className="flex-1 min-h-[200px] lg:min-h-0">
<CameraFeed
subscribe={subscribe}
topic={currentCam?.topic}
/>
</div>
</div>
{/* ── Right: Controls ── */}
<div className="flex flex-col gap-3 w-full lg:w-72 shrink-0">
{/* E-stop */}
<button
onClick={handleEstop}
className={`w-full py-4 text-sm font-black tracking-widest rounded-lg border-2 transition-all ${
isEstopped
? 'bg-red-900 border-red-500 text-red-200 animate-pulse'
: 'bg-red-950 border-red-800 text-red-400 hover:bg-red-900 hover:border-red-600'
}`}
>
{isEstopped ? '🛑 E-STOP — TAP TO RESUME' : '⚡ E-STOP'}
</button>
{/* Speed presets */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 space-y-2">
<div className="text-xs font-bold tracking-widest text-cyan-700">SPEED PRESET</div>
<div className="grid grid-cols-3 gap-2">
{SPEED_PRESETS.map((p) => (
<button
key={p.label}
onClick={() => setSpeedPreset(p.value)}
disabled={isEstopped}
className={`py-2 text-xs font-bold rounded border tracking-widest transition-colors ${
speedPreset === p.value && !isEstopped
? `${p.bg} ${p.border} ${p.color}`
: 'bg-gray-900 border-gray-800 text-gray-600 hover:text-gray-300 disabled:opacity-40 disabled:cursor-not-allowed'
}`}
>
{p.label}
<div className="text-gray-500 font-normal mt-0.5">{(p.value * 100).toFixed(0)}%</div>
</button>
))}
</div>
</div>
{/* Velocity display */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 grid grid-cols-2 gap-2">
<div>
<div className="text-gray-600 text-xs mb-1">LINEAR</div>
<div className={`text-xl font-mono ${isEstopped ? 'text-gray-700' : 'text-cyan-300'}`}>
{linearVel.toFixed(2)}
</div>
<div className="text-gray-700 text-xs">m/s</div>
</div>
<div>
<div className="text-gray-600 text-xs mb-1">ANGULAR</div>
<div className={`text-xl font-mono ${isEstopped ? 'text-gray-700' : 'text-amber-300'}`}>
{angularVel.toFixed(2)}
</div>
<div className="text-gray-700 text-xs">rad/s</div>
</div>
</div>
{/* Joystick */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 flex flex-col items-center gap-2">
<div className="text-xs font-bold tracking-widest text-cyan-700">JOYSTICK</div>
<VirtualJoystick onMove={handleJoystick} />
<div className="text-xs text-gray-700 text-center">
drag or use W A S D / arrow keys
</div>
</div>
{/* Key reference */}
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-700 space-y-1">
<div className="font-bold text-gray-600 mb-1">KEYBOARD</div>
<div className="grid grid-cols-2 gap-x-3">
<div><span className="text-gray-500">W/</span> Forward</div>
<div><span className="text-gray-500">S/</span> Backward</div>
<div><span className="text-gray-500">A/</span> Turn Left</div>
<div><span className="text-gray-500">D/</span> Turn Right</div>
<div className="col-span-2"><span className="text-gray-500">Space</span> Quick stop</div>
</div>
</div>
</div>
</div>
);
}