mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-18 02:20:47 -04:00
big boy webui new new new new new 100 files changed 80 years
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// Bottom-left Corner Pod
|
||||
// Purpose: Presents only the assigned rover's available physical light, laser, and horn controls.
|
||||
import { useRef } from 'react';
|
||||
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
|
||||
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
|
||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import HornSettingsExpansion from './HornSettingsExpansion.jsx';
|
||||
import CornerPodToggle from './CornerPodToggle.jsx';
|
||||
import usePodVisibility from './usePodVisibility.js';
|
||||
|
||||
function RoundControl({ label, icon, keyLabel, active, tone, disabled = false, onClick, onPointerDown, onPointerUp, large = false, className = '' }) {
|
||||
const ControlIcon = icon;
|
||||
const toneClass = tone === 'horn'
|
||||
? active ? 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50' : 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
|
||||
: active ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
className={`flex shrink-0 select-none flex-col items-center justify-center gap-1 rounded-full border-2 ${toneClass} ${large ? 'h-20 w-20' : 'h-16 w-16'} disabled:cursor-not-allowed disabled:opacity-40 ${className}`}
|
||||
>
|
||||
<ControlIcon className={large ? 'text-xl' : 'text-base'} aria-hidden="true" />
|
||||
<KeyPill label={keyLabel} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BottomLeftPod({ roverId }) {
|
||||
const [open, setOpen] = usePodVisibility('peripherals', true);
|
||||
const [hornSettingsOpen, setHornSettingsOpen] = usePodVisibility('hornSettings', false);
|
||||
const headlight = useControlSelector((control) => control.pipeline?.headlight);
|
||||
const laser = useControlSelector((control) => control.pipeline?.laser);
|
||||
const hornDevice = useControlSelector((control) => control.pipeline?.horn);
|
||||
const headlightOn = useControlSelector((control) => Boolean(control.pipeline?.headlightState?.headlightOn));
|
||||
const laserOn = useControlSelector((control) => Boolean(control.pipeline?.laserState?.laserOn));
|
||||
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const { setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
|
||||
const canControl = useCanControlRover(roverId);
|
||||
const hornPointerRef = useRef(null);
|
||||
const available = Boolean(headlight || laser || hornDevice);
|
||||
|
||||
if (!available) return null;
|
||||
const startHornPointer = (event) => {
|
||||
if (!canControl) return;
|
||||
if (hornPointerRef.current != null) return;
|
||||
event.preventDefault();
|
||||
hornPointerRef.current = event.pointerId;
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
startHorn();
|
||||
};
|
||||
const stopHornPointer = (event) => {
|
||||
if (hornPointerRef.current !== event.pointerId) return;
|
||||
hornPointerRef.current = null;
|
||||
stopHorn();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{open ? (
|
||||
<div className="pointer-events-auto absolute bottom-0 left-0 z-20 h-40 w-40 rounded-tr-[4.25rem] bg-black/60">
|
||||
{/*
|
||||
The horn's center is the origin of an invisible 76px-radius arc. Headlight and laser
|
||||
sit at -75 and -15 degrees on either side of that arc's diagonal midpoint. Their
|
||||
sixty-degree separation leaves a visible gap between the small circles while keeping
|
||||
both controls equally distant from the horn. These explicit positions are the
|
||||
rendered result of that geometry, not unrelated visual nudges.
|
||||
*/}
|
||||
{/* Physical rover actions become visibly and behaviorally unavailable
|
||||
while another queued driver owns the turn. Pod/settings controls
|
||||
remain interactive because they do not mutate rover hardware. */}
|
||||
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={formatKeyLabel(keymap?.hornHonk?.[0])} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
|
||||
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={formatKeyLabel(keymap?.headlightToggle?.[0])} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
|
||||
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={formatKeyLabel(keymap?.laserToggle?.[0])} active={laserOn} disabled={!canControl} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
|
||||
<CornerPodToggle corner="bottom-left" expanded label="Hide rover controls" onClick={() => setOpen(false)} />
|
||||
</div>
|
||||
) : (
|
||||
<CornerPodToggle corner="bottom-left" expanded={false} label="Show rover controls" onClick={() => setOpen(true)} />
|
||||
)}
|
||||
{hornDevice ? <HornSettingsExpansion open={hornSettingsOpen} podOpen={open} onOpenChange={setHornSettingsOpen} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Bottom-right Corner Pod
|
||||
// Purpose: Provides a compact circular camera-tilt control using the existing servo command path.
|
||||
import { useCallback } from 'react';
|
||||
import { FaVideo } from 'react-icons/fa';
|
||||
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
|
||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
|
||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import ChatExpansion from './ChatExpansion.jsx';
|
||||
import CornerPodToggle from './CornerPodToggle.jsx';
|
||||
import usePodVisibility from './usePodVisibility.js';
|
||||
|
||||
const ARC_CENTER = 72;
|
||||
const ARC_RADIUS = 60;
|
||||
const ARC_START_DEGREES = 105;
|
||||
const ARC_SWEEP_DEGREES = 240;
|
||||
|
||||
function pointOnArc(fraction) {
|
||||
// The unused third is centered on the physical bottom-right screen corner. Starting at
|
||||
// 105 degrees and sweeping clockwise to 345 degrees leaves that exact corner-facing gap.
|
||||
const degrees = ARC_START_DEGREES + fraction * ARC_SWEEP_DEGREES;
|
||||
const radians = (degrees * Math.PI) / 180;
|
||||
return {
|
||||
x: ARC_CENTER + Math.cos(radians) * ARC_RADIUS,
|
||||
y: ARC_CENTER + Math.sin(radians) * ARC_RADIUS,
|
||||
};
|
||||
}
|
||||
|
||||
export default function BottomRightPod({ roverId }) {
|
||||
const layout = useDriverLayout();
|
||||
const [open, setOpen] = usePodVisibility('camera', true);
|
||||
const camera = useControlSelector((control) => control.state.camera);
|
||||
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const { setServoAngle } = useControlActions();
|
||||
const canControl = useCanControlRover(roverId);
|
||||
const config = camera?.config;
|
||||
const enabled = Boolean(roverId && camera?.enabled && config);
|
||||
const min = Number(config?.minAngle);
|
||||
const max = Number(config?.maxAngle);
|
||||
const value = Number.isFinite(camera?.angle) ? camera.angle : Number(config?.homeAngle) || 0;
|
||||
const fraction = max > min ? Math.max(0, Math.min(1, (value - min) / (max - min))) : 0.5;
|
||||
const knob = pointOnArc(fraction);
|
||||
// Keep this derived state in one place because it also tells the independent chat
|
||||
// expansion whether it must offset itself above a visible camera-control pod.
|
||||
const showCameraControls = layout === 'desktop';
|
||||
const cameraPodOpen = showCameraControls && enabled && open;
|
||||
|
||||
const updateFromPointer = useCallback((event) => {
|
||||
if (!canControl || !enabled || dockAssistActive || !(max > min)) return;
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const x = ((event.clientX - bounds.left) / bounds.width) * 144;
|
||||
const y = ((event.clientY - bounds.top) / bounds.height) * 144;
|
||||
const pointerDegrees = ((Math.atan2(y - ARC_CENTER, x - ARC_CENTER) * 180) / Math.PI + 360) % 360;
|
||||
let arcDegrees = (pointerDegrees - ARC_START_DEGREES + 360) % 360;
|
||||
if (arcDegrees > ARC_SWEEP_DEGREES) {
|
||||
/*
|
||||
Pointer input inside the open corner-facing third is outside the slider. Snap it to
|
||||
whichever visible endpoint is closer so the gap remains visually and behaviorally open.
|
||||
*/
|
||||
const distanceFromEnd = arcDegrees - ARC_SWEEP_DEGREES;
|
||||
const distanceFromStart = 360 - arcDegrees;
|
||||
arcDegrees = distanceFromStart < distanceFromEnd ? 0 : ARC_SWEEP_DEGREES;
|
||||
}
|
||||
const nextFraction = arcDegrees / ARC_SWEEP_DEGREES;
|
||||
setServoAngle(min + nextFraction * (max - min));
|
||||
}, [canControl, dockAssistActive, enabled, max, min, setServoAngle]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{cameraPodOpen ? (
|
||||
<div className="pointer-events-auto absolute bottom-0 right-0 z-20 flex h-[8.5rem] w-[8.5rem] items-center justify-center rounded-tl-[4.25rem] bg-black/60">
|
||||
{/* The pod shell and its visibility toggle stay usable while waiting, but
|
||||
every camera mutation is blocked and visibly muted until control returns. */}
|
||||
<svg viewBox="0 0 144 144" aria-disabled={!canControl} className={`h-[8.5rem] w-[8.5rem] touch-none ${canControl ? '' : 'pointer-events-none opacity-40'}`} onPointerDown={updateFromPointer} onPointerMove={(event) => { if (event.buttons) updateFromPointer(event); }}>
|
||||
<path d="M 56.47 129.96 A 60 60 0 1 1 129.96 56.47" pathLength="1" fill="none" stroke="#064e3b" strokeWidth="12" strokeLinecap="round" />
|
||||
<path d="M 56.47 129.96 A 60 60 0 1 1 129.96 56.47" pathLength="1" fill="none" stroke="#34d399" strokeWidth="12" strokeLinecap="round" strokeDasharray={`${fraction} 1`} />
|
||||
<circle cx={knob.x} cy={knob.y} r="7" fill="#ecfdf5" stroke="#059669" strokeWidth="3" />
|
||||
</svg>
|
||||
<button type="button" aria-label="Reset camera tilt" disabled={!canControl} onClick={() => setServoAngle(0)} className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-0.5 rounded bg-black/55 px-1.5 py-1 font-bold text-white disabled:cursor-not-allowed disabled:opacity-40">
|
||||
{/* The icon/value stack mirrors the battery pod and clarifies that this
|
||||
circular gauge changes the live rover camera's tilt angle. */}
|
||||
<FaVideo className="text-base" aria-hidden="true" />
|
||||
<span className="text-sm leading-none">{value.toFixed(1)}°</span>
|
||||
</button>
|
||||
{/* These positions continue around the same circle just beyond the two slider endpoints.
|
||||
Together they occupy the open third facing the corner without enlarging the pod. */}
|
||||
<div className="absolute left-[61%] top-[90%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraDown?.[0])} /></div>
|
||||
<div className="absolute left-[90%] top-[61%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraUp?.[0])} /></div>
|
||||
<CornerPodToggle corner="bottom-right" expanded label="Hide camera tilt" onClick={() => setOpen(false)} />
|
||||
</div>
|
||||
) : showCameraControls && enabled ? (
|
||||
<CornerPodToggle corner="bottom-right" expanded={false} label="Show camera tilt" onClick={() => setOpen(true)} />
|
||||
) : null}
|
||||
{/* Chat is an independent expansion. It remains usable even when this rover
|
||||
has no camera-servo configuration or the camera pod is collapsed. */}
|
||||
<ChatExpansion podOpen={cameraPodOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// New-drive Chat Expansion
|
||||
// Purpose: Keeps chat collapsed to a corner icon while reusing the established HUD composer behavior.
|
||||
import { useCallback, useState } from 'react';
|
||||
import { FaComment } from 'react-icons/fa';
|
||||
import { useChatActions } from '../../../../context/ChatContext.jsx';
|
||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||
import { useControlSelector } from '../../../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
|
||||
import HudChatInput from '../../HudChatInput/index.jsx';
|
||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
|
||||
export default function ChatExpansion({ podOpen }) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const chatKeyLabel = useControlSelector((control) => formatKeyLabel(control.state.keymap?.chatFocus?.[0]));
|
||||
const { blurChat, focusChat } = useChatActions();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const setChatOpen = useCallback((nextOpen) => {
|
||||
const next = Boolean(nextOpen);
|
||||
setOpen(next);
|
||||
if (!next) blurChat();
|
||||
}, [blurChat]);
|
||||
|
||||
const toggleChat = useCallback(() => {
|
||||
if (open) {
|
||||
setChatOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(true);
|
||||
/* HudChatInput remains mounted while visually collapsed, so the existing
|
||||
ChatContext HUD ref is ready immediately. Waiting one animation frame lets
|
||||
the opening presentation commit before the browser paints the focus ring. */
|
||||
window.requestAnimationFrame(focusChat);
|
||||
}, [focusChat, open, setChatOpen]);
|
||||
|
||||
// Spectators use the sidebar transcript but cannot send through the legacy HUD
|
||||
// composer, so hiding this expansion avoids presenting an inert control.
|
||||
if (role === 'spectator') return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={open ? 'Close chat' : 'Open chat'}
|
||||
aria-pressed={open}
|
||||
onClick={toggleChat}
|
||||
className={`pointer-events-auto absolute z-20 flex h-8 items-center justify-center gap-1 bg-black/60 px-1.5 text-sm text-white transition hover:bg-black/80 ${podOpen ? 'bottom-[8.5rem] right-0 rounded-tl-lg' : 'bottom-0 right-10 rounded-t-lg'}`}
|
||||
>
|
||||
<FaComment aria-hidden="true" />
|
||||
{/* The pill reflects the live keymap so remapping chat focus updates this
|
||||
compact HUD hint without duplicating or hardcoding the default key. */}
|
||||
{chatKeyLabel ? <KeyPill label={chatKeyLabel} /> : null}
|
||||
</button>
|
||||
|
||||
<HudChatInput variant="newdrive" open={open} onOpenChange={setChatOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Corner Pod Toggle
|
||||
// Purpose: Reserves each exact video corner for a pod's always-reachable collapse control.
|
||||
import { FaArrowUp } from 'react-icons/fa';
|
||||
|
||||
const CORNERS = {
|
||||
'top-left': {
|
||||
button: 'left-0 top-0 [clip-path:polygon(0_0,100%_0,0_100%)]',
|
||||
icon: 'left-1 top-1',
|
||||
collapseRotation: '-rotate-45',
|
||||
expandRotation: 'rotate-[135deg]',
|
||||
},
|
||||
'top-right': {
|
||||
button: 'right-0 top-0 [clip-path:polygon(0_0,100%_0,100%_100%)]',
|
||||
icon: 'right-1 top-1',
|
||||
collapseRotation: 'rotate-45',
|
||||
expandRotation: '-rotate-[135deg]',
|
||||
},
|
||||
'bottom-left': {
|
||||
button: 'bottom-0 left-0 [clip-path:polygon(0_0,0_100%,100%_100%)]',
|
||||
icon: 'bottom-1 left-1',
|
||||
collapseRotation: '-rotate-[135deg]',
|
||||
expandRotation: 'rotate-45',
|
||||
},
|
||||
'bottom-right': {
|
||||
button: 'bottom-0 right-0 [clip-path:polygon(100%_0,0_100%,100%_100%)]',
|
||||
icon: 'bottom-1 right-1',
|
||||
collapseRotation: 'rotate-[135deg]',
|
||||
expandRotation: '-rotate-45',
|
||||
},
|
||||
};
|
||||
|
||||
export default function CornerPodToggle({ corner, expanded, label, onClick }) {
|
||||
const placement = CORNERS[corner] || CORNERS['top-left'];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className={`pointer-events-auto absolute z-40 h-10 w-10 bg-black/90 text-white/90 hover:bg-black hover:text-white ${placement.button}`}
|
||||
>
|
||||
{/* One arrow icon is rotated toward the physical corner for collapse and directly away
|
||||
from it for expansion. The triangular hit target itself never moves or disappears. */}
|
||||
<FaArrowUp
|
||||
className={`absolute text-[0.65rem] ${placement.icon} ${expanded ? placement.collapseRotation : placement.expandRotation}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Expansion Toggle
|
||||
// Purpose: Provides a slim edge strip that remains part of an expansion in both visibility states.
|
||||
import { FaChevronDown, FaChevronLeft, FaChevronRight, FaChevronUp } from 'react-icons/fa';
|
||||
|
||||
const ICONS = { down: FaChevronDown, left: FaChevronLeft, right: FaChevronRight, up: FaChevronUp };
|
||||
|
||||
export default function ExpansionToggle({ direction, label, onClick, className = '' }) {
|
||||
const Icon = ICONS[direction] || FaChevronLeft;
|
||||
const horizontalEdge = direction === 'up' || direction === 'down';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className={`flex shrink-0 items-center justify-center bg-black/60 text-[0.5rem] text-white/75 hover:bg-black hover:text-white ${horizontalEdge ? 'h-3 w-8' : 'h-8 w-3'} ${className}`}
|
||||
>
|
||||
{/* The black strip is intentionally retained around the chevron. A collapsed expansion is
|
||||
therefore still a thin piece of that expansion, never a loose button over a pod. */}
|
||||
<Icon aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Horn Settings Expansion
|
||||
// Purpose: Keeps the persisted horn sound controls available independently of the peripheral pod.
|
||||
import { useCallback } from 'react';
|
||||
import { HORN_MAX_FREQUENCY } from '../../../../controls/constants.js';
|
||||
import { useSettingsNamespace } from '../../../../settings/index.js';
|
||||
import { HORN_SETTINGS_DEFAULTS } from '../../../../settings/namespaces.js';
|
||||
import ExpansionToggle from './ExpansionToggle.jsx';
|
||||
|
||||
function clampFrequency(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return 0;
|
||||
return Math.min(HORN_MAX_FREQUENCY, Math.round(numeric));
|
||||
}
|
||||
|
||||
export default function HornSettingsExpansion({ open, podOpen, onOpenChange }) {
|
||||
const { value, save } = useSettingsNamespace('horn', HORN_SETTINGS_DEFAULTS);
|
||||
const waveform = value?.waveform === 'sine' ? 'sine' : 'saw';
|
||||
const frequencies = [...(Array.isArray(value?.freqs) ? value.freqs : HORN_SETTINGS_DEFAULTS.freqs), 0, 0, 0, 0]
|
||||
.slice(0, 4)
|
||||
.map(clampFrequency);
|
||||
|
||||
const updateFrequency = useCallback((index, nextValue) => {
|
||||
save((current) => {
|
||||
// Build from the persisted values on every edit so rapid changes cannot overwrite a
|
||||
// neighboring frequency input with a stale render-time copy.
|
||||
const next = [...(Array.isArray(current?.freqs) ? current.freqs : HORN_SETTINGS_DEFAULTS.freqs), 0, 0, 0, 0]
|
||||
.slice(0, 4)
|
||||
.map(clampFrequency);
|
||||
next[index] = clampFrequency(nextValue);
|
||||
return { ...(current || {}), freqs: next };
|
||||
});
|
||||
}, [save]);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<div className={`pointer-events-auto absolute left-10 z-20 flex h-3 w-8 bg-black/60 ${podOpen ? 'bottom-40' : 'bottom-0'}`}>
|
||||
<ExpansionToggle direction="up" label="Show horn settings" onClick={() => onOpenChange(true)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`pointer-events-auto absolute left-0 z-20 w-52 rounded-tr-xl bg-black/60 p-2 text-xs text-white ${podOpen ? 'bottom-40' : 'bottom-0'}`}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-cyan-100">Horn settings</span>
|
||||
{/* This arrow belongs to the expansion. Closing the peripheral pod never changes
|
||||
hornSettings visibility; it only moves this panel into the vacated corner. */}
|
||||
<ExpansionToggle direction="down" label="Hide horn settings" onClick={() => onOpenChange(false)} />
|
||||
</div>
|
||||
<label className="flex items-center justify-between gap-2 text-slate-300">
|
||||
<span>Wave</span>
|
||||
<select
|
||||
value={waveform}
|
||||
onChange={(event) => save((current) => ({ ...(current || {}), waveform: event.target.value === 'sine' ? 'sine' : 'saw' }))}
|
||||
className="rounded bg-slate-900 px-2 py-1 text-white ring-1 ring-slate-600"
|
||||
>
|
||||
<option value="saw">Saw</option>
|
||||
<option value="sine">Sine</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="mt-2 grid grid-cols-2 gap-1.5">
|
||||
{frequencies.map((frequency, index) => (
|
||||
<label key={index} className="flex items-center gap-1 text-slate-400">
|
||||
<span>{index + 1}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={HORN_MAX_FREQUENCY}
|
||||
value={frequency}
|
||||
onChange={(event) => updateFrequency(index, event.target.value)}
|
||||
className="min-w-0 flex-1 rounded bg-slate-900 px-1.5 py-1 text-right font-mono text-white ring-1 ring-slate-600"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Top-left Corner Pod
|
||||
// Purpose: Shows the user's current-turn or queue-wait countdown and the rover identity expansion.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useControlSelector } from '../../../../controls/index.js';
|
||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||
import { useSharedClock } from '../../../../hooks/useSharedClock.js';
|
||||
import RoverLabel from '../../../RoverLabel/index.jsx';
|
||||
import CornerPodToggle from './CornerPodToggle.jsx';
|
||||
import ExpansionToggle from './ExpansionToggle.jsx';
|
||||
import usePodVisibility from './usePodVisibility.js';
|
||||
|
||||
export default function TopLeftPod({ roverId }) {
|
||||
const [timerOpen, setTimerOpen] = usePodVisibility('turnTimer', true);
|
||||
const [nameOpen, setNameOpen] = usePodVisibility('roverName', true);
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const turnInfo = useSessionSelector((state) => state.session?.turnQueues?.[roverId] || null);
|
||||
const activeDriverId = useSessionSelector((state) => state.session?.activeDrivers?.[roverId] || null);
|
||||
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
||||
const deadline = turnInfo?.deadline || null;
|
||||
const idleDeadline = turnInfo?.idleDeadline || null;
|
||||
const queue = turnInfo?.queue || [];
|
||||
// Direct ownership is published independently from the detailed queue and is
|
||||
// therefore the reliable initial-load/reconnect source for the current turn.
|
||||
const currentDriverId = activeDriverId || turnInfo?.current || null;
|
||||
const currentIndex = currentDriverId ? queue.indexOf(currentDriverId) : -1;
|
||||
const userIndex = socketId ? queue.indexOf(socketId) : -1;
|
||||
const turnActive = mode === 'turns' && queue.length > 1 && currentIndex >= 0 && userIndex >= 0;
|
||||
const hasTurnDeadline = Boolean(turnActive && deadline);
|
||||
const now = useSharedClock(1000, hasTurnDeadline);
|
||||
const currentTurnSeconds = hasTurnDeadline ? Math.max(0, Math.ceil((deadline - now) / 1000)) : null;
|
||||
const idleSkipSeconds = idleDeadline ? Math.max(0, Math.ceil((idleDeadline - now) / 1000)) : null;
|
||||
const turnsAhead = turnActive ? (userIndex - currentIndex + queue.length) % queue.length : null;
|
||||
const seconds = currentTurnSeconds == null || turnsAhead == null
|
||||
? null
|
||||
: currentTurnSeconds + Math.max(0, turnsAhead - 1) * 60;
|
||||
const isCurrentTurn = turnsAhead === 0;
|
||||
const isWaitingForTurn = turnActive && !isCurrentTurn;
|
||||
const [showTurnCue, setShowTurnCue] = useState(false);
|
||||
const [turnCueStartedAt, setTurnCueStartedAt] = useState(null);
|
||||
const previousCurrentRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const wasCurrent = previousCurrentRef.current;
|
||||
previousCurrentRef.current = isCurrentTurn;
|
||||
let timer = 0;
|
||||
|
||||
if (turnActive && isCurrentTurn && wasCurrent !== true) {
|
||||
const startedAt = Date.now();
|
||||
/*
|
||||
A direct-load current turn and a live ownership handoff both need the same cue. Defer
|
||||
the visual state update by one task to remain compatible with the repo's React Compiler
|
||||
rules while preserving the actual handoff timestamp for the minimum display period.
|
||||
*/
|
||||
timer = setTimeout(() => {
|
||||
setTurnCueStartedAt(startedAt);
|
||||
setShowTurnCue(true);
|
||||
}, 0);
|
||||
} else if ((!turnActive || !isCurrentTurn) && showTurnCue) {
|
||||
timer = setTimeout(() => {
|
||||
setShowTurnCue(false);
|
||||
setTurnCueStartedAt(null);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [isCurrentTurn, showTurnCue, turnActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showTurnCue || !turnCueStartedAt || lastControlIntentAt <= turnCueStartedAt) return undefined;
|
||||
/*
|
||||
The first real control intent proves the user has noticed and started driving. Keep the
|
||||
cue for at least two seconds anyway, then return to the compact timer. With no input the
|
||||
cue remains large so the live server idle-skip deadline cannot be overlooked.
|
||||
*/
|
||||
const remainingMinimumMs = Math.max(0, 2000 - (Date.now() - turnCueStartedAt));
|
||||
const timer = setTimeout(() => setShowTurnCue(false), remainingMinimumMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [lastControlIntentAt, showTurnCue, turnCueStartedAt]);
|
||||
const gaugePercent = useMemo(() => {
|
||||
if (seconds == null) return 0;
|
||||
/*
|
||||
The server's rover turns are sixty seconds long. Waiting users need one complete
|
||||
turn added for each driver between the current driver and themselves. A complete
|
||||
queue rotation is a stable scale across handoffs, so the ring drains continuously
|
||||
instead of jumping back to full when the current driver changes.
|
||||
*/
|
||||
const rotationSeconds = Math.max(60, queue.length * 60);
|
||||
return Math.max(0, Math.min(1, seconds / rotationSeconds));
|
||||
}, [queue.length, seconds]);
|
||||
const visibleGaugePercent = showTurnCue && idleSkipSeconds != null
|
||||
// The server's initial inactivity grace is seven seconds. Mirroring that known lifecycle
|
||||
// makes the enlarged ring itself reinforce the prominent skip countdown in the center.
|
||||
? Math.max(0, Math.min(1, idleSkipSeconds / 7))
|
||||
: gaugePercent;
|
||||
const showTimer = Boolean(turnActive && timerOpen);
|
||||
const showLargeTimer = isWaitingForTurn || showTurnCue;
|
||||
const timerLabel = seconds == null
|
||||
// Ownership should remain visible while the detailed deadline is in flight.
|
||||
? isCurrentTurn ? 'Your turn' : 'Waiting'
|
||||
: seconds >= 60
|
||||
? `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||
: `${seconds}s`;
|
||||
|
||||
return (
|
||||
<div className={`pointer-events-auto absolute left-0 top-0 flex items-start ${showLargeTimer ? 'z-[100]' : 'z-20'}`}>
|
||||
{showTimer ? (
|
||||
<div
|
||||
className={`relative flex items-center justify-center transition-[width,height,border-radius,background-color] duration-500 ease-out motion-reduce:transition-none ${
|
||||
showLargeTimer
|
||||
// The waiting/handoff state replaces the old full-screen turn cue.
|
||||
// It must be opaque and above every other in-video HUD surface so
|
||||
// sensor graphics, chat, and docking controls cannot muddy the text.
|
||||
? 'h-[25.5rem] w-[25.5rem] rounded-br-[12.75rem] bg-black'
|
||||
: 'h-[8.5rem] w-[8.5rem] rounded-br-[4.25rem] bg-black/60'
|
||||
}`}
|
||||
>
|
||||
{/* The SVG fills the shell. Its circle geometry supplies the same slim visible inset
|
||||
used by the other pods instead of stacking SVG padding on top of shell padding. */}
|
||||
<svg className="h-full w-full -rotate-90" viewBox="0 0 100 100" aria-hidden="true">
|
||||
<circle cx="50" cy="50" r="41" fill="none" stroke="#334155" strokeWidth="10" />
|
||||
<circle cx="50" cy="50" r="41" fill="none" stroke={showTurnCue ? '#fbbf24' : '#38bdf8'} strokeWidth="10" strokeLinecap="round" pathLength="1" strokeDasharray={`${visibleGaugePercent} 1`} />
|
||||
</svg>
|
||||
<span className={`absolute flex flex-col items-center text-center text-white ${showLargeTimer ? 'max-w-[55%]' : 'max-w-[70%]'}`}>
|
||||
{/* The handoff text replaces the retired desktop full-screen TurnsOverlay.
|
||||
Typography grows with the pod, while the corner toggle deliberately remains
|
||||
fixed-size so it never becomes a giant obstruction over the video. */}
|
||||
{showLargeTimer ? (
|
||||
<>
|
||||
<span className={`mb-2 text-[1.75rem] font-bold leading-tight transition-colors duration-300 ${showTurnCue ? 'text-amber-200' : 'text-sky-100'}`}>
|
||||
{showTurnCue ? 'It’s your turn!' : 'Someone else is driving'}
|
||||
</span>
|
||||
<strong className="text-[3.375rem] leading-none">
|
||||
{showTurnCue && idleSkipSeconds != null ? `${idleSkipSeconds}s` : timerLabel}
|
||||
</strong>
|
||||
<span className={`mt-2 text-[1rem] font-semibold leading-tight ${showTurnCue ? 'text-amber-200' : 'text-sky-200'}`}>
|
||||
{showTurnCue
|
||||
? idleSkipSeconds != null
|
||||
? 'Start driving or your turn will be skipped'
|
||||
: 'You’re driving'
|
||||
: 'until your turn'}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong className="text-lg leading-none">{timerLabel}</strong>
|
||||
{seconds != null ? <span className="mt-1 text-[0.6rem] font-semibold text-sky-200">left</span> : null}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<CornerPodToggle corner="top-left" expanded label="Hide turn timer" onClick={() => setTimerOpen(false)} />
|
||||
</div>
|
||||
) : turnActive ? (
|
||||
<CornerPodToggle corner="top-left" expanded={false} label="Show turn timer" onClick={() => setTimerOpen(true)} />
|
||||
) : null}
|
||||
|
||||
{/* The rover name is an independent edge expansion. Its visibility control lives in
|
||||
the expansion itself, and its position naturally moves into the corner whenever
|
||||
the conditional timer pod is absent or manually collapsed. */}
|
||||
{nameOpen ? (
|
||||
<div className={`flex h-11 items-center gap-2 bg-black/60 px-2 ${showTimer ? '' : 'rounded-br-xl'}`}>
|
||||
<RoverLabel roverId={roverId} fallback={roverId} className="px-2 py-1 text-base" />
|
||||
<ExpansionToggle direction="up" label="Hide rover name" onClick={() => setNameOpen(false)} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={`flex h-3 w-8 bg-black/60 ${showTimer ? '' : 'ml-10'}`}>
|
||||
<ExpansionToggle direction="down" label="Show rover name" onClick={() => setNameOpen(true)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Top-right Corner Pod
|
||||
// Purpose: Combines the battery/current gauge with a compact attached advanced-power expansion.
|
||||
import { createElement, useMemo } from 'react';
|
||||
import { FaArrowDown, FaArrowUp, FaBatteryHalf, FaBolt, FaExclamationTriangle, FaMemory, FaThermometerHalf, FaWifi } from 'react-icons/fa';
|
||||
import { useControlSelector } from '../../../../controls/index.js';
|
||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
|
||||
import { hostStatsEqual, selectHostStats, selectSpectatorTelemetry, spectatorTelemetryEqual } from '../../../../context/telemetryViews.js';
|
||||
import CornerPodToggle from './CornerPodToggle.jsx';
|
||||
import ExpansionToggle from './ExpansionToggle.jsx';
|
||||
import usePodVisibility from './usePodVisibility.js';
|
||||
|
||||
function finite(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function clampPercent(value) {
|
||||
const number = finite(value);
|
||||
return number == null ? 0 : Math.max(0, Math.min(100, number));
|
||||
}
|
||||
|
||||
function MetricRow({ icon, label, value, percent, iconClass, fillClass }) {
|
||||
return (
|
||||
<div className="min-w-0" title={label}>
|
||||
<div className="flex items-center gap-1">
|
||||
{createElement(icon, { className: `shrink-0 text-[0.65rem] ${iconClass}`, 'aria-hidden': true })}
|
||||
<span className="min-w-0 flex-1 truncate text-[0.62rem] leading-none text-slate-200">{label}</span>
|
||||
<strong className="shrink-0 text-[0.62rem] leading-none text-white">{value}</strong>
|
||||
</div>
|
||||
{/* Every meter uses an explicit real-world display range defined by its caller. The bar
|
||||
therefore adds information instead of merely decorating the latest numeric value.
|
||||
Keeping it on its own line gives both the title and meter the full panel width. */}
|
||||
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-slate-700">
|
||||
<div className={`h-full rounded-full ${fillClass}`} style={{ width: `${clampPercent(percent)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WifiTile({ signal }) {
|
||||
const bars = signal == null ? 0 : signal >= -55 ? 4 : signal >= -65 ? 3 : signal >= -75 ? 2 : 1;
|
||||
const tone = signal == null ? 'bg-slate-600' : signal < -80 ? 'bg-red-400' : signal < -70 ? 'bg-amber-400' : 'bg-emerald-400';
|
||||
return (
|
||||
<div className="min-w-0" title="Wi-Fi signal strength">
|
||||
<div className="flex items-center gap-1">
|
||||
<FaWifi className={`text-[0.65rem] ${signal == null ? 'text-slate-400' : 'text-emerald-300'}`} aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.62rem] leading-none text-slate-200">Wi-Fi signal</span>
|
||||
<strong className="shrink-0 text-[0.62rem] leading-none text-white">{signal == null ? '--' : `${Math.round(signal)} dBm`}</strong>
|
||||
</div>
|
||||
<div className="mt-1 flex h-2 items-end gap-0.5" aria-hidden="true">
|
||||
{[1, 2, 3, 4].map((bar) => (
|
||||
<span key={bar} className={`flex-1 rounded-sm ${bar <= bars ? tone : 'bg-slate-700'}`} style={{ height: `${25 * bar}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeedTile({ icon, label, value, colorClass }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1" title={label}>
|
||||
{createElement(icon, { className: colorClass, 'aria-hidden': true })}
|
||||
<span className="min-w-0 flex-1 truncate text-[0.62rem] leading-none text-slate-200">{label}</span>
|
||||
<strong className="shrink-0 text-[0.62rem] leading-none text-white">{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TopRightPod({ roverId }) {
|
||||
const [batteryOpen, setBatteryOpen] = usePodVisibility('battery', true);
|
||||
const [powerOpen, setPowerOpen] = usePodVisibility('advancedPower', false);
|
||||
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
|
||||
const batteryState = useSessionSelector((state) => {
|
||||
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
|
||||
return rover?.batteryState || null;
|
||||
});
|
||||
const electrical = useTelemetrySelector(roverId, selectSpectatorTelemetry, spectatorTelemetryEqual);
|
||||
const host = useTelemetrySelector(powerOpen ? roverId : null, selectHostStats, hostStatsEqual);
|
||||
const percent = Math.max(0, Math.min(100, finite(batteryState?.percentDisplay) ?? 0));
|
||||
const current = finite(electrical?.currentMa) ?? 0;
|
||||
const currentPercent = Math.max(0, Math.min(1, Math.abs(current) / 2500));
|
||||
const urgentBattery = Boolean(batteryState?.urgentActive);
|
||||
const lowBattery = Boolean(batteryState?.warnActive || urgentBattery);
|
||||
// Warning and urgent are separate server-owned thresholds. Amber gives the first threshold
|
||||
// a clear but calm identity; red is reserved for the genuinely time-sensitive state.
|
||||
const batteryTone = urgentBattery ? '#ef4444' : lowBattery ? '#f59e0b' : '#22c55e';
|
||||
const currentTone = current < 0 ? '#f59e0b' : '#22c55e';
|
||||
const circumference = 2 * Math.PI * 42;
|
||||
const currentCircumference = 2 * Math.PI * 32;
|
||||
const batteryDash = useMemo(() => `${(percent / 100) * circumference} ${circumference}`, [circumference, percent]);
|
||||
const wifi = host?.wifi || {};
|
||||
const signal = finite(wifi.signalDbm);
|
||||
const voltage = finite(electrical?.voltageMv);
|
||||
const batteryCharge = finite(electrical?.batteryChargeMah);
|
||||
const batteryCapacity = finite(electrical?.batteryCapacityMah);
|
||||
const cpuTemp = finite(host?.cpuTempC);
|
||||
const memoryUsed = finite(host?.memoryUsedPct);
|
||||
const voltagePercent = voltage == null ? 0 : ((voltage - 12000) / 5000) * 100;
|
||||
const batteryMahPercent = batteryCharge != null && batteryCapacity > 0 ? (batteryCharge / batteryCapacity) * 100 : 0;
|
||||
const cpuTempPercent = cpuTemp == null ? 0 : ((cpuTemp - 30) / 55) * 100;
|
||||
const cpuTempTone = cpuTemp >= 80 ? 'bg-red-400' : cpuTemp >= 70 ? 'bg-amber-400' : 'bg-emerald-400';
|
||||
const memoryTone = memoryUsed >= 90 ? 'bg-red-400' : memoryUsed >= 75 ? 'bg-amber-400' : 'bg-violet-400';
|
||||
const download = finite(wifi.downloadMbps);
|
||||
const upload = finite(wifi.uploadMbps);
|
||||
const docked = Boolean(electrical?.homeBase);
|
||||
const chargingLabel = String(electrical?.chargingStateLabel || '').toLowerCase();
|
||||
const charging = docked && chargingLabel !== '' && chargingLabel !== 'not charging';
|
||||
const autoDocking = !docked && !dockAssistActive && String(electrical?.oiModeLabel || '').toLowerCase() === 'passive';
|
||||
|
||||
// The warning describes the next useful fact instead of blindly telling every user to dock.
|
||||
// This matters during assist, autonomous docking, and charging, where the old overlay's generic
|
||||
// instruction was either redundant or actively misleading.
|
||||
let warningMessage = urgentBattery ? 'Battery critical · Dock now' : 'Battery low · Dock soon';
|
||||
if (charging) {
|
||||
warningMessage = urgentBattery ? 'Battery critical · Charging' : 'Battery low · Charging';
|
||||
} else if (docked) {
|
||||
warningMessage = urgentBattery ? 'Battery critical · On dock' : 'Battery low · On dock';
|
||||
} else if (dockAssistActive) {
|
||||
warningMessage = urgentBattery ? 'Battery critical · Continue docking' : 'Battery low · Continue docking';
|
||||
} else if (autoDocking) {
|
||||
warningMessage = urgentBattery ? 'Battery critical · Returning to dock' : 'Battery low · Returning to dock';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto absolute right-0 top-0 z-20 flex flex-col items-end">
|
||||
{batteryOpen ? (
|
||||
<div className="relative flex h-[8.5rem] w-[8.5rem] items-center justify-center rounded-bl-[4.25rem] bg-black/60">
|
||||
{/* Let the gauge geometry define the visible inset so this pod does not carry an
|
||||
extra layer of shell padding that the camera pod does not have. */}
|
||||
<svg className="h-[8.5rem] w-[8.5rem] -rotate-90" viewBox="0 0 100 100" aria-hidden="true">
|
||||
<circle cx="50" cy="50" r="42" fill="none" stroke="#334155" strokeWidth="9" />
|
||||
<circle cx="50" cy="50" r="42" fill="none" stroke={batteryTone} strokeWidth="9" strokeLinecap="round" strokeDasharray={batteryDash} />
|
||||
<circle cx="50" cy="50" r="32" fill="none" stroke="#334155" strokeWidth="5" />
|
||||
<circle cx="50" cy="50" r="32" fill="none" stroke={currentTone} strokeWidth="5" strokeLinecap="round" strokeDasharray={`${currentPercent * currentCircumference} ${currentCircumference}`} />
|
||||
</svg>
|
||||
{/* Keeping the icon and value in one centered stack makes the gauge's
|
||||
meaning obvious without changing either circular telemetry ring. */}
|
||||
<span className="absolute flex flex-col items-center justify-center gap-0.5 text-white">
|
||||
{lowBattery ? (
|
||||
<FaExclamationTriangle className={urgentBattery ? 'text-lg text-red-300' : 'text-lg text-amber-300'} aria-hidden="true" />
|
||||
) : (
|
||||
<FaBatteryHalf className="text-lg" aria-hidden="true" />
|
||||
)}
|
||||
<strong className="text-xl leading-none">{percent}%</strong>
|
||||
</span>
|
||||
<CornerPodToggle corner="top-right" expanded label="Hide battery pod" onClick={() => setBatteryOpen(false)} />
|
||||
</div>
|
||||
) : (
|
||||
<CornerPodToggle corner="top-right" expanded={false} label="Show battery pod" onClick={() => setBatteryOpen(true)} />
|
||||
)}
|
||||
|
||||
{/* This is status, not another docking control. Keeping it attached to the battery pod
|
||||
preserves one canonical Dock action while still making the reason for urgency obvious. */}
|
||||
{lowBattery ? (
|
||||
<div
|
||||
className={`absolute top-12 flex items-center gap-1.5 whitespace-nowrap rounded-l px-2.5 py-1.5 text-xs font-bold text-white transition-[right,background-color] ${
|
||||
batteryOpen ? 'right-[8.5rem]' : 'right-0'
|
||||
} ${
|
||||
urgentBattery ? 'bg-red-950' : 'bg-amber-950'
|
||||
}`}
|
||||
role="status"
|
||||
>
|
||||
<FaExclamationTriangle className={urgentBattery ? 'text-red-300' : 'text-amber-300'} aria-hidden="true" />
|
||||
<span>{warningMessage}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Advanced power is an independently persisted right-edge expansion. Its own arrow is
|
||||
retained when closed, and the whole panel moves into the corner if the pod closes. */}
|
||||
{powerOpen ? (
|
||||
<div className={`absolute right-0 w-56 rounded-bl-xl bg-black/60 p-1.5 pl-4 text-white ${batteryOpen ? 'top-[8.5rem]' : 'top-0'}`}>
|
||||
<ExpansionToggle direction="right" label="Hide power and computer" onClick={() => setPowerOpen(false)} className="absolute left-0 top-1/2 -translate-y-1/2" />
|
||||
<div className="space-y-1.5">
|
||||
<MetricRow icon={FaBolt} label="Roomba voltage" value={voltage == null ? '--' : `${(voltage / 1000).toFixed(1)} V`} percent={voltagePercent} iconClass="text-sky-300" fillClass="bg-sky-400" />
|
||||
<MetricRow icon={FaBolt} label="Roomba current" value={`${current > 0 ? '+' : ''}${Math.round(current)} mA`} percent={currentPercent * 100} iconClass={current < 0 ? 'text-amber-300' : 'text-emerald-300'} fillClass={current < 0 ? 'bg-amber-400' : 'bg-emerald-400'} />
|
||||
<MetricRow icon={FaBatteryHalf} label="Battery charge" value={batteryCharge == null ? '--' : `${Math.round(batteryCharge)} mAh`} percent={batteryMahPercent} iconClass="text-emerald-300" fillClass="bg-emerald-400" />
|
||||
<MetricRow icon={FaThermometerHalf} label="Computer temperature" value={cpuTemp == null ? '--' : `${cpuTemp.toFixed(1)} C`} percent={cpuTempPercent} iconClass={cpuTemp >= 80 ? 'text-red-300' : cpuTemp >= 70 ? 'text-amber-300' : 'text-emerald-300'} fillClass={cpuTempTone} />
|
||||
<MetricRow icon={FaMemory} label="Memory usage" value={memoryUsed == null ? '--' : `${Math.round(memoryUsed)}%`} percent={memoryUsed} iconClass={memoryUsed >= 90 ? 'text-red-300' : memoryUsed >= 75 ? 'text-amber-300' : 'text-violet-300'} fillClass={memoryTone} />
|
||||
<WifiTile signal={signal} />
|
||||
<SpeedTile icon={FaArrowDown} label="Download speed" value={download == null ? '--' : `${download.toFixed(1)} Mb/s`} colorClass="text-sky-300" />
|
||||
<SpeedTile icon={FaArrowUp} label="Upload speed" value={upload == null ? '--' : `${upload.toFixed(1)} Mb/s`} colorClass="text-violet-300" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`absolute right-0 flex h-8 w-3 bg-black/60 ${batteryOpen ? 'top-[8.5rem]' : 'top-10'}`}>
|
||||
<ExpansionToggle direction="left" label="Show power and computer" onClick={() => setPowerOpen(true)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// New Drive Corner Pods
|
||||
// Purpose: Composes the four independently owned corner controls around the shared video stage.
|
||||
import TopLeftPod from './TopLeftPod.jsx';
|
||||
import TopRightPod from './TopRightPod.jsx';
|
||||
import BottomLeftPod from './BottomLeftPod.jsx';
|
||||
import BottomRightPod from './BottomRightPod.jsx';
|
||||
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
|
||||
|
||||
export default function CornerPods({ roverId }) {
|
||||
const layout = useDriverLayout();
|
||||
const showPhysicalControlPods = layout === 'desktop';
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopLeftPod roverId={roverId} />
|
||||
<TopRightPod roverId={roverId} />
|
||||
{/* The mobile layouts already provide large touch controls around the video.
|
||||
Omitting this pod avoids presenting duplicate horn, light, and laser actions. */}
|
||||
{showPhysicalControlPods ? <BottomLeftPod roverId={roverId} /> : null}
|
||||
{/* BottomRightPod also owns the independent chat expansion, so it remains mounted
|
||||
on mobile and determines its own camera-control visibility from layout context. */}
|
||||
<BottomRightPod roverId={roverId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Corner Pod Visibility
|
||||
// Purpose: Persists each independent pod or expansion without coupling unrelated corner controls.
|
||||
import { useCallback } from 'react';
|
||||
import { useSettingsNamespace } from '../../../../settings/index.js';
|
||||
|
||||
export default function usePodVisibility(key, defaultOpen = true) {
|
||||
const { value, save } = useSettingsNamespace('newdrivePods', {});
|
||||
const open = value?.[key] == null ? defaultOpen : value[key] !== false;
|
||||
const setOpen = useCallback(
|
||||
(nextOpen) => save((current) => ({ ...(current || {}), [key]: Boolean(nextOpen) })),
|
||||
[key, save],
|
||||
);
|
||||
return [open, setOpen];
|
||||
}
|
||||
Reference in New Issue
Block a user