import { useCallback, useEffect, useRef, useState } from 'react'; import { useControlSystem } from '../controls/index.js'; import { clampUnit } from '../controls/controlMath.js'; import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx'; const SOURCE = 'mobile-joystick'; const JOYSTICK_RADIUS = 80; const JOYSTICK_SMOOTHING = 0.15; const AUX_BUTTONS = [ { id: 'main-forward', label: 'Main Brush +', values: { main: 127 }, hold: true, color: 'bg-emerald-600' }, { id: 'main-reverse', label: 'Main Brush -', values: { main: -127 }, hold: true, color: 'bg-emerald-800' }, { id: 'all-forward', label: 'All Motors +', values: { main: 127, side: 127, vacuum: 127 }, hold: true, color: 'bg-fuchsia-600' }, { id: 'side-forward', label: 'Side Brush +', values: { side: 127 }, hold: true, color: 'bg-cyan-600' }, { id: 'side-reverse', label: 'Side Brush -', 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: 'stop-all', label: 'Stop', values: { main: 0, side: 0, vacuum: 0 }, hold: false, color: 'bg-slate-900' }, ]; function FloatingJoystick({ disabled, layout, radius, 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), radius); const angle = Math.atan2(dy, dx); const knobX = Math.cos(angle) * distance; const knobY = Math.sin(angle) * distance; const vector = { x: clampUnit(knobX / radius), y: clampUnit(-knobY / 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 (
event.preventDefault()} > {!visual.active && (

Touch and hold anywhere

Joystick will follow your thumb

)} {visual.active && ( <>
)}
); } function MobileJoystickPanel({ layout }) { const { state: { roverId, camera }, pipeline, actions: { setDriveVector, registerInputState, setServoAngle, toggleNightVision }, } = useControlSystem(); const driveDockState = useDriveDockState(roverId); const dockedNotDriving = driveDockState.docked && !driveDockState.driving; const disabled = !roverId; const cameraConfig = camera?.config; const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig); const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision); const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45; const cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45; const cameraValue = typeof camera?.angle === 'number' ? camera.angle : typeof cameraConfig?.homeAngle === 'number' ? cameraConfig.homeAngle : (cameraMin + cameraMax) / 2; const joystickRadius = JOYSTICK_RADIUS; const smoothing = JOYSTICK_SMOOTHING; const smoothedVectorRef = useRef({ x: 0, y: 0, boost: false }); useEffect(() => { if (disabled) { smoothedVectorRef.current = { x: 0, y: 0, boost: false }; } }, [disabled]); const handleMove = useCallback( (vector = {}) => { if (disabled) return; const next = { x: clampUnit(vector.x ?? 0), y: clampUnit(vector.y ?? 0), boost: Boolean(vector.boost), }; const applied = smoothing > 0 ? { x: smoothedVectorRef.current.x + (next.x - smoothedVectorRef.current.x) * (1 - smoothing), y: smoothedVectorRef.current.y + (next.y - smoothedVectorRef.current.y) * (1 - smoothing), boost: next.boost, } : next; smoothedVectorRef.current = applied; setDriveVector(applied, { source: SOURCE }); registerInputState(SOURCE, { vector: applied, lastEvent: 'move' }); }, [disabled, registerInputState, setDriveVector, smoothing], ); const handleStop = useCallback(() => { if (disabled) return; const zero = { x: 0, y: 0, boost: false }; smoothedVectorRef.current = zero; setDriveVector(zero, { source: SOURCE }); registerInputState(SOURCE, { vector: zero, lastEvent: 'stop' }); }, [disabled, registerInputState, setDriveVector]); const handleCameraSlider = (event) => { if (!cameraEnabled) return; const next = Number(event.target.value); if (Number.isNaN(next)) return; setServoAngle(next); }; const fillClass = dockedNotDriving ? 'max-h-screen self-start' : ''; const containerClass = `flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`; return (
{!dockedNotDriving ? ( <> {nightVisionAvailable && ( )} {cameraEnabled && (
Camera Tilt {cameraValue.toFixed(1)}°
)} ) : null} {/* Panic stop button can be re-enabled here if needed */}
); } function AuxMotorPanel({ orientation }) { const { state: { roverId }, actions: { setAuxMotors }, } = useControlSystem(); const activeRef = useRef(null); const disabled = !roverId; const handlePress = useCallback( (button) => { if (disabled) return; if (button.hold === false) { setAuxMotors(button.values); activeRef.current = null; return; } activeRef.current = button.id; setAuxMotors(button.values); }, [disabled, setAuxMotors], ); const handleRelease = useCallback( (button) => { if (disabled || button.hold === false) return; if (activeRef.current === button.id) { activeRef.current = null; setAuxMotors({ main: 0, side: 0, vacuum: 0 }); } }, [disabled, setAuxMotors], ); const gridCols = orientation === 'landscape' ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'; return (

Aux controls

{AUX_BUTTONS.map((button) => ( ))}
); } export function MobileLandscapeAuxColumn() { return (
); } export function MobileLandscapeControlColumn() { return (
); } export default function MobilePortraitControls() { return (
); }