big boy webui new new new new new 100 files changed 80 years

This commit is contained in:
legop3
2026-08-18 22:01:25 -04:00
parent 0f0f82e5f6
commit 737760ff56
79 changed files with 2714 additions and 388 deletions
@@ -0,0 +1,70 @@
// Bottom Sensor HUD Geometry
// Purpose: Defines one physical front-rover curve shared by bumpers, IR sensors, and cliffs.
// Scope: Contains static display geometry only; telemetry components decide which shapes are visible.
export const SENSOR_HUD_VIEW_BOX = '0 0 1000 140';
export const LEFT_BUMPER_PATH = 'M 8 128 Q 248 60 488 60';
export const RIGHT_BUMPER_PATH = 'M 512 60 Q 752 60 992 128';
/* Horizontal center tangents keep the mirrored halves circular instead of forming a pointed crown. */
const LEFT_CURVE = Object.freeze({ x0: 8, x1: 488, y0: 128, controlY: 60, y1: 60 });
const RIGHT_CURVE = Object.freeze({ x0: 512, x1: 992, y0: 60, controlY: 60, y1: 128 });
function curveForX(x) {
return x < 500 ? LEFT_CURVE : RIGHT_CURVE;
}
export function bumperCurvePoint(x) {
const curve = curveForX(x);
const t = Math.max(0, Math.min(1, (x - curve.x0) / (curve.x1 - curve.x0)));
const oneMinusT = 1 - t;
const y =
oneMinusT * oneMinusT * curve.y0 +
2 * oneMinusT * t * curve.controlY +
t * t * curve.y1;
const dyDt =
2 * oneMinusT * (curve.controlY - curve.y0) +
2 * t * (curve.y1 - curve.controlY);
const slope = dyDt / (curve.x1 - curve.x0);
return { x, y, slope };
}
function segmentPath(startX, endX, verticalOffset) {
const start = bumperCurvePoint(startX);
const end = bumperCurvePoint(endX);
const middle = bumperCurvePoint((startX + endX) / 2);
/* Solve the quadratic control height so the segment passes through the shared curve midpoint. */
const controlY = 2 * (middle.y + verticalOffset) - (start.y + end.y) / 2 - verticalOffset;
return `M ${startX} ${start.y + verticalOffset} Q ${(startX + endX) / 2} ${controlY} ${endX} ${end.y + verticalOffset}`;
}
const IR_SENSOR_X = [75, 225, 400, 600, 775, 925];
const IR_SENSOR_WIDTHS = [78, 70, 64, 64, 70, 78];
const IR_OFFSET_ABOVE_BUMPER = 10;
export const IR_SENSOR_GEOMETRY = IR_SENSOR_X.map((x, index) => {
const point = bumperCurvePoint(x);
/* The upward normal makes outer sensors fan sideways and center sensors project upward. */
const normalLength = Math.hypot(point.slope, 1);
return Object.freeze({
key: ['left', 'front-left', 'center-left', 'center-right', 'front-right', 'right'][index],
tipX: x,
tipY: point.y - IR_OFFSET_ABOVE_BUMPER,
directionX: point.slope / normalLength,
directionY: -1 / normalLength,
width: IR_SENSOR_WIDTHS[index],
});
});
const CLIFF_OFFSET_BELOW_BUMPER = 21;
const CLIFF_RANGES = [
['left', 185, 315],
['frontLeft', 345, 485],
['frontRight', 515, 655],
['right', 685, 815],
];
export const CLIFF_ARCS = CLIFF_RANGES.map(([key, startX, endX]) => Object.freeze({
key,
path: segmentPath(startX, endX, CLIFF_OFFSET_BELOW_BUMPER),
}));
@@ -0,0 +1,24 @@
// New Generation Bottom Sensor HUD
// Purpose: Gives all bottom sensor visuals one SVG coordinate space and one physical curve.
// Scope: Owns shared placement only; child components retain independent telemetry subscriptions.
import BumperIndicators from '../BumperIndicators/index.jsx';
import IrProximityHud from '../IrProximityHud/index.jsx';
import CliffIndicators from '../CliffIndicators/index.jsx';
import { SENSOR_HUD_VIEW_BOX } from './geometry.js';
import './styles.css';
export default function BottomSensorHud({ roverId }) {
return (
<svg
className="newgen-bottom-sensor-hud"
viewBox={SENSOR_HUD_VIEW_BOX}
preserveAspectRatio="none"
role="img"
aria-label="Rover front sensors"
>
<IrProximityHud roverId={roverId} />
<BumperIndicators roverId={roverId} />
<CliffIndicators roverId={roverId} />
</svg>
);
}
@@ -0,0 +1,11 @@
.newgen-bottom-sensor-hud {
pointer-events: none;
position: absolute;
z-index: 20;
right: 0;
bottom: -2.5rem;
left: 0;
width: 100%;
height: clamp(7rem, 18vh, 11rem);
overflow: visible;
}
@@ -0,0 +1,56 @@
// New Generation Bumper Indicators
// Purpose: Projects the rover's two physical bumper halves across the bottom of the video.
// Scope: Visualizes server telemetry only; it does not infer collisions or change control behavior.
import { useId } from 'react';
import {
shallowObjectEqual,
useVisualTelemetrySelector,
} from '../../../../context/TelemetryContext.jsx';
import { LEFT_BUMPER_PATH, RIGHT_BUMPER_PATH } from '../BottomSensorHud/geometry.js';
import './styles.css';
const EMPTY_BUMPERS = Object.freeze({ bumpLeft: false, bumpRight: false });
function selectBumpers(frame) {
const contact = frame?.sensors?.bumpsAndWheelDrops;
if (!contact) return EMPTY_BUMPERS;
return {
bumpLeft: Boolean(contact.bumpLeft),
bumpRight: Boolean(contact.bumpRight),
};
}
function ActiveBumperArc({ pathId, path, label }) {
return (
<g className="newgen-bumper-arc">
{/*
The broad rounded stroke uses the same visual idea as TopDownMap's
ArcSegment, while this wider quadratic path is shaped for the camera
viewport instead of the circular top-down rover geometry.
*/}
<path id={pathId} d={path} className="newgen-bumper-path" />
<text className="newgen-bumper-label" dy="0.35em">
<textPath href={`#${pathId}`} startOffset="50%" textAnchor="middle">
{label}
</textPath>
</text>
</g>
);
}
export default function BumperIndicators({ roverId }) {
const telemetry = useVisualTelemetrySelector(roverId, selectBumpers, shallowObjectEqual);
const instanceId = useId().replaceAll(':', '');
if (!telemetry.bumpLeft && !telemetry.bumpRight) return null;
return (
<g className="newgen-bumper-visual" aria-label="Active rover bumper sensors">
{telemetry.bumpLeft ? (
<ActiveBumperArc pathId={`${instanceId}-left-bumper`} path={LEFT_BUMPER_PATH} label="BUMPER" />
) : null}
{telemetry.bumpRight ? (
<ActiveBumperArc pathId={`${instanceId}-right-bumper`} path={RIGHT_BUMPER_PATH} label="BUMPER" />
) : null}
</g>
);
}
@@ -0,0 +1,18 @@
.newgen-bumper-path {
fill: none;
stroke: rgb(239 68 68 / 0.48);
stroke-width: 26;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.newgen-bumper-label {
fill: white;
font-family: system-ui, sans-serif;
font-size: 20px;
font-weight: 700;
letter-spacing: 1px;
paint-order: stroke;
stroke: rgb(0 0 0 / 0.65);
stroke-width: 1.5px;
}
@@ -0,0 +1,61 @@
// New Generation Cliff Indicators
// Purpose: Projects the four underside cliff sensors just below the front bumper curve.
// Scope: Uses the rover's cliff booleans directly without interpreting raw cliff signal values.
import { useId } from 'react';
import {
shallowObjectEqual,
useVisualTelemetrySelector,
} from '../../../../context/TelemetryContext.jsx';
import { CLIFF_ARCS } from '../BottomSensorHud/geometry.js';
import './styles.css';
const EMPTY_CLIFFS = Object.freeze({
left: false,
frontLeft: false,
frontRight: false,
right: false,
});
function selectCliffs(frame) {
const sensors = frame?.sensors;
if (!sensors) return EMPTY_CLIFFS;
return {
left: Boolean(sensors.cliffLeft),
frontLeft: Boolean(sensors.cliffFrontLeft),
frontRight: Boolean(sensors.cliffFrontRight),
right: Boolean(sensors.cliffRight),
};
}
function ActiveCliffArc({ pathId, path }) {
return (
<g>
<path id={pathId} d={path} className="newgen-cliff-path" />
<text className="newgen-cliff-label" dy="0.35em">
<textPath href={`#${pathId}`} startOffset="50%" textAnchor="middle">
CLIFF
</textPath>
</text>
</g>
);
}
export default function CliffIndicators({ roverId }) {
const cliffs = useVisualTelemetrySelector(roverId, selectCliffs, shallowObjectEqual);
const instanceId = useId().replaceAll(':', '');
if (!Object.values(cliffs).some(Boolean)) return null;
return (
<g className="newgen-cliff-visual" aria-label="Active rover cliff sensors">
{CLIFF_ARCS.map((sensor) => (
cliffs[sensor.key] ? (
<ActiveCliffArc
key={sensor.key}
pathId={`${instanceId}-${sensor.key}-cliff`}
path={sensor.path}
/>
) : null
))}
</g>
);
}
@@ -0,0 +1,18 @@
.newgen-cliff-path {
fill: none;
stroke: rgb(245 158 11 / 0.68);
stroke-width: 22;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.newgen-cliff-label {
fill: white;
font-family: system-ui, sans-serif;
font-size: 15px;
font-weight: 700;
letter-spacing: 1px;
paint-order: stroke;
stroke: rgb(0 0 0 / 0.65);
stroke-width: 1.5px;
}
@@ -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 ? 'Its 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'
: 'Youre 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];
}
@@ -0,0 +1,392 @@
// New Generation Docking HUD
// Purpose: Provides a single low-friction transition between docked, driving, and manual docking.
// Scope: Owns presentation and the existing manual-assist lifecycle for the current driver HUD;
// the archived desktop layout retains its previous DriveDockAction behavior.
import { useCallback, useEffect, useRef, useState } from 'react';
import { FaChargingStation, FaChevronDown } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/telemetryViews.js';
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
import { useSettingsNamespace } from '../../../../settings/index.js';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
import ExpansionToggle from '../CornerPods/ExpansionToggle.jsx';
import usePodVisibility from '../CornerPods/usePodVisibility.js';
function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndock }) {
const waitingForTurn = controlsDisabled && !pending;
return (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center p-6">
<button
type="button"
disabled={pending || controlsDisabled}
onClick={onUndock}
className={`pointer-events-auto flex w-[min(32rem,80%)] flex-col items-center gap-2 px-8 py-7 text-center text-white shadow-2xl ring-2 transition focus-visible:outline-none focus-visible:ring-4 ${
waitingForTurn
? 'cursor-not-allowed bg-slate-950/95 ring-slate-400/70'
: 'bg-emerald-950/90 ring-emerald-300/80 hover:bg-emerald-900/95 focus-visible:ring-emerald-200 disabled:cursor-wait disabled:opacity-75'
}`}
>
<strong className="text-3xl leading-tight">{pending ? 'Undocking…' : 'Your rover is docked'}</strong>
{pending ? (
null
) : waitingForTurn ? (
/* A disabled action must explain the ownership constraint instead of
continuing to advertise a click and keybind that cannot succeed. */
<span className="text-lg font-semibold leading-snug text-slate-300">
Wait for your turn to undock.
</span>
) : (
<span className="text-lg font-semibold leading-snug text-emerald-50">
Click here
{driveKeyLabel ? (
<>
{' '}or press <KeyPill label={driveKeyLabel} />
</>
) : null}
{' '}to undock and drive the rover
</span>
)}
{error ? <span className="text-sm font-semibold text-red-200">{error}</span> : null}
</button>
</div>
);
}
function AutoDockingAction({ driveKeyLabel, pending, controlsDisabled, error, onResumeDriving }) {
const waitingForTurn = controlsDisabled && !pending;
return (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center p-6">
<button
type="button"
disabled={pending || controlsDisabled}
onClick={onResumeDriving}
className={`pointer-events-auto flex w-[min(30rem,80%)] flex-col items-center gap-2 px-7 py-6 text-center text-white shadow-2xl ring-2 transition focus-visible:outline-none focus-visible:ring-4 ${
waitingForTurn
? 'cursor-not-allowed bg-slate-950/95 ring-slate-400/70'
: 'bg-amber-950/90 ring-amber-300/80 hover:bg-amber-900/95 focus-visible:ring-amber-200 disabled:cursor-wait disabled:opacity-75'
}`}
>
<strong className="text-3xl leading-tight">
{pending ? 'Starting driving…' : 'Rover is docking itself'}
</strong>
{pending ? null : waitingForTurn ? (
/* Automatic docking is still important context when another user owns
the turn, but the recovery action must not imply that it is available. */
<span className="text-lg font-semibold leading-snug text-slate-300">
Wait for your turn to resume driving.
</span>
) : (
<span className="text-lg font-semibold leading-snug text-amber-50">
Click here
{driveKeyLabel ? (
<>
{' '}or press <KeyPill label={driveKeyLabel} />
</>
) : null}
{' '}to stop automatic docking and resume driving
</span>
)}
{error ? <span className="text-sm font-semibold text-red-200">{error}</span> : null}
</button>
</div>
);
}
function DockAssistAction({ active, pending, controlsDisabled, error, dockKeyLabel, onDock, onCancel, cornerOffsetClass, open, onOpenChange, batterySeverity }) {
const batteryUrgent = batterySeverity === 'urgent';
const batteryLow = batterySeverity === 'low';
if (!open) {
return (
<button
type="button"
aria-label="Show rover docking control"
title="Dock rover"
onClick={() => onOpenChange(true)}
className={`pointer-events-auto absolute top-0 z-20 flex h-6 w-10 items-center justify-center gap-1 rounded-bl text-[0.6rem] transition ${
batteryUrgent
? 'bg-red-950 text-red-100 hover:bg-red-900'
: batteryLow
? 'bg-amber-950 text-amber-100 hover:bg-amber-900'
: 'bg-indigo-950/75 text-indigo-100 hover:bg-indigo-900'
} ${cornerOffsetClass === 'right-0' ? 'right-10' : cornerOffsetClass}`}
>
{/* The collapsed tab retains feature identity instead of becoming an
anonymous expansion arrow whose purpose must be remembered. */}
<FaChargingStation aria-hidden="true" />
<FaChevronDown aria-hidden="true" />
</button>
);
}
if (active) {
return (
<div className="pointer-events-none absolute inset-0 z-[60] flex items-center justify-center">
{/* Once assist is active, the camera image is the user's task context. Centering this
one-line instruction connects it to that view instead of leaving guidance beside the
corner button that already completed its action. */}
<div className="pointer-events-auto flex items-center gap-2 rounded bg-cyan-950/60 p-1.5 text-cyan-50 shadow-xl ring-2 ring-cyan-200/90">
<strong className="whitespace-nowrap text-sm">Dock assist is active, drive forward onto the dock.</strong>
<button
type="button"
onClick={onCancel}
disabled={controlsDisabled}
className="bg-slate-800 px-2 py-1 text-xs font-bold text-white transition hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-200 disabled:cursor-not-allowed disabled:opacity-40"
>
Cancel
</button>
{error ? <span className="text-xs font-semibold text-red-200">{error}</span> : null}
</div>
</div>
);
}
return (
<div className={`pointer-events-auto absolute top-0 z-20 flex items-stretch ${cornerOffsetClass}`}>
<ExpansionToggle direction="up" label="Hide dock controls" onClick={() => onOpenChange(false)} />
<button
type="button"
aria-label="Start rover docking assist"
disabled={pending || controlsDisabled}
onClick={onDock}
className={`flex items-center gap-1.5 rounded-bl-xl px-4 py-2 text-base font-bold shadow-xl ring-1 transition focus-visible:outline-none focus-visible:ring-2 disabled:cursor-wait disabled:opacity-75 ${
batteryUrgent
? 'bg-red-950 text-red-50 ring-red-300/80 hover:bg-red-900 focus-visible:ring-red-200'
: batteryLow
? 'bg-amber-950 text-amber-50 ring-amber-300/80 hover:bg-amber-900 focus-visible:ring-amber-200'
: 'bg-indigo-950/60 text-indigo-50 ring-indigo-300/70 hover:bg-indigo-900 focus-visible:ring-indigo-200'
}`}
>
<FaChargingStation className="shrink-0" aria-hidden="true" />
<span>{pending ? 'Starting…' : batteryUrgent ? 'Dock now' : batteryLow ? 'Dock soon' : 'Dock rover'}</span>
{dockKeyLabel && !pending ? <KeyPill label={dockKeyLabel} /> : null}
</button>
{/* The expansion toggle stays on the far side of this panel so the battery pod's
triangular control remains unobstructed when both occupy the top-right area. */}
{error ? <div className="mt-2 max-w-64 bg-red-950/90 px-3 py-2 text-sm font-semibold text-red-100">{error}</div> : null}
</div>
);
}
function UndockTransitionGhost({ onFinish }) {
const ghostRef = useRef(null);
useEffect(() => {
const element = ghostRef.current;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!element || reducedMotion || typeof element.animate !== 'function') {
onFinish();
return undefined;
}
/*
This intentionally animates a disposable visual copy instead of trying to morph the
centered action into the differently structured corner control. Moving left/top while
scaling and fading is inexpensive, communicates where Dock moved, and needs no viewport
measurements or persistent layout state.
*/
const animation = element.animate(
[
{
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%) scale(1)',
opacity: 1,
},
{
left: 'calc(100% - 10rem)',
top: '0',
transform: 'translate(0, 0) scale(0.2)',
opacity: 0,
},
],
{
duration: 850,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'forwards',
},
);
animation.onfinish = onFinish;
return () => {
animation.onfinish = null;
animation.cancel();
};
}, [onFinish]);
return (
<div
ref={ghostRef}
className="pointer-events-none absolute z-40 flex w-[min(32rem,80%)] origin-top-left flex-col items-center gap-2 bg-emerald-950/90 px-8 py-7 text-center text-white shadow-2xl ring-2 ring-emerald-300/80"
style={{ left: '50%', top: '50%', transform: 'translate(-50%, -50%) scale(1)' }}
aria-hidden="true"
>
<strong className="text-3xl leading-tight">Rover docked</strong>
<span className="text-lg font-semibold text-emerald-50">Dock control moved here</span>
</div>
);
}
export default function DockingHud({ roverId }) {
const layout = useDriverLayout();
const actions = useControlActions();
const keymap = useControlSelector((control) => control.state.keymap);
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
// success sounds, camera positioning, speed cap, and automatic exit after charging begins.
const dockAssist = useManualDockAssist({ manageLifecycle: true });
const canControl = useCanControlRover(roverId);
const batteryState = useSessionSelector((state) => {
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
return rover?.batteryState || null;
});
// DockingHud already owns the canonical desktop docking action, so battery urgency only
// changes that action's emphasis and wording instead of introducing a competing button.
const batterySeverity = batteryState?.urgentActive ? 'urgent' : batteryState?.warnActive ? 'low' : null;
const { value: podSettings } = useSettingsNamespace('newdrivePods', {});
const [dockExpansionOpen, setDockExpansionOpen] = usePodVisibility('dockAssist', true);
// The action name is presentation state as well as busy state. Keeping the
// reason prevents a passive, already-undocked rover from ever saying "Undocking".
const [pendingAction, setPendingAction] = useState(null);
const [error, setError] = useState('');
const [showUndockTransition, setShowUndockTransition] = useState(false);
const docked = Boolean(dockTelemetry?.homeBase);
const oiMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase();
// The established UI contract treats exactly passive + undocked as the Roomba's
// autonomous docking attempt. Unknown telemetry must not fabricate that state.
const autoDocking = !docked && !dockAssist.active && oiMode === 'passive';
const pending = pendingAction !== null;
/* Mobile already presents its own touch-oriented driving controls. The docked
action therefore keeps its plain-language instruction without advertising a
keyboard shortcut that is irrelevant on that layout. */
const driveKeyLabel = layout === 'desktop'
? formatKeyLabel(keymap?.driveMacro?.[0])
: '';
const dockKeyLabel = layout === 'desktop'
? formatKeyLabel(keymap?.dockMacro?.[0])
: '';
const batteryPodOpen = podSettings?.battery !== false;
// The camera arc is the shared circular-pod reference size. Keep the dock expansion flush
// against the battery shell after enlarging that gauge to the same 8.5-rem footprint.
const cornerOffsetClass = batteryPodOpen ? 'right-[8.5rem]' : 'right-0';
const previousDockedRef = useRef(docked);
const finishUndockTransition = useCallback(() => setShowUndockTransition(false), []);
useEffect(() => {
const wasDocked = previousDockedRef.current;
// Only a real live transition plays the cue. An already-undocked rover must not animate
// merely because the user opened or refreshed the page.
if (wasDocked && !docked) {
setShowUndockTransition(true);
} else if (docked) {
setShowUndockTransition(false);
}
previousDockedRef.current = docked;
}, [docked]);
const startDriving = async (action) => {
if (!roverId || pending || !canControl) return;
setPendingAction(action);
setError('');
try {
// The established drive sequence is the canonical undock path: it restores the camera,
// enters full Open Interface mode, and performs the short physical back-away from the dock.
dockAssist.exitAssist();
actions.setMode('drive');
await actions.runMacro('drive-sequence');
} catch (caughtError) {
setError(caughtError?.message || 'Unable to start driving. Please try again.');
} finally {
setPendingAction(null);
}
};
const startUndocking = () => {
startDriving('undocking');
};
const resumeDriving = () => {
startDriving('resuming');
};
const startDocking = () => {
if (!roverId || pending || !canControl) return;
setError('');
try {
// Enter on the first click. The explanation appears as the resulting active state instead
// of forcing the user through a modal and a second confirmation action.
dockAssist.enterAssist();
} catch (caughtError) {
setError(caughtError?.message || 'Unable to start dock assist. Please try again.');
}
};
return (
<>
{/* This single layer both dims and blocks the ordinary rover HUD. A passive
undocked rover is still moving autonomously, so it gets a lighter but equally
blocking shield. Explicit pending state keeps the correct shield mounted until
the complete drive sequence finishes even as telemetry changes underneath it. */}
<div
className={`absolute inset-0 z-[25] transition-all duration-300 ${
docked || pendingAction === 'undocking'
? 'pointer-events-auto bg-black/75 opacity-100'
: autoDocking || pendingAction === 'resuming'
? 'pointer-events-auto bg-black/55 opacity-100'
: 'pointer-events-none opacity-0'
}`}
aria-hidden="true"
/>
{docked || pendingAction === 'undocking' ? (
<DockedAction
driveKeyLabel={driveKeyLabel}
pending={pendingAction === 'undocking'}
controlsDisabled={!canControl}
error={error}
onUndock={startUndocking}
/>
) : autoDocking || pendingAction === 'resuming' ? (
<AutoDockingAction
driveKeyLabel={driveKeyLabel}
pending={pendingAction === 'resuming'}
controlsDisabled={!canControl}
error={error}
onResumeDriving={resumeDriving}
/>
) : (
<>
{dockAssist.active ? (
/* Dock assist needs the unobscured camera image. A thin cyan frame communicates the
temporary mode without adding another instruction surface over the video. */
<div className="pointer-events-none absolute inset-0 z-50 ring-8 ring-inset ring-cyan-200/90" aria-hidden="true" />
) : null}
{/* Desktop keeps the compact corner entry point. Mobile starts assist from
its dedicated control column, but once active it still mounts this component
so the centered camera instruction and cancel action remain available. */}
{layout === 'desktop' || dockAssist.active ? (
<DockAssistAction
active={dockAssist.active}
pending={pending}
controlsDisabled={!canControl}
error={error}
dockKeyLabel={dockKeyLabel}
onDock={startDocking}
onCancel={dockAssist.exitAssist}
cornerOffsetClass={cornerOffsetClass}
open={dockExpansionOpen}
onOpenChange={setDockExpansionOpen}
batterySeverity={batterySeverity}
/>
) : null}
{showUndockTransition ? <UndockTransitionGhost onFinish={finishUndockTransition} /> : null}
</>
)}
</>
);
}
@@ -0,0 +1,109 @@
// New Generation IR Proximity HUD
// Purpose: Projects the six light-bump sensors as cones above their physical bumper positions.
// Scope: Mirrors the top-down map's signal geometry without retaining history or smoothing telemetry.
import { memo } from 'react';
import { useVisualTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import {
lightBumpTelemetryEqual,
selectLightBumpTelemetry,
} from '../../../../context/telemetryViews.js';
import { IR_SENSOR_GEOMETRY } from '../BottomSensorHud/geometry.js';
import './styles.css';
const IR_NOISE_THRESHOLD = 40;
const IR_FULL_STRENGTH = 1200;
const FAR_CONE_LENGTH = 105;
const NEAR_CONE_LENGTH = 12;
const STRIPE_COUNT = 7;
const FADE_IN_RANGE = 0.1;
const MAX_CONE_OPACITY = 1;
const RED_POINT = 0.75;
function clamp01(value) {
return Math.max(0, Math.min(1, value));
}
function buildStripePath(sensor, length) {
const perpendicularX = -sensor.directionY;
const perpendicularY = sensor.directionX;
/*
Keep all seven stripes in one multi-subpath so the browser still manages
only one SVG path per sensor. This function runs only for the memoized
sensor whose raw value changed, preserving the exact tuned width and length
geometry without reconciling 42 separate elements.
*/
return Array.from({ length: STRIPE_COUNT }, (_, index) => {
const fraction = (index + 1) / STRIPE_COUNT;
const stripeDistance = length * fraction;
const centerX = sensor.tipX + sensor.directionX * stripeDistance;
const centerY = sensor.tipY + sensor.directionY * stripeDistance;
const halfWidth = (sensor.width * fraction) / 2;
const leftX = centerX + perpendicularX * halfWidth;
const leftY = centerY + perpendicularY * halfWidth;
const rightX = centerX - perpendicularX * halfWidth;
const rightY = centerY - perpendicularY * halfWidth;
const curveDepth = Math.max(1.5, length * 0.045 * fraction);
const controlX = centerX + sensor.directionX * curveDepth;
const controlY = centerY + sensor.directionY * curveDepth;
return `M ${leftX} ${leftY} Q ${controlX} ${controlY} ${rightX} ${rightY}`;
}).join(' ');
}
function buildPresentation(value) {
const numericValue = Number(value) || 0;
const active = numericValue > IR_NOISE_THRESHOLD;
const normalized = clamp01(
(numericValue - IR_NOISE_THRESHOLD) / (IR_FULL_STRENGTH - IR_NOISE_THRESHOLD),
);
/*
Match the top-down map's display curve. This is an instantaneous spatial
mapping, not temporal smoothing: the current raw value alone determines the
cone length for this frame.
*/
const eased = Math.pow(normalized, 0.35);
const length = FAR_CONE_LENGTH - (FAR_CONE_LENGTH - NEAR_CONE_LENGTH) * eased;
const colorStrength = clamp01(normalized / RED_POINT);
return {
length,
/* Color reaches red independently of distance, making its warning point directly tunable. */
color: `hsl(${120 * (1 - colorStrength)} 90% 50%)`,
/*
Opacity is driven by the raw sensor range, not merely by a CSS transition.
The first fifth of the usable range fades the cone from invisible to its
normal opacity before distance and color become the dominant cues.
*/
opacity: active ? clamp01(normalized / FADE_IN_RANGE) * MAX_CONE_OPACITY : 0,
};
}
const IrSensorCone = memo(function IrSensorCone({ sensor, value }) {
const presentation = buildPresentation(value);
const stripePath = buildStripePath(sensor, presentation.length);
return (
<path
d={stripePath}
className="newgen-ir-proximity-cone"
stroke={presentation.color}
style={{ opacity: presentation.opacity }}
/>
);
});
export default function IrProximityHud({ roverId }) {
const values = useVisualTelemetrySelector(
roverId,
selectLightBumpTelemetry,
lightBumpTelemetryEqual,
);
return (
<g className="newgen-ir-proximity" aria-label="Front infrared proximity sensors">
{IR_SENSOR_GEOMETRY.map((sensor, index) => (
<IrSensorCone key={sensor.key} sensor={sensor} value={values[index]} />
))}
</g>
);
}
@@ -0,0 +1,11 @@
.newgen-ir-proximity-cone {
fill: none;
stroke-width: 6;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
transition: opacity 150ms linear;
}
@media (prefers-reduced-motion: reduce) {
.newgen-ir-proximity-cone { transition: none; }
}
@@ -0,0 +1,43 @@
// New Generation Wheel Drop Indicators
// Purpose: Mirrors the top-down map's red wheel overlay at the left and right video edges.
// Scope: Displays the two physical wheel-drop booleans without generic HUD chrome or control behavior.
import {
shallowObjectEqual,
useVisualTelemetrySelector,
} from '../../../../context/TelemetryContext.jsx';
import './styles.css';
const EMPTY_WHEEL_DROPS = Object.freeze({ left: false, right: false });
function selectWheelDrops(frame) {
const contact = frame?.sensors?.bumpsAndWheelDrops;
if (!contact) return EMPTY_WHEEL_DROPS;
return {
left: Boolean(contact.wheelDropLeft),
right: Boolean(contact.wheelDropRight),
};
}
function WheelDropOverlay({ side }) {
return (
<div
className={`newgen-wheel-drop newgen-wheel-drop--${side}`}
role="alert"
aria-label={`${side} wheel off ground`}
>
{/* Mirrored rotation follows the left/right text orientation used by TopDownMap's wheel glyphs. */}
<span>WHEEL OFF GROUND</span>
</div>
);
}
export default function WheelDropIndicators({ roverId }) {
const drops = useVisualTelemetrySelector(roverId, selectWheelDrops, shallowObjectEqual);
return (
<>
{drops.left ? <WheelDropOverlay side="left" /> : null}
{drops.right ? <WheelDropOverlay side="right" /> : null}
</>
);
}
@@ -0,0 +1,30 @@
.newgen-wheel-drop {
pointer-events: none;
position: absolute;
z-index: 20;
top: 50%;
display: flex;
width: 4rem;
height: 15rem;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
background: rgb(239 68 68 / 0.36);
color: white;
transform: translateY(-50%);
}
.newgen-wheel-drop--left { left: 1rem; }
.newgen-wheel-drop--right { right: 1rem; }
.newgen-wheel-drop span {
white-space: nowrap;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1;
text-shadow: 0 1px 2px rgb(0 0 0 / 0.65);
}
.newgen-wheel-drop--left span { transform: rotate(-90deg); }
.newgen-wheel-drop--right span { transform: rotate(90deg); }