mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
guh
This commit is contained in:
+15
-13
@@ -80,23 +80,25 @@ function MobilePortraitLayout() {
|
||||
function MobileLandscapeLayout() {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<section className="grid grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-1">
|
||||
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-1">
|
||||
<MobileLandscapeAuxColumn />
|
||||
<DriverVideoPanel />
|
||||
<MobileLandscapeControlColumn />
|
||||
</section>
|
||||
<RoomCameraPanel />
|
||||
<DrivePanel />
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<AuthPanel />
|
||||
<AdminPanel />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<LogPanel />
|
||||
<TelemetryPanel />
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex flex-col gap-1 pb-2">
|
||||
<RoomCameraPanel />
|
||||
<DrivePanel />
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<AuthPanel />
|
||||
<AdminPanel />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<LogPanel />
|
||||
<TelemetryPanel />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,134 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Joystick } from 'react-joystick-component';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useControlSystem } from '../controls/index.js';
|
||||
import { clampUnit } from '../controls/controlMath.js';
|
||||
import DriveModeToggle from './controls/DriveModeToggle.jsx';
|
||||
|
||||
const SOURCE = 'mobile-joystick';
|
||||
const JOYSTICK_RADIUS = 80;
|
||||
const AUX_BUTTONS = [
|
||||
{ id: 'main-forward', label: 'Main +', values: { main: 127 }, hold: true },
|
||||
{ id: 'main-reverse', label: 'Main -', values: { main: -127 }, hold: true },
|
||||
{ id: 'side-forward', label: 'Side +', values: { side: 127 }, hold: true },
|
||||
{ id: 'side-reverse', label: 'Side -', values: { side: -70 }, hold: true },
|
||||
{ id: 'vacuum-fast', label: 'Vacuum Max', values: { vacuum: 127 }, hold: true },
|
||||
{ id: 'vacuum-slow', label: 'Vacuum Low', values: { vacuum: 50 }, hold: true },
|
||||
{ id: 'all-forward', label: 'All +', values: { main: 127, side: 127, vacuum: 127 }, hold: true },
|
||||
{ id: 'stop-all', label: 'Stop', values: { main: 0, side: 0, vacuum: 0 }, hold: false },
|
||||
{ id: 'main-forward', label: 'Main +', values: { main: 127 }, hold: true, color: 'bg-emerald-600' },
|
||||
{ id: 'main-reverse', label: 'Main -', values: { main: -127 }, hold: true, color: 'bg-emerald-800' },
|
||||
{ id: 'side-forward', label: 'Side +', values: { side: 127 }, hold: true, color: 'bg-cyan-600' },
|
||||
{ id: 'side-reverse', label: 'Side -', values: { side: -70 }, hold: true, color: 'bg-cyan-800' },
|
||||
{ id: 'vacuum-fast', label: 'Vacuum Max', values: { vacuum: 127 }, hold: true, color: 'bg-amber-500 text-amber-950' },
|
||||
{ id: 'vacuum-slow', label: 'Vacuum Low', values: { vacuum: 50 }, hold: true, color: 'bg-amber-700' },
|
||||
{ id: 'all-forward', label: 'All +', values: { main: 127, side: 127, vacuum: 127 }, hold: true, color: 'bg-fuchsia-600' },
|
||||
{ id: 'stop-all', label: 'Stop', values: { main: 0, side: 0, vacuum: 0 }, hold: false, color: 'bg-slate-900' },
|
||||
];
|
||||
|
||||
function FloatingJoystick({ disabled, layout, 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), JOYSTICK_RADIUS);
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const knobX = Math.cos(angle) * distance;
|
||||
const knobY = Math.sin(angle) * distance;
|
||||
const vector = {
|
||||
x: clampUnit(knobX / JOYSTICK_RADIUS),
|
||||
y: clampUnit(-knobY / JOYSTICK_RADIUS),
|
||||
boost: false,
|
||||
};
|
||||
setVisual((prev) => ({ ...prev, knob: { x: knobX, y: knobY } }));
|
||||
onMove?.(vector);
|
||||
},
|
||||
[disabled, onMove],
|
||||
);
|
||||
|
||||
const handlePointerEnd = useCallback(
|
||||
(event) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
const container = containerRef.current;
|
||||
container?.releasePointerCapture?.(event.pointerId);
|
||||
stopTracking();
|
||||
},
|
||||
[stopTracking],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
stopTracking();
|
||||
}
|
||||
}, [disabled, stopTracking]);
|
||||
|
||||
const heightClass = layout === 'landscape' ? 'h-[260px]' : 'h-[220px]';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="presentation"
|
||||
className={`relative w-full ${heightClass} select-none overflow-hidden rounded-lg border border-slate-800 bg-slate-900/70`}
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerLeave={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{!visual.active && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center text-xs text-slate-400">
|
||||
<p>Touch and hold anywhere</p>
|
||||
<p>Joystick will follow your thumb</p>
|
||||
</div>
|
||||
)}
|
||||
{visual.active && (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute h-28 w-28 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-cyan-400/60 bg-cyan-400/10"
|
||||
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 rounded-full bg-cyan-300/80 shadow-lg"
|
||||
style={{
|
||||
left: visual.base.x + visual.knob.x,
|
||||
top: visual.base.y + visual.knob.y,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileJoystickPanel({ layout }) {
|
||||
const {
|
||||
state: { roverId },
|
||||
@@ -24,15 +137,15 @@ function MobileJoystickPanel({ layout }) {
|
||||
const disabled = !roverId;
|
||||
|
||||
const handleMove = useCallback(
|
||||
(event = {}) => {
|
||||
(vector = {}) => {
|
||||
if (disabled) return;
|
||||
const vector = {
|
||||
x: clampUnit(event.x ?? 0),
|
||||
y: clampUnit(event.y ?? 0),
|
||||
boost: Boolean(event.shiftKey),
|
||||
const next = {
|
||||
x: clampUnit(vector.x ?? 0),
|
||||
y: clampUnit(vector.y ?? 0),
|
||||
boost: Boolean(vector.boost),
|
||||
};
|
||||
setDriveVector(vector, { source: SOURCE });
|
||||
registerInputState(SOURCE, { vector, lastEvent: 'move' });
|
||||
setDriveVector(next, { source: SOURCE });
|
||||
registerInputState(SOURCE, { vector: next, lastEvent: 'move' });
|
||||
},
|
||||
[disabled, registerInputState, setDriveVector],
|
||||
);
|
||||
@@ -45,31 +158,18 @@ function MobileJoystickPanel({ layout }) {
|
||||
}, [disabled, registerInputState, setDriveVector]);
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||
<div className="flex flex-col gap-1 text-slate-100">
|
||||
<DriveModeToggle size="compact" />
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-center">
|
||||
<Joystick
|
||||
size={layout === 'landscape' ? 140 : 120}
|
||||
baseColor="#0f172a"
|
||||
stickColor="#38bdf8"
|
||||
throttle={60}
|
||||
move={handleMove}
|
||||
stop={handleStop}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[0.7rem] text-slate-400">Drag to steer · Release to stop sending drive commands.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopAllMotion}
|
||||
disabled={disabled}
|
||||
className="rounded-sm bg-red-600 px-1 py-1 text-xs font-semibold uppercase tracking-wide text-red-100 disabled:opacity-40"
|
||||
>
|
||||
Panic Stop
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<FloatingJoystick disabled={disabled} layout={layout} onMove={handleMove} onStop={handleStop} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopAllMotion}
|
||||
disabled={disabled}
|
||||
className="rounded bg-red-600 px-2 py-2 text-xs font-semibold uppercase tracking-wide text-red-100 disabled:opacity-40"
|
||||
>
|
||||
Panic Stop
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,12 +206,12 @@ function AuxMotorPanel({ orientation }) {
|
||||
[disabled, setAuxMotors],
|
||||
);
|
||||
|
||||
const buttonClasses = orientation === 'landscape' ? 'text-xs' : 'text-[0.75rem]';
|
||||
const gridCols = orientation === 'landscape' ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2';
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||
<p className="text-xs text-slate-400">Aux motors · hold to run</p>
|
||||
<div className="mt-1 grid grid-cols-2 gap-1">
|
||||
<div className="flex flex-col gap-1 text-slate-100">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Aux controls</p>
|
||||
<div className={`grid ${gridCols} gap-1`}>
|
||||
{AUX_BUTTONS.map((button) => (
|
||||
<button
|
||||
key={button.id}
|
||||
@@ -124,19 +224,20 @@ function AuxMotorPanel({ orientation }) {
|
||||
onPointerUp={() => handleRelease(button)}
|
||||
onPointerLeave={() => handleRelease(button)}
|
||||
onPointerCancel={() => handleRelease(button)}
|
||||
className={`rounded-sm bg-black/40 px-1 py-1 text-left text-slate-100 disabled:opacity-30 ${buttonClasses}`}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
className={`rounded-lg px-2 py-2 text-left text-sm font-semibold uppercase tracking-wide text-white transition ${button.color} disabled:opacity-30`}
|
||||
>
|
||||
{button.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileLandscapeAuxColumn() {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex h-full flex-col gap-1">
|
||||
<AuxMotorPanel orientation="landscape" />
|
||||
</div>
|
||||
);
|
||||
@@ -144,7 +245,7 @@ export function MobileLandscapeAuxColumn() {
|
||||
|
||||
export function MobileLandscapeControlColumn() {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex h-full flex-col gap-1">
|
||||
<MobileJoystickPanel layout="landscape" />
|
||||
</div>
|
||||
);
|
||||
@@ -152,8 +253,8 @@ export function MobileLandscapeControlColumn() {
|
||||
|
||||
export default function MobilePortraitControls() {
|
||||
return (
|
||||
<section className="rounded-sm bg-transparent">
|
||||
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
|
||||
<section className="w-full">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<AuxMotorPanel orientation="portrait" />
|
||||
<MobileJoystickPanel layout="portrait" />
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useControlSystem } from '../ControlContext.jsx';
|
||||
const SOURCE = 'keyboard';
|
||||
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||
const ZERO_AUX = { main: 0, side: 0, vacuum: 0 };
|
||||
const SERVO_REPEAT_MS = 110;
|
||||
|
||||
function shouldIgnoreEvent(event) {
|
||||
const target = event.target;
|
||||
@@ -118,8 +119,19 @@ export default function KeyboardInputManager() {
|
||||
const pressedKeysRef = useRef(new Set());
|
||||
const lastVectorRef = useRef(ZERO_VECTOR);
|
||||
const lastAuxRef = useRef(ZERO_AUX);
|
||||
const cameraHoldRef = useRef(new Set());
|
||||
const servoIntervalRef = useRef(null);
|
||||
|
||||
const updateFromKeys = useCallback(() => {
|
||||
const actionKeys = useMemo(() => {
|
||||
const entries = Object.values(keymap);
|
||||
const set = new Set();
|
||||
entries.forEach((bindingSet) => {
|
||||
bindingSet?.forEach((binding) => set.add(binding));
|
||||
});
|
||||
return set;
|
||||
}, [keymap]);
|
||||
|
||||
const driveFromKeys = useCallback(() => {
|
||||
const keys = pressedKeysRef.current;
|
||||
const vector = computeDriveVector(keys, keymap);
|
||||
const aux = computeAuxMotors(keys, keymap);
|
||||
@@ -138,46 +150,100 @@ export default function KeyboardInputManager() {
|
||||
});
|
||||
}, [keymap, registerInputState, setAuxMotors, setDriveVector]);
|
||||
|
||||
const stopServoLoop = useCallback(() => {
|
||||
if (servoIntervalRef.current) {
|
||||
clearTimeout(servoIntervalRef.current);
|
||||
servoIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startServoLoop = useCallback(() => {
|
||||
if (servoIntervalRef.current) return;
|
||||
const tick = () => {
|
||||
const up = cameraHoldRef.current.has('up') ? 1 : 0;
|
||||
const down = cameraHoldRef.current.has('down') ? 1 : 0;
|
||||
const direction = up - down;
|
||||
if (direction === 0) {
|
||||
servoIntervalRef.current = null;
|
||||
return;
|
||||
}
|
||||
nudgeServo(direction * servoStep);
|
||||
servoIntervalRef.current = setTimeout(tick, SERVO_REPEAT_MS);
|
||||
};
|
||||
servoIntervalRef.current = setTimeout(tick, 0);
|
||||
}, [nudgeServo, servoStep]);
|
||||
|
||||
const updateServoHold = useCallback(() => {
|
||||
if (cameraHoldRef.current.size === 0) {
|
||||
stopServoLoop();
|
||||
} else {
|
||||
startServoLoop();
|
||||
}
|
||||
}, [startServoLoop, stopServoLoop]);
|
||||
|
||||
const resetAll = useCallback(() => {
|
||||
pressedKeysRef.current.clear();
|
||||
cameraHoldRef.current.clear();
|
||||
stopServoLoop();
|
||||
lastVectorRef.current = ZERO_VECTOR;
|
||||
lastAuxRef.current = ZERO_AUX;
|
||||
stopAllMotion();
|
||||
registerInputState(SOURCE, { keys: [], vector: ZERO_VECTOR, aux: ZERO_AUX });
|
||||
}, [registerInputState, stopAllMotion]);
|
||||
}, [registerInputState, stopAllMotion, stopServoLoop]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event) {
|
||||
if (shouldIgnoreEvent(event)) return;
|
||||
const key = event.key?.toLowerCase();
|
||||
if (!key) return;
|
||||
if (pressedKeysRef.current.has(key)) return;
|
||||
pressedKeysRef.current.add(key);
|
||||
if (actionKeys.has(key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (!pressedKeysRef.current.has(key)) {
|
||||
pressedKeysRef.current.add(key);
|
||||
}
|
||||
|
||||
if (bindingHas(keymap.driveMacro, key)) {
|
||||
event.preventDefault();
|
||||
setMode('drive');
|
||||
runMacro('drive-sequence');
|
||||
} else if (bindingHas(keymap.dockMacro, key)) {
|
||||
event.preventDefault();
|
||||
setMode('dock');
|
||||
runMacro('seek-dock');
|
||||
} else if (bindingHas(keymap.cameraUp, key)) {
|
||||
event.preventDefault();
|
||||
nudgeServo(servoStep);
|
||||
cameraHoldRef.current.add('up');
|
||||
updateServoHold();
|
||||
} else if (bindingHas(keymap.cameraDown, key)) {
|
||||
event.preventDefault();
|
||||
nudgeServo(-servoStep);
|
||||
cameraHoldRef.current.add('down');
|
||||
updateServoHold();
|
||||
}
|
||||
|
||||
updateFromKeys();
|
||||
driveFromKeys();
|
||||
}
|
||||
|
||||
function handleKeyUp(event) {
|
||||
const key = event.key?.toLowerCase();
|
||||
if (!key || !pressedKeysRef.current.has(key)) return;
|
||||
if (!key || !pressedKeysRef.current.has(key)) {
|
||||
if (bindingHas(keymap.cameraUp, key)) {
|
||||
cameraHoldRef.current.delete('up');
|
||||
updateServoHold();
|
||||
} else if (bindingHas(keymap.cameraDown, key)) {
|
||||
cameraHoldRef.current.delete('down');
|
||||
updateServoHold();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pressedKeysRef.current.delete(key);
|
||||
updateFromKeys();
|
||||
|
||||
if (bindingHas(keymap.cameraUp, key)) {
|
||||
cameraHoldRef.current.delete('up');
|
||||
updateServoHold();
|
||||
} else if (bindingHas(keymap.cameraDown, key)) {
|
||||
cameraHoldRef.current.delete('down');
|
||||
updateServoHold();
|
||||
}
|
||||
|
||||
driveFromKeys();
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
@@ -192,11 +258,18 @@ export default function KeyboardInputManager() {
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
};
|
||||
}, [keymap, nudgeServo, resetAll, runMacro, servoStep, setMode, updateFromKeys]);
|
||||
}, [actionKeys, driveFromKeys, keymap, resetAll, runMacro, setMode, updateServoHold]);
|
||||
|
||||
useEffect(() => {
|
||||
resetAll();
|
||||
}, [state.roverId, resetAll]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
stopServoLoop();
|
||||
},
|
||||
[stopServoLoop],
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user