mobeil controls

This commit is contained in:
legop3
2026-04-28 14:55:12 -04:00
parent bff79b5f8c
commit c31408611e
9 changed files with 170 additions and 156 deletions
+3 -1
View File
@@ -65,7 +65,7 @@
- [x] admin panel - [x] admin panel
- [x] drive dock action - [x] drive dock action
- [x] gamepad mapping settings - [x] gamepad mapping settings
- [ ] mobile controls - [x] mobile controls
- [ ] top down map - [ ] top down map
- [x] video tile - [x] video tile
- [ ] Sweep `webui` for unused or unneeded files/code with verification - [ ] Sweep `webui` for unused or unneeded files/code with verification
@@ -78,6 +78,7 @@
- admin panel - admin panel
- drive dock action - drive dock action
- gamepad mapping settings - gamepad mapping settings
- mobile controls
### LARGE CHANGES ### LARGE CHANGES
- Split `webui/src/mini/MiniSummaryApp.jsx` into folderized modules under `webui/src/mini/MiniSummaryApp/` with a compatibility entrypoint preserved. - Split `webui/src/mini/MiniSummaryApp.jsx` into folderized modules under `webui/src/mini/MiniSummaryApp/` with a compatibility entrypoint preserved.
@@ -87,6 +88,7 @@
- Split `webui/src/components/AdminPanel.jsx` into `webui/src/components/AdminPanel/` and extracted monitor/health/log/LLM helper modules; updated consumers to folder entrypoint and removed external wrapper file. - Split `webui/src/components/AdminPanel.jsx` into `webui/src/components/AdminPanel/` and extracted monitor/health/log/LLM helper modules; updated consumers to folder entrypoint and removed external wrapper file.
- Moved `DriveDockAction` to `webui/src/components/DriveDockAction/index.jsx` and updated all consumers to folder entrypoint imports. - Moved `DriveDockAction` to `webui/src/components/DriveDockAction/index.jsx` and updated all consumers to folder entrypoint imports.
- Split `webui/src/components/GamepadMappingSettings.jsx` into `webui/src/components/GamepadMappingSettings/` with extracted constants/helpers/SliderField modules and removed the standalone component file. - Split `webui/src/components/GamepadMappingSettings.jsx` into `webui/src/components/GamepadMappingSettings/` with extracted constants/helpers/SliderField modules and removed the standalone component file.
- Split `webui/src/components/MobileControls.jsx` into `webui/src/components/MobileControls/` with extracted joystick/aux/constants modules; preserved named exports and moved app import to folder entrypoint.
## Done criteria (per item) ## Done criteria (per item)
- [ ] Folderized structure created. - [ ] Folderized structure created.
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,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="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BmNE-cUP.js"></script> <script type="module" crossorigin src="/assets/index-CTHeAti4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DVTOmRBl.css"> <link rel="stylesheet" crossorigin href="/assets/index-DVTOmRBl.css">
</head> </head>
<body> <body>
+1 -1
View File
@@ -5,7 +5,7 @@ import AlertFeed from './components/AlertFeed.jsx';
import MobileControls, { import MobileControls, {
MobileActionsColumn, MobileActionsColumn,
MobileDriveColumn, MobileDriveColumn,
} from './components/MobileControls.jsx'; } from './components/MobileControls/index.jsx';
import { import {
ControlSystemProvider, ControlSystemProvider,
KeyboardInputManager, KeyboardInputManager,
@@ -0,0 +1,114 @@
// Floating on-screen joystick used for mobile driving.
import { useCallback, useEffect, useRef, useState } from 'react';
import { clampUnit } from '../../controls/controlMath.js';
export default 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, radius],
);
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 = 'h-full';
return (
<div
ref={containerRef}
role="presentation"
className={`relative w-full ${heightClass} select-none overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900/70 text-slate-100 shadow-md`}
style={{ touchAction: 'none' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerLeave={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onContextMenu={(event) => event.preventDefault()}
>
{!visual.active && (
<div className="absolute inset-x-0 top-0 flex flex-col items-center gap-0 text-center pt-0.5">
<span className="font-semibold text-slate-200">Joystick area</span>
<span className="text-sm text-slate-300">Touch and hold to use the joystick</span>
</div>
)}
{visual.active && (
<>
<div
className="pointer-events-none absolute h-28 w-28 -translate-x-1/2 -translate-y-1/2 bg-cyan-400/10 outline outline-2 outline-cyan-400/60 [clip-path:circle(50%)]"
style={{ left: visual.base.x, top: visual.base.y }}
/>
<div
className="pointer-events-none absolute h-12 w-12 -translate-x-1/2 -translate-y-1/2 bg-cyan-300/80 shadow-lg [clip-path:circle(50%)]"
style={{
left: visual.base.x + visual.knob.x,
top: visual.base.y + visual.knob.y,
}}
/>
</>
)}
</div>
);
}
@@ -0,0 +1,20 @@
// Hold-to-run aux motor button for mobile controls.
export default function MobileAuxButton({ id, label, values, color, disabled, onPress, onRelease }) {
return (
<button
type="button"
disabled={disabled}
onPointerDown={(event) => {
event.preventDefault();
onPress(id, values);
}}
onPointerUp={() => onRelease(id)}
onPointerLeave={() => onRelease(id)}
onPointerCancel={() => onRelease(id)}
onContextMenu={(event) => event.preventDefault()}
className={`flex h-full w-full items-center justify-center rounded-xl border-2 px-1 py-0.75 text-center text-sm font-semibold text-white transition select-none no-touch-select ${color} hover:brightness-110 active:brightness-125 active:scale-[0.99] disabled:opacity-30`}
>
{label}
</button>
);
}
@@ -1,130 +1,21 @@
import { useCallback, useEffect, useRef, useState } from 'react'; // Mobile controls layout and interaction wiring.
import { useControlSystem } from '../controls/index.js'; import { useCallback, useEffect, useRef } from 'react';
import { clampUnit } from '../controls/controlMath.js'; import { useControlSystem } from '../../controls/index.js';
import DriveDockAction, { useDriveDockState } from './DriveDockAction/index.jsx'; import { clampUnit } from '../../controls/controlMath.js';
import NightVisionControl from './NightVisionControl.jsx'; import DriveDockAction, { useDriveDockState } from '../DriveDockAction/index.jsx';
import HornControl from './HornControl.jsx'; import NightVisionControl from '../NightVisionControl.jsx';
import CameraTiltControl from './CameraTiltControl.jsx'; import HornControl from '../HornControl.jsx';
import CameraTiltControl from '../CameraTiltControl.jsx';
const SOURCE = 'mobile-joystick'; import FloatingJoystick from './FloatingJoystick.jsx';
const JOYSTICK_RADIUS = 80; import MobileAuxButton from './MobileAuxButton.jsx';
const JOYSTICK_SMOOTHING = 0.15; import {
const AUX_ZERO = { main: 0, side: 0, vacuum: 0 }; SOURCE,
const AUX_ALL_FORWARD = { main: 127, side: 127, vacuum: 127 }; JOYSTICK_RADIUS,
const AUX_ALL_BACKWARD = { main: -127, side: -127, vacuum: -127 }; JOYSTICK_SMOOTHING,
AUX_ZERO,
function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) { AUX_ALL_FORWARD,
const containerRef = useRef(null); AUX_ALL_BACKWARD,
const pointerIdRef = useRef(null); } from './constants.js';
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 = 'h-full';
return (
<div
ref={containerRef}
role="presentation"
className={`relative w-full ${heightClass} select-none overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900/70 text-slate-100 shadow-md`}
style={{ touchAction: 'none' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerLeave={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onContextMenu={(event) => event.preventDefault()}
>
{!visual.active && (
<div className="absolute inset-x-0 top-0 flex flex-col items-center gap-0 text-center pt-0.5">
<span className="font-semibold text-slate-200">Joystick area</span>
<span className="text-sm text-slate-300">Touch and hold to use the joystick</span>
</div>
)}
{visual.active && (
<>
<div
className="pointer-events-none absolute h-28 w-28 -translate-x-1/2 -translate-y-1/2 bg-cyan-400/10 outline outline-2 outline-cyan-400/60 [clip-path:circle(50%)]"
style={{ left: visual.base.x, top: visual.base.y }}
/>
<div
className="pointer-events-none absolute h-12 w-12 -translate-x-1/2 -translate-y-1/2 bg-cyan-300/80 shadow-lg [clip-path:circle(50%)]"
style={{
left: visual.base.x + visual.knob.x,
top: visual.base.y + visual.knob.y,
}}
/>
</>
)}
</div>
);
}
function MobileJoystickPanel({ layout }) { function MobileJoystickPanel({ layout }) {
const { const {
@@ -140,9 +31,7 @@ function MobileJoystickPanel({ layout }) {
const smoothedVectorRef = useRef({ x: 0, y: 0, boost: false }); const smoothedVectorRef = useRef({ x: 0, y: 0, boost: false });
useEffect(() => { useEffect(() => {
if (disabled) { if (disabled) smoothedVectorRef.current = { x: 0, y: 0, boost: false };
smoothedVectorRef.current = { x: 0, y: 0, boost: false };
}
}, [disabled]); }, [disabled]);
const handleMove = useCallback( const handleMove = useCallback(
@@ -198,32 +87,10 @@ function MobileJoystickPanel({ layout }) {
/> />
</div> </div>
) : null} ) : null}
{/* Panic stop button can be re-enabled here if needed */}
</div> </div>
); );
} }
function MobileAuxButton({ id, label, values, color, disabled, onPress, onRelease }) {
return (
<button
type="button"
disabled={disabled}
onPointerDown={(event) => {
event.preventDefault();
onPress(id, values);
}}
onPointerUp={() => onRelease(id)}
onPointerLeave={() => onRelease(id)}
onPointerCancel={() => onRelease(id)}
onContextMenu={(event) => event.preventDefault()}
className={`flex h-full w-full items-center justify-center rounded-xl border-2 px-1 py-0.75 text-center text-sm font-semibold text-white transition select-none no-touch-select ${color} hover:brightness-110 active:brightness-125 active:scale-[0.99] disabled:opacity-30`}
>
{label}
</button>
);
}
function MobileActionsColumnContent({ layout }) { function MobileActionsColumnContent({ layout }) {
const { const {
state: { roverId, camera, horn }, state: { roverId, camera, horn },
@@ -0,0 +1,7 @@
// Mobile controls constants.
export const SOURCE = 'mobile-joystick';
export const JOYSTICK_RADIUS = 80;
export const JOYSTICK_SMOOTHING = 0.15;
export const AUX_ZERO = { main: 0, side: 0, vacuum: 0 };
export const AUX_ALL_FORWARD = { main: 127, side: 127, vacuum: 127 };
export const AUX_ALL_BACKWARD = { main: -127, side: -127, vacuum: -127 };
@@ -0,0 +1,4 @@
import MobilePortraitControls, { MobileActionsColumn, MobileDriveColumn } from './MobileControlsContent.jsx';
export { MobileActionsColumn, MobileDriveColumn };
export default MobilePortraitControls;