mobile reorganizations

This commit is contained in:
legop3
2026-06-10 15:17:28 -04:00
parent 4fd44d6ad6
commit cb5b58666c
11 changed files with 669 additions and 642 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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
View File
@@ -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;