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'; import NightVisionControl from './NightVisionControl.jsx'; import HornControl from './HornControl.jsx'; import CameraTiltControl from './CameraTiltControl.jsx'; const SOURCE = 'mobile-joystick'; const JOYSTICK_RADIUS = 80; const JOYSTICK_SMOOTHING = 0.15; const AUX_ZERO = { main: 0, side: 0, vacuum: 0 }; const AUX_ALL_FORWARD = { main: 127, side: 127, vacuum: 127 }; const AUX_ALL_BACKWARD = { main: -127, side: -127, vacuum: -127 }; 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 }, actions: { setDriveVector, registerInputState }, } = useControlSystem(); const driveDockState = useDriveDockState(roverId); const dockedNotDriving = driveDockState.docked && !driveDockState.driving; const disabled = !roverId; 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 fillClass = dockedNotDriving ? 'max-h-screen self-start' : ''; const containerClass = `flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`; return (
{!dockedNotDriving ? ( <> ) : null} {/* Panic stop button can be re-enabled here if needed */}
); } function MobileAuxButton({ id, label, values, color, disabled, onPress, onRelease }) { return ( ); } function MobileLeftColumnContent({ layout }) { const { state: { roverId, camera, horn }, pipeline, actions: { setServoAngle, setNightVision, setAuxMotors, startHorn, stopHorn }, } = useControlSystem(); const disabled = !roverId; const activeRef = useRef(null); const cameraConfig = camera?.config; const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig); const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision); const nightVisionState = pipeline?.nightVisionState; const hornAvailable = Boolean(roverId && pipeline?.horn); const hornBlocked = horn?.overheated; 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 handleNightVisionToggle = useCallback( (nextOn) => { if (!nightVisionAvailable) return; setNightVision(nextOn); }, [nightVisionAvailable, setNightVision], ); const handleAuxPress = useCallback( (id, values) => { if (disabled) return; activeRef.current = id; setAuxMotors(values); }, [disabled, setAuxMotors], ); const handleAuxRelease = useCallback( (id) => { if (disabled) return; if (activeRef.current === id) { activeRef.current = null; setAuxMotors(AUX_ZERO); } }, [disabled, setAuxMotors], ); return (
{cameraEnabled ? (
) : null} {nightVisionAvailable ? ( ) : null}
{hornAvailable ? ( ) : null}
); } export function MobileLeftColumn({ layout, className = '' }) { return (
); } export function MobileRightColumn({ layout, className = '' }) { return (
); } export default function MobilePortraitControls() { const columnHeight = 'h-[min(60svh,24rem)]'; return (
); }