replacing the joystick for mobile!

This commit is contained in:
legop3
2026-06-10 13:53:27 -04:00
parent 8d90a29345
commit bcbd5d03c7
10 changed files with 562 additions and 433 deletions
@@ -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 };
@@ -16,6 +16,13 @@ import {
SONG_NOTE_RANGE,
SONG_REPEAT_MS,
} from '../constants.js';
import {
bindingActive,
computeKeyboardAuxMotors,
computeKeyboardDriveVector,
getKeyboardDriveSpeedOptions,
resolveKeyboardSpeeds,
} from './driveIntent.js';
const SOURCE = 'keyboard';
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
@@ -44,12 +51,6 @@ function nextVideoFilter(value) {
return VIDEO_FILTER_SEQUENCE[(currentIndex + 1) % VIDEO_FILTER_SEQUENCE.length];
}
function clampSpeed(value, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(0, Math.min(500, num));
}
function clampTiltInterval(value, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
@@ -80,61 +81,6 @@ function shouldIgnoreEvent(event) {
return isTextInputElement(event?.target);
}
function bindingActive(bindingSet, keys) {
if (!bindingSet || bindingSet.size === 0) return false;
for (const key of keys) {
if (bindingSet.has(key)) return true;
}
return false;
}
function computeDriveVector(keys, keymap) {
const forward = bindingActive(keymap.driveForward, keys);
const backward = bindingActive(keymap.driveBackward, keys);
const left = bindingActive(keymap.driveLeft, keys);
const right = bindingActive(keymap.driveRight, keys);
const boost = bindingActive(keymap.boostModifier, keys);
const slow = bindingActive(keymap.slowModifier, keys);
let y = 0;
if (forward && !backward) y = 1;
else if (backward && !forward) y = -1;
let x = 0;
if (left && !right) x = -1;
else if (right && !left) x = 1;
const scale = slow ? 0.4 : 1;
return {
x: x * scale,
y: y * scale,
boost: boost && !slow,
};
}
function computeAuxMotors(keys, keymap) {
const allForward = bindingActive(keymap.auxAllForward, keys);
if (allForward) {
return { main: 127, side: 127, vacuum: 127 };
}
const main = bindingActive(keymap.auxMainForward, keys)
? 127
: bindingActive(keymap.auxMainReverse, keys)
? -127
: 0;
const side = bindingActive(keymap.auxSideForward, keys)
? 127
: bindingActive(keymap.auxSideReverse, keys)
? -70
: 0;
const vacuum = bindingActive(keymap.auxVacuumFast, keys)
? 127
: bindingActive(keymap.auxVacuumSlow, keys)
? 50
: 0;
return { main, side, vacuum };
}
export default function KeyboardInputManager() {
const {
state,
@@ -170,14 +116,8 @@ export default function KeyboardInputManager() {
return tokens;
}, [keymap]);
const keyboardSpeeds = useMemo(() => {
const defaults = INPUT_SETTINGS_DEFAULTS.keyboard;
const current = inputSettings?.keyboard ?? {};
return {
baseSpeed: clampSpeed(current.baseSpeed, defaults.baseSpeed),
turboSpeed: clampSpeed(current.turboSpeed, defaults.turboSpeed),
precisionSpeed: clampSpeed(current.precisionSpeed, defaults.precisionSpeed),
};
}, [inputSettings?.keyboard]);
return resolveKeyboardSpeeds(inputSettings);
}, [inputSettings]);
const servoRepeatMs = useMemo(() => {
const defaults = INPUT_SETTINGS_DEFAULTS.keyboard;
const current = inputSettings?.keyboard ?? {};
@@ -205,12 +145,9 @@ export default function KeyboardInputManager() {
const driveFromKeys = useCallback(() => {
const tokensSnapshot = new Set(activeTokensRef.current);
const slowActive = bindingActive(keymap.slowModifier, tokensSnapshot);
const speedOptions = slowActive
? { baseSpeed: keyboardSpeeds.precisionSpeed, boostSpeed: keyboardSpeeds.precisionSpeed }
: { baseSpeed: keyboardSpeeds.baseSpeed, boostSpeed: keyboardSpeeds.turboSpeed };
const vector = computeDriveVector(tokensSnapshot, keymap);
const aux = computeAuxMotors(tokensSnapshot, keymap);
const speedOptions = getKeyboardDriveSpeedOptions(tokensSnapshot, keymap, keyboardSpeeds);
const vector = computeKeyboardDriveVector(tokensSnapshot, keymap);
const aux = computeKeyboardAuxMotors(tokensSnapshot, keymap);
if (
vector.x !== lastVectorRef.current.x ||
vector.y !== lastVectorRef.current.y ||
+85
View File
@@ -0,0 +1,85 @@
// Drive Intent Helpers
// Purpose: Shares keyboard-style drive intent math between physical keyboard input and mobile thumb controls.
// Scope: Converts active key tokens into normalized drive vectors, auxiliary motor values, and speed options.
import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
export function clampKeyboardSpeed(value, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(0, Math.min(500, num));
}
export function resolveKeyboardSpeeds(inputSettings) {
const defaults = INPUT_SETTINGS_DEFAULTS.keyboard;
const current = inputSettings?.keyboard ?? {};
return {
baseSpeed: clampKeyboardSpeed(current.baseSpeed, defaults.baseSpeed),
turboSpeed: clampKeyboardSpeed(current.turboSpeed, defaults.turboSpeed),
precisionSpeed: clampKeyboardSpeed(current.precisionSpeed, defaults.precisionSpeed),
};
}
export function bindingActive(bindingSet, keys) {
if (!bindingSet || bindingSet.size === 0) return false;
for (const key of keys) {
if (bindingSet.has(key)) return true;
}
return false;
}
export function computeKeyboardDriveVector(keys, keymap) {
const forward = bindingActive(keymap.driveForward, keys);
const backward = bindingActive(keymap.driveBackward, keys);
const left = bindingActive(keymap.driveLeft, keys);
const right = bindingActive(keymap.driveRight, keys);
const boost = bindingActive(keymap.boostModifier, keys);
const slow = bindingActive(keymap.slowModifier, keys);
let y = 0;
if (forward && !backward) y = 1;
else if (backward && !forward) y = -1;
let x = 0;
if (left && !right) x = -1;
else if (right && !left) x = 1;
// The keyboard precision modifier intentionally reduces the vector as well as the
// speed cap. Mobile precision mode uses the same path so thumb driving feels like
// holding the same keyboard modifier, not like a separate mobile-only tune.
const scale = slow ? 0.4 : 1;
return {
x: x * scale,
y: y * scale,
boost: boost && !slow,
};
}
export function computeKeyboardAuxMotors(keys, keymap) {
const allForward = bindingActive(keymap.auxAllForward, keys);
if (allForward) {
return { main: 127, side: 127, vacuum: 127 };
}
const main = bindingActive(keymap.auxMainForward, keys)
? 127
: bindingActive(keymap.auxMainReverse, keys)
? -127
: 0;
const side = bindingActive(keymap.auxSideForward, keys)
? 127
: bindingActive(keymap.auxSideReverse, keys)
? -70
: 0;
const vacuum = bindingActive(keymap.auxVacuumFast, keys)
? 127
: bindingActive(keymap.auxVacuumSlow, keys)
? 50
: 0;
return { main, side, vacuum };
}
export function getKeyboardDriveSpeedOptions(keys, keymap, keyboardSpeeds) {
const slowActive = bindingActive(keymap.slowModifier, keys);
return slowActive
? { baseSpeed: keyboardSpeeds.precisionSpeed, boostSpeed: keyboardSpeeds.precisionSpeed }
: { baseSpeed: keyboardSpeeds.baseSpeed, boostSpeed: keyboardSpeeds.turboSpeed };
}