mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
mobile reorganizations
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-C3cFkTvH.js"></script>
|
<script type="module" crossorigin src="/assets/index-l3JqdlyF.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BVKa8Zph.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BVKa8Zph.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+22
-9
@@ -5,10 +5,8 @@ import TelemetryPanel from './components/TelemetryPanel/index.jsx';
|
|||||||
import PiHostStatsCard from './components/PiHostStatsCard/index.jsx';
|
import PiHostStatsCard from './components/PiHostStatsCard/index.jsx';
|
||||||
import ReplaySourcesPanel from './components/ReplaySourcesPanel/index.jsx';
|
import ReplaySourcesPanel from './components/ReplaySourcesPanel/index.jsx';
|
||||||
import AlertFeed from './components/AlertFeed/index.jsx';
|
import AlertFeed from './components/AlertFeed/index.jsx';
|
||||||
import MobileControls, {
|
import AuxColumn from './components/MobileControls/AuxColumn.jsx';
|
||||||
MobileActionsColumn,
|
import MovementColumn from './components/MobileControls/MovementColumn.jsx';
|
||||||
MobileDriveColumn,
|
|
||||||
} from './components/MobileControls/index.jsx';
|
|
||||||
import {
|
import {
|
||||||
ControlSystemProvider,
|
ControlSystemProvider,
|
||||||
KeyboardInputManager,
|
KeyboardInputManager,
|
||||||
@@ -187,10 +185,25 @@ function MobileFeatureTabs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MobilePortraitLayout({ onOpenHelpOverlay, swapMobileControlColumns = false }) {
|
function MobilePortraitLayout({ onOpenHelpOverlay, swapMobileControlColumns = false }) {
|
||||||
|
const columnHeight = 'h-[min(60svh,24rem)]';
|
||||||
|
const firstColumn = swapMobileControlColumns
|
||||||
|
? <MovementColumn layout="portrait" className={columnHeight} />
|
||||||
|
: <AuxColumn layout="portrait" className={columnHeight} />;
|
||||||
|
const secondColumn = swapMobileControlColumns
|
||||||
|
? <AuxColumn layout="portrait" className={columnHeight} />
|
||||||
|
: <MovementColumn layout="portrait" className={columnHeight} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col ${themeGapClass}`}>
|
<div className={`flex flex-col ${themeGapClass}`}>
|
||||||
<DriverVideo layoutFormat="mobile-portrait" />
|
<DriverVideo layoutFormat="mobile-portrait" />
|
||||||
<MobileControls swapColumns={swapMobileControlColumns} />
|
{/* Portrait owns the two-column placement because the same reusable mobile
|
||||||
|
columns sit in different grid contexts in portrait and landscape. */}
|
||||||
|
<section className="mobile-touch-control text-white">
|
||||||
|
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 items-stretch">
|
||||||
|
{firstColumn}
|
||||||
|
{secondColumn}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div className={`grid ${themeGapClass} grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]`}>
|
<div className={`grid ${themeGapClass} grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]`}>
|
||||||
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
|
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
|
||||||
<RoverQueuesPanel />
|
<RoverQueuesPanel />
|
||||||
@@ -208,11 +221,11 @@ function MobilePortraitLayout({ onOpenHelpOverlay, swapMobileControlColumns = fa
|
|||||||
function MobileLandscapeLayout({ onOpenHelpOverlay, swapMobileControlColumns = false }) {
|
function MobileLandscapeLayout({ onOpenHelpOverlay, swapMobileControlColumns = false }) {
|
||||||
const columnClass = 'self-start h-[min(100svh,32rem)]';
|
const columnClass = 'self-start h-[min(100svh,32rem)]';
|
||||||
const firstColumn = swapMobileControlColumns
|
const firstColumn = swapMobileControlColumns
|
||||||
? <MobileDriveColumn layout="landscape" className={columnClass} />
|
? <MovementColumn layout="landscape" className={columnClass} />
|
||||||
: <MobileActionsColumn layout="landscape" className={columnClass} />;
|
: <AuxColumn layout="landscape" className={columnClass} />;
|
||||||
const secondColumn = swapMobileControlColumns
|
const secondColumn = swapMobileControlColumns
|
||||||
? <MobileActionsColumn layout="landscape" className={columnClass} />
|
? <AuxColumn layout="landscape" className={columnClass} />
|
||||||
: <MobileDriveColumn layout="landscape" className={columnClass} />;
|
: <MovementColumn layout="landscape" className={columnClass} />;
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col ${themeGapClass}`}>
|
<div className={`flex flex-col ${themeGapClass}`}>
|
||||||
<section className={`grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] ${themeGapClass}`}>
|
<section className={`grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] ${themeGapClass}`}>
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Aux Column
|
||||||
|
// Purpose: Assembles the mobile auxiliary controls column, which is the left column by default.
|
||||||
|
// Scope: Owns mobile aux/camera/night vision/horn wiring while reusing desktop variation components where intended.
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { useControlSystem } from '../../controls/index.js';
|
||||||
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
|
import HornControl from '../HornControl/index.jsx';
|
||||||
|
import NightVisionControl from '../NightVisionControl/index.jsx';
|
||||||
|
import { AUX_ZERO } from './constants.js';
|
||||||
|
import VacuumControls from './VacuumControls.jsx';
|
||||||
|
import VerticalCameraTilt from './VerticalCameraTilt.jsx';
|
||||||
|
|
||||||
|
function AuxColumnContent() {
|
||||||
|
const {
|
||||||
|
state: { roverId, camera, horn },
|
||||||
|
pipeline,
|
||||||
|
actions: { setServoAngle, setNightVision, setAuxMotors, startHorn, stopHorn },
|
||||||
|
} = useControlSystem();
|
||||||
|
const dockAssist = useManualDockAssist();
|
||||||
|
const disabled = !roverId;
|
||||||
|
const activeAuxButtonRef = 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 cameraDisabled = Boolean(disabled || dockAssist.cameraLocked);
|
||||||
|
|
||||||
|
const handleNightVisionToggle = useCallback(
|
||||||
|
(nextOn) => {
|
||||||
|
if (!nightVisionAvailable) return;
|
||||||
|
setNightVision(nextOn);
|
||||||
|
},
|
||||||
|
[nightVisionAvailable, setNightVision],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAuxPress = useCallback(
|
||||||
|
(id, values) => {
|
||||||
|
if (disabled) return;
|
||||||
|
activeAuxButtonRef.current = id;
|
||||||
|
setAuxMotors(values);
|
||||||
|
},
|
||||||
|
[disabled, setAuxMotors],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAuxRelease = useCallback(
|
||||||
|
(id) => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (activeAuxButtonRef.current === id) {
|
||||||
|
activeAuxButtonRef.current = null;
|
||||||
|
setAuxMotors(AUX_ZERO);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, setAuxMotors],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-touch-control grid h-full min-h-0 w-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)] gap-0.5 text-slate-100">
|
||||||
|
<VacuumControls
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={handleAuxPress}
|
||||||
|
onRelease={handleAuxRelease}
|
||||||
|
/>
|
||||||
|
<div className="mobile-touch-control flex min-h-0 items-stretch gap-0.5">
|
||||||
|
{cameraEnabled ? (
|
||||||
|
// Match the desktop camera tilt card's emerald styling so the vertical
|
||||||
|
// mobile control reads as the same feature in a phone-sized layout.
|
||||||
|
<div className="mobile-touch-control flex-1 min-h-0 rounded-xl border-2 border-emerald-300/70 bg-emerald-900 px-1 py-1 text-emerald-50">
|
||||||
|
<VerticalCameraTilt
|
||||||
|
value={cameraValue}
|
||||||
|
min={cameraMin}
|
||||||
|
max={cameraMax}
|
||||||
|
step={0.5}
|
||||||
|
disabled={cameraDisabled}
|
||||||
|
onChange={setServoAngle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{nightVisionAvailable ? (
|
||||||
|
<NightVisionControl
|
||||||
|
nightVisionOn={nightVisionState?.nightVisionOn}
|
||||||
|
disabled={disabled}
|
||||||
|
onToggle={handleNightVisionToggle}
|
||||||
|
heightClass="h-full"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mobile-touch-control min-h-0">
|
||||||
|
{hornAvailable ? (
|
||||||
|
<HornControl
|
||||||
|
disabled={disabled || hornBlocked}
|
||||||
|
onStart={startHorn}
|
||||||
|
onStop={stopHorn}
|
||||||
|
active={horn?.active}
|
||||||
|
heat={horn?.heat}
|
||||||
|
defaultShowSettings={false}
|
||||||
|
showSettingsToggle
|
||||||
|
compactSettings
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuxColumn({ layout, className = '' }) {
|
||||||
|
return (
|
||||||
|
<div className={`mobile-touch-control flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}>
|
||||||
|
<AuxColumnContent />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// 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 { useControlSystem } 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 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({ disabled = false }) {
|
||||||
|
const {
|
||||||
|
state: { keymap: rawKeymap },
|
||||||
|
actions: { setDriveVector, registerInputState },
|
||||||
|
} = useControlSystem();
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
setDriveVector(vector, { source: SOURCE, speedOptions });
|
||||||
|
registerInputState(SOURCE, {
|
||||||
|
keys: Array.from(tokens),
|
||||||
|
vector,
|
||||||
|
activeCell: cell?.id ?? 'stop',
|
||||||
|
speedMode: modeId,
|
||||||
|
lastEvent,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
buildVirtualKeyTokens,
|
||||||
|
disabled,
|
||||||
|
keyboardSpeeds,
|
||||||
|
keymap,
|
||||||
|
registerInputState,
|
||||||
|
setDriveVector,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopDrivePad = useCallback(
|
||||||
|
(lastEvent = 'stop') => {
|
||||||
|
clearRepeatTimer();
|
||||||
|
activeCellRef.current = null;
|
||||||
|
setActiveInputLabel('stop');
|
||||||
|
sendDriveCell({ id: 'stop', actions: [] }, lastEvent, 'normal');
|
||||||
|
},
|
||||||
|
[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) => {
|
||||||
|
setSpeedMode(nextMode);
|
||||||
|
speedModeRef.current = nextMode;
|
||||||
|
if (activeCellRef.current) {
|
||||||
|
sendDriveCell(activeCellRef.current, 'speed', nextMode);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sendDriveCell],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => clearRepeatTimer();
|
||||||
|
}, [clearRepeatTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!disabled) return;
|
||||||
|
stopDrivePad('disabled');
|
||||||
|
}, [disabled, stopDrivePad]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-touch-control flex flex-1 min-h-0 flex-col overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900 text-slate-100 shadow-md">
|
||||||
|
<div className="mobile-touch-control grid grid-cols-3 gap-0.5 border-b border-slate-700 bg-slate-950 p-0.5">
|
||||||
|
{DRIVE_PAD_SPEED_MODES.map((mode) => {
|
||||||
|
const active = speedMode === mode.id;
|
||||||
|
const speedValue =
|
||||||
|
mode.id === 'precision'
|
||||||
|
? keyboardSpeeds.precisionSpeed
|
||||||
|
: mode.id === 'turbo'
|
||||||
|
? keyboardSpeeds.turboSpeed
|
||||||
|
: keyboardSpeeds.baseSpeed;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mode.id}
|
||||||
|
type="button"
|
||||||
|
className={`mobile-touch-control min-h-9 rounded-md px-1 text-xs font-semibold ${
|
||||||
|
active
|
||||||
|
? 'bg-cyan-300 text-slate-950'
|
||||||
|
: 'bg-slate-800 text-slate-200'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSpeedModeChange(mode.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<span className="block leading-tight">{mode.label}</span>
|
||||||
|
<span className="block font-mono text-[0.7rem] leading-tight">{speedValue}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mobile-touch-control min-h-0 flex-1">
|
||||||
|
<FloatingJoystick
|
||||||
|
activeInputLabel={activeInputLabel}
|
||||||
|
disabled={disabled}
|
||||||
|
onCellChange={handleCellChange}
|
||||||
|
onStop={() => stopDrivePad('stop')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,499 +0,0 @@
|
|||||||
// Mobile Controls Content
|
|
||||||
// Purpose: Defines the Mobile Controls Content module and the local helpers/components used in this file.
|
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useControlSystem } from '../../controls/index.js';
|
|
||||||
import { normalizeKeymapEntries } from '../../controls/keymapUtils.js';
|
|
||||||
import {
|
|
||||||
computeKeyboardDriveVector,
|
|
||||||
getKeyboardDriveSpeedOptions,
|
|
||||||
resolveKeyboardSpeeds,
|
|
||||||
} from '../../controls/inputs/driveIntent.js';
|
|
||||||
import DriveDockAction, { useDriveDockState } from '../DriveDockAction/index.jsx';
|
|
||||||
import NightVisionControl from '../NightVisionControl/index.jsx';
|
|
||||||
import HornControl from '../HornControl/index.jsx';
|
|
||||||
import FloatingJoystick from './FloatingJoystick.jsx';
|
|
||||||
import MobileAuxButton from './MobileAuxButton.jsx';
|
|
||||||
import {
|
|
||||||
SOURCE,
|
|
||||||
DRIVE_PAD_REPEAT_MS,
|
|
||||||
DRIVE_PAD_SPEED_MODES,
|
|
||||||
AUX_ZERO,
|
|
||||||
AUX_ALL_FORWARD,
|
|
||||||
AUX_ALL_BACKWARD,
|
|
||||||
} from './constants.js';
|
|
||||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
|
||||||
import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.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];
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampCameraAngle(value, min, max) {
|
|
||||||
if (!Number.isFinite(value)) return min;
|
|
||||||
return Math.max(min, Math.min(max, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCameraAngle(value) {
|
|
||||||
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
|
|
||||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}°`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileCameraTiltSlider({
|
|
||||||
value,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
step = 0.5,
|
|
||||||
disabled = false,
|
|
||||||
onChange,
|
|
||||||
}) {
|
|
||||||
const trackRef = useRef(null);
|
|
||||||
const pointerIdRef = useRef(null);
|
|
||||||
|
|
||||||
const valuePercent = useMemo(() => {
|
|
||||||
if (max === min) return 50;
|
|
||||||
return ((clampCameraAngle(value, min, max) - min) / (max - min)) * 100;
|
|
||||||
}, [max, min, value]);
|
|
||||||
const disabledClass = disabled ? 'opacity-50' : '';
|
|
||||||
|
|
||||||
const valueFromPointer = useCallback(
|
|
||||||
(event) => {
|
|
||||||
const track = trackRef.current;
|
|
||||||
if (!track) return value;
|
|
||||||
const rect = track.getBoundingClientRect();
|
|
||||||
const usableHeight = Math.max(1, rect.height);
|
|
||||||
const rawPercent = 1 - (event.clientY - rect.top) / usableHeight;
|
|
||||||
const unclamped = min + clampCameraAngle(rawPercent, 0, 1) * (max - min);
|
|
||||||
const stepped = Math.round(unclamped / step) * step;
|
|
||||||
return clampCameraAngle(stepped, min, max);
|
|
||||||
},
|
|
||||||
[max, min, step, value],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendPointerValue = useCallback(
|
|
||||||
(event) => {
|
|
||||||
const next = valueFromPointer(event);
|
|
||||||
onChange?.(next);
|
|
||||||
},
|
|
||||||
[onChange, valueFromPointer],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
|
||||||
(event) => {
|
|
||||||
if (disabled) return;
|
|
||||||
if (pointerIdRef.current !== null) return;
|
|
||||||
|
|
||||||
/*
|
|
||||||
Native vertical range inputs are unreliable as a second simultaneous
|
|
||||||
touch on mobile Safari while the drive pad owns another active pointer.
|
|
||||||
This custom track captures only the camera finger, so the drive thumb can
|
|
||||||
continue moving without stealing or cancelling camera tilt updates.
|
|
||||||
*/
|
|
||||||
event.preventDefault();
|
|
||||||
pointerIdRef.current = event.pointerId;
|
|
||||||
trackRef.current?.setPointerCapture?.(event.pointerId);
|
|
||||||
sendPointerValue(event);
|
|
||||||
},
|
|
||||||
[disabled, sendPointerValue],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerMove = useCallback(
|
|
||||||
(event) => {
|
|
||||||
if (pointerIdRef.current !== event.pointerId) return;
|
|
||||||
event.preventDefault();
|
|
||||||
sendPointerValue(event);
|
|
||||||
},
|
|
||||||
[sendPointerValue],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerEnd = useCallback((event) => {
|
|
||||||
if (pointerIdRef.current !== event.pointerId) return;
|
|
||||||
event.preventDefault();
|
|
||||||
pointerIdRef.current = null;
|
|
||||||
trackRef.current?.releasePointerCapture?.(event.pointerId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mobile-touch-control flex h-full items-center justify-center gap-0.5">
|
|
||||||
<span className="mobile-touch-control text-sm font-semibold text-emerald-50 [writing-mode:vertical-rl] rotate-180">
|
|
||||||
Camera tilt
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
ref={trackRef}
|
|
||||||
role="slider"
|
|
||||||
aria-label="Camera tilt"
|
|
||||||
aria-valuemin={min}
|
|
||||||
aria-valuemax={max}
|
|
||||||
aria-valuenow={Number.isFinite(value) ? value : min}
|
|
||||||
aria-valuetext={formatCameraAngle(value)}
|
|
||||||
aria-disabled={disabled}
|
|
||||||
tabIndex={disabled ? -1 : 0}
|
|
||||||
onPointerDown={handlePointerDown}
|
|
||||||
onPointerMove={handlePointerMove}
|
|
||||||
onPointerUp={handlePointerEnd}
|
|
||||||
onPointerCancel={handlePointerEnd}
|
|
||||||
onLostPointerCapture={(event) => {
|
|
||||||
if (pointerIdRef.current === event.pointerId) pointerIdRef.current = null;
|
|
||||||
}}
|
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
|
||||||
/*
|
|
||||||
touchAction stays inline as a second line of defense because this is the
|
|
||||||
element that must keep browser panning/zooming out of the multi-touch
|
|
||||||
camera gesture.
|
|
||||||
*/
|
|
||||||
style={{ touchAction: 'none' }}
|
|
||||||
className={`mobile-touch-control mobile-drag-control relative h-full w-6 rounded-full border border-emerald-100/80 bg-emerald-950 shadow-inner focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200 ${disabledClass}`.trim()}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute inset-x-1 bottom-1 rounded-full bg-emerald-400"
|
|
||||||
style={{ height: `${valuePercent}%` }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute left-1/2 h-3.5 w-3.5 -translate-x-1/2 rounded-full border border-emerald-950 bg-emerald-200 shadow"
|
|
||||||
style={{ bottom: `clamp(0.25rem, calc(${valuePercent}% - 0.4375rem), calc(100% - 1.125rem))` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileJoystickPanel({ layout }) {
|
|
||||||
const {
|
|
||||||
state: { roverId, keymap: rawKeymap },
|
|
||||||
actions: { setDriveVector, registerInputState },
|
|
||||||
} = useControlSystem();
|
|
||||||
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
|
|
||||||
const driveDockState = useDriveDockState(roverId);
|
|
||||||
const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
|
|
||||||
const expandAction = dockedNotDriving || driveDockState.dockingInProgress;
|
|
||||||
const disabled = !roverId;
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
setDriveVector(vector, { source: SOURCE, speedOptions });
|
|
||||||
registerInputState(SOURCE, {
|
|
||||||
keys: Array.from(tokens),
|
|
||||||
vector,
|
|
||||||
activeCell: cell?.id ?? 'stop',
|
|
||||||
speedMode: modeId,
|
|
||||||
lastEvent,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[
|
|
||||||
buildVirtualKeyTokens,
|
|
||||||
disabled,
|
|
||||||
keyboardSpeeds,
|
|
||||||
keymap,
|
|
||||||
registerInputState,
|
|
||||||
setDriveVector,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const stopDrivePad = useCallback(
|
|
||||||
(lastEvent = 'stop') => {
|
|
||||||
clearRepeatTimer();
|
|
||||||
activeCellRef.current = null;
|
|
||||||
setActiveInputLabel('stop');
|
|
||||||
sendDriveCell({ id: 'stop', actions: [] }, lastEvent, 'normal');
|
|
||||||
},
|
|
||||||
[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) => {
|
|
||||||
setSpeedMode(nextMode);
|
|
||||||
speedModeRef.current = nextMode;
|
|
||||||
if (activeCellRef.current) {
|
|
||||||
sendDriveCell(activeCellRef.current, 'speed', nextMode);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[sendDriveCell],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => clearRepeatTimer();
|
|
||||||
}, [clearRepeatTimer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!disabled) return;
|
|
||||||
stopDrivePad('disabled');
|
|
||||||
}, [disabled, stopDrivePad]);
|
|
||||||
|
|
||||||
// The mobile control column itself also blocks selection because Safari can
|
|
||||||
// otherwise start selecting text from a child label before the child's pointer
|
|
||||||
// handler gets enough movement to claim the gesture.
|
|
||||||
const fillClass = dockedNotDriving ? 'max-h-screen self-start' : '';
|
|
||||||
const containerClass = `mobile-touch-control flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={containerClass} data-mobile-layout={layout}>
|
|
||||||
<DriveDockAction
|
|
||||||
layout="mobile"
|
|
||||||
expand={expandAction}
|
|
||||||
driveDockState={driveDockState}
|
|
||||||
compactHeightClass="min-h-[5rem]"
|
|
||||||
/>
|
|
||||||
{!expandAction ? (
|
|
||||||
// Keep the drive launcher card fully opaque so camera video or page
|
|
||||||
// backgrounds never show through the target the driver is trying to hold.
|
|
||||||
<div className="mobile-touch-control flex flex-1 min-h-0 flex-col overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900 text-slate-100 shadow-md">
|
|
||||||
<div className="mobile-touch-control grid grid-cols-3 gap-0.5 border-b border-slate-700 bg-slate-950 p-0.5">
|
|
||||||
{DRIVE_PAD_SPEED_MODES.map((mode) => {
|
|
||||||
const active = speedMode === mode.id;
|
|
||||||
const speedValue =
|
|
||||||
mode.id === 'precision'
|
|
||||||
? keyboardSpeeds.precisionSpeed
|
|
||||||
: mode.id === 'turbo'
|
|
||||||
? keyboardSpeeds.turboSpeed
|
|
||||||
: keyboardSpeeds.baseSpeed;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={mode.id}
|
|
||||||
type="button"
|
|
||||||
className={`mobile-touch-control min-h-9 rounded-md px-1 text-xs font-semibold ${
|
|
||||||
active
|
|
||||||
? 'bg-cyan-300 text-slate-950'
|
|
||||||
: 'bg-slate-800 text-slate-200'
|
|
||||||
}`}
|
|
||||||
onClick={() => handleSpeedModeChange(mode.id)}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<span className="block leading-tight">{mode.label}</span>
|
|
||||||
<span className="block font-mono text-[0.7rem] leading-tight">{speedValue}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="mobile-touch-control min-h-0 flex-1">
|
|
||||||
<FloatingJoystick
|
|
||||||
activeInputLabel={activeInputLabel}
|
|
||||||
disabled={disabled}
|
|
||||||
onCellChange={handleCellChange}
|
|
||||||
onStop={() => stopDrivePad('stop')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileActionsColumnContent() {
|
|
||||||
const {
|
|
||||||
state: { roverId, camera, horn },
|
|
||||||
pipeline,
|
|
||||||
actions: { setServoAngle, setNightVision, setAuxMotors, startHorn, stopHorn },
|
|
||||||
} = useControlSystem();
|
|
||||||
const dockAssist = useManualDockAssist();
|
|
||||||
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 cameraDisabled = Boolean(disabled || dockAssist.cameraLocked);
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="mobile-touch-control grid h-full min-h-0 w-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)] gap-0.5 text-slate-100">
|
|
||||||
<div className="mobile-touch-control grid min-h-0 grid-rows-2 gap-0.5">
|
|
||||||
<MobileAuxButton
|
|
||||||
id="aux-vac-forward"
|
|
||||||
label="Vacuum Forward"
|
|
||||||
values={AUX_ALL_FORWARD}
|
|
||||||
color="bg-fuchsia-600"
|
|
||||||
disabled={disabled}
|
|
||||||
onPress={handleAuxPress}
|
|
||||||
onRelease={handleAuxRelease}
|
|
||||||
/>
|
|
||||||
<MobileAuxButton
|
|
||||||
id="aux-vac-backward"
|
|
||||||
label="Vacuum Backward"
|
|
||||||
values={AUX_ALL_BACKWARD}
|
|
||||||
color="bg-fuchsia-800"
|
|
||||||
disabled={disabled}
|
|
||||||
onPress={handleAuxPress}
|
|
||||||
onRelease={handleAuxRelease}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mobile-touch-control flex min-h-0 items-stretch gap-0.5">
|
|
||||||
{cameraEnabled ? (
|
|
||||||
// Match the desktop camera tilt card's emerald styling so the vertical
|
|
||||||
// mobile control reads as the same feature in a phone-sized layout.
|
|
||||||
<div className="mobile-touch-control flex-1 min-h-0 rounded-xl border-2 border-emerald-300/70 bg-emerald-900 px-1 py-1 text-emerald-50">
|
|
||||||
<MobileCameraTiltSlider
|
|
||||||
value={cameraValue}
|
|
||||||
min={cameraMin}
|
|
||||||
max={cameraMax}
|
|
||||||
step={0.5}
|
|
||||||
disabled={cameraDisabled}
|
|
||||||
onChange={setServoAngle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{nightVisionAvailable ? (
|
|
||||||
<NightVisionControl
|
|
||||||
nightVisionOn={nightVisionState?.nightVisionOn}
|
|
||||||
disabled={disabled}
|
|
||||||
onToggle={handleNightVisionToggle}
|
|
||||||
heightClass="h-full"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="mobile-touch-control min-h-0">
|
|
||||||
{hornAvailable ? (
|
|
||||||
<HornControl
|
|
||||||
disabled={disabled || hornBlocked}
|
|
||||||
onStart={startHorn}
|
|
||||||
onStop={stopHorn}
|
|
||||||
active={horn?.active}
|
|
||||||
heat={horn?.heat}
|
|
||||||
defaultShowSettings={false}
|
|
||||||
showSettingsToggle
|
|
||||||
compactSettings
|
|
||||||
className="h-full"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MobileActionsColumn({ layout, className = '' }) {
|
|
||||||
return (
|
|
||||||
<div className={`mobile-touch-control flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}>
|
|
||||||
<MobileActionsColumnContent />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MobileDriveColumn({ layout, className = '' }) {
|
|
||||||
return (
|
|
||||||
<div className={`mobile-touch-control flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}>
|
|
||||||
<MobileJoystickPanel layout={layout === 'landscape' ? 'landscape' : 'portrait'} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MobilePortraitControls({ swapColumns = false }) {
|
|
||||||
const columnHeight = 'h-[min(60svh,24rem)]';
|
|
||||||
const firstColumn = swapColumns
|
|
||||||
? <MobileDriveColumn layout="portrait" className={columnHeight} />
|
|
||||||
: <MobileActionsColumn layout="portrait" className={columnHeight} />;
|
|
||||||
const secondColumn = swapColumns
|
|
||||||
? <MobileActionsColumn layout="portrait" className={columnHeight} />
|
|
||||||
: <MobileDriveColumn layout="portrait" className={columnHeight} />;
|
|
||||||
return (
|
|
||||||
// Portrait controls need layout grouping but no painted panel behind them;
|
|
||||||
// each child control owns its own visible surface.
|
|
||||||
<section className="mobile-touch-control text-white">
|
|
||||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 items-stretch">
|
|
||||||
{firstColumn}
|
|
||||||
{secondColumn}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Movement Column
|
||||||
|
// Purpose: Assembles the mobile movement column, which is the right column by default.
|
||||||
|
// Scope: Integrates drive/dock actions with the mobile control pad without hiding that dependency inside the pad.
|
||||||
|
import { useControlSystem } from '../../controls/index.js';
|
||||||
|
import DriveDockAction, { useDriveDockState } from '../DriveDockAction/index.jsx';
|
||||||
|
import ControlPadPanel from './ControlPadPanel.jsx';
|
||||||
|
|
||||||
|
function MovementColumnContent({ layout }) {
|
||||||
|
const {
|
||||||
|
state: { roverId },
|
||||||
|
} = useControlSystem();
|
||||||
|
const driveDockState = useDriveDockState(roverId);
|
||||||
|
const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
|
||||||
|
const expandAction = dockedNotDriving || driveDockState.dockingInProgress;
|
||||||
|
const disabled = !roverId;
|
||||||
|
|
||||||
|
/*
|
||||||
|
DriveDockAction owns whether the rover is ready to accept movement, while
|
||||||
|
ControlPadPanel owns only movement intent. Keeping the integration here makes
|
||||||
|
the column layout explicit and prevents the pad component from knowing about
|
||||||
|
docking state.
|
||||||
|
*/
|
||||||
|
const fillClass = dockedNotDriving ? 'max-h-screen self-start' : '';
|
||||||
|
const containerClass = `mobile-touch-control flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClass} data-mobile-layout={layout}>
|
||||||
|
<DriveDockAction
|
||||||
|
layout="mobile"
|
||||||
|
expand={expandAction}
|
||||||
|
driveDockState={driveDockState}
|
||||||
|
compactHeightClass="min-h-[5rem]"
|
||||||
|
/>
|
||||||
|
{!expandAction ? <ControlPadPanel disabled={disabled} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MovementColumn({ layout, className = '' }) {
|
||||||
|
return (
|
||||||
|
<div className={`mobile-touch-control flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}>
|
||||||
|
<MovementColumnContent layout={layout === 'landscape' ? 'landscape' : 'portrait'} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Vacuum Controls
|
||||||
|
// Purpose: Renders the mobile-only vacuum forward/backward auxiliary motor controls.
|
||||||
|
// Scope: Owns only the two vacuum buttons; the parent column owns rover state and aux motor commands.
|
||||||
|
import MobileAuxButton from './MobileAuxButton.jsx';
|
||||||
|
import { AUX_ALL_BACKWARD, AUX_ALL_FORWARD } from './constants.js';
|
||||||
|
|
||||||
|
export default function VacuumControls({
|
||||||
|
disabled,
|
||||||
|
onPress,
|
||||||
|
onRelease,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mobile-touch-control grid min-h-0 grid-rows-2 gap-0.5">
|
||||||
|
<MobileAuxButton
|
||||||
|
id="aux-vac-forward"
|
||||||
|
label="Vacuum Forward"
|
||||||
|
values={AUX_ALL_FORWARD}
|
||||||
|
color="bg-fuchsia-600"
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}
|
||||||
|
onRelease={onRelease}
|
||||||
|
/>
|
||||||
|
<MobileAuxButton
|
||||||
|
id="aux-vac-backward"
|
||||||
|
label="Vacuum Backward"
|
||||||
|
values={AUX_ALL_BACKWARD}
|
||||||
|
color="bg-fuchsia-800"
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}
|
||||||
|
onRelease={onRelease}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Vertical Camera Tilt
|
||||||
|
// Purpose: Provides the mobile-only camera tilt slider that supports simultaneous two-finger mobile driving.
|
||||||
|
// Scope: Owns pointer tracking and visual slider state for the compact vertical mobile camera control.
|
||||||
|
import { useCallback, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
function clampCameraAngle(value, min, max) {
|
||||||
|
if (!Number.isFinite(value)) return min;
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCameraAngle(value) {
|
||||||
|
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
|
||||||
|
return `${value > 0 ? '+' : ''}${value.toFixed(1)}°`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerticalCameraTilt({
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step = 0.5,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
}) {
|
||||||
|
const trackRef = useRef(null);
|
||||||
|
const pointerIdRef = useRef(null);
|
||||||
|
|
||||||
|
const valuePercent = useMemo(() => {
|
||||||
|
if (max === min) return 50;
|
||||||
|
return ((clampCameraAngle(value, min, max) - min) / (max - min)) * 100;
|
||||||
|
}, [max, min, value]);
|
||||||
|
const disabledClass = disabled ? 'opacity-50' : '';
|
||||||
|
|
||||||
|
const valueFromPointer = useCallback(
|
||||||
|
(event) => {
|
||||||
|
const track = trackRef.current;
|
||||||
|
if (!track) return value;
|
||||||
|
const rect = track.getBoundingClientRect();
|
||||||
|
const usableHeight = Math.max(1, rect.height);
|
||||||
|
const rawPercent = 1 - (event.clientY - rect.top) / usableHeight;
|
||||||
|
const unclamped = min + clampCameraAngle(rawPercent, 0, 1) * (max - min);
|
||||||
|
const stepped = Math.round(unclamped / step) * step;
|
||||||
|
return clampCameraAngle(stepped, min, max);
|
||||||
|
},
|
||||||
|
[max, min, step, value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendPointerValue = useCallback(
|
||||||
|
(event) => {
|
||||||
|
const next = valueFromPointer(event);
|
||||||
|
onChange?.(next);
|
||||||
|
},
|
||||||
|
[onChange, valueFromPointer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (pointerIdRef.current !== null) return;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Native vertical range inputs are unreliable as a second simultaneous
|
||||||
|
touch on mobile Safari while the drive pad owns another active pointer.
|
||||||
|
This custom track captures only the camera finger, so the drive thumb can
|
||||||
|
continue moving without stealing or cancelling camera tilt updates.
|
||||||
|
*/
|
||||||
|
event.preventDefault();
|
||||||
|
pointerIdRef.current = event.pointerId;
|
||||||
|
trackRef.current?.setPointerCapture?.(event.pointerId);
|
||||||
|
sendPointerValue(event);
|
||||||
|
},
|
||||||
|
[disabled, sendPointerValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (pointerIdRef.current !== event.pointerId) return;
|
||||||
|
event.preventDefault();
|
||||||
|
sendPointerValue(event);
|
||||||
|
},
|
||||||
|
[sendPointerValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerEnd = useCallback((event) => {
|
||||||
|
if (pointerIdRef.current !== event.pointerId) return;
|
||||||
|
event.preventDefault();
|
||||||
|
pointerIdRef.current = null;
|
||||||
|
trackRef.current?.releasePointerCapture?.(event.pointerId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-touch-control flex h-full items-center justify-center gap-0.5">
|
||||||
|
<span className="mobile-touch-control text-sm font-semibold text-emerald-50 [writing-mode:vertical-rl] rotate-180">
|
||||||
|
Camera tilt
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
role="slider"
|
||||||
|
aria-label="Camera tilt"
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-valuenow={Number.isFinite(value) ? value : min}
|
||||||
|
aria-valuetext={formatCameraAngle(value)}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerEnd}
|
||||||
|
onPointerCancel={handlePointerEnd}
|
||||||
|
onLostPointerCapture={(event) => {
|
||||||
|
if (pointerIdRef.current === event.pointerId) pointerIdRef.current = null;
|
||||||
|
}}
|
||||||
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
|
/*
|
||||||
|
touchAction stays inline as a second line of defense because this is the
|
||||||
|
element that must keep browser panning/zooming out of the multi-touch
|
||||||
|
camera gesture.
|
||||||
|
*/
|
||||||
|
style={{ touchAction: 'none' }}
|
||||||
|
className={`mobile-touch-control mobile-drag-control relative h-full w-6 rounded-full border border-emerald-100/80 bg-emerald-950 shadow-inner focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200 ${disabledClass}`.trim()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-x-1 bottom-1 rounded-full bg-emerald-400"
|
||||||
|
style={{ height: `${valuePercent}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute left-1/2 h-3.5 w-3.5 -translate-x-1/2 rounded-full border border-emerald-950 bg-emerald-200 shadow"
|
||||||
|
style={{ bottom: `clamp(0.25rem, calc(${valuePercent}% - 0.4375rem), calc(100% - 1.125rem))` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// Mobile Controls
|
|
||||||
// Purpose: Defines the Mobile Controls module and the local helpers/components used in this file.
|
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
|
||||||
import MobilePortraitControls, { MobileActionsColumn, MobileDriveColumn } from './MobileControlsContent.jsx';
|
|
||||||
|
|
||||||
export { MobileActionsColumn, MobileDriveColumn };
|
|
||||||
export default MobilePortraitControls;
|
|
||||||
Reference in New Issue
Block a user