import { useCallback, useEffect, useRef, useState } from 'react'; import { useControlSystem } from '../controls/index.js'; import { clampUnit } from '../controls/controlMath.js'; import DriveModeToggle from './controls/DriveModeToggle.jsx'; const SOURCE = 'mobile-joystick'; const JOYSTICK_RADIUS = 80; const AUX_BUTTONS = [ { id: 'main-forward', label: 'Main +', values: { main: 127 }, hold: true, color: 'bg-emerald-600' }, { id: 'main-reverse', label: 'Main -', values: { main: -127 }, hold: true, color: 'bg-emerald-800' }, { id: 'side-forward', label: 'Side +', values: { side: 127 }, hold: true, color: 'bg-cyan-600' }, { id: 'side-reverse', label: 'Side -', values: { side: -70 }, hold: true, color: 'bg-cyan-800' }, { id: 'vacuum-fast', label: 'Vacuum Max', values: { vacuum: 127 }, hold: true, color: 'bg-amber-500 text-amber-950' }, { id: 'vacuum-slow', label: 'Vacuum Low', values: { vacuum: 50 }, hold: true, color: 'bg-amber-700' }, { id: 'all-forward', label: 'All +', values: { main: 127, side: 127, vacuum: 127 }, hold: true, color: 'bg-fuchsia-600' }, { id: 'stop-all', label: 'Stop', values: { main: 0, side: 0, vacuum: 0 }, hold: false, color: 'bg-slate-900' }, ]; function FloatingJoystick({ disabled, layout, onMove, onStop }) { const containerRef = useRef(null); const pointerIdRef = useRef(null); const baseRef = useRef({ x: 0, y: 0 }); const [visual, setVisual] = useState({ active: false, base: { x: 0, y: 0 }, knob: { x: 0, y: 0 } }); const stopTracking = useCallback(() => { pointerIdRef.current = null; setVisual({ active: false, base: { x: 0, y: 0 }, knob: { x: 0, y: 0 } }); onStop?.(); }, [onStop]); const handlePointerDown = useCallback( (event) => { if (disabled) return; if (pointerIdRef.current !== null) return; event.preventDefault(); const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; baseRef.current = { x, y }; pointerIdRef.current = event.pointerId; container.setPointerCapture?.(event.pointerId); setVisual({ active: true, base: { x, y }, knob: { x: 0, y: 0 } }); }, [disabled], ); const handlePointerMove = useCallback( (event) => { if (disabled || pointerIdRef.current !== event.pointerId) return; event.preventDefault(); const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const currentX = event.clientX - rect.left; const currentY = event.clientY - rect.top; const dx = currentX - baseRef.current.x; const dy = currentY - baseRef.current.y; const distance = Math.min(Math.hypot(dx, dy), JOYSTICK_RADIUS); const angle = Math.atan2(dy, dx); const knobX = Math.cos(angle) * distance; const knobY = Math.sin(angle) * distance; const vector = { x: clampUnit(knobX / JOYSTICK_RADIUS), y: clampUnit(-knobY / JOYSTICK_RADIUS), boost: false, }; setVisual((prev) => ({ ...prev, knob: { x: knobX, y: knobY } })); onMove?.(vector); }, [disabled, onMove], ); const handlePointerEnd = useCallback( (event) => { if (pointerIdRef.current !== event.pointerId) return; event.preventDefault(); const container = containerRef.current; container?.releasePointerCapture?.(event.pointerId); stopTracking(); }, [stopTracking], ); useEffect(() => { if (disabled) { stopTracking(); } }, [disabled, stopTracking]); const heightClass = layout === 'landscape' ? 'h-[260px]' : 'h-[220px]'; return (
Touch and hold anywhere
Joystick will follow your thumb
Aux controls