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 (
);
}