more organized backend and better mobile control columns

This commit is contained in:
legop3
2026-01-31 20:49:14 -05:00
parent d64e99b674
commit 133b433255
18 changed files with 763 additions and 668 deletions
+5 -4
View File
@@ -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
+17 -85
View File
@@ -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)}>
+165
View File
@@ -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>
);
}
+57 -111
View File
@@ -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)}°`;
}
+44 -22
View File
@@ -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">
-93
View File
@@ -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>
);
}
+83 -30
View File
@@ -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>
);
}
+146 -123
View File
@@ -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>
);
+7 -5
View File
@@ -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>
+65 -3
View File
@@ -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>
);
}