Files
MultiRoombaRover/webui/src/components/HudOverlays/newgen/DockingHud/index.jsx
T
2026-09-12 20:52:34 -04:00

403 lines
18 KiB
React

// 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 } from 'react-icons/fa';
import { useControlActions } from '../../../../controls/index.js';
import ControlHint from '../../../ControlHint/index.jsx';
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 ExpansionPanel from '../CornerPods/ExpansionPanel.jsx';
import usePodVisibility from '../CornerPods/usePodVisibility.js';
function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndock }) {
const [hidden, setHidden] = useState(false);
const waitingForTurn = controlsDisabled && !pending;
/* The dismissal belongs to this mounted docked episode. DockingHud unmounts
this component when the rover leaves the base, and its roverId key remounts
it for a different assignment, so no persistence or reset effect is needed. */
if (hidden && !pending) return null;
const mainToneClass = 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';
const hideToneClass = waitingForTurn
? 'bg-slate-950/95 ring-slate-400/70 hover:bg-slate-900'
: 'bg-emerald-950/90 ring-emerald-300/80 hover:bg-emerald-900/95 focus-visible:ring-emerald-200';
return (
<>
{/* The docked shield is owned by the dismissible action so hiding the
prompt also reveals the video and ordinary HUD instead of leaving an
unexplained dark, input-blocking layer behind. */}
<div className="pointer-events-auto absolute inset-0 z-[25] bg-black/75" aria-hidden="true" />
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center p-6">
<div className="relative w-[min(32rem,80%)]">
<button
type="button"
disabled={pending || controlsDisabled}
onClick={onUndock}
className={`pointer-events-auto flex w-full 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 ${mainToneClass}`}
>
<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>
{!pending ? (
<button
type="button"
onClick={() => setHidden(true)}
className={`pointer-events-auto absolute left-1/2 top-full -translate-x-1/2 rounded-b-lg px-6 py-1.5 text-sm font-bold text-white shadow-xl ring-2 transition focus-visible:outline-none focus-visible:ring-4 ${hideToneClass}`}
>
Hide
</button>
) : null}
</div>
</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';
// When the battery pod is collapsed its triangular reopen control owns the
// outermost forty pixels. Docking uses the next top-edge slot in both open
// and collapsed states so the two independent controls never overlap.
const dockPositionClass = cornerOffsetClass === 'right-0' ? 'right-10' : cornerOffsetClass;
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 (
<ExpansionPanel
open={open}
onOpenChange={onOpenChange}
anchorClassName={`absolute top-0 ${dockPositionClass}`}
panelAlign="right"
panelClassName="flex items-start"
openDirection="down"
closeDirection="up"
openLabel="Show rover docking control"
closeLabel="Hide dock controls"
>
<div className="relative">
<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 pb-2 pt-4 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>
</div>
{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}
</ExpansionPanel>
);
}
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 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' ? <ControlHint actionId="driveMacro" /> : '';
const dockKeyLabel = layout === 'desktop' ? <ControlHint actionId="dockMacro" /> : '';
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 (
<>
{/* Automatic docking keeps its own lighter blocking shield. The ordinary
docked shield lives inside DockedAction because the new Hide control
must dismiss the prompt and its dimming as one coherent surface. */}
<div
className={`absolute inset-0 z-[25] transition-all duration-300 ${
autoDocking || pendingAction === 'resuming'
? 'pointer-events-auto bg-black/55 opacity-100'
: 'pointer-events-none opacity-0'
}`}
aria-hidden="true"
/>
{docked || pendingAction === 'undocking' ? (
<DockedAction
key={roverId}
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}
</>
)}
</>
);
}