mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 01:50:47 -04:00
more organized backend and better mobile control columns
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
# general idea:
|
||||||
|
- consolidate each rover control button to it's own component which is used standalone
|
||||||
|
- rework mobile controls (throw away the current left column)
|
||||||
|
- check for duplicates of any control components in redundant places
|
||||||
|
- main culprits
|
||||||
|
- dock drive action
|
||||||
|
- mobile control columns
|
||||||
|
- night vision (headlight) button
|
||||||
|
- horn button
|
||||||
|
- camera tilt slider
|
||||||
|
- also plan to rework mobile controls, but just ensure consolidation first
|
||||||
|
|
||||||
|
## dock / drive action:
|
||||||
|
- ensure that it is one standalone component
|
||||||
|
- revamp the system which allows it to hide / show other componenets that it "covers"
|
||||||
|
|
||||||
|
## camera tilt slider:
|
||||||
|
- the biggest issue currently
|
||||||
|
- it seems to be the root of the problems of things not being consolidated
|
||||||
|
- ensure that it is ONLY the camera slider component
|
||||||
|
- keep styling
|
||||||
|
- add option for a vertical slider with just a degrees number at the top
|
||||||
|
|
||||||
|
## night vision (headlight) button
|
||||||
|
- center label, make it large and match the labels of the horn and drive/dock action labels
|
||||||
|
- center status badge
|
||||||
|
- allow height class to be changed for different modes
|
||||||
|
|
||||||
|
## horn button
|
||||||
|
- the newest one
|
||||||
|
- mostly good
|
||||||
|
- for mobile layouts, make it hide the settings by default, and when shown make them nicely laid out for fitting in a skinny column on mobile
|
||||||
|
|
||||||
|
|
||||||
|
# new mobile control columns:
|
||||||
|
- get rid of the current entire left column with all the aux buttons, we are replacing it
|
||||||
|
- the new left column:
|
||||||
|
- top section
|
||||||
|
- vertical camera slider
|
||||||
|
- night vision button
|
||||||
|
- middle section (two new buttons, side by side)
|
||||||
|
- all aux forward button
|
||||||
|
- all aux backward button
|
||||||
|
- bottom section
|
||||||
|
- horn button
|
||||||
|
- the modified right column:
|
||||||
|
- no more camera slider
|
||||||
|
- no more night vision toggle
|
||||||
|
- still dock/drive action full-column, which minimizes itself
|
||||||
|
- when minimized, make the dock button a little taller
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-gGNnvKWF.js"></script>
|
<script type="module" crossorigin src="/assets/index-DzclZiZg.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Bi3rvCda.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-3N52jGFe.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const {
|
|||||||
AttachmentBuilder,
|
AttachmentBuilder,
|
||||||
PermissionsBitField,
|
PermissionsBitField,
|
||||||
WebhookClient,
|
WebhookClient,
|
||||||
|
MessageFlags,
|
||||||
} = require('discord.js');
|
} = require('discord.js');
|
||||||
const logger = require('../globals/logger').child('discordBot');
|
const logger = require('../globals/logger').child('discordBot');
|
||||||
const io = require('../globals/io');
|
const io = require('../globals/io');
|
||||||
@@ -884,7 +885,7 @@ async function sendTypingMessage(entry, payload) {
|
|||||||
const username = formatWebhookUsername(payload);
|
const username = formatWebhookUsername(payload);
|
||||||
const content = `-# *${username} is typing...*`;
|
const content = `-# *${username} is typing...*`;
|
||||||
try {
|
try {
|
||||||
const message = await channel.send({ content, allowedMentions: { parse: [] } });
|
const message = await channel.send({ content, allowedMentions: { parse: [] }, flags: [MessageFlags.SuppressNotifications]});
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
clearTypingMessage(entry.guildId, typingId);
|
clearTypingMessage(entry.guildId, typingId);
|
||||||
}, 20000);
|
}, 20000);
|
||||||
|
|||||||
+5
-4
@@ -3,8 +3,8 @@ import TelemetryPanel from './components/TelemetryPanel.jsx';
|
|||||||
import ReplaySourcesPanel from './components/ReplaySourcesPanel.jsx';
|
import ReplaySourcesPanel from './components/ReplaySourcesPanel.jsx';
|
||||||
import AlertFeed from './components/AlertFeed.jsx';
|
import AlertFeed from './components/AlertFeed.jsx';
|
||||||
import MobileControls, {
|
import MobileControls, {
|
||||||
MobileLandscapeAuxColumn,
|
MobileLeftColumn,
|
||||||
MobileLandscapeControlColumn,
|
MobileRightColumn,
|
||||||
} from './components/MobileControls.jsx';
|
} from './components/MobileControls.jsx';
|
||||||
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js';
|
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js';
|
||||||
import { SettingsProvider } from './settings/index.js';
|
import { SettingsProvider } from './settings/index.js';
|
||||||
@@ -139,10 +139,11 @@ function MobilePortraitLayout({ onOpenHelpOverlay }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MobileLandscapeLayout({ onOpenHelpOverlay }) {
|
function MobileLandscapeLayout({ onOpenHelpOverlay }) {
|
||||||
|
const columnClass = 'self-start h-[min(100svh,32rem)]';
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<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">
|
<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>
|
<div>
|
||||||
<DriverVideoPanel layoutFormat="mobile-landscape" />
|
<DriverVideoPanel layoutFormat="mobile-landscape" />
|
||||||
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||||
@@ -151,7 +152,7 @@ function MobileLandscapeLayout({ onOpenHelpOverlay }) {
|
|||||||
</div>
|
</div>
|
||||||
{/* <TelemetryPanel /> */}
|
{/* <TelemetryPanel /> */}
|
||||||
</div>
|
</div>
|
||||||
<MobileLandscapeControlColumn />
|
<MobileRightColumn layout="landscape" className={columnClass} />
|
||||||
</section>
|
</section>
|
||||||
<div className="flex flex-col gap-0.5 pb-0">
|
<div className="flex flex-col gap-0.5 pb-0">
|
||||||
<MobileFeatureTabs
|
<MobileFeatureTabs
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useControlSystem } from '../controls/index.js';
|
import { useControlSystem } from '../controls/index.js';
|
||||||
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
||||||
import NightVisionControl from './NightVisionControl.jsx';
|
import NightVisionControl from './NightVisionControl.jsx';
|
||||||
import HornControl from './HornControl.jsx';
|
import HornControl from './HornControl.jsx';
|
||||||
|
import CameraTiltControl from './CameraTiltControl.jsx';
|
||||||
|
|
||||||
const SLIDER_THROTTLE_MS = 150;
|
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() {
|
export default function CameraServoPanel() {
|
||||||
const {
|
const {
|
||||||
state: { roverId, camera, keymap, horn },
|
state: { roverId, camera, keymap, horn },
|
||||||
pipeline,
|
pipeline,
|
||||||
actions: { setServoAngle, nudgeServo, goServoHome, setNightVision, startHorn, stopHorn },
|
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
const config = camera?.config;
|
const config = camera?.config;
|
||||||
const enabled = Boolean(roverId && camera?.enabled && config);
|
const enabled = Boolean(roverId && camera?.enabled && config);
|
||||||
@@ -36,60 +31,6 @@ export default function CameraServoPanel() {
|
|||||||
|
|
||||||
if (!enabled && !nightVisionAvailable && !hornAvailable) return null;
|
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) => {
|
const handleNightVisionToggle = (nextOn) => {
|
||||||
if (!nightVisionAvailable) return;
|
if (!nightVisionAvailable) return;
|
||||||
setNightVision(nextOn);
|
setNightVision(nextOn);
|
||||||
@@ -116,30 +57,21 @@ export default function CameraServoPanel() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{enabled && (
|
{enabled && (
|
||||||
<>
|
<CameraTiltControl
|
||||||
<div className="flex items-center justify-between text-sm text-slate-300">
|
value={value}
|
||||||
<span>Camera Tilt</span>
|
min={min}
|
||||||
<span className="font-mono text-sm text-slate-100">{formatDegrees(value)}</span>
|
max={max}
|
||||||
</div>
|
step={0.5}
|
||||||
<div>
|
onChange={setServoAngle}
|
||||||
<input
|
throttleMs={SLIDER_THROTTLE_MS}
|
||||||
type="range"
|
className="space-y-0.5"
|
||||||
className="w-full accent-emerald-400"
|
labelRowClass="text-sm text-slate-300"
|
||||||
min={min}
|
valueClass="font-mono text-sm text-slate-100"
|
||||||
max={max}
|
sliderClass="w-full"
|
||||||
step={0.5}
|
accentClass="accent-emerald-400"
|
||||||
value={pendingAngle}
|
endpointClass="text-xs text-slate-400"
|
||||||
onChange={handleSlider}
|
endpointLabelClass=""
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{/* <div className="flex gap-0.5 text-sm">
|
{/* <div className="flex gap-0.5 text-sm">
|
||||||
<button type="button" className="flex-1 button-dark" onClick={() => handleNudge(-1)}>
|
<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 { useSession } from '../context/SessionContext.jsx';
|
||||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||||
import { useControlSystem } from '../controls/index.js';
|
import { useControlSystem } from '../controls/index.js';
|
||||||
@@ -8,6 +8,7 @@ import RoverRoster from './RoverRoster.jsx';
|
|||||||
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||||
import NightVisionControl from './NightVisionControl.jsx';
|
import NightVisionControl from './NightVisionControl.jsx';
|
||||||
import HornControl from './HornControl.jsx';
|
import HornControl from './HornControl.jsx';
|
||||||
|
import CameraTiltControl from './CameraTiltControl.jsx';
|
||||||
|
|
||||||
export function RoverRosterPanel({ title = 'Rovers' }) {
|
export function RoverRosterPanel({ title = 'Rovers' }) {
|
||||||
const { session, requestControl } = useSession();
|
const { session, requestControl } = useSession();
|
||||||
@@ -49,39 +50,17 @@ export function RoverRosterPanel({ title = 'Rovers' }) {
|
|||||||
|
|
||||||
export default function ControlSummary() {
|
export default function ControlSummary() {
|
||||||
const {
|
const {
|
||||||
state: { roverId, keymap },
|
state: { roverId, keymap, camera, horn },
|
||||||
|
pipeline,
|
||||||
|
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
const frame = useTelemetryFrame(roverId);
|
const frame = useTelemetryFrame(roverId);
|
||||||
const sensors = frame?.sensors || {};
|
const sensors = frame?.sensors || {};
|
||||||
const driveDockState = useDriveDockState(roverId);
|
const driveDockState = useDriveDockState(roverId);
|
||||||
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
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 config = camera?.config;
|
||||||
const enabled = Boolean(roverId && camera?.enabled && config);
|
const cameraEnabled = Boolean(roverId && camera?.enabled && config);
|
||||||
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
|
const nightVisionAvailable = Boolean(roverId && pipeline?.nightVision);
|
||||||
const nightVisionState = pipeline?.nightVisionState;
|
const nightVisionState = pipeline?.nightVisionState;
|
||||||
const hornAvailable = Boolean(roverId && pipeline?.horn);
|
const hornAvailable = Boolean(roverId && pipeline?.horn);
|
||||||
@@ -94,95 +73,62 @@ export function InlineCameraTilt({ keymap }) {
|
|||||||
: typeof config?.homeAngle === 'number'
|
: typeof config?.homeAngle === 'number'
|
||||||
? config.homeAngle
|
? config.homeAngle
|
||||||
: (min + max) / 2;
|
: (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 nightVisionLabel = formatKeyLabel(keymap?.nightVisionToggle?.[0]);
|
||||||
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
|
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
|
||||||
|
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
||||||
|
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="surface space-y-0.5 p-0 text-sm text-slate-200">
|
<section className="panel-section">
|
||||||
{nightVisionAvailable && (
|
<div className="grid items-stretch gap-0.5 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] md:min-h-[18rem]">
|
||||||
<NightVisionControl
|
<div className="flex h-full w-full items-stretch justify-center">
|
||||||
nightVisionOn={nightVisionState?.nightVisionOn}
|
<div className="aspect-square h-full w-full">
|
||||||
disabled={!roverId}
|
<TopDownMap sensors={sensors} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
|
||||||
</div>
|
<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 isMobile = layout === 'mobile';
|
||||||
const {
|
const {
|
||||||
state: { roverId, keymap },
|
state: { roverId, keymap },
|
||||||
@@ -175,9 +181,10 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, fi
|
|||||||
};
|
};
|
||||||
|
|
||||||
const baseCardClasses =
|
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 ctaText = 'text-center';
|
||||||
const ctaLayout = 'items-center justify-between';
|
const ctaLayout = 'items-center justify-between';
|
||||||
|
const compactLayout = 'items-center justify-center';
|
||||||
const ctaTextAndLayout = `${ctaText} ${ctaLayout}`;
|
const ctaTextAndLayout = `${ctaText} ${ctaLayout}`;
|
||||||
const ctaSize = isMobile ? 'text-sm font-semibold' : '';
|
const ctaSize = isMobile ? 'text-sm font-semibold' : '';
|
||||||
const emeraldCta =
|
const emeraldCta =
|
||||||
@@ -187,32 +194,41 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, fi
|
|||||||
const indigoCta =
|
const indigoCta =
|
||||||
'border-indigo-300/70 bg-indigo-900 text-indigo-50 hover:bg-indigo-800 focus-visible:ring-indigo-300';
|
'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 filledHeight = expand ? 'h-full flex-1' : fill ? 'flex-1' : '';
|
||||||
|
const compactHeight = isMobile && !expand ? compactHeightClass : '';
|
||||||
|
const layoutClass = isMobile && !expand ? compactLayout : ctaLayout;
|
||||||
|
|
||||||
if (!driving && !dockingInProgress) {
|
if (!driving && !dockingInProgress) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleStartDrive}
|
onClick={handleStartDrive}
|
||||||
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
disabled={driveDisabled}
|
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="space-y-0.5 w-full">
|
||||||
<div className="flex w-full flex-col items-center gap-0.25">
|
<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>
|
<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">
|
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||||
{driveKeyLabel ? <KeyPill label={driveKeyLabel} /> : null}
|
{driveKeyLabel ? <KeyPill label={driveKeyLabel} /> : null}
|
||||||
<ActionPill label="Click to start" tone="emerald" />
|
<ActionPill label="Click to start" tone="emerald" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-emerald-50/90">{startDriveInstructions.summary}</p>
|
{expand ? (
|
||||||
<StepList steps={startDriveInstructions.steps} tone="emerald" />
|
<>
|
||||||
</div>
|
<p className="text-sm text-emerald-50/90">{startDriveInstructions.summary}</p>
|
||||||
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
<StepList steps={startDriveInstructions.steps} tone="emerald" />
|
||||||
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
|
</>
|
||||||
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
|
) : null}
|
||||||
</div>
|
</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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -228,24 +244,29 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, fi
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={driveDisabled}
|
disabled={driveDisabled}
|
||||||
onClick={handleReturnToDrive}
|
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="space-y-0.5 w-full">
|
||||||
<div className="flex w-full flex-col items-center gap-0.25">
|
<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>
|
<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">
|
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||||
<ActionPill label="Click to return to driving mode" tone="amber" />
|
<ActionPill label="Click to return to driving mode" tone="amber" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
||||||
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
{expand ? (
|
||||||
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
|
<>
|
||||||
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
|
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
||||||
</div>
|
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
|
||||||
<StepList steps={inProgressCopy.steps} tone="amber" />
|
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
|
||||||
|
</div>
|
||||||
|
<StepList steps={inProgressCopy.steps} tone="amber" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -256,22 +277,23 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, fi
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={dockDisabled}
|
disabled={dockDisabled}
|
||||||
onClick={handleOpenDock}
|
onClick={handleOpenDock}
|
||||||
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
className={
|
className={
|
||||||
isMobile
|
isMobile
|
||||||
? `flex w-full items-center justify-center ${baseCardClasses} ${ctaTextAndLayout} ${ctaSize} ${indigoCta}`
|
? `flex w-full ${baseCardClasses} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${indigoCta}`
|
||||||
: `${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${indigoCta}`
|
: `${baseCardClasses} ${filledHeight} ${ctaText} ${ctaLayout} ${ctaSize} ${indigoCta}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex w-full flex-col items-center gap-0.25">
|
<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>
|
<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">
|
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||||
{dockKeyLabel ? <KeyPill label={dockKeyLabel} /> : null}
|
{dockKeyLabel ? <KeyPill label={dockKeyLabel} /> : null}
|
||||||
<ActionPill label="Click to begin docking" tone="indigo" />
|
<ActionPill label="Click to begin docking" tone="indigo" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!isMobile && (
|
{!isMobile && expand && (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-indigo-50/90">{dockButtonCaption}</p>
|
<p className="text-sm text-indigo-50/90">{dockButtonCaption}</p>
|
||||||
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
<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,
|
active,
|
||||||
heat = 0,
|
heat = 0,
|
||||||
className = '',
|
className = '',
|
||||||
|
defaultShowSettings = true,
|
||||||
|
showSettingsToggle = false,
|
||||||
|
compactSettings = false,
|
||||||
}) {
|
}) {
|
||||||
const [pressed, setPressed] = useState(false);
|
const [pressed, setPressed] = useState(false);
|
||||||
|
const [showSettings, setShowSettings] = useState(defaultShowSettings);
|
||||||
const { value: hornSettings, save: saveHornSettings } = useSettingsNamespace(
|
const { value: hornSettings, save: saveHornSettings } = useSettingsNamespace(
|
||||||
'horn',
|
'horn',
|
||||||
HORN_SETTINGS_DEFAULTS,
|
HORN_SETTINGS_DEFAULTS,
|
||||||
@@ -40,7 +44,7 @@ export default function HornControl({
|
|||||||
const clampedHeat = Math.max(0, Math.min(1, Number(heat) || 0));
|
const clampedHeat = Math.max(0, Math.min(1, Number(heat) || 0));
|
||||||
const buttonClasses = useMemo(() => {
|
const buttonClasses = useMemo(() => {
|
||||||
const base =
|
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 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';
|
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]
|
return [base, isActive ? active : inactive, 'disabled:opacity-50', className]
|
||||||
@@ -95,18 +99,31 @@ export default function HornControl({
|
|||||||
const handlePointerDown = (event) => {
|
const handlePointerDown = (event) => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
const tag = event.target?.tagName?.toLowerCase();
|
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();
|
event.preventDefault();
|
||||||
start();
|
start();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerUp = (event) => {
|
const handlePointerUp = (event) => {
|
||||||
const tag = event.target?.tagName?.toLowerCase();
|
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();
|
event.preventDefault();
|
||||||
stop();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -116,6 +133,7 @@ export default function HornControl({
|
|||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerLeave={stop}
|
onPointerLeave={stop}
|
||||||
onPointerCancel={stop}
|
onPointerCancel={stop}
|
||||||
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
onBlur={stop}
|
onBlur={stop}
|
||||||
className={buttonClasses}
|
className={buttonClasses}
|
||||||
>
|
>
|
||||||
@@ -144,6 +162,7 @@ export default function HornControl({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
{settingsToggle}
|
||||||
<span
|
<span
|
||||||
className={`rounded px-1 py-0.5 text-[0.65rem] font-semibold ${
|
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'
|
isActive ? 'bg-fuchsia-200 text-fuchsia-900' : 'bg-slate-800 text-slate-200'
|
||||||
@@ -152,33 +171,67 @@ export default function HornControl({
|
|||||||
{isActive ? 'Honk' : 'Hold'}
|
{isActive ? 'Honk' : 'Hold'}
|
||||||
</span>
|
</span>
|
||||||
</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]">
|
{showSettings ? (
|
||||||
<label className="flex items-center gap-0.5 text-slate-200">
|
compactSettings ? (
|
||||||
<span className="text-slate-300">Wave</span>
|
<div className="relative z-10 flex flex-col gap-0.5 text-[0.65rem]">
|
||||||
<select
|
<label className="flex items-center justify-between gap-0.5 text-slate-200">
|
||||||
value={formattedWaveform}
|
<span className="text-slate-300">Wave</span>
|
||||||
onChange={updateWaveform}
|
<select
|
||||||
className="rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.65rem] text-slate-100"
|
value={formattedWaveform}
|
||||||
>
|
onChange={updateWaveform}
|
||||||
<option value="saw">Saw</option>
|
className="rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.65rem] text-slate-100"
|
||||||
<option value="sine">Sine</option>
|
>
|
||||||
</select>
|
<option value="saw">Saw</option>
|
||||||
</label>
|
<option value="sine">Sine</option>
|
||||||
{freqs.map((freq, idx) => (
|
</select>
|
||||||
<label key={`horn-freq-${idx}`} className="flex items-center gap-0.5 text-slate-200">
|
</label>
|
||||||
<span className="text-[0.6rem] text-slate-400">{idx + 1}</span>
|
<div className="grid grid-cols-2 gap-0.5">
|
||||||
<input
|
{freqs.map((freq, idx) => (
|
||||||
type="number"
|
<label key={`horn-freq-${idx}`} className="flex items-center gap-0.5 text-slate-200">
|
||||||
min={0}
|
<span className="text-[0.6rem] text-slate-400">{idx + 1}</span>
|
||||||
max={5000}
|
<input
|
||||||
step={1}
|
type="number"
|
||||||
value={freq}
|
min={0}
|
||||||
onChange={(event) => updateFreq(idx, event.target.value)}
|
max={5000}
|
||||||
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"
|
step={1}
|
||||||
/>
|
value={freq}
|
||||||
</label>
|
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"
|
||||||
</div>
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,14 @@ import { clampUnit } from '../controls/controlMath.js';
|
|||||||
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||||
import NightVisionControl from './NightVisionControl.jsx';
|
import NightVisionControl from './NightVisionControl.jsx';
|
||||||
import HornControl from './HornControl.jsx';
|
import HornControl from './HornControl.jsx';
|
||||||
|
import CameraTiltControl from './CameraTiltControl.jsx';
|
||||||
|
|
||||||
const SOURCE = 'mobile-joystick';
|
const SOURCE = 'mobile-joystick';
|
||||||
const JOYSTICK_RADIUS = 80;
|
const JOYSTICK_RADIUS = 80;
|
||||||
const JOYSTICK_SMOOTHING = 0.15;
|
const JOYSTICK_SMOOTHING = 0.15;
|
||||||
const AUX_BUTTONS = [
|
const AUX_ZERO = { main: 0, side: 0, vacuum: 0 };
|
||||||
{ id: 'main-forward', label: 'Main Brush +', values: { main: 127 }, hold: true, color: 'bg-emerald-600' },
|
const AUX_ALL_FORWARD = { main: 127, side: 127, vacuum: 127 };
|
||||||
{ id: 'main-reverse', label: 'Main Brush -', values: { main: -127 }, hold: true, color: 'bg-emerald-800' },
|
const AUX_ALL_BACKWARD = { main: -127, side: -127, vacuum: -127 };
|
||||||
{ 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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
|
function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
@@ -134,27 +128,12 @@ function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
|
|||||||
|
|
||||||
function MobileJoystickPanel({ layout }) {
|
function MobileJoystickPanel({ layout }) {
|
||||||
const {
|
const {
|
||||||
state: { roverId, camera, horn },
|
state: { roverId },
|
||||||
pipeline,
|
actions: { setDriveVector, registerInputState },
|
||||||
actions: { setDriveVector, registerInputState, setServoAngle, setNightVision, startHorn, stopHorn },
|
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
const driveDockState = useDriveDockState(roverId);
|
const driveDockState = useDriveDockState(roverId);
|
||||||
const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
|
const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
|
||||||
const disabled = !roverId;
|
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 joystickRadius = JOYSTICK_RADIUS;
|
||||||
const smoothing = JOYSTICK_SMOOTHING;
|
const smoothing = JOYSTICK_SMOOTHING;
|
||||||
const smoothedVectorRef = useRef({ x: 0, y: 0, boost: false });
|
const smoothedVectorRef = useRef({ x: 0, y: 0, boost: false });
|
||||||
@@ -196,62 +175,20 @@ function MobileJoystickPanel({ layout }) {
|
|||||||
registerInputState(SOURCE, { vector: zero, lastEvent: 'stop' });
|
registerInputState(SOURCE, { vector: zero, lastEvent: 'stop' });
|
||||||
}, [disabled, registerInputState, setDriveVector]);
|
}, [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 fillClass = dockedNotDriving ? 'max-h-screen self-start' : '';
|
||||||
const containerClass = `flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`;
|
const containerClass = `flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={containerClass}>
|
<div className={containerClass}>
|
||||||
<DriveDockAction layout="mobile" expand={dockedNotDriving} driveDockState={driveDockState} />
|
<DriveDockAction
|
||||||
|
layout="mobile"
|
||||||
|
expand={dockedNotDriving}
|
||||||
|
driveDockState={driveDockState}
|
||||||
|
compactHeightClass="min-h-[5rem]"
|
||||||
|
/>
|
||||||
{!dockedNotDriving ? (
|
{!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
|
<FloatingJoystick
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
layout={layout}
|
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 {
|
const {
|
||||||
state: { roverId },
|
state: { roverId, camera, horn },
|
||||||
actions: { setAuxMotors },
|
pipeline,
|
||||||
|
actions: { setServoAngle, setNightVision, setAuxMotors, startHorn, stopHorn },
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
const activeRef = useRef(null);
|
|
||||||
const disabled = !roverId;
|
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(
|
const handleNightVisionToggle = useCallback(
|
||||||
(button) => {
|
(nextOn) => {
|
||||||
|
if (!nightVisionAvailable) return;
|
||||||
|
setNightVision(nextOn);
|
||||||
|
},
|
||||||
|
[nightVisionAvailable, setNightVision],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAuxPress = useCallback(
|
||||||
|
(id, values) => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
if (button.hold === false) {
|
activeRef.current = id;
|
||||||
setAuxMotors(button.values);
|
setAuxMotors(values);
|
||||||
activeRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeRef.current = button.id;
|
|
||||||
setAuxMotors(button.values);
|
|
||||||
},
|
},
|
||||||
[disabled, setAuxMotors],
|
[disabled, setAuxMotors],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRelease = useCallback(
|
const handleAuxRelease = useCallback(
|
||||||
(button) => {
|
(id) => {
|
||||||
if (disabled || button.hold === false) return;
|
if (disabled) return;
|
||||||
if (activeRef.current === button.id) {
|
if (activeRef.current === id) {
|
||||||
activeRef.current = null;
|
activeRef.current = null;
|
||||||
setAuxMotors({ main: 0, side: 0, vacuum: 0 });
|
setAuxMotors(AUX_ZERO);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[disabled, setAuxMotors],
|
[disabled, setAuxMotors],
|
||||||
);
|
);
|
||||||
|
|
||||||
const gridCols = orientation === 'landscape' ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-0.5 text-slate-100">
|
<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">
|
||||||
<p className="text-xs text-slate-400">Aux controls</p>
|
<div className="flex min-h-0 items-stretch gap-0.5">
|
||||||
<div className={`grid ${gridCols} gap-0.5`}>
|
{cameraEnabled ? (
|
||||||
{AUX_BUTTONS.map((button) => (
|
<div
|
||||||
<button
|
className={`flex-1 min-h-0 rounded bg-zinc-950 p-0.25 ${
|
||||||
key={button.id}
|
layout === 'landscape' ? 'pt-3' : ''
|
||||||
type="button"
|
}`.trim()}
|
||||||
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`}
|
|
||||||
>
|
>
|
||||||
{button.label}
|
<CameraTiltControl
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MobileLandscapeAuxColumn() {
|
export function MobileLeftColumn({ layout, className = '' }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-0.5">
|
<div className={`flex flex-col gap-0.5 ${className}`.trim()}>
|
||||||
<AuxMotorPanel orientation="landscape" />
|
<MobileLeftColumnContent layout={layout} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MobileLandscapeControlColumn() {
|
export function MobileRightColumn({ layout, className = '' }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-0.5">
|
<div className={`flex flex-col gap-0.5 ${className}`.trim()}>
|
||||||
<MobileJoystickPanel layout="landscape" />
|
<MobileJoystickPanel layout={layout === 'landscape' ? 'landscape' : 'portrait'} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobilePortraitControls() {
|
export default function MobilePortraitControls() {
|
||||||
|
const columnHeight = 'h-[min(60svh,24rem)]';
|
||||||
return (
|
return (
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<div className="grid grid-cols-2 gap-0.5 items-stretch">
|
<div className="grid grid-cols-2 gap-0.5 items-stretch">
|
||||||
<AuxMotorPanel orientation="portrait" />
|
<MobileLeftColumn layout="portrait" className={columnHeight} />
|
||||||
<MobileJoystickPanel layout="portrait" />
|
<MobileRightColumn layout="portrait" className={columnHeight} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default function NightVisionControl({
|
|||||||
onToggle,
|
onToggle,
|
||||||
keyLabel,
|
keyLabel,
|
||||||
className = '',
|
className = '',
|
||||||
|
heightClass = '',
|
||||||
}) {
|
}) {
|
||||||
const [optimistic, setOptimistic] = useState(
|
const [optimistic, setOptimistic] = useState(
|
||||||
isBoolean(nightVisionOn) ? nightVisionOn : null,
|
isBoolean(nightVisionOn) ? nightVisionOn : null,
|
||||||
@@ -37,31 +38,32 @@ export default function NightVisionControl({
|
|||||||
|
|
||||||
const buttonClasses = useMemo(() => {
|
const buttonClasses = useMemo(() => {
|
||||||
const base =
|
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 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';
|
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)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
}, [className, displayOn]);
|
}, [className, displayOn, heightClass]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-pressed={displayOn}
|
aria-pressed={displayOn}
|
||||||
className={buttonClasses}
|
className={buttonClasses}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-0.5">
|
<span className="flex items-center gap-0.5">
|
||||||
<span>Night Vision</span>
|
<span className="text-sm font-semibold">Night Vision</span>
|
||||||
{keyLabel ? (
|
{keyLabel ? (
|
||||||
<span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
|
<span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
|
||||||
{keyLabel}
|
{keyLabel}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</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}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { InlineCameraTilt } from './ControlSummary.jsx';
|
|
||||||
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
||||||
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
||||||
import SettingsPanel from './SettingsPanel.jsx';
|
import SettingsPanel from './SettingsPanel.jsx';
|
||||||
@@ -13,6 +12,10 @@ import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
|||||||
import { useControlSystem } from '../controls/index.js';
|
import { useControlSystem } from '../controls/index.js';
|
||||||
import RoverQueuesPanel from './RoverQueuesPanel.jsx';
|
import RoverQueuesPanel from './RoverQueuesPanel.jsx';
|
||||||
import RawUserPilePanel from './RawUserPilePanel.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() {
|
function TopDownMapPanel() {
|
||||||
const {
|
const {
|
||||||
@@ -32,15 +35,74 @@ function TopDownMapPanel() {
|
|||||||
|
|
||||||
function DriveDockPanel() {
|
function DriveDockPanel() {
|
||||||
const {
|
const {
|
||||||
state: { roverId, keymap },
|
state: { roverId, keymap, camera, horn },
|
||||||
|
pipeline,
|
||||||
|
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
const driveDockState = useDriveDockState(roverId);
|
const driveDockState = useDriveDockState(roverId);
|
||||||
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
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 (
|
return (
|
||||||
<section className="panel-section grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
|
<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} />
|
<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>
|
</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