mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
more organized backend and better mobile control columns
This commit is contained in:
+5
-4
@@ -3,8 +3,8 @@ import TelemetryPanel from './components/TelemetryPanel.jsx';
|
||||
import ReplaySourcesPanel from './components/ReplaySourcesPanel.jsx';
|
||||
import AlertFeed from './components/AlertFeed.jsx';
|
||||
import MobileControls, {
|
||||
MobileLandscapeAuxColumn,
|
||||
MobileLandscapeControlColumn,
|
||||
MobileLeftColumn,
|
||||
MobileRightColumn,
|
||||
} from './components/MobileControls.jsx';
|
||||
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js';
|
||||
import { SettingsProvider } from './settings/index.js';
|
||||
@@ -139,10 +139,11 @@ function MobilePortraitLayout({ onOpenHelpOverlay }) {
|
||||
}
|
||||
|
||||
function MobileLandscapeLayout({ onOpenHelpOverlay }) {
|
||||
const columnClass = 'self-start h-[min(100svh,32rem)]';
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5">
|
||||
<MobileLandscapeAuxColumn />
|
||||
<MobileLeftColumn layout="landscape" className={columnClass} />
|
||||
<div>
|
||||
<DriverVideoPanel layoutFormat="mobile-landscape" />
|
||||
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||
@@ -151,7 +152,7 @@ function MobileLandscapeLayout({ onOpenHelpOverlay }) {
|
||||
</div>
|
||||
{/* <TelemetryPanel /> */}
|
||||
</div>
|
||||
<MobileLandscapeControlColumn />
|
||||
<MobileRightColumn layout="landscape" className={columnClass} />
|
||||
</section>
|
||||
<div className="flex flex-col gap-0.5 pb-0">
|
||||
<MobileFeatureTabs
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useControlSystem } from '../controls/index.js';
|
||||
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
||||
import NightVisionControl from './NightVisionControl.jsx';
|
||||
import HornControl from './HornControl.jsx';
|
||||
import CameraTiltControl from './CameraTiltControl.jsx';
|
||||
|
||||
const SLIDER_THROTTLE_MS = 150;
|
||||
|
||||
function formatDegrees(value) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}°`;
|
||||
}
|
||||
|
||||
export default function CameraServoPanel() {
|
||||
const {
|
||||
state: { roverId, camera, keymap, horn },
|
||||
pipeline,
|
||||
actions: { setServoAngle, nudgeServo, goServoHome, setNightVision, startHorn, stopHorn },
|
||||
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
|
||||
} = useControlSystem();
|
||||
const config = camera?.config;
|
||||
const enabled = Boolean(roverId && camera?.enabled && config);
|
||||
@@ -36,60 +31,6 @@ export default function CameraServoPanel() {
|
||||
|
||||
if (!enabled && !nightVisionAvailable && !hornAvailable) return null;
|
||||
|
||||
const [pendingAngle, setPendingAngle] = useState(value);
|
||||
const throttleRef = useRef(null);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingRef.current) {
|
||||
setPendingAngle(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const step =
|
||||
typeof config?.nudgeDegrees === 'number' && config.nudgeDegrees > 0
|
||||
? config.nudgeDegrees
|
||||
: 1;
|
||||
|
||||
const scheduleSend = (next) => {
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
}
|
||||
throttleRef.current = setTimeout(() => {
|
||||
setServoAngle(next);
|
||||
}, SLIDER_THROTTLE_MS);
|
||||
};
|
||||
|
||||
const handleSlider = (event) => {
|
||||
const next = Number.parseFloat(event.target.value);
|
||||
if (Number.isNaN(next)) return;
|
||||
draggingRef.current = true;
|
||||
setPendingAngle(next);
|
||||
scheduleSend(next);
|
||||
};
|
||||
|
||||
const commitSlider = () => {
|
||||
draggingRef.current = false;
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
}
|
||||
setServoAngle(pendingAngle);
|
||||
};
|
||||
|
||||
const handleNudge = (direction) => {
|
||||
const delta = step * direction;
|
||||
nudgeServo(delta);
|
||||
};
|
||||
|
||||
const handleNightVisionToggle = (nextOn) => {
|
||||
if (!nightVisionAvailable) return;
|
||||
setNightVision(nextOn);
|
||||
@@ -116,30 +57,21 @@ export default function CameraServoPanel() {
|
||||
/>
|
||||
)}
|
||||
{enabled && (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm text-slate-300">
|
||||
<span>Camera Tilt</span>
|
||||
<span className="font-mono text-sm text-slate-100">{formatDegrees(value)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
className="w-full accent-emerald-400"
|
||||
min={min}
|
||||
max={max}
|
||||
step={0.5}
|
||||
value={pendingAngle}
|
||||
onChange={handleSlider}
|
||||
onMouseUp={commitSlider}
|
||||
onTouchEnd={commitSlider}
|
||||
onPointerUp={commitSlider}
|
||||
/>
|
||||
<div className="mt-0 flex justify-between text-xs text-slate-400">
|
||||
<span>{formatDegrees(min)}</span>
|
||||
<span>{formatDegrees(max)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<CameraTiltControl
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={0.5}
|
||||
onChange={setServoAngle}
|
||||
throttleMs={SLIDER_THROTTLE_MS}
|
||||
className="space-y-0.5"
|
||||
labelRowClass="text-sm text-slate-300"
|
||||
valueClass="font-mono text-sm text-slate-100"
|
||||
sliderClass="w-full"
|
||||
accentClass="accent-emerald-400"
|
||||
endpointClass="text-xs text-slate-400"
|
||||
endpointLabelClass=""
|
||||
/>
|
||||
)}
|
||||
{/* <div className="flex gap-0.5 text-sm">
|
||||
<button type="button" className="flex-1 button-dark" onClick={() => handleNudge(-1)}>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
function formatDegrees(value) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}°`;
|
||||
}
|
||||
|
||||
function KeyPill({ label }) {
|
||||
if (!label) return null;
|
||||
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
|
||||
}
|
||||
|
||||
export default function CameraTiltControl({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 0.5,
|
||||
label = 'Camera Tilt',
|
||||
orientation = 'horizontal',
|
||||
onChange,
|
||||
onCommit,
|
||||
throttleMs = 0,
|
||||
disabled = false,
|
||||
keyDownLabel,
|
||||
keyUpLabel,
|
||||
className = '',
|
||||
labelRowClass = '',
|
||||
labelClass = '',
|
||||
valueClass = '',
|
||||
sliderClass = '',
|
||||
endpointClass = '',
|
||||
endpointLabelClass = '',
|
||||
accentClass = '',
|
||||
showValue = true,
|
||||
showEndpoints,
|
||||
}) {
|
||||
const isVertical = orientation === 'vertical';
|
||||
const defaultShowEndpoints = !isVertical;
|
||||
const shouldShowEndpoints = typeof showEndpoints === 'boolean' ? showEndpoints : defaultShowEndpoints;
|
||||
const [pending, setPending] = useState(value);
|
||||
const draggingRef = useRef(false);
|
||||
const throttleRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingRef.current) {
|
||||
setPending(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const scheduleSend = (next) => {
|
||||
if (!onChange) return;
|
||||
if (throttleMs > 0) {
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
}
|
||||
throttleRef.current = setTimeout(() => {
|
||||
onChange(next);
|
||||
}, throttleMs);
|
||||
return;
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const handleSlider = (event) => {
|
||||
const next = Number.parseFloat(event.target.value);
|
||||
if (Number.isNaN(next)) return;
|
||||
draggingRef.current = true;
|
||||
setPending(next);
|
||||
scheduleSend(next);
|
||||
};
|
||||
|
||||
const commitSlider = () => {
|
||||
draggingRef.current = false;
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
throttleRef.current = null;
|
||||
}
|
||||
if (onCommit) {
|
||||
onCommit(pending);
|
||||
} else if (throttleMs > 0) {
|
||||
onChange?.(pending);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof min !== 'number' || typeof max !== 'number') return null;
|
||||
|
||||
const mergedSliderClass = [
|
||||
isVertical ? 'h-28 w-5' : 'w-full',
|
||||
accentClass,
|
||||
sliderClass,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const slider = (
|
||||
<input
|
||||
type="range"
|
||||
orient={isVertical ? 'vertical' : undefined}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={Number.isFinite(pending) ? pending : min}
|
||||
onChange={handleSlider}
|
||||
onMouseUp={commitSlider}
|
||||
onTouchEnd={commitSlider}
|
||||
onPointerUp={commitSlider}
|
||||
disabled={disabled}
|
||||
className={mergedSliderClass}
|
||||
style={
|
||||
isVertical
|
||||
? { writingMode: 'bt-lr', WebkitAppearance: 'slider-vertical' }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isVertical) {
|
||||
return (
|
||||
<div className={`flex h-full flex-col items-center gap-0.5 ${className}`.trim()}>
|
||||
{showValue ? (
|
||||
<div className={`flex flex-col items-center ${labelRowClass}`.trim()}>
|
||||
<span className={valueClass}>{formatDegrees(value)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex min-h-0 flex-1 items-center gap-0.5">
|
||||
<span className={labelClass}>{label}</span>
|
||||
{slider}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-0.5 ${className}`.trim()}>
|
||||
<div className={`flex items-center justify-between ${labelRowClass}`.trim()}>
|
||||
<span className={labelClass}>{label}</span>
|
||||
{showValue ? <span className={valueClass}>{formatDegrees(value)}</span> : null}
|
||||
</div>
|
||||
<div>
|
||||
{slider}
|
||||
{shouldShowEndpoints ? (
|
||||
<div className={`mt-0 flex items-center justify-between ${endpointClass}`.trim()}>
|
||||
<span className={`flex items-center gap-0.5 ${endpointLabelClass}`.trim()}>
|
||||
{keyDownLabel ? <KeyPill label={keyDownLabel} /> : null}
|
||||
{formatDegrees(min)}
|
||||
</span>
|
||||
<span className={`flex items-center gap-0.5 ${endpointLabelClass}`.trim()}>
|
||||
{formatDegrees(max)}
|
||||
{keyUpLabel ? <KeyPill label={keyUpLabel} /> : null}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||
import { useControlSystem } from '../controls/index.js';
|
||||
@@ -8,6 +8,7 @@ import RoverRoster from './RoverRoster.jsx';
|
||||
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||
import NightVisionControl from './NightVisionControl.jsx';
|
||||
import HornControl from './HornControl.jsx';
|
||||
import CameraTiltControl from './CameraTiltControl.jsx';
|
||||
|
||||
export function RoverRosterPanel({ title = 'Rovers' }) {
|
||||
const { session, requestControl } = useSession();
|
||||
@@ -49,39 +50,17 @@ export function RoverRosterPanel({ title = 'Rovers' }) {
|
||||
|
||||
export default function ControlSummary() {
|
||||
const {
|
||||
state: { roverId, keymap },
|
||||
state: { roverId, keymap, camera, horn },
|
||||
pipeline,
|
||||
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
|
||||
} = useControlSystem();
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const sensors = frame?.sensors || {};
|
||||
const driveDockState = useDriveDockState(roverId);
|
||||
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
||||
|
||||
return (
|
||||
<section className="panel-section">
|
||||
<div className="grid items-stretch gap-0.5 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] md:min-h-[18rem]">
|
||||
<div className="flex h-full w-full items-stretch justify-center">
|
||||
<div className="aspect-square h-full w-full">
|
||||
<TopDownMap sensors={sensors} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
|
||||
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
|
||||
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineCameraTilt({ keymap }) {
|
||||
const {
|
||||
state: { roverId, camera, horn },
|
||||
pipeline,
|
||||
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
|
||||
} = useControlSystem();
|
||||
|
||||
const config = camera?.config;
|
||||
const enabled = Boolean(roverId && camera?.enabled && config);
|
||||
const cameraEnabled = Boolean(roverId && camera?.enabled && config);
|
||||
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
|
||||
const nightVisionState = pipeline?.nightVisionState;
|
||||
const hornAvailable = Boolean(roverId && pipeline?.horn);
|
||||
@@ -94,95 +73,62 @@ export function InlineCameraTilt({ keymap }) {
|
||||
: typeof config?.homeAngle === 'number'
|
||||
? config.homeAngle
|
||||
: (min + max) / 2;
|
||||
|
||||
const [pendingAngle, setPendingAngle] = useState(value);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingRef.current) {
|
||||
setPendingAngle(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleSlider = (event) => {
|
||||
const next = Number.parseFloat(event.target.value);
|
||||
if (Number.isNaN(next)) return;
|
||||
draggingRef.current = true;
|
||||
setPendingAngle(next);
|
||||
setServoAngle(next);
|
||||
};
|
||||
|
||||
const commitSlider = () => {
|
||||
draggingRef.current = false;
|
||||
};
|
||||
|
||||
if (!enabled && !nightVisionAvailable && !hornAvailable) return null;
|
||||
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
||||
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
||||
const nightVisionLabel = formatKeyLabel(keymap?.nightVisionToggle?.[0]);
|
||||
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
|
||||
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
||||
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
||||
|
||||
return (
|
||||
<div className="surface space-y-0.5 p-0 text-sm text-slate-200">
|
||||
{nightVisionAvailable && (
|
||||
<NightVisionControl
|
||||
nightVisionOn={nightVisionState?.nightVisionOn}
|
||||
disabled={!roverId}
|
||||
onToggle={setNightVision}
|
||||
keyLabel={nightVisionLabel}
|
||||
/>
|
||||
)}
|
||||
{hornAvailable && (
|
||||
<HornControl
|
||||
disabled={!roverId || hornBlocked}
|
||||
onStart={startHorn}
|
||||
onStop={stopHorn}
|
||||
keyLabel={hornLabel}
|
||||
active={horn?.active}
|
||||
heat={horn?.heat}
|
||||
/>
|
||||
)}
|
||||
{enabled && (
|
||||
<div className="space-y-0.5 px-1 py-1">
|
||||
<div className="flex items-center justify-between text-xs text-slate-300">
|
||||
<span>Camera tilt</span>
|
||||
<span className="font-mono text-slate-100">{formatDegrees(value)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
className="w-full accent-emerald-400"
|
||||
min={min}
|
||||
max={max}
|
||||
step={0.5}
|
||||
value={pendingAngle}
|
||||
onChange={handleSlider}
|
||||
onMouseUp={commitSlider}
|
||||
onTouchEnd={commitSlider}
|
||||
onPointerUp={commitSlider}
|
||||
/>
|
||||
<div className="mt-0 flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||
<span className="flex items-center gap-0.5">
|
||||
{downLabel ? <KeyPill label={downLabel} /> : null}
|
||||
{formatDegrees(min)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
{formatDegrees(max)}
|
||||
{upLabel ? <KeyPill label={upLabel} /> : null}
|
||||
</span>
|
||||
</div>
|
||||
<section className="panel-section">
|
||||
<div className="grid items-stretch gap-0.5 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] md:min-h-[18rem]">
|
||||
<div className="flex h-full w-full items-stretch justify-center">
|
||||
<div className="aspect-square h-full w-full">
|
||||
<TopDownMap sensors={sensors} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
|
||||
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
|
||||
{!hideInlineControls ? (
|
||||
<div className="surface space-y-0.5 p-0 text-sm text-slate-200">
|
||||
{nightVisionAvailable && (
|
||||
<NightVisionControl
|
||||
nightVisionOn={nightVisionState?.nightVisionOn}
|
||||
disabled={!roverId}
|
||||
onToggle={setNightVision}
|
||||
keyLabel={nightVisionLabel}
|
||||
/>
|
||||
)}
|
||||
{hornAvailable && (
|
||||
<HornControl
|
||||
disabled={!roverId || hornBlocked}
|
||||
onStart={startHorn}
|
||||
onStop={stopHorn}
|
||||
keyLabel={hornLabel}
|
||||
active={horn?.active}
|
||||
heat={horn?.heat}
|
||||
/>
|
||||
)}
|
||||
{cameraEnabled && (
|
||||
<CameraTiltControl
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={setServoAngle}
|
||||
keyDownLabel={downLabel}
|
||||
keyUpLabel={upLabel}
|
||||
className="space-y-0.5 px-1 py-1"
|
||||
labelRowClass="text-xs text-slate-300"
|
||||
labelClass=""
|
||||
valueClass="font-mono text-slate-100"
|
||||
sliderClass="w-full"
|
||||
accentClass="accent-emerald-400"
|
||||
endpointClass="text-[0.7rem] text-slate-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyPill({ label }) {
|
||||
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
|
||||
}
|
||||
|
||||
function formatDegrees(value) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}°`;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,13 @@ function DockModal({ instructions, onConfirm, onCancel, pending }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function DriveDockAction({ layout = 'desktop', expand = false, fill = false, driveDockState }) {
|
||||
export default function DriveDockAction({
|
||||
layout = 'desktop',
|
||||
expand = false,
|
||||
fill = false,
|
||||
driveDockState,
|
||||
compactHeightClass = '',
|
||||
}) {
|
||||
const isMobile = layout === 'mobile';
|
||||
const {
|
||||
state: { roverId, keymap },
|
||||
@@ -175,9 +181,10 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, fi
|
||||
};
|
||||
|
||||
const baseCardClasses =
|
||||
'flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-0.75 py-0.75 text-slate-100 shadow-md transition hover:-translate-y-0.5 hover:shadow-xl focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60';
|
||||
'flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-0.75 py-0.75 text-slate-100 shadow-md transition hover:-translate-y-0.5 hover:shadow-xl focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60 select-none no-touch-select';
|
||||
const ctaText = 'text-center';
|
||||
const ctaLayout = 'items-center justify-between';
|
||||
const compactLayout = 'items-center justify-center';
|
||||
const ctaTextAndLayout = `${ctaText} ${ctaLayout}`;
|
||||
const ctaSize = isMobile ? 'text-sm font-semibold' : '';
|
||||
const emeraldCta =
|
||||
@@ -187,32 +194,41 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, fi
|
||||
const indigoCta =
|
||||
'border-indigo-300/70 bg-indigo-900 text-indigo-50 hover:bg-indigo-800 focus-visible:ring-indigo-300';
|
||||
const filledHeight = expand ? 'h-full flex-1' : fill ? 'flex-1' : '';
|
||||
const compactHeight = isMobile && !expand ? compactHeightClass : '';
|
||||
const layoutClass = isMobile && !expand ? compactLayout : ctaLayout;
|
||||
|
||||
if (!driving && !dockingInProgress) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartDrive}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
disabled={driveDisabled}
|
||||
className={`${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${emeraldCta}`}
|
||||
className={`${baseCardClasses} ${filledHeight} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${emeraldCta}`}
|
||||
>
|
||||
<div className="space-y-0.5 w-full">
|
||||
<div className="flex w-full flex-col items-center gap-0.25">
|
||||
<span className="text-base font-semibold text-emerald-50 md:text-lg">Start Driving</span>
|
||||
{!isMobile ? (
|
||||
{!isMobile && expand ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||
{driveKeyLabel ? <KeyPill label={driveKeyLabel} /> : null}
|
||||
<ActionPill label="Click to start" tone="emerald" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm text-emerald-50/90">{startDriveInstructions.summary}</p>
|
||||
<StepList steps={startDriveInstructions.steps} tone="emerald" />
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
||||
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
|
||||
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
|
||||
{expand ? (
|
||||
<>
|
||||
<p className="text-sm text-emerald-50/90">{startDriveInstructions.summary}</p>
|
||||
<StepList steps={startDriveInstructions.steps} tone="emerald" />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{expand ? (
|
||||
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
||||
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
|
||||
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -228,24 +244,29 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, fi
|
||||
type="button"
|
||||
disabled={driveDisabled}
|
||||
onClick={handleReturnToDrive}
|
||||
className={`${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${amberCta}`}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
className={`${baseCardClasses} ${filledHeight} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${amberCta}`}
|
||||
>
|
||||
<div className="space-y-0.5 w-full">
|
||||
<div className="flex w-full flex-col items-center gap-0.25">
|
||||
<span className="text-base font-semibold text-amber-50 md:text-lg">Docking in Progress</span>
|
||||
{!isMobile ? (
|
||||
{!isMobile && expand ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||
<ActionPill label="Click to return to driving mode" tone="amber" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm text-amber-50/90">{inProgressCopy.summary}</p>
|
||||
{expand ? <p className="text-sm text-amber-50/90">{inProgressCopy.summary}</p> : null}
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
||||
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
|
||||
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
|
||||
</div>
|
||||
<StepList steps={inProgressCopy.steps} tone="amber" />
|
||||
{expand ? (
|
||||
<>
|
||||
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
||||
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
|
||||
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
|
||||
</div>
|
||||
<StepList steps={inProgressCopy.steps} tone="amber" />
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -256,22 +277,23 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, fi
|
||||
type="button"
|
||||
disabled={dockDisabled}
|
||||
onClick={handleOpenDock}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
className={
|
||||
isMobile
|
||||
? `flex w-full items-center justify-center ${baseCardClasses} ${ctaTextAndLayout} ${ctaSize} ${indigoCta}`
|
||||
: `${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${indigoCta}`
|
||||
? `flex w-full ${baseCardClasses} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${indigoCta}`
|
||||
: `${baseCardClasses} ${filledHeight} ${ctaText} ${ctaLayout} ${ctaSize} ${indigoCta}`
|
||||
}
|
||||
>
|
||||
<div className="flex w-full flex-col items-center gap-0.25">
|
||||
<span className="text-base font-semibold text-indigo-50 md:text-lg">Dock and Charge</span>
|
||||
{!isMobile ? (
|
||||
{!isMobile && expand ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||
{dockKeyLabel ? <KeyPill label={dockKeyLabel} /> : null}
|
||||
<ActionPill label="Click to begin docking" tone="indigo" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!isMobile && (
|
||||
{!isMobile && expand && (
|
||||
<>
|
||||
<p className="text-sm text-indigo-50/90">{dockButtonCaption}</p>
|
||||
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||
import { useControlSystem } from '../controls/index.js';
|
||||
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
||||
|
||||
export default function DrivePanel() {
|
||||
const {
|
||||
state: { roverId },
|
||||
actions,
|
||||
} = useControlSystem();
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const sensors = frame?.sensors || {};
|
||||
const drivingMode = (sensors.oiMode?.label || '').toLowerCase() === 'full';
|
||||
const docked = Boolean(sensors.chargingSources?.homeBase);
|
||||
const charging = Boolean(
|
||||
sensors.chargingState?.label && sensors.chargingState.label.toLowerCase() !== 'not charging',
|
||||
);
|
||||
|
||||
const handleStartDrive = () => {
|
||||
if (!roverId) return;
|
||||
actions.setMode('drive');
|
||||
actions.runMacro('drive-sequence');
|
||||
};
|
||||
|
||||
const handleDock = () => {
|
||||
if (!roverId) return;
|
||||
actions.setMode('dock');
|
||||
actions.runMacro('seek-dock');
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
{/* <div className="flex items-center justify-between text-sm text-slate-300">
|
||||
<span>Drive control</span>
|
||||
<span>{roverId ? 'Ready to drive' : 'No rover assigned'}</span>
|
||||
</div> */}
|
||||
<div className="space-y-0.5">
|
||||
<ActionCard
|
||||
title="Startfdsafsdfakjfdhlkfshlkjss Driving"
|
||||
description="Press to enable driving mode, then start moving."
|
||||
statuses={[{ label: drivingMode ? 'Ready!' : 'Press to enter driving mode!', active: drivingMode }]}
|
||||
tone="emerald"
|
||||
onClick={handleStartDrive}
|
||||
disabled={!roverId}
|
||||
/>
|
||||
<ActionCard
|
||||
title="Dock and Charge"
|
||||
description="Line the rover up about a foot from the dock, then press to trigger a docking attempt."
|
||||
statuses={[
|
||||
{ label: docked ? 'Docked!' : 'Not Docked!', active: docked },
|
||||
{ label: charging ? 'Charging!' : 'Not Charging!', active: charging },
|
||||
]}
|
||||
tone="indigo"
|
||||
onClick={handleDock}
|
||||
disabled={!roverId}
|
||||
/>
|
||||
</div>
|
||||
{/* <DiscordInviteButton /> */}
|
||||
{/* kjfhdljhdsaflkjf */}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionCard({ title, description, statuses, tone, onClick, disabled, footnote }) {
|
||||
const colors =
|
||||
tone === 'indigo'
|
||||
? { base: 'bg-indigo-600', hover: 'hover:bg-indigo-500' }
|
||||
: { base: 'bg-emerald-600', hover: 'hover:bg-emerald-500' };
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-full ${colors.base} ${colors.hover} px-0.5 py-0.5 text-left text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed text-center content-center`}
|
||||
>
|
||||
<p className="text-base font-semibold">{title}</p>
|
||||
<p className="text-sm text-white/90">{description}</p>
|
||||
{/* center statuses in button */}
|
||||
<div className="mt-0 flex flex-wrap gap-0.5 items-center w-full justify-center">
|
||||
{statuses.map((status) => (
|
||||
<span
|
||||
key={status.label}
|
||||
className={`p-1 text-xs font-semibold ${
|
||||
status.active ? 'bg-green-600 text-white' : 'bg-red-500 text-white'
|
||||
}`}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{footnote && <p className="mt-0 text-xs text-emerald-50/80">{footnote}</p>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -17,8 +17,12 @@ export default function HornControl({
|
||||
active,
|
||||
heat = 0,
|
||||
className = '',
|
||||
defaultShowSettings = true,
|
||||
showSettingsToggle = false,
|
||||
compactSettings = false,
|
||||
}) {
|
||||
const [pressed, setPressed] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(defaultShowSettings);
|
||||
const { value: hornSettings, save: saveHornSettings } = useSettingsNamespace(
|
||||
'horn',
|
||||
HORN_SETTINGS_DEFAULTS,
|
||||
@@ -40,7 +44,7 @@ export default function HornControl({
|
||||
const clampedHeat = Math.max(0, Math.min(1, Number(heat) || 0));
|
||||
const buttonClasses = useMemo(() => {
|
||||
const base =
|
||||
'group relative flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-1 py-1.5 text-xs font-semibold';
|
||||
'group relative flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-1 py-1.5 text-xs font-semibold select-none no-touch-select';
|
||||
const active = 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50';
|
||||
const inactive = 'border-cyan-300/70 bg-cyan-900 text-cyan-50 hover:bg-cyan-800';
|
||||
return [base, isActive ? active : inactive, 'disabled:opacity-50', className]
|
||||
@@ -95,18 +99,31 @@ export default function HornControl({
|
||||
const handlePointerDown = (event) => {
|
||||
if (disabled) return;
|
||||
const tag = event.target?.tagName?.toLowerCase();
|
||||
if (tag === 'input' || tag === 'select' || tag === 'option' || tag === 'label') return;
|
||||
if (tag === 'input' || tag === 'select' || tag === 'option' || tag === 'label' || tag === 'button') return;
|
||||
event.preventDefault();
|
||||
start();
|
||||
};
|
||||
|
||||
const handlePointerUp = (event) => {
|
||||
const tag = event.target?.tagName?.toLowerCase();
|
||||
if (tag === 'input' || tag === 'select' || tag === 'option' || tag === 'label') return;
|
||||
if (tag === 'input' || tag === 'select' || tag === 'option' || tag === 'label' || tag === 'button') return;
|
||||
event.preventDefault();
|
||||
stop();
|
||||
};
|
||||
|
||||
const settingsToggle = showSettingsToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setShowSettings((prev) => !prev);
|
||||
}}
|
||||
className="rounded bg-black/40 px-1 py-0.5 text-[0.6rem] font-semibold text-white/90 hover:text-white"
|
||||
>
|
||||
{showSettings ? 'Hide' : 'Settings'}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
@@ -116,6 +133,7 @@ export default function HornControl({
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={stop}
|
||||
onPointerCancel={stop}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onBlur={stop}
|
||||
className={buttonClasses}
|
||||
>
|
||||
@@ -144,6 +162,7 @@ export default function HornControl({
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{settingsToggle}
|
||||
<span
|
||||
className={`rounded px-1 py-0.5 text-[0.65rem] font-semibold ${
|
||||
isActive ? 'bg-fuchsia-200 text-fuchsia-900' : 'bg-slate-800 text-slate-200'
|
||||
@@ -152,33 +171,67 @@ export default function HornControl({
|
||||
{isActive ? 'Honk' : 'Hold'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative z-10 grid grid-cols-[minmax(0,1.2fr)_repeat(4,minmax(0,1fr))] items-center gap-0.5 text-[0.65rem]">
|
||||
<label className="flex items-center gap-0.5 text-slate-200">
|
||||
<span className="text-slate-300">Wave</span>
|
||||
<select
|
||||
value={formattedWaveform}
|
||||
onChange={updateWaveform}
|
||||
className="rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.65rem] text-slate-100"
|
||||
>
|
||||
<option value="saw">Saw</option>
|
||||
<option value="sine">Sine</option>
|
||||
</select>
|
||||
</label>
|
||||
{freqs.map((freq, idx) => (
|
||||
<label key={`horn-freq-${idx}`} className="flex items-center gap-0.5 text-slate-200">
|
||||
<span className="text-[0.6rem] text-slate-400">{idx + 1}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={5000}
|
||||
step={1}
|
||||
value={freq}
|
||||
onChange={(event) => updateFreq(idx, event.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{showSettings ? (
|
||||
compactSettings ? (
|
||||
<div className="relative z-10 flex flex-col gap-0.5 text-[0.65rem]">
|
||||
<label className="flex items-center justify-between gap-0.5 text-slate-200">
|
||||
<span className="text-slate-300">Wave</span>
|
||||
<select
|
||||
value={formattedWaveform}
|
||||
onChange={updateWaveform}
|
||||
className="rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.65rem] text-slate-100"
|
||||
>
|
||||
<option value="saw">Saw</option>
|
||||
<option value="sine">Sine</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
{freqs.map((freq, idx) => (
|
||||
<label key={`horn-freq-${idx}`} className="flex items-center gap-0.5 text-slate-200">
|
||||
<span className="text-[0.6rem] text-slate-400">{idx + 1}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={5000}
|
||||
step={1}
|
||||
value={freq}
|
||||
onChange={(event) => updateFreq(idx, event.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative z-10 grid grid-cols-[minmax(0,1.2fr)_repeat(4,minmax(0,1fr))] items-center gap-0.5 text-[0.65rem]">
|
||||
<label className="flex items-center gap-0.5 text-slate-200">
|
||||
<span className="text-slate-300">Wave</span>
|
||||
<select
|
||||
value={formattedWaveform}
|
||||
onChange={updateWaveform}
|
||||
className="rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.65rem] text-slate-100"
|
||||
>
|
||||
<option value="saw">Saw</option>
|
||||
<option value="sine">Sine</option>
|
||||
</select>
|
||||
</label>
|
||||
{freqs.map((freq, idx) => (
|
||||
<label key={`horn-freq-${idx}`} className="flex items-center gap-0.5 text-slate-200">
|
||||
<span className="text-[0.6rem] text-slate-400">{idx + 1}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={5000}
|
||||
step={1}
|
||||
value={freq}
|
||||
onChange={(event) => updateFreq(idx, event.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,20 +4,14 @@ import { clampUnit } from '../controls/controlMath.js';
|
||||
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||
import NightVisionControl from './NightVisionControl.jsx';
|
||||
import HornControl from './HornControl.jsx';
|
||||
import CameraTiltControl from './CameraTiltControl.jsx';
|
||||
|
||||
const SOURCE = 'mobile-joystick';
|
||||
const JOYSTICK_RADIUS = 80;
|
||||
const JOYSTICK_SMOOTHING = 0.15;
|
||||
const AUX_BUTTONS = [
|
||||
{ id: 'main-forward', label: 'Main Brush +', values: { main: 127 }, hold: true, color: 'bg-emerald-600' },
|
||||
{ id: 'main-reverse', label: 'Main Brush -', values: { main: -127 }, hold: true, color: 'bg-emerald-800' },
|
||||
{ id: 'all-forward', label: 'All Motors +', values: { main: 127, side: 127, vacuum: 127 }, hold: true, color: 'bg-fuchsia-600' },
|
||||
{ id: 'side-forward', label: 'Side Brush +', values: { side: 127 }, hold: true, color: 'bg-cyan-600' },
|
||||
{ id: 'side-reverse', label: 'Side Brush -', values: { side: -70 }, hold: true, color: 'bg-cyan-800' },
|
||||
{ id: 'vacuum-fast', label: 'Vacuum Max', values: { vacuum: 127 }, hold: true, color: 'bg-amber-500 text-amber-950' },
|
||||
{ id: 'vacuum-slow', label: 'Vacuum Low', values: { vacuum: 50 }, hold: true, color: 'bg-amber-700' },
|
||||
{ id: 'stop-all', label: 'Stop', values: { main: 0, side: 0, vacuum: 0 }, hold: false, color: 'bg-slate-900' },
|
||||
];
|
||||
const AUX_ZERO = { main: 0, side: 0, vacuum: 0 };
|
||||
const AUX_ALL_FORWARD = { main: 127, side: 127, vacuum: 127 };
|
||||
const AUX_ALL_BACKWARD = { main: -127, side: -127, vacuum: -127 };
|
||||
|
||||
function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
|
||||
const containerRef = useRef(null);
|
||||
@@ -134,27 +128,12 @@ function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
|
||||
|
||||
function MobileJoystickPanel({ layout }) {
|
||||
const {
|
||||
state: { roverId, camera, horn },
|
||||
pipeline,
|
||||
actions: { setDriveVector, registerInputState, setServoAngle, setNightVision, startHorn, stopHorn },
|
||||
state: { roverId },
|
||||
actions: { setDriveVector, registerInputState },
|
||||
} = useControlSystem();
|
||||
const driveDockState = useDriveDockState(roverId);
|
||||
const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
|
||||
const disabled = !roverId;
|
||||
const cameraConfig = camera?.config;
|
||||
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
|
||||
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
|
||||
const nightVisionState = pipeline?.nightVisionState;
|
||||
const hornAvailable = Boolean(roverId && pipeline?.horn);
|
||||
const hornBlocked = horn?.overheated;
|
||||
const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45;
|
||||
const cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45;
|
||||
const cameraValue =
|
||||
typeof camera?.angle === 'number'
|
||||
? camera.angle
|
||||
: typeof cameraConfig?.homeAngle === 'number'
|
||||
? cameraConfig.homeAngle
|
||||
: (cameraMin + cameraMax) / 2;
|
||||
const joystickRadius = JOYSTICK_RADIUS;
|
||||
const smoothing = JOYSTICK_SMOOTHING;
|
||||
const smoothedVectorRef = useRef({ x: 0, y: 0, boost: false });
|
||||
@@ -196,62 +175,20 @@ function MobileJoystickPanel({ layout }) {
|
||||
registerInputState(SOURCE, { vector: zero, lastEvent: 'stop' });
|
||||
}, [disabled, registerInputState, setDriveVector]);
|
||||
|
||||
const handleCameraSlider = (event) => {
|
||||
if (!cameraEnabled) return;
|
||||
const next = Number(event.target.value);
|
||||
if (Number.isNaN(next)) return;
|
||||
setServoAngle(next);
|
||||
};
|
||||
|
||||
const handleNightVisionToggle = useCallback(
|
||||
(nextOn) => {
|
||||
if (!nightVisionAvailable) return;
|
||||
setNightVision(nextOn);
|
||||
},
|
||||
[nightVisionAvailable, setNightVision],
|
||||
);
|
||||
|
||||
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}>
|
||||
<DriveDockAction layout="mobile" expand={dockedNotDriving} driveDockState={driveDockState} />
|
||||
<DriveDockAction
|
||||
layout="mobile"
|
||||
expand={dockedNotDriving}
|
||||
driveDockState={driveDockState}
|
||||
compactHeightClass="min-h-[5rem]"
|
||||
/>
|
||||
{!dockedNotDriving ? (
|
||||
<>
|
||||
{nightVisionAvailable && (
|
||||
<NightVisionControl
|
||||
nightVisionOn={nightVisionState?.nightVisionOn}
|
||||
disabled={disabled}
|
||||
onToggle={handleNightVisionToggle}
|
||||
/>
|
||||
)}
|
||||
{hornAvailable && (
|
||||
<HornControl
|
||||
disabled={disabled || hornBlocked}
|
||||
onStart={startHorn}
|
||||
onStop={stopHorn}
|
||||
active={horn?.active}
|
||||
heat={horn?.heat}
|
||||
/>
|
||||
)}
|
||||
{cameraEnabled && (
|
||||
<div className="bg-zinc-950 p-0.5 text-xs">
|
||||
<div className="flex items-center justify-between text-[0.75rem] text-slate-400">
|
||||
<span>Camera Tilt</span>
|
||||
<span className="font-mono text-slate-200">{cameraValue.toFixed(1)}°</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={cameraMin}
|
||||
max={cameraMax}
|
||||
step={0.5}
|
||||
value={cameraValue}
|
||||
onChange={handleCameraSlider}
|
||||
className="mt-0 w-full accent-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<FloatingJoystick
|
||||
disabled={disabled}
|
||||
layout={layout}
|
||||
@@ -267,90 +204,176 @@ function MobileJoystickPanel({ layout }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AuxMotorPanel({ orientation }) {
|
||||
function MobileAuxButton({ id, label, values, color, disabled, onPress, onRelease }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
onPress(id, values);
|
||||
}}
|
||||
onPointerUp={() => onRelease(id)}
|
||||
onPointerLeave={() => onRelease(id)}
|
||||
onPointerCancel={() => onRelease(id)}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
className={`flex h-full w-full items-center justify-center rounded-xl border-2 px-1 py-0.75 text-center text-sm font-semibold text-white transition select-none no-touch-select ${color} hover:brightness-110 active:brightness-125 active:scale-[0.99] disabled:opacity-30`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileLeftColumnContent({ layout }) {
|
||||
const {
|
||||
state: { roverId },
|
||||
actions: { setAuxMotors },
|
||||
state: { roverId, camera, horn },
|
||||
pipeline,
|
||||
actions: { setServoAngle, setNightVision, setAuxMotors, startHorn, stopHorn },
|
||||
} = useControlSystem();
|
||||
const activeRef = useRef(null);
|
||||
const disabled = !roverId;
|
||||
const activeRef = useRef(null);
|
||||
const cameraConfig = camera?.config;
|
||||
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
|
||||
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
|
||||
const nightVisionState = pipeline?.nightVisionState;
|
||||
const hornAvailable = Boolean(roverId && pipeline?.horn);
|
||||
const hornBlocked = horn?.overheated;
|
||||
const cameraMin = typeof cameraConfig?.minAngle === 'number' ? cameraConfig.minAngle : -45;
|
||||
const cameraMax = typeof cameraConfig?.maxAngle === 'number' ? cameraConfig.maxAngle : 45;
|
||||
const cameraValue =
|
||||
typeof camera?.angle === 'number'
|
||||
? camera.angle
|
||||
: typeof cameraConfig?.homeAngle === 'number'
|
||||
? cameraConfig.homeAngle
|
||||
: (cameraMin + cameraMax) / 2;
|
||||
|
||||
const handlePress = useCallback(
|
||||
(button) => {
|
||||
const handleNightVisionToggle = useCallback(
|
||||
(nextOn) => {
|
||||
if (!nightVisionAvailable) return;
|
||||
setNightVision(nextOn);
|
||||
},
|
||||
[nightVisionAvailable, setNightVision],
|
||||
);
|
||||
|
||||
const handleAuxPress = useCallback(
|
||||
(id, values) => {
|
||||
if (disabled) return;
|
||||
if (button.hold === false) {
|
||||
setAuxMotors(button.values);
|
||||
activeRef.current = null;
|
||||
return;
|
||||
}
|
||||
activeRef.current = button.id;
|
||||
setAuxMotors(button.values);
|
||||
activeRef.current = id;
|
||||
setAuxMotors(values);
|
||||
},
|
||||
[disabled, setAuxMotors],
|
||||
);
|
||||
|
||||
const handleRelease = useCallback(
|
||||
(button) => {
|
||||
if (disabled || button.hold === false) return;
|
||||
if (activeRef.current === button.id) {
|
||||
const handleAuxRelease = useCallback(
|
||||
(id) => {
|
||||
if (disabled) return;
|
||||
if (activeRef.current === id) {
|
||||
activeRef.current = null;
|
||||
setAuxMotors({ main: 0, side: 0, vacuum: 0 });
|
||||
setAuxMotors(AUX_ZERO);
|
||||
}
|
||||
},
|
||||
[disabled, setAuxMotors],
|
||||
);
|
||||
|
||||
const gridCols = orientation === 'landscape' ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2';
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-0.5 text-slate-100">
|
||||
<p className="text-xs text-slate-400">Aux controls</p>
|
||||
<div className={`grid ${gridCols} gap-0.5`}>
|
||||
{AUX_BUTTONS.map((button) => (
|
||||
<button
|
||||
key={button.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
handlePress(button);
|
||||
}}
|
||||
onPointerUp={() => handleRelease(button)}
|
||||
onPointerLeave={() => handleRelease(button)}
|
||||
onPointerCancel={() => handleRelease(button)}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
className={`px-0.5 py-0.5 text-left text-sm font-semibold text-white transition select-none no-touch-select ${button.color} hover:brightness-110 disabled:opacity-30 h-10`}
|
||||
<div className="grid h-full min-h-0 w-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)] gap-0.5 text-slate-100">
|
||||
<div className="flex min-h-0 items-stretch gap-0.5">
|
||||
{cameraEnabled ? (
|
||||
<div
|
||||
className={`flex-1 min-h-0 rounded bg-zinc-950 p-0.25 ${
|
||||
layout === 'landscape' ? 'pt-3' : ''
|
||||
}`.trim()}
|
||||
>
|
||||
{button.label}
|
||||
</button>
|
||||
))}
|
||||
<CameraTiltControl
|
||||
value={cameraValue}
|
||||
min={cameraMin}
|
||||
max={cameraMax}
|
||||
step={0.5}
|
||||
onChange={setServoAngle}
|
||||
orientation="vertical"
|
||||
label="Camera Tilt"
|
||||
labelClass="text-sm font-semibold text-white [writing-mode:vertical-rl] rotate-180"
|
||||
labelRowClass="text-[0.7rem] text-slate-300"
|
||||
valueClass="font-mono text-slate-200"
|
||||
className="h-full gap-0"
|
||||
sliderClass="h-full w-7"
|
||||
accentClass="accent-cyan-400"
|
||||
showEndpoints={false}
|
||||
showValue={false}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{nightVisionAvailable ? (
|
||||
<NightVisionControl
|
||||
nightVisionOn={nightVisionState?.nightVisionOn}
|
||||
disabled={disabled}
|
||||
onToggle={handleNightVisionToggle}
|
||||
heightClass="h-full"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid min-h-0 grid-rows-2 gap-0.5">
|
||||
<MobileAuxButton
|
||||
id="aux-vac-forward"
|
||||
label="Vacuum Forward"
|
||||
values={AUX_ALL_FORWARD}
|
||||
color="bg-fuchsia-600"
|
||||
disabled={disabled}
|
||||
onPress={handleAuxPress}
|
||||
onRelease={handleAuxRelease}
|
||||
/>
|
||||
<MobileAuxButton
|
||||
id="aux-vac-backward"
|
||||
label="Vacuum Backward"
|
||||
values={AUX_ALL_BACKWARD}
|
||||
color="bg-fuchsia-800"
|
||||
disabled={disabled}
|
||||
onPress={handleAuxPress}
|
||||
onRelease={handleAuxRelease}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0">
|
||||
{hornAvailable ? (
|
||||
<HornControl
|
||||
disabled={disabled || hornBlocked}
|
||||
onStart={startHorn}
|
||||
onStop={stopHorn}
|
||||
active={horn?.active}
|
||||
heat={horn?.heat}
|
||||
defaultShowSettings={false}
|
||||
showSettingsToggle
|
||||
compactSettings
|
||||
className="h-full"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileLandscapeAuxColumn() {
|
||||
export function MobileLeftColumn({ layout, className = '' }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-0.5">
|
||||
<AuxMotorPanel orientation="landscape" />
|
||||
<div className={`flex flex-col gap-0.5 ${className}`.trim()}>
|
||||
<MobileLeftColumnContent layout={layout} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileLandscapeControlColumn() {
|
||||
export function MobileRightColumn({ layout, className = '' }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-0.5">
|
||||
<MobileJoystickPanel layout="landscape" />
|
||||
<div className={`flex flex-col gap-0.5 ${className}`.trim()}>
|
||||
<MobileJoystickPanel layout={layout === 'landscape' ? 'landscape' : 'portrait'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MobilePortraitControls() {
|
||||
const columnHeight = 'h-[min(60svh,24rem)]';
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="grid grid-cols-2 gap-0.5 items-stretch">
|
||||
<AuxMotorPanel orientation="portrait" />
|
||||
<MobileJoystickPanel layout="portrait" />
|
||||
<MobileLeftColumn layout="portrait" className={columnHeight} />
|
||||
<MobileRightColumn layout="portrait" className={columnHeight} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ export default function NightVisionControl({
|
||||
onToggle,
|
||||
keyLabel,
|
||||
className = '',
|
||||
heightClass = '',
|
||||
}) {
|
||||
const [optimistic, setOptimistic] = useState(
|
||||
isBoolean(nightVisionOn) ? nightVisionOn : null,
|
||||
@@ -37,31 +38,32 @@ export default function NightVisionControl({
|
||||
|
||||
const buttonClasses = useMemo(() => {
|
||||
const base =
|
||||
'group flex w-full items-center justify-between rounded-xl border-2 px-1 py-0.75 text-xs font-semibold';
|
||||
'group flex w-full flex-col items-center justify-center gap-0.35 rounded-xl border-2 px-1 py-0.75 text-center select-none no-touch-select';
|
||||
const active = 'border-emerald-300/70 bg-emerald-800 text-emerald-50 hover:bg-emerald-700';
|
||||
const inactive = 'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800';
|
||||
return [base, displayOn ? active : inactive, 'disabled:opacity-50', className]
|
||||
return [base, displayOn ? active : inactive, 'disabled:opacity-50', heightClass, className]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}, [className, displayOn]);
|
||||
}, [className, displayOn, heightClass]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
disabled={disabled}
|
||||
aria-pressed={displayOn}
|
||||
className={buttonClasses}
|
||||
>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span>Night Vision</span>
|
||||
<span className="text-sm font-semibold">Night Vision</span>
|
||||
{keyLabel ? (
|
||||
<span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
|
||||
{keyLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={`rounded px-1 py-0.5 text-[0.65rem] font-semibold ${statusClasses}`}>
|
||||
<span className={`rounded px-1 py-0.5 text-[0.7rem] font-semibold ${statusClasses}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InlineCameraTilt } from './ControlSummary.jsx';
|
||||
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
||||
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
||||
import SettingsPanel from './SettingsPanel.jsx';
|
||||
@@ -13,6 +12,10 @@ import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||
import { useControlSystem } from '../controls/index.js';
|
||||
import RoverQueuesPanel from './RoverQueuesPanel.jsx';
|
||||
import RawUserPilePanel from './RawUserPilePanel.jsx';
|
||||
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
||||
import NightVisionControl from './NightVisionControl.jsx';
|
||||
import HornControl from './HornControl.jsx';
|
||||
import CameraTiltControl from './CameraTiltControl.jsx';
|
||||
|
||||
function TopDownMapPanel() {
|
||||
const {
|
||||
@@ -32,15 +35,74 @@ function TopDownMapPanel() {
|
||||
|
||||
function DriveDockPanel() {
|
||||
const {
|
||||
state: { roverId, keymap },
|
||||
state: { roverId, keymap, camera, horn },
|
||||
pipeline,
|
||||
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
|
||||
} = useControlSystem();
|
||||
const driveDockState = useDriveDockState(roverId);
|
||||
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
||||
|
||||
const config = camera?.config;
|
||||
const cameraEnabled = Boolean(roverId && camera?.enabled && config);
|
||||
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
|
||||
const nightVisionState = pipeline?.nightVisionState;
|
||||
const hornAvailable = Boolean(roverId && pipeline?.horn);
|
||||
const hornBlocked = horn?.overheated;
|
||||
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
|
||||
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
|
||||
const value =
|
||||
typeof camera?.angle === 'number'
|
||||
? camera.angle
|
||||
: typeof config?.homeAngle === 'number'
|
||||
? config.homeAngle
|
||||
: (min + max) / 2;
|
||||
const nightVisionLabel = formatKeyLabel(keymap?.nightVisionToggle?.[0]);
|
||||
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
|
||||
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
||||
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
||||
|
||||
return (
|
||||
<section className="panel-section grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
|
||||
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
|
||||
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
|
||||
{!hideInlineControls ? (
|
||||
<div className="surface space-y-0.5 p-0 text-sm text-slate-200">
|
||||
{nightVisionAvailable && (
|
||||
<NightVisionControl
|
||||
nightVisionOn={nightVisionState?.nightVisionOn}
|
||||
disabled={!roverId}
|
||||
onToggle={setNightVision}
|
||||
keyLabel={nightVisionLabel}
|
||||
/>
|
||||
)}
|
||||
{hornAvailable && (
|
||||
<HornControl
|
||||
disabled={!roverId || hornBlocked}
|
||||
onStart={startHorn}
|
||||
onStop={stopHorn}
|
||||
keyLabel={hornLabel}
|
||||
active={horn?.active}
|
||||
heat={horn?.heat}
|
||||
/>
|
||||
)}
|
||||
{cameraEnabled && (
|
||||
<CameraTiltControl
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={setServoAngle}
|
||||
keyDownLabel={downLabel}
|
||||
keyUpLabel={upLabel}
|
||||
className="space-y-0.5 px-1 py-1"
|
||||
labelRowClass="text-xs text-slate-300"
|
||||
labelClass=""
|
||||
valueClass="font-mono text-slate-100"
|
||||
sliderClass="w-full"
|
||||
accentClass="accent-emerald-400"
|
||||
endpointClass="text-[0.7rem] text-slate-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useControlSystem } from '../../controls/index.js';
|
||||
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
||||
|
||||
export default function DriveModeToggle({ size = 'default' }) {
|
||||
const {
|
||||
state: { roverId },
|
||||
actions,
|
||||
} = useControlSystem();
|
||||
const [pending, setPending] = useState(null);
|
||||
const disabled = !roverId || pending !== null;
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const oiLabel = frame?.sensors?.oiMode?.label || 'Unknown';
|
||||
const oiNormalized = oiLabel.toLowerCase();
|
||||
const driveReady = oiNormalized === 'full';
|
||||
const currentLabel = driveReady ? 'Drive Ready' : `OI Mode: ${oiLabel}`;
|
||||
|
||||
const handleDrive = async () => {
|
||||
if (!roverId) return;
|
||||
setPending('drive');
|
||||
try {
|
||||
actions.setMode('drive');
|
||||
await actions.runMacro('drive-sequence');
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDock = async () => {
|
||||
if (!roverId) return;
|
||||
setPending('dock');
|
||||
try {
|
||||
actions.setMode('dock');
|
||||
await actions.runMacro('seek-dock');
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const pillClass = size === 'compact' ? 'text-xs px-0.5 py-0.5' : 'text-sm px-0.5 py-0.5';
|
||||
|
||||
return (
|
||||
<div className="bg-black/30 p-0.5 text-slate-100">
|
||||
<div className="flex items-center">
|
||||
<span className={`${driveReady ? 'bg-emerald-700' : 'bg-indigo-700'} ${pillClass}`}>
|
||||
{currentLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0 grid grid-cols-2 gap-0.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDrive}
|
||||
disabled={disabled}
|
||||
className={`bg-emerald-600 px-0.5 py-0.5 font-semibold text-emerald-50 transition-colors hover:bg-emerald-500 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`}
|
||||
>
|
||||
Drive
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDock}
|
||||
disabled={disabled}
|
||||
className={`bg-indigo-600 px-0.5 py-0.5 font-semibold text-indigo-50 transition-colors hover:bg-indigo-500 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`}
|
||||
>
|
||||
Dock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user