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:
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
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-BJBZKaDf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BIlplCN6.css">
|
||||
<script type="module" crossorigin src="/assets/index-DJTuj0vY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-_P0iSe8G.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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