diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx index 358955a..9b8596c 100644 --- a/ui/social-bot/src/App.jsx +++ b/ui/social-bot/src/App.jsx @@ -76,6 +76,9 @@ import { TempGauge } from './components/TempGauge.jsx'; // Node list viewer import { NodeList } from './components/NodeList.jsx'; +// Gamepad teleoperation (issue #319) +import { Teleop } from './components/Teleop.jsx'; + const TAB_GROUPS = [ { label: 'SOCIAL', @@ -269,16 +272,7 @@ export default function App() { {activeTab === 'motor-current-graph' && } {activeTab === 'thermal' && } {activeTab === 'map' && } - {activeTab === 'control' && ( -
-
- -
-
- -
-
- )} + {activeTab === 'control' && } {activeTab === 'health' && } {activeTab === 'cameras' && } diff --git a/ui/social-bot/src/components/Teleop.jsx b/ui/social-bot/src/components/Teleop.jsx new file mode 100644 index 0000000..9b245d7 --- /dev/null +++ b/ui/social-bot/src/components/Teleop.jsx @@ -0,0 +1,384 @@ +/** + * Teleop.jsx — Gamepad and keyboard teleoperation controller + * + * Features: + * - Virtual dual-stick gamepad (left=linear, right=angular velocity) + * - WASD keyboard fallback for manual driving + * - Speed limiter slider for safe operation + * - E-stop button for emergency stop + * - Real-time velocity display (m/s and rad/s) + * - Publishes geometry_msgs/Twist to /cmd_vel + * - Visual feedback with stick position and velocity vectors + * - 10% deadzone on both axes + */ + +import { useEffect, useRef, useState } from 'react'; + +const MAX_LINEAR_VELOCITY = 0.5; // m/s +const MAX_ANGULAR_VELOCITY = 1.0; // rad/s +const DEADZONE = 0.1; // 10% deadzone +const STICK_UPDATE_RATE = 50; // ms + +function VirtualStick({ + position, + onMove, + label, + color, + maxValue = 1.0, +}) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const isDraggingRef = useRef(false); + + // Draw stick + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + const width = canvas.width; + const height = canvas.height; + const centerX = width / 2; + const centerY = height / 2; + const baseRadius = Math.min(width, height) * 0.35; + const knobRadius = Math.min(width, height) * 0.15; + + // Clear canvas + ctx.fillStyle = '#1f2937'; + ctx.fillRect(0, 0, width, height); + + // Draw base circle + ctx.strokeStyle = '#374151'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(centerX, centerY, baseRadius, 0, Math.PI * 2); + ctx.stroke(); + + // Draw center crosshair + ctx.strokeStyle = '#4b5563'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(centerX - baseRadius * 0.3, centerY); + ctx.lineTo(centerX + baseRadius * 0.3, centerY); + ctx.moveTo(centerX, centerY - baseRadius * 0.3); + ctx.lineTo(centerX, centerY + baseRadius * 0.3); + ctx.stroke(); + + // Draw deadzone circle + ctx.strokeStyle = '#4b5563'; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.5; + ctx.beginPath(); + ctx.arc(centerX, centerY, baseRadius * DEADZONE, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = 1.0; + + // Draw knob at current position + const knobX = centerX + (position.x / maxValue) * baseRadius; + const knobY = centerY - (position.y / maxValue) * baseRadius; + + // Knob shadow + ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; + ctx.beginPath(); + ctx.arc(knobX + 2, knobY + 2, knobRadius, 0, Math.PI * 2); + ctx.fill(); + + // Knob + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(knobX, knobY, knobRadius, 0, Math.PI * 2); + ctx.fill(); + + // Knob border + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(knobX, knobY, knobRadius, 0, Math.PI * 2); + ctx.stroke(); + + // Draw velocity vector + if (Math.abs(position.x) > DEADZONE || Math.abs(position.y) > DEADZONE) { + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.globalAlpha = 0.7; + ctx.beginPath(); + ctx.moveTo(centerX, centerY); + ctx.lineTo(knobX, knobY); + ctx.stroke(); + ctx.globalAlpha = 1.0; + } + }, [position, color, maxValue]); + + const handlePointerDown = (e) => { + isDraggingRef.current = true; + updateStickPosition(e); + }; + + const handlePointerMove = (e) => { + if (!isDraggingRef.current) return; + updateStickPosition(e); + }; + + const handlePointerUp = () => { + isDraggingRef.current = false; + onMove({ x: 0, y: 0 }); + }; + + const updateStickPosition = (e) => { + const canvas = canvasRef.current; + const rect = canvas.getBoundingClientRect(); + const centerX = rect.width / 2; + const centerY = rect.height / 2; + const baseRadius = Math.min(rect.width, rect.height) * 0.35; + + const x = e.clientX - rect.left - centerX; + const y = -(e.clientY - rect.top - centerY); + + const magnitude = Math.sqrt(x * x + y * y); + const angle = Math.atan2(y, x); + + let clampedMagnitude = Math.min(magnitude, baseRadius) / baseRadius; + + // Apply deadzone + if (clampedMagnitude < DEADZONE) { + clampedMagnitude = 0; + } + + onMove({ + x: Math.cos(angle) * clampedMagnitude, + y: Math.sin(angle) * clampedMagnitude, + }); + }; + + return ( +
+
{label}
+ +
+ X: {position.x.toFixed(2)} Y: {position.y.toFixed(2)} +
+
+ ); +} + +export function Teleop({ publish }) { + const [leftStick, setLeftStick] = useState({ x: 0, y: 0 }); + const [rightStick, setRightStick] = useState({ x: 0, y: 0 }); + const [speedLimit, setSpeedLimit] = useState(1.0); + const [isEstopped, setIsEstopped] = useState(false); + const [linearVel, setLinearVel] = useState(0); + const [angularVel, setAngularVel] = useState(0); + + const keysPressed = useRef({}); + const publishIntervalRef = useRef(null); + + // Keyboard handling + useEffect(() => { + const handleKeyDown = (e) => { + const key = e.key.toLowerCase(); + if (['w', 'a', 's', 'd', ' '].includes(key)) { + keysPressed.current[key] = true; + e.preventDefault(); + } + }; + + const handleKeyUp = (e) => { + const key = e.key.toLowerCase(); + if (['w', 'a', 's', 'd', ' '].includes(key)) { + keysPressed.current[key] = false; + e.preventDefault(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + }; + }, []); + + // Calculate velocities from input + useEffect(() => { + const interval = setInterval(() => { + let linear = leftStick.y; + let angular = rightStick.x; + + // WASD fallback + if (keysPressed.current['w']) linear = Math.min(1, linear + 0.5); + if (keysPressed.current['s']) linear = Math.max(-1, linear - 0.5); + if (keysPressed.current['d']) angular = Math.min(1, angular + 0.5); + if (keysPressed.current['a']) angular = Math.max(-1, angular - 0.5); + + // Clamp to [-1, 1] + linear = Math.max(-1, Math.min(1, linear)); + angular = Math.max(-1, Math.min(1, angular)); + + // Apply speed limit + const speedFactor = isEstopped ? 0 : speedLimit; + const finalLinear = linear * MAX_LINEAR_VELOCITY * speedFactor; + const finalAngular = angular * MAX_ANGULAR_VELOCITY * speedFactor; + + setLinearVel(finalLinear); + setAngularVel(finalAngular); + + // Publish Twist + if (publish) { + publish('/cmd_vel', 'geometry_msgs/Twist', { + linear: { x: finalLinear, y: 0, z: 0 }, + angular: { x: 0, y: 0, z: finalAngular }, + }); + } + }, STICK_UPDATE_RATE); + + return () => clearInterval(interval); + }, [leftStick, rightStick, speedLimit, isEstopped, publish]); + + const handleEstop = () => { + setIsEstopped(!isEstopped); + // Publish stop immediately + if (publish) { + publish('/cmd_vel', 'geometry_msgs/Twist', { + linear: { x: 0, y: 0, z: 0 }, + angular: { x: 0, y: 0, z: 0 }, + }); + } + }; + + return ( +
+ {/* Status bar */} +
+
+
+ {isEstopped ? '🛑 E-STOP ACTIVE' : '⚡ TELEOP READY'} +
+ +
+ + {/* Velocity display */} +
+
+
LINEAR
+
+ {linearVel.toFixed(2)} m/s +
+
+
+
ANGULAR
+
+ {angularVel.toFixed(2)} rad/s +
+
+
+
+ + {/* Gamepad area */} +
+ + +
+ + {/* Speed limiter */} +
+
+
+ SPEED LIMITER +
+
+ {(speedLimit * 100).toFixed(0)}% +
+
+ setSpeedLimit(parseFloat(e.target.value))} + disabled={isEstopped} + className="w-full cursor-pointer" + style={{ + accentColor: isEstopped ? '#6b7280' : '#06b6d4', + }} + /> +
+
0%
+
50%
+
100%
+
+
+ + {/* Control info */} +
+
KEYBOARD FALLBACK
+
+
+ W/S: Forward/Back +
+
+ A/D: Turn Left/Right +
+
+
+ + {/* Topic info */} +
+
+ Topic: + /cmd_vel (geometry_msgs/Twist) +
+
+ Max Linear: + {MAX_LINEAR_VELOCITY} m/s +
+
+ Max Angular: + {MAX_ANGULAR_VELOCITY} rad/s +
+
+ Deadzone: + {(DEADZONE * 100).toFixed(0)}% +
+
+
+ ); +}