mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
replacing the joystick for mobile!
This commit is contained in:
@@ -1,76 +1,155 @@
|
||||
// Floating Joystick
|
||||
// Purpose: Defines the Floating Joystick module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
// Floating Drive Pad
|
||||
// Purpose: Provides a one-thumb mobile drive surface that emits keyboard-style fixed drive intents.
|
||||
// Scope: Owns pointer tracking, fixed overlay positioning, and active 3x3 drive-zone feedback.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { clampUnit } from '../../controls/controlMath.js';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
const JOYSTICK_DEADZONE = 0.08;
|
||||
const STRAIGHT_GATE_MIN_Y = 0.28;
|
||||
const STRAIGHT_GATE_MIN_X = 0.10;
|
||||
const STRAIGHT_GATE_Y_RATIO = 0.24;
|
||||
const STEERING_CURVE = 1.25;
|
||||
const THROTTLE_CURVE = 1.08;
|
||||
const PAD_MARGIN = 12;
|
||||
const CELL_COUNT = 3;
|
||||
const DEFAULT_PAD_SIZE = 180;
|
||||
|
||||
function applySignedCurve(value, exponent) {
|
||||
if (!value) return 0;
|
||||
return Math.sign(value) * Math.pow(Math.abs(value), exponent);
|
||||
const DRIVE_CELLS = [
|
||||
{ id: 'forward-left', label: 'fwd left', col: 0, row: 0, actions: ['driveForward', 'driveLeft'] },
|
||||
{ id: 'forward', label: 'fwd', col: 1, row: 0, actions: ['driveForward'] },
|
||||
{ id: 'forward-right', label: 'fwd right', col: 2, row: 0, actions: ['driveForward', 'driveRight'] },
|
||||
{ id: 'left', label: 'left', col: 0, row: 1, actions: ['driveLeft'] },
|
||||
{ id: 'stop', label: 'stop', col: 1, row: 1, actions: [] },
|
||||
{ id: 'right', label: 'right', col: 2, row: 1, actions: ['driveRight'] },
|
||||
{ id: 'back-left', label: 'back left', col: 0, row: 2, actions: ['driveBackward', 'driveLeft'] },
|
||||
{ id: 'back', label: 'back', col: 1, row: 2, actions: ['driveBackward'] },
|
||||
{ id: 'back-right', label: 'back right', col: 2, row: 2, actions: ['driveBackward', 'driveRight'] },
|
||||
];
|
||||
|
||||
const DRIVE_CELL_BY_POSITION = DRIVE_CELLS.reduce((lookup, cell) => {
|
||||
lookup[`${cell.row}:${cell.col}`] = cell;
|
||||
return lookup;
|
||||
}, {});
|
||||
|
||||
const STOP_CELL = DRIVE_CELLS.find((cell) => cell.id === 'stop');
|
||||
|
||||
function clamp(value, min, max) {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function applyRadialDeadzone(x, y) {
|
||||
const magnitude = Math.hypot(x, y);
|
||||
if (magnitude <= JOYSTICK_DEADZONE) return { x: 0, y: 0 };
|
||||
|
||||
// Rescaling the remaining travel keeps the joystick from feeling like it loses
|
||||
// range after the deadzone. A thumb that reaches the outer ring still sends a
|
||||
// full-strength command, but small center noise is ignored.
|
||||
const scaledMagnitude = (magnitude - JOYSTICK_DEADZONE) / (1 - JOYSTICK_DEADZONE);
|
||||
const ratio = scaledMagnitude / magnitude;
|
||||
function getViewportSize() {
|
||||
if (typeof window === 'undefined') return { width: DEFAULT_PAD_SIZE, height: DEFAULT_PAD_SIZE };
|
||||
return {
|
||||
x: clampUnit(x * ratio),
|
||||
y: clampUnit(y * ratio),
|
||||
width: window.innerWidth || DEFAULT_PAD_SIZE,
|
||||
height: window.innerHeight || DEFAULT_PAD_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
function applyStraightGate(x, y) {
|
||||
const absY = Math.abs(y);
|
||||
const absX = Math.abs(x);
|
||||
if (absY < STRAIGHT_GATE_MIN_Y) return x;
|
||||
function getPadSize(container) {
|
||||
if (!container) return DEFAULT_PAD_SIZE;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const viewport = getViewportSize();
|
||||
const availableWidth = Math.max(96, Math.min(rect.width, viewport.width - PAD_MARGIN * 2));
|
||||
|
||||
const gate = Math.min(0.45, Math.max(STRAIGHT_GATE_MIN_X, absY * STRAIGHT_GATE_Y_RATIO));
|
||||
if (absX <= gate) return 0;
|
||||
|
||||
// Once the thumb clearly leaves the straight-ahead corridor, compress only the
|
||||
// part that was reserved for accidental drift. This avoids a hard steering jump
|
||||
// at the corridor edge while preserving full left/right authority.
|
||||
return Math.sign(x) * ((absX - gate) / (1 - gate));
|
||||
// The floating grid should match the visible drive card width instead of being a
|
||||
// fixed global overlay size. That keeps the active pad visually connected to the
|
||||
// card and prevents it from feeling oversized in portrait split-column layouts.
|
||||
return Math.round(availableWidth);
|
||||
}
|
||||
|
||||
function shapeDriveVector(rawX, rawY) {
|
||||
const deadzoned = applyRadialDeadzone(clampUnit(rawX), clampUnit(rawY));
|
||||
const gatedX = applyStraightGate(deadzoned.x, deadzoned.y);
|
||||
function clampPadCenter(clientX, clientY, padSize) {
|
||||
const viewport = getViewportSize();
|
||||
const halfPad = padSize / 2;
|
||||
const minX = Math.min(viewport.width - halfPad, halfPad + PAD_MARGIN);
|
||||
const maxX = Math.max(halfPad + PAD_MARGIN, viewport.width - halfPad - PAD_MARGIN);
|
||||
const minY = Math.min(viewport.height - halfPad, halfPad + PAD_MARGIN);
|
||||
const maxY = Math.max(halfPad + PAD_MARGIN, viewport.height - halfPad - PAD_MARGIN);
|
||||
|
||||
// Steering gets a stronger curve than throttle because accidental horizontal
|
||||
// drift is the main problem when driving straight on glass. Intentional turns
|
||||
// still reach full output as the thumb approaches the edge of the ring.
|
||||
// The floating pad is fixed to the viewport instead of the card so overflow-hidden
|
||||
// containers cannot clip it. Clamping keeps the visible 3x3 target usable even when
|
||||
// the driver starts near the edge of a landscape phone screen.
|
||||
return {
|
||||
x: clampUnit(applySignedCurve(gatedX, STEERING_CURVE)),
|
||||
y: clampUnit(applySignedCurve(deadzoned.y, THROTTLE_CURVE)),
|
||||
boost: false,
|
||||
x: clamp(clientX, minX, maxX),
|
||||
y: clamp(clientY, minY, maxY),
|
||||
};
|
||||
}
|
||||
|
||||
export default function FloatingJoystick({ disabled, radius, onMove, onStop }) {
|
||||
function cellFromPointer(activePad, clientX, clientY) {
|
||||
const padSize = activePad?.size || DEFAULT_PAD_SIZE;
|
||||
const halfPad = padSize / 2;
|
||||
const cellSize = padSize / CELL_COUNT;
|
||||
const localX = clamp(clientX - activePad.center.x + halfPad, 0, padSize - 1);
|
||||
const localY = clamp(clientY - activePad.center.y + halfPad, 0, padSize - 1);
|
||||
const col = clamp(Math.floor(localX / cellSize), 0, CELL_COUNT - 1);
|
||||
const row = clamp(Math.floor(localY / cellSize), 0, CELL_COUNT - 1);
|
||||
return DRIVE_CELL_BY_POSITION[`${row}:${col}`] || STOP_CELL;
|
||||
}
|
||||
|
||||
function FloatingPadOverlay({ center, size, activeCellId }) {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const halfPad = size / 2;
|
||||
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed inset-0 z-[1000]" aria-hidden="true">
|
||||
<div
|
||||
className="absolute grid grid-cols-3 grid-rows-3 overflow-hidden rounded-lg border-2 border-cyan-300/80 bg-slate-950/90 shadow-2xl shadow-cyan-950/50 backdrop-blur-sm"
|
||||
style={{
|
||||
height: size,
|
||||
left: center.x - halfPad,
|
||||
top: center.y - halfPad,
|
||||
width: size,
|
||||
}}
|
||||
>
|
||||
{DRIVE_CELLS.map((cell) => {
|
||||
const active = cell.id === activeCellId;
|
||||
const isStop = cell.id === 'stop';
|
||||
const baseClass =
|
||||
'flex items-center justify-center border border-slate-700/80 px-1 text-center text-xs font-semibold leading-tight';
|
||||
const activeClass = active
|
||||
? isStop
|
||||
? 'bg-rose-500 text-white'
|
||||
: 'bg-cyan-300 text-slate-950'
|
||||
: isStop
|
||||
? 'bg-slate-900 text-slate-300'
|
||||
: 'bg-slate-800/80 text-slate-200';
|
||||
return (
|
||||
<div key={cell.id} className={`${baseClass} ${activeClass}`}>
|
||||
{cell.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default function FloatingJoystick({ disabled, onCellChange, 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 activePadRef = useRef(null);
|
||||
const activeCellIdRef = useRef(null);
|
||||
const [activePad, setActivePad] = useState(null);
|
||||
|
||||
const stopTracking = useCallback(() => {
|
||||
pointerIdRef.current = null;
|
||||
setVisual({ active: false, base: { x: 0, y: 0 }, knob: { x: 0, y: 0 } });
|
||||
activeCellIdRef.current = null;
|
||||
setActivePad(null);
|
||||
onStop?.();
|
||||
}, [onStop]);
|
||||
|
||||
const updateActiveCell = useCallback(
|
||||
(event) => {
|
||||
const currentPad = activePadRef.current;
|
||||
if (!currentPad) return;
|
||||
const cell = cellFromPointer(currentPad, event.clientX, event.clientY);
|
||||
if (activeCellIdRef.current !== cell.id) {
|
||||
activeCellIdRef.current = cell.id;
|
||||
onCellChange?.(cell);
|
||||
}
|
||||
setActivePad({
|
||||
...currentPad,
|
||||
activeCellId: cell.id,
|
||||
});
|
||||
},
|
||||
[onCellChange],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event) => {
|
||||
if (disabled) return;
|
||||
@@ -78,46 +157,39 @@ export default function FloatingJoystick({ disabled, radius, onMove, onStop }) {
|
||||
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;
|
||||
const size = getPadSize(container);
|
||||
const center = clampPadCenter(event.clientX, event.clientY, size);
|
||||
activePadRef.current = { center, size, activeCellId: STOP_CELL.id };
|
||||
container.setPointerCapture?.(event.pointerId);
|
||||
setVisual({ active: true, base: { x, y }, knob: { x: 0, y: 0 } });
|
||||
|
||||
// Starting in the center mirrors the old floating joystick behavior: putting
|
||||
// a thumb down establishes the control origin, and movement after that chooses
|
||||
// a direction. This prevents accidental drive commands from a simple touch.
|
||||
activeCellIdRef.current = STOP_CELL.id;
|
||||
setActivePad(activePadRef.current);
|
||||
onCellChange?.(STOP_CELL);
|
||||
},
|
||||
[disabled],
|
||||
[disabled, onCellChange],
|
||||
);
|
||||
|
||||
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 = shapeDriveVector(knobX / radius, -knobY / radius);
|
||||
setVisual((prev) => ({ ...prev, knob: { x: knobX, y: knobY } }));
|
||||
onMove?.(vector);
|
||||
updateActiveCell(event);
|
||||
},
|
||||
[disabled, onMove, radius],
|
||||
[disabled, updateActiveCell],
|
||||
);
|
||||
|
||||
const handlePointerEnd = useCallback(
|
||||
(event) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
const container = containerRef.current;
|
||||
container?.releasePointerCapture?.(event.pointerId);
|
||||
const pointerId = event.pointerId;
|
||||
stopTracking();
|
||||
containerRef.current?.releasePointerCapture?.(pointerId);
|
||||
},
|
||||
[stopTracking],
|
||||
);
|
||||
@@ -125,54 +197,43 @@ export default function FloatingJoystick({ disabled, radius, onMove, onStop }) {
|
||||
useEffect(() => {
|
||||
if (!disabled) return undefined;
|
||||
|
||||
// Defer the disabled cleanup out of the effect body so React's set-state-in-effect
|
||||
// lint rule is satisfied while still clearing the active visual shortly after
|
||||
// rover access is lost.
|
||||
// Defer visual cleanup so this component stays compatible with the repo's strict
|
||||
// React hook lint rules while still guaranteeing that losing rover access stops
|
||||
// any active mobile drive gesture.
|
||||
const timer = setTimeout(stopTracking, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [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
|
||||
ref={containerRef}
|
||||
role="presentation"
|
||||
className="relative flex h-full min-h-[10rem] w-full select-none items-center justify-center overflow-hidden text-slate-100"
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onLostPointerCapture={(event) => {
|
||||
if (pointerIdRef.current === event.pointerId) stopTracking();
|
||||
}}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="pointer-events-none flex flex-col items-center gap-0.5 text-center">
|
||||
<span className="text-sm font-semibold text-slate-100">drive pad</span>
|
||||
<span className="px-2 text-xs leading-tight text-slate-300">
|
||||
hold and drag for keyboard-style driving
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{visual.active && (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 bg-cyan-400/10 outline outline-2 outline-cyan-400/60 [clip-path:circle(50%)]"
|
||||
style={{
|
||||
height: radius * 2,
|
||||
left: visual.base.x,
|
||||
top: visual.base.y,
|
||||
width: radius * 2,
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
{activePad ? (
|
||||
<FloatingPadOverlay
|
||||
center={activePad.center}
|
||||
size={activePad.size}
|
||||
activeCellId={activePad.activeCellId}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
// 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, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useControlSystem } from '../../controls/index.js';
|
||||
import { clampUnit } from '../../controls/controlMath.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';
|
||||
@@ -12,129 +17,143 @@ import FloatingJoystick from './FloatingJoystick.jsx';
|
||||
import MobileAuxButton from './MobileAuxButton.jsx';
|
||||
import {
|
||||
SOURCE,
|
||||
JOYSTICK_RADIUS,
|
||||
JOYSTICK_SEND_INTERVAL_MS,
|
||||
JOYSTICK_SMOOTHING,
|
||||
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 MobileJoystickPanel({ layout }) {
|
||||
const {
|
||||
state: { roverId },
|
||||
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 joystickRadius = JOYSTICK_RADIUS;
|
||||
const smoothing = JOYSTICK_SMOOTHING;
|
||||
const smoothedVectorRef = useRef({ x: 0, y: 0, boost: false });
|
||||
const pendingVectorRef = useRef(null);
|
||||
const sendTimerRef = useRef(null);
|
||||
const lastSentAtRef = useRef(0);
|
||||
const [speedMode, setSpeedMode] = useState('normal');
|
||||
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 clearPendingSend = useCallback(() => {
|
||||
if (!sendTimerRef.current) return;
|
||||
clearTimeout(sendTimerRef.current);
|
||||
sendTimerRef.current = null;
|
||||
const clearRepeatTimer = useCallback(() => {
|
||||
if (!repeatTimerRef.current) return;
|
||||
clearInterval(repeatTimerRef.current);
|
||||
repeatTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const sendMobileDriveVector = useCallback(
|
||||
(vector, lastEvent = 'move') => {
|
||||
lastSentAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
setDriveVector(vector, { source: SOURCE });
|
||||
registerInputState(SOURCE, { vector, lastEvent });
|
||||
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;
|
||||
},
|
||||
[registerInputState, setDriveVector],
|
||||
[keymap],
|
||||
);
|
||||
|
||||
const flushPendingDriveVector = useCallback(() => {
|
||||
const pending = pendingVectorRef.current;
|
||||
pendingVectorRef.current = null;
|
||||
sendTimerRef.current = null;
|
||||
if (!pending || disabled) return;
|
||||
sendMobileDriveVector(pending, 'move');
|
||||
}, [disabled, sendMobileDriveVector]);
|
||||
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);
|
||||
|
||||
const queueDriveVector = useCallback(
|
||||
(vector) => {
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const elapsed = now - lastSentAtRef.current;
|
||||
|
||||
if (elapsed >= JOYSTICK_SEND_INTERVAL_MS) {
|
||||
clearPendingSend();
|
||||
pendingVectorRef.current = null;
|
||||
sendMobileDriveVector(vector, 'move');
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep only the newest shaped vector while the send gate is closed. This gives
|
||||
// the rover a stable command rhythm without letting an older thumb position
|
||||
// overwrite the driver's latest correction when the timer fires.
|
||||
pendingVectorRef.current = vector;
|
||||
if (sendTimerRef.current) return;
|
||||
sendTimerRef.current = setTimeout(
|
||||
flushPendingDriveVector,
|
||||
Math.max(0, JOYSTICK_SEND_INTERVAL_MS - elapsed),
|
||||
);
|
||||
// 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,
|
||||
});
|
||||
},
|
||||
[clearPendingSend, flushPendingDriveVector, sendMobileDriveVector],
|
||||
[
|
||||
buildVirtualKeyTokens,
|
||||
disabled,
|
||||
keyboardSpeeds,
|
||||
keymap,
|
||||
registerInputState,
|
||||
setDriveVector,
|
||||
],
|
||||
);
|
||||
|
||||
const stopDrivePad = useCallback(
|
||||
(lastEvent = 'stop') => {
|
||||
clearRepeatTimer();
|
||||
activeCellRef.current = null;
|
||||
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;
|
||||
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 () => clearPendingSend();
|
||||
}, [clearPendingSend]);
|
||||
return () => clearRepeatTimer();
|
||||
}, [clearRepeatTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) return;
|
||||
|
||||
// Losing the rover assignment or docking into a non-driving state must leave no
|
||||
// delayed drive command behind. A queued non-zero vector sent after disable would
|
||||
// be especially confusing because the visible joystick has already disappeared.
|
||||
clearPendingSend();
|
||||
pendingVectorRef.current = null;
|
||||
smoothedVectorRef.current = { x: 0, y: 0, boost: false };
|
||||
}, [clearPendingSend, disabled]);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(vector = {}) => {
|
||||
if (disabled) return;
|
||||
const next = {
|
||||
x: clampUnit(vector.x ?? 0),
|
||||
y: clampUnit(vector.y ?? 0),
|
||||
boost: Boolean(vector.boost),
|
||||
};
|
||||
const applied =
|
||||
smoothing > 0
|
||||
? {
|
||||
x: smoothedVectorRef.current.x + (next.x - smoothedVectorRef.current.x) * (1 - smoothing),
|
||||
y: smoothedVectorRef.current.y + (next.y - smoothedVectorRef.current.y) * (1 - smoothing),
|
||||
boost: next.boost,
|
||||
}
|
||||
: next;
|
||||
|
||||
// This light low-pass filter removes high-frequency thumb tremor after the
|
||||
// joystick has already applied its deadzone and straight corridor. It is kept
|
||||
// small so the one-thumb control still responds quickly when the driver commits
|
||||
// to a turn or releases back toward center.
|
||||
smoothedVectorRef.current = applied;
|
||||
queueDriveVector(applied);
|
||||
},
|
||||
[disabled, queueDriveVector, smoothing],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (disabled) return;
|
||||
const zero = { x: 0, y: 0, boost: false };
|
||||
clearPendingSend();
|
||||
pendingVectorRef.current = null;
|
||||
smoothedVectorRef.current = zero;
|
||||
sendMobileDriveVector(zero, 'stop');
|
||||
}, [clearPendingSend, disabled, sendMobileDriveVector]);
|
||||
stopDrivePad('disabled');
|
||||
}, [disabled, stopDrivePad]);
|
||||
|
||||
const fillClass = dockedNotDriving ? 'max-h-screen self-start' : '';
|
||||
const containerClass = `flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`;
|
||||
@@ -148,13 +167,41 @@ function MobileJoystickPanel({ layout }) {
|
||||
compactHeightClass="min-h-[5rem]"
|
||||
/>
|
||||
{!expandAction ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<FloatingJoystick
|
||||
disabled={disabled}
|
||||
radius={joystickRadius}
|
||||
onMove={handleMove}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900/70 text-slate-100 shadow-md">
|
||||
<div className="grid grid-cols-3 gap-0.5 border-b border-slate-700 bg-slate-950/80 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={`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="min-h-0 flex-1">
|
||||
<FloatingJoystick
|
||||
disabled={disabled}
|
||||
onCellChange={handleCellChange}
|
||||
onStop={() => stopDrivePad('stop')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
// constants
|
||||
// Purpose: Defines the constants module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
export const SOURCE = 'mobile-joystick';
|
||||
// The visual base ring is drawn from this same value, so the driver's thumb reaches
|
||||
// "full stick" when the UI also shows the knob at the edge. The previous math used
|
||||
// a larger invisible radius than the drawn ring, which made intentional full turns
|
||||
// require more thumb travel than the control appeared to promise.
|
||||
export const JOYSTICK_RADIUS = 64;
|
||||
export const SOURCE = 'mobile-drive-pad';
|
||||
|
||||
// Mobile pointer events can arrive much faster than the rover command loop can usefully
|
||||
// react. Sending at a steady cadence keeps tiny thumb jitter and browser event bursts
|
||||
// from turning into uneven wheel commands while still feeling live under one thumb.
|
||||
export const JOYSTICK_SEND_INTERVAL_MS = 90;
|
||||
export const JOYSTICK_SMOOTHING = 0.15;
|
||||
// The mobile pad repeats the current fixed keyboard-style command while the thumb is
|
||||
// held down. The repeat keeps delayed rover/network paths fed with the same intent
|
||||
// without turning touch jitter into analog speed changes.
|
||||
export const DRIVE_PAD_REPEAT_MS = 100;
|
||||
export const DRIVE_PAD_SPEED_MODES = [
|
||||
{ id: 'precision', label: 'precision', modifierAction: 'slowModifier' },
|
||||
{ id: 'normal', label: 'normal', modifierAction: null },
|
||||
{ id: 'turbo', label: 'turbo', modifierAction: 'boostModifier' },
|
||||
];
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user