This commit is contained in:
legop3
2025-11-17 21:36:09 -05:00
parent dc6a4b3c3a
commit 12b5e18f0a
8 changed files with 268 additions and 92 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
<script type="module" crossorigin src="/assets/index-De1-bHUg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BMozRdoG.css">
<script type="module" crossorigin src="/assets/index-Bsnm9v3l.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CkUABVeV.css">
</head>
<body>
<div id="root"></div>
+15 -13
View File
@@ -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>
);
}
+152 -51
View File
@@ -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;
}