mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -04:00
117 lines
4.2 KiB
React
117 lines
4.2 KiB
React
// 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.
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { clampUnit } from '../../controls/controlMath.js';
|
|
|
|
export default function FloatingJoystick({ disabled, layout, radius, 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), radius);
|
|
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,
|
|
};
|
|
setVisual((prev) => ({ ...prev, knob: { x: knobX, y: knobY } }));
|
|
onMove?.(vector);
|
|
},
|
|
[disabled, onMove, radius],
|
|
);
|
|
|
|
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 = '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>
|
|
)}
|
|
{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 }}
|
|
/>
|
|
<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>
|
|
);
|
|
}
|