mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
better mobile joystick?
This commit is contained in:
@@ -4,7 +4,62 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { clampUnit } from '../../controls/controlMath.js';
|
||||
|
||||
export default function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
|
||||
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;
|
||||
|
||||
function applySignedCurve(value, exponent) {
|
||||
if (!value) return 0;
|
||||
return Math.sign(value) * Math.pow(Math.abs(value), exponent);
|
||||
}
|
||||
|
||||
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;
|
||||
return {
|
||||
x: clampUnit(x * ratio),
|
||||
y: clampUnit(y * ratio),
|
||||
};
|
||||
}
|
||||
|
||||
function applyStraightGate(x, y) {
|
||||
const absY = Math.abs(y);
|
||||
const absX = Math.abs(x);
|
||||
if (absY < STRAIGHT_GATE_MIN_Y) return x;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
function shapeDriveVector(rawX, rawY) {
|
||||
const deadzoned = applyRadialDeadzone(clampUnit(rawX), clampUnit(rawY));
|
||||
const gatedX = applyStraightGate(deadzoned.x, deadzoned.y);
|
||||
|
||||
// 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.
|
||||
return {
|
||||
x: clampUnit(applySignedCurve(gatedX, STEERING_CURVE)),
|
||||
y: clampUnit(applySignedCurve(deadzoned.y, THROTTLE_CURVE)),
|
||||
boost: false,
|
||||
};
|
||||
}
|
||||
|
||||
export default function FloatingJoystick({ disabled, radius, onMove, onStop }) {
|
||||
const containerRef = useRef(null);
|
||||
const pointerIdRef = useRef(null);
|
||||
const baseRef = useRef({ x: 0, y: 0 });
|
||||
@@ -49,11 +104,7 @@ export default function FloatingJoystick({ disabled, layout, radius, onMove, onS
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const knobX = Math.cos(angle) * distance;
|
||||
const knobY = Math.sin(angle) * distance;
|
||||
const vector = {
|
||||
x: clampUnit(knobX / radius),
|
||||
y: clampUnit(-knobY / radius),
|
||||
boost: false,
|
||||
};
|
||||
const vector = shapeDriveVector(knobX / radius, -knobY / radius);
|
||||
setVisual((prev) => ({ ...prev, knob: { x: knobX, y: knobY } }));
|
||||
onMove?.(vector);
|
||||
},
|
||||
@@ -72,7 +123,13 @@ export default function FloatingJoystick({ disabled, layout, radius, onMove, onS
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) stopTracking();
|
||||
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.
|
||||
const timer = setTimeout(stopTracking, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [disabled, stopTracking]);
|
||||
|
||||
const heightClass = 'h-full';
|
||||
@@ -99,8 +156,13 @@ export default function FloatingJoystick({ disabled, layout, radius, onMove, onS
|
||||
{visual.active && (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute h-28 w-28 -translate-x-1/2 -translate-y-1/2 bg-cyan-400/10 outline outline-2 outline-cyan-400/60 [clip-path:circle(50%)]"
|
||||
style={{ left: visual.base.x, top: visual.base.y }}
|
||||
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%)]"
|
||||
|
||||
@@ -13,6 +13,7 @@ import MobileAuxButton from './MobileAuxButton.jsx';
|
||||
import {
|
||||
SOURCE,
|
||||
JOYSTICK_RADIUS,
|
||||
JOYSTICK_SEND_INTERVAL_MS,
|
||||
JOYSTICK_SMOOTHING,
|
||||
AUX_ZERO,
|
||||
AUX_ALL_FORWARD,
|
||||
@@ -32,10 +33,72 @@ function MobileJoystickPanel({ layout }) {
|
||||
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 clearPendingSend = useCallback(() => {
|
||||
if (!sendTimerRef.current) return;
|
||||
clearTimeout(sendTimerRef.current);
|
||||
sendTimerRef.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 });
|
||||
},
|
||||
[registerInputState, setDriveVector],
|
||||
);
|
||||
|
||||
const flushPendingDriveVector = useCallback(() => {
|
||||
const pending = pendingVectorRef.current;
|
||||
pendingVectorRef.current = null;
|
||||
sendTimerRef.current = null;
|
||||
if (!pending || disabled) return;
|
||||
sendMobileDriveVector(pending, 'move');
|
||||
}, [disabled, sendMobileDriveVector]);
|
||||
|
||||
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),
|
||||
);
|
||||
},
|
||||
[clearPendingSend, flushPendingDriveVector, sendMobileDriveVector],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) smoothedVectorRef.current = { x: 0, y: 0, boost: false };
|
||||
}, [disabled]);
|
||||
return () => clearPendingSend();
|
||||
}, [clearPendingSend]);
|
||||
|
||||
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 = {}) => {
|
||||
@@ -53,26 +116,31 @@ function MobileJoystickPanel({ layout }) {
|
||||
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;
|
||||
setDriveVector(applied, { source: SOURCE });
|
||||
registerInputState(SOURCE, { vector: applied, lastEvent: 'move' });
|
||||
queueDriveVector(applied);
|
||||
},
|
||||
[disabled, registerInputState, setDriveVector, smoothing],
|
||||
[disabled, queueDriveVector, smoothing],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (disabled) return;
|
||||
const zero = { x: 0, y: 0, boost: false };
|
||||
clearPendingSend();
|
||||
pendingVectorRef.current = null;
|
||||
smoothedVectorRef.current = zero;
|
||||
setDriveVector(zero, { source: SOURCE });
|
||||
registerInputState(SOURCE, { vector: zero, lastEvent: 'stop' });
|
||||
}, [disabled, registerInputState, setDriveVector]);
|
||||
sendMobileDriveVector(zero, 'stop');
|
||||
}, [clearPendingSend, disabled, sendMobileDriveVector]);
|
||||
|
||||
const fillClass = dockedNotDriving ? 'max-h-screen self-start' : '';
|
||||
const containerClass = `flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`;
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
<div className={containerClass} data-mobile-layout={layout}>
|
||||
<DriveDockAction
|
||||
layout="mobile"
|
||||
expand={expandAction}
|
||||
@@ -83,7 +151,6 @@ function MobileJoystickPanel({ layout }) {
|
||||
<div className="flex-1 min-h-0">
|
||||
<FloatingJoystick
|
||||
disabled={disabled}
|
||||
layout={layout}
|
||||
radius={joystickRadius}
|
||||
onMove={handleMove}
|
||||
onStop={handleStop}
|
||||
@@ -94,7 +161,7 @@ function MobileJoystickPanel({ layout }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MobileActionsColumnContent({ layout }) {
|
||||
function MobileActionsColumnContent() {
|
||||
const {
|
||||
state: { roverId, camera, horn },
|
||||
pipeline,
|
||||
@@ -222,15 +289,15 @@ function MobileActionsColumnContent({ layout }) {
|
||||
|
||||
export function MobileActionsColumn({ layout, className = '' }) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-0.5 ${className}`.trim()}>
|
||||
<MobileActionsColumnContent layout={layout} />
|
||||
<div className={`flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}>
|
||||
<MobileActionsColumnContent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileDriveColumn({ layout, className = '' }) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-0.5 ${className}`.trim()}>
|
||||
<div className={`flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}>
|
||||
<MobileJoystickPanel layout={layout === 'landscape' ? 'landscape' : 'portrait'} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
// 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';
|
||||
export const JOYSTICK_RADIUS = 80;
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
export const AUX_ZERO = { main: 0, side: 0, vacuum: 0 };
|
||||
export const AUX_ALL_FORWARD = { main: 127, side: 127, vacuum: 127 };
|
||||
|
||||
Reference in New Issue
Block a user