mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 01:50:47 -04:00
big boy webui new new new new new 100 files changed 80 years
This commit is contained in:
@@ -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 ? '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];
|
||||
}
|
||||
@@ -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); }
|
||||
Reference in New Issue
Block a user