// Control Pad Panel // Purpose: Provides the mobile movement control pad and speed mode selector. // Scope: Converts touch pad cells into keyboard-style drive vectors; movement column owns drive/dock placement. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useControlActions, useControlSelector } from '../../controls/index.js'; import { normalizeKeymapEntries } from '../../controls/keymapUtils.js'; import { computeKeyboardDriveVector, getKeyboardDriveSpeedOptions, resolveKeyboardSpeeds, } from '../../controls/inputs/driveIntent.js'; import { useSettingsNamespace } from '../../settings/index.js'; import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js'; import { triggerTouchHaptic } from '../../lib/touchHaptics.js'; import FloatingJoystick from './FloatingJoystick.jsx'; import { DRIVE_PAD_REPEAT_MS, DRIVE_PAD_SPEED_MODES, SOURCE, } from './constants.js'; function firstTokenForAction(keymap, actionId) { const bindingSet = keymap?.[actionId]; if (!bindingSet || bindingSet.size === 0) return null; return bindingSet.values().next().value ?? null; } function getSpeedModeConfig(speedMode) { return DRIVE_PAD_SPEED_MODES.find((mode) => mode.id === speedMode) || DRIVE_PAD_SPEED_MODES[1]; } export default function ControlPadPanel({ compact = false, disabled = false }) { const rawKeymap = useControlSelector((control) => control.state.keymap); const { setCameraPrecisionMode, setDriveVector, registerInputState } = useControlActions(); const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS); const [speedMode, setSpeedMode] = useState('normal'); const [activeInputLabel, setActiveInputLabel] = useState('stop'); const speedModeRef = useRef('normal'); const activeCellRef = useRef(null); const repeatTimerRef = useRef(null); const keymap = useMemo(() => normalizeKeymapEntries(rawKeymap), [rawKeymap]); const keyboardSpeeds = useMemo(() => resolveKeyboardSpeeds(inputSettings), [inputSettings]); const clearRepeatTimer = useCallback(() => { if (!repeatTimerRef.current) return; clearInterval(repeatTimerRef.current); repeatTimerRef.current = null; }, []); const buildVirtualKeyTokens = useCallback( (cell, modeId = speedModeRef.current) => { const tokens = new Set(); const speedModeConfig = getSpeedModeConfig(modeId); const actionIds = [ ...(Array.isArray(cell?.actions) ? cell.actions : []), speedModeConfig.modifierAction, ].filter(Boolean); actionIds.forEach((actionId) => { const token = firstTokenForAction(keymap, actionId); if (token) tokens.add(token); }); return tokens; }, [keymap], ); const sendDriveCell = useCallback( (cell, lastEvent = 'move', modeId = speedModeRef.current) => { if (disabled) return; const tokens = buildVirtualKeyTokens(cell, modeId); const vector = computeKeyboardDriveVector(tokens, keymap); const speedOptions = getKeyboardDriveSpeedOptions(tokens, keymap, keyboardSpeeds); const precisionActive = modeId === 'precision'; // Mobile deliberately routes through the keyboard vector/speed helpers. The // thumb pad only chooses which virtual keys are down, so changes to keyboard // drive behavior automatically stay matched here. /* The selected mobile speed mode is persistent, unlike the keyboard's held Shift modifier. Publishing camera precision here makes the servo tilt controls follow the mobile movement mode even before the driver starts dragging on the pad. */ setCameraPrecisionMode(precisionActive); setDriveVector(vector, { source: SOURCE, speedOptions }); registerInputState(SOURCE, { keys: Array.from(tokens), vector, activeCell: cell?.id ?? 'stop', speedMode: modeId, lastEvent, }); }, [ buildVirtualKeyTokens, disabled, keyboardSpeeds, keymap, registerInputState, setCameraPrecisionMode, setDriveVector, ], ); const stopDrivePad = useCallback( (lastEvent = 'stop') => { clearRepeatTimer(); activeCellRef.current = null; setActiveInputLabel('stop'); sendDriveCell({ id: 'stop', actions: [] }, lastEvent, speedModeRef.current); }, [clearRepeatTimer, sendDriveCell], ); const startRepeatTimer = useCallback(() => { if (repeatTimerRef.current) return; repeatTimerRef.current = setInterval(() => { if (!activeCellRef.current) return; sendDriveCell(activeCellRef.current, 'repeat'); }, DRIVE_PAD_REPEAT_MS); }, [sendDriveCell]); const handleCellChange = useCallback( (cell) => { if (disabled) return; activeCellRef.current = cell; setActiveInputLabel(cell?.label || 'stop'); sendDriveCell(cell, 'move'); startRepeatTimer(); }, [disabled, sendDriveCell, startRepeatTimer], ); const handleSpeedModeChange = useCallback( (nextMode) => { if (nextMode === speedModeRef.current) return; setSpeedMode(nextMode); speedModeRef.current = nextMode; // Confirm the accepted mode transition here so tapping an already-active // mode cannot produce feedback for a state change that did not happen. triggerTouchHaptic('button'); setCameraPrecisionMode(nextMode === 'precision'); if (activeCellRef.current) { sendDriveCell(activeCellRef.current, 'speed', nextMode); } }, [sendDriveCell, setCameraPrecisionMode], ); useEffect(() => { return () => { clearRepeatTimer(); /* Mobile controls can unmount during an orientation/layout change while a pointer is still captured by the disappearing element. Publish a neutral vector directly during cleanup so neither rover drive nor PTZ pan/tilt can retain the last cell merely because pointerup had nowhere to land. */ activeCellRef.current = null; setDriveVector({ x: 0, y: 0, boost: false }, { source: SOURCE }); registerInputState(SOURCE, { keys: [], vector: { x: 0, y: 0, boost: false }, activeCell: 'stop', speedMode: speedModeRef.current, lastEvent: 'unmount', }); // Clear the shared flag too, so a stale mobile precision choice cannot // leave desktop/keyboard camera tilt in fine-step mode. setCameraPrecisionMode(false); }; }, [clearRepeatTimer, registerInputState, setCameraPrecisionMode, setDriveVector]); useEffect(() => { if (!disabled) return; stopDrivePad('disabled'); setCameraPrecisionMode(false); }, [disabled, setCameraPrecisionMode, stopDrivePad]); return (