more tweaks

This commit is contained in:
legop3
2026-06-10 14:44:23 -04:00
parent 426fd3e7c4
commit 4fd44d6ad6
7 changed files with 194 additions and 32 deletions
@@ -12,7 +12,6 @@ import {
import DriveDockAction, { useDriveDockState } from '../DriveDockAction/index.jsx';
import NightVisionControl from '../NightVisionControl/index.jsx';
import HornControl from '../HornControl/index.jsx';
import CameraTiltControl from '../CameraTiltControl/index.jsx';
import FloatingJoystick from './FloatingJoystick.jsx';
import MobileAuxButton from './MobileAuxButton.jsx';
import {
@@ -37,6 +36,134 @@ function getSpeedModeConfig(speedMode) {
return DRIVE_PAD_SPEED_MODES.find((mode) => mode.id === speedMode) || DRIVE_PAD_SPEED_MODES[1];
}
function clampCameraAngle(value, min, max) {
if (!Number.isFinite(value)) return min;
return Math.max(min, Math.min(max, value));
}
function formatCameraAngle(value) {
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
return `${value > 0 ? '+' : ''}${value.toFixed(1)}°`;
}
function MobileCameraTiltSlider({
value,
min,
max,
step = 0.5,
disabled = false,
onChange,
}) {
const trackRef = useRef(null);
const pointerIdRef = useRef(null);
const valuePercent = useMemo(() => {
if (max === min) return 50;
return ((clampCameraAngle(value, min, max) - min) / (max - min)) * 100;
}, [max, min, value]);
const disabledClass = disabled ? 'opacity-50' : '';
const valueFromPointer = useCallback(
(event) => {
const track = trackRef.current;
if (!track) return value;
const rect = track.getBoundingClientRect();
const usableHeight = Math.max(1, rect.height);
const rawPercent = 1 - (event.clientY - rect.top) / usableHeight;
const unclamped = min + clampCameraAngle(rawPercent, 0, 1) * (max - min);
const stepped = Math.round(unclamped / step) * step;
return clampCameraAngle(stepped, min, max);
},
[max, min, step, value],
);
const sendPointerValue = useCallback(
(event) => {
const next = valueFromPointer(event);
onChange?.(next);
},
[onChange, valueFromPointer],
);
const handlePointerDown = useCallback(
(event) => {
if (disabled) return;
if (pointerIdRef.current !== null) return;
/*
Native vertical range inputs are unreliable as a second simultaneous
touch on mobile Safari while the drive pad owns another active pointer.
This custom track captures only the camera finger, so the drive thumb can
continue moving without stealing or cancelling camera tilt updates.
*/
event.preventDefault();
pointerIdRef.current = event.pointerId;
trackRef.current?.setPointerCapture?.(event.pointerId);
sendPointerValue(event);
},
[disabled, sendPointerValue],
);
const handlePointerMove = useCallback(
(event) => {
if (pointerIdRef.current !== event.pointerId) return;
event.preventDefault();
sendPointerValue(event);
},
[sendPointerValue],
);
const handlePointerEnd = useCallback((event) => {
if (pointerIdRef.current !== event.pointerId) return;
event.preventDefault();
pointerIdRef.current = null;
trackRef.current?.releasePointerCapture?.(event.pointerId);
}, []);
return (
<div className="mobile-touch-control flex h-full items-center justify-center gap-0.5">
<span className="mobile-touch-control text-sm font-semibold text-emerald-50 [writing-mode:vertical-rl] rotate-180">
Camera tilt
</span>
<div
ref={trackRef}
role="slider"
aria-label="Camera tilt"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={Number.isFinite(value) ? value : min}
aria-valuetext={formatCameraAngle(value)}
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onLostPointerCapture={(event) => {
if (pointerIdRef.current === event.pointerId) pointerIdRef.current = null;
}}
onContextMenu={(event) => event.preventDefault()}
/*
touchAction stays inline as a second line of defense because this is the
element that must keep browser panning/zooming out of the multi-touch
camera gesture.
*/
style={{ touchAction: 'none' }}
className={`mobile-touch-control mobile-drag-control relative h-full w-6 rounded-full border border-emerald-100/80 bg-emerald-950 shadow-inner focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200 ${disabledClass}`.trim()}
>
<div
className="pointer-events-none absolute inset-x-1 bottom-1 rounded-full bg-emerald-400"
style={{ height: `${valuePercent}%` }}
/>
<div
className="pointer-events-none absolute left-1/2 h-3.5 w-3.5 -translate-x-1/2 rounded-full border border-emerald-950 bg-emerald-200 shadow"
style={{ bottom: `clamp(0.25rem, calc(${valuePercent}% - 0.4375rem), calc(100% - 1.125rem))` }}
/>
</div>
</div>
);
}
function MobileJoystickPanel({ layout }) {
const {
state: { roverId, keymap: rawKeymap },
@@ -297,23 +424,13 @@ function MobileActionsColumnContent() {
// Match the desktop camera tilt card's emerald styling so the vertical
// mobile control reads as the same feature in a phone-sized layout.
<div className="mobile-touch-control flex-1 min-h-0 rounded-xl border-2 border-emerald-300/70 bg-emerald-900 px-1 py-1 text-emerald-50">
<CameraTiltControl
<MobileCameraTiltSlider
value={cameraValue}
min={cameraMin}
max={cameraMax}
step={0.5}
disabled={cameraDisabled}
onChange={setServoAngle}
orientation="vertical"
label="Camera tilt"
labelClass="mobile-touch-control text-sm font-semibold text-emerald-50 [writing-mode:vertical-rl] rotate-180"
labelRowClass="mobile-touch-control text-[0.7rem] text-emerald-100"
valueClass="font-mono text-slate-100"
className="h-full gap-0"
sliderClass="mobile-touch-control mobile-drag-control h-full w-7"
accentClass="accent-emerald-400"
showEndpoints={false}
showValue={false}
/>
</div>
) : null}