/** * JoystickTeleop.jsx — Virtual joystick for robot teleop * * Features: * - Virtual thumbstick UI with visual feedback * - Touch & mouse support * - Publishes geometry_msgs/Twist to /cmd_vel * - 10% deadzone for both axes * - Max linear velocity: 0.5 m/s * - Max angular velocity: 1.0 rad/s * - Real-time velocity display */ import { useEffect, useRef, useState } from 'react'; const DEADZONE = 0.1; // 10% const MAX_LINEAR_VEL = 0.5; // m/s const MAX_ANGULAR_VEL = 1.0; // rad/s const RADIUS = 80; // pixels const STICK_RADIUS = 25; // pixels function JoystickTeleop({ publish }) { const canvasRef = useRef(null); const containerRef = useRef(null); const [joystickPos, setJoystickPos] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [linearVel, setLinearVel] = useState(0); const [angularVel, setAngularVel] = useState(0); const velocityTimerRef = useRef(null); // Draw joystick on canvas useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const rect = canvas.getBoundingClientRect(); const centerX = canvas.width / 2; const centerY = canvas.height / 2; // Clear canvas ctx.fillStyle = '#1f2937'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Draw outer circle (background) ctx.strokeStyle = '#374151'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(centerX, centerY, RADIUS, 0, Math.PI * 2); ctx.stroke(); // Draw crosshair ctx.strokeStyle = '#4b5563'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(centerX - 30, centerY); ctx.lineTo(centerX + 30, centerY); ctx.moveTo(centerX, centerY - 30); ctx.lineTo(centerX, centerY + 30); ctx.stroke(); // Draw deadzone indicator ctx.strokeStyle = '#6b7280'; ctx.lineWidth = 1; ctx.setLineDash([2, 2]); ctx.beginPath(); ctx.arc(centerX, centerY, RADIUS * DEADZONE, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); // Draw stick const stickX = centerX + joystickPos.x * RADIUS; const stickY = centerY + joystickPos.y * RADIUS; ctx.fillStyle = isDragging ? '#06b6d4' : '#0891b2'; ctx.beginPath(); ctx.arc(stickX, stickY, STICK_RADIUS, 0, Math.PI * 2); ctx.fill(); // Draw direction indicator (arrow) if (Math.abs(joystickPos.x) > 0.01 || Math.abs(joystickPos.y) > 0.01) { const angle = Math.atan2(-joystickPos.y, joystickPos.x); const arrowLen = 40; const arrowX = centerX + Math.cos(angle) * arrowLen; const arrowY = centerY - Math.sin(angle) * arrowLen; ctx.strokeStyle = '#06b6d4'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.lineTo(arrowX, arrowY); ctx.stroke(); } }, [joystickPos, isDragging]); // Handle joystick input const handleInput = (clientX, clientY) => { const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const centerX = canvas.width / 2; const centerY = canvas.height / 2; // Calculate relative position let x = (clientX - rect.left - centerX) / RADIUS; let y = (clientY - rect.top - centerY) / RADIUS; // Normalize to unit circle const distance = Math.sqrt(x * x + y * y); if (distance > 1) { x /= distance; y /= distance; } // Apply deadzone const deadzoneMagnitude = Math.sqrt(x * x + y * y); let adjustedX = x; let adjustedY = y; if (deadzoneMagnitude < DEADZONE) { adjustedX = 0; adjustedY = 0; } else { // Scale values above deadzone const scale = (deadzoneMagnitude - DEADZONE) / (1 - DEADZONE); adjustedX = (x / deadzoneMagnitude) * scale; adjustedY = (y / deadzoneMagnitude) * scale; } setJoystickPos({ x: adjustedX, y: adjustedY }); // Calculate velocities // X-axis (left-right) = angular velocity // Y-axis (up-down) = linear velocity (negative because canvas Y is inverted) const linear = -adjustedY * MAX_LINEAR_VEL; const angular = adjustedX * MAX_ANGULAR_VEL; setLinearVel(linear); setAngularVel(angular); // Publish Twist message if (publish) { const twist = { linear: { x: linear, y: 0, z: 0 }, angular: { x: 0, y: 0, z: angular } }; publish('/cmd_vel', 'geometry_msgs/Twist', twist); } }; const handleMouseDown = (e) => { setIsDragging(true); handleInput(e.clientX, e.clientY); }; const handleMouseMove = (e) => { if (isDragging) { handleInput(e.clientX, e.clientY); } }; const handleMouseUp = () => { setIsDragging(false); setJoystickPos({ x: 0, y: 0 }); setLinearVel(0); setAngularVel(0); if (publish) { const twist = { linear: { x: 0, y: 0, z: 0 }, angular: { x: 0, y: 0, z: 0 } }; publish('/cmd_vel', 'geometry_msgs/Twist', twist); } }; // Touch support const handleTouchStart = (e) => { setIsDragging(true); const touch = e.touches[0]; handleInput(touch.clientX, touch.clientY); }; const handleTouchMove = (e) => { if (isDragging) { e.preventDefault(); const touch = e.touches[0]; handleInput(touch.clientX, touch.clientY); } }; const handleTouchEnd = () => { setIsDragging(false); setJoystickPos({ x: 0, y: 0 }); setLinearVel(0); setAngularVel(0); if (publish) { const twist = { linear: { x: 0, y: 0, z: 0 }, angular: { x: 0, y: 0, z: 0 } }; publish('/cmd_vel', 'geometry_msgs/Twist', twist); } }; useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; canvas.addEventListener('mousedown', handleMouseDown); window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); canvas.addEventListener('touchstart', handleTouchStart); canvas.addEventListener('touchmove', handleTouchMove); canvas.addEventListener('touchend', handleTouchEnd); return () => { canvas.removeEventListener('mousedown', handleMouseDown); window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); canvas.removeEventListener('touchstart', handleTouchStart); canvas.removeEventListener('touchmove', handleTouchMove); canvas.removeEventListener('touchend', handleTouchEnd); }; }, [isDragging]); // Cleanup on unmount useEffect(() => { return () => { if (velocityTimerRef.current) { clearTimeout(velocityTimerRef.current); } // Send stop command on unmount if (publish) { const twist = { linear: { x: 0, y: 0, z: 0 }, angular: { x: 0, y: 0, z: 0 } }; publish('/cmd_vel', 'geometry_msgs/Twist', twist); } }; }, [publish]); return (
JOYSTICK TELEOP
{/* Joystick canvas */}
{/* Controls and info */}
{/* Velocity displays */}
LINEAR VELOCITY
{linearVel.toFixed(3)} m/s
Range: -0.500 to +0.500 m/s
ANGULAR VELOCITY
{angularVel.toFixed(3)} rad/s
Range: -1.000 to +1.000 rad/s
{/* Instructions */}
CONTROLS:
▲ Forward / ▼ Backward
◄ Rotate Left / ► Rotate Right
Deadzone: 10%
{/* Status indicators */}
{isDragging ? 'ACTIVE' : 'READY'}
{/* Topic info */}
Publishing to: /cmd_vel geometry_msgs/Twist
); } export default JoystickTeleop;