mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 18:10:47 -04:00
Compare commits
2
Commits
5ab62c3633
...
c8836f7d65
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8836f7d65 | ||
|
|
f77a969098 |
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 337 KiB |
@@ -12,8 +12,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<!-- site-metadata:inject -->
|
<!-- site-metadata:inject -->
|
||||||
<!-- analytics:inject -->
|
<!-- analytics:inject -->
|
||||||
<script type="module" crossorigin src="/assets/index-C0lpCbci.js"></script>
|
<script type="module" crossorigin src="/assets/index-BZ2ymoHR.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BITzjVQo.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BFNKIMjg.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -97,8 +97,20 @@ roverManager.managerEvents.on('private', ({ roverId, open }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
roverManager.managerEvents.on('rover', ({ action }) => {
|
roverManager.managerEvents.on('rover', ({ roverId, action }) => {
|
||||||
if (action === 'removed' || action === 'upsert') {
|
if (action === 'removed') {
|
||||||
|
/*
|
||||||
|
The physical rover record is the authority for current driver ownership.
|
||||||
|
Once it disappears, every assignment that names it must be released and
|
||||||
|
run through ordinary placement again. Leaving those map entries intact
|
||||||
|
lets the same id become visible after reconnect without recreating its
|
||||||
|
driver membership, which is the exact stale-UI/video-auth split this
|
||||||
|
lifecycle boundary must prevent.
|
||||||
|
*/
|
||||||
|
reassignFromRover(roverId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'upsert') {
|
||||||
reassignWaiting();
|
reassignWaiting();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const {
|
|||||||
} = privateAccess;
|
} = privateAccess;
|
||||||
|
|
||||||
const roverLifecycle = createRoverLifecycle({
|
const roverLifecycle = createRoverLifecycle({
|
||||||
|
io,
|
||||||
rovers,
|
rovers,
|
||||||
socketToRovers,
|
socketToRovers,
|
||||||
managerEvents,
|
managerEvents,
|
||||||
@@ -86,6 +87,7 @@ const roverLifecycle = createRoverLifecycle({
|
|||||||
const {
|
const {
|
||||||
requestControl,
|
requestControl,
|
||||||
releaseControl,
|
releaseControl,
|
||||||
|
removeRoverDrivers,
|
||||||
isDriver,
|
isDriver,
|
||||||
canDrive,
|
canDrive,
|
||||||
getRoversForSocket,
|
getRoversForSocket,
|
||||||
@@ -119,6 +121,7 @@ const rosterLifecycle = createRosterLifecycle({
|
|||||||
normalizePrivateSafety,
|
normalizePrivateSafety,
|
||||||
stopDockGuard: (...args) => stopDockGuard(...args),
|
stopDockGuard: (...args) => stopDockGuard(...args),
|
||||||
getControlDenialReason,
|
getControlDenialReason,
|
||||||
|
removeRoverDrivers,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function createRosterLifecycle(deps) {
|
|||||||
isRoverVisibleToSocket,
|
isRoverVisibleToSocket,
|
||||||
normalizePrivateSafety,
|
normalizePrivateSafety,
|
||||||
stopDockGuard,
|
stopDockGuard,
|
||||||
|
removeRoverDrivers,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
function ensureRecord(id) {
|
function ensureRecord(id) {
|
||||||
@@ -106,7 +107,14 @@ function createRosterLifecycle(deps) {
|
|||||||
function removeRover(id) {
|
function removeRover(id) {
|
||||||
const record = rovers.get(id);
|
const record = rovers.get(id);
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
|
/*
|
||||||
|
Remove the public record before emitting driver-removal events. Any
|
||||||
|
session sync caused by those events must already see this rover as
|
||||||
|
offline, while removeRoverDrivers still receives the captured record so
|
||||||
|
it can clean the reverse membership index and Socket.IO room membership.
|
||||||
|
*/
|
||||||
rovers.delete(id);
|
rovers.delete(id);
|
||||||
|
removeRoverDrivers(id, record);
|
||||||
stopDockGuard(id);
|
stopDockGuard(id);
|
||||||
privateButtonStates.delete(id);
|
privateButtonStates.delete(id);
|
||||||
privateNoUsersSince.delete(id);
|
privateNoUsersSince.delete(id);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// Scope: Keeps runtime behavior unchanged by reusing rover-manager state maps and injected policy helpers.
|
// Scope: Keeps runtime behavior unchanged by reusing rover-manager state maps and injected policy helpers.
|
||||||
function createRoverLifecycle(deps) {
|
function createRoverLifecycle(deps) {
|
||||||
const {
|
const {
|
||||||
|
io,
|
||||||
rovers,
|
rovers,
|
||||||
socketToRovers,
|
socketToRovers,
|
||||||
managerEvents,
|
managerEvents,
|
||||||
@@ -85,6 +86,40 @@ function createRoverLifecycle(deps) {
|
|||||||
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'remove' });
|
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'remove' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeRoverDrivers(roverId, removedRecord = null) {
|
||||||
|
/*
|
||||||
|
A rover connection owns the record that contains its driver set, but the
|
||||||
|
reverse socket-to-rover index outlives that record. Disconnect cleanup
|
||||||
|
must therefore remove both halves before a reconnect creates a fresh
|
||||||
|
record with the same id. Otherwise session assignment can name the rover
|
||||||
|
while video/control authorization correctly sees no driver membership.
|
||||||
|
|
||||||
|
removedRecord is accepted because rosterLifecycle deliberately deletes
|
||||||
|
the public rover record first. Session syncs triggered by the driver
|
||||||
|
events below will consequently hide the unavailable rover immediately,
|
||||||
|
even before assignmentService finishes normal reassignment.
|
||||||
|
*/
|
||||||
|
const record = removedRecord || rovers.get(roverId);
|
||||||
|
if (!record) return [];
|
||||||
|
const driverIds = Array.from(record.drivers || []);
|
||||||
|
|
||||||
|
driverIds.forEach((socketId) => {
|
||||||
|
const joined = socketToRovers.get(socketId);
|
||||||
|
if (joined) {
|
||||||
|
joined.delete(roverId);
|
||||||
|
if (joined.size === 0) socketToRovers.delete(socketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
|
socket?.leave(record.room);
|
||||||
|
record.drivers.delete(socketId);
|
||||||
|
turnService.driverRemoved(roverId, socketId);
|
||||||
|
managerEvents.emit('driver', { socketId, roverId, action: 'remove' });
|
||||||
|
});
|
||||||
|
|
||||||
|
return driverIds;
|
||||||
|
}
|
||||||
|
|
||||||
function isDriver(roverId, socket) {
|
function isDriver(roverId, socket) {
|
||||||
const record = rovers.get(roverId);
|
const record = rovers.get(roverId);
|
||||||
if (!record) return false;
|
if (!record) return false;
|
||||||
@@ -175,6 +210,7 @@ function createRoverLifecycle(deps) {
|
|||||||
removeSocket,
|
removeSocket,
|
||||||
requestControl,
|
requestControl,
|
||||||
releaseControl,
|
releaseControl,
|
||||||
|
removeRoverDrivers,
|
||||||
isDriver,
|
isDriver,
|
||||||
canDrive,
|
canDrive,
|
||||||
getRoversForSocket,
|
getRoversForSocket,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Rover Manager Lifecycle Tests
|
||||||
|
// Purpose: Verifies that physical rover removal clears every ownership index before a same-id reconnect.
|
||||||
|
// Scope: Covers driver membership cleanup only; assignment placement policy remains in assignmentService.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const EventEmitter = require('node:events');
|
||||||
|
const { createRoverLifecycle } = require('./roverLifecycle');
|
||||||
|
|
||||||
|
test('removing a rover clears driver sets, reverse membership, rooms, and turns', () => {
|
||||||
|
const roverId = 'rover-one';
|
||||||
|
const socketId = 'driver-one';
|
||||||
|
const leftRooms = [];
|
||||||
|
const removedTurns = [];
|
||||||
|
const driverEvents = [];
|
||||||
|
const socket = {
|
||||||
|
id: socketId,
|
||||||
|
leave: (room) => leftRooms.push(room),
|
||||||
|
};
|
||||||
|
const record = {
|
||||||
|
id: roverId,
|
||||||
|
room: `rover:${roverId}`,
|
||||||
|
drivers: new Set([socketId]),
|
||||||
|
};
|
||||||
|
const rovers = new Map([[roverId, record]]);
|
||||||
|
const socketToRovers = new Map([[socketId, new Set([roverId])]]);
|
||||||
|
const managerEvents = new EventEmitter();
|
||||||
|
managerEvents.on('driver', (event) => driverEvents.push(event));
|
||||||
|
|
||||||
|
const lifecycle = createRoverLifecycle({
|
||||||
|
io: { sockets: { sockets: new Map([[socketId, socket]]) } },
|
||||||
|
rovers,
|
||||||
|
socketToRovers,
|
||||||
|
managerEvents,
|
||||||
|
turnService: {
|
||||||
|
driverRemoved: (removedRoverId, removedSocketId) => {
|
||||||
|
removedTurns.push([removedRoverId, removedSocketId]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isAdmin: () => false,
|
||||||
|
sendAlert: () => {},
|
||||||
|
ALERT_COLOR: '#000000',
|
||||||
|
getMode: () => 'public',
|
||||||
|
getControlDenialReason: () => null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Mirror rosterLifecycle's ordering: the public record is gone before the
|
||||||
|
captured record is supplied for complete membership cleanup. */
|
||||||
|
rovers.delete(roverId);
|
||||||
|
const removedDriverIds = lifecycle.removeRoverDrivers(roverId, record);
|
||||||
|
|
||||||
|
assert.deepEqual(removedDriverIds, [socketId]);
|
||||||
|
assert.equal(record.drivers.size, 0);
|
||||||
|
assert.equal(socketToRovers.has(socketId), false);
|
||||||
|
assert.deepEqual(leftRooms, [`rover:${roverId}`]);
|
||||||
|
assert.deepEqual(removedTurns, [[roverId, socketId]]);
|
||||||
|
assert.deepEqual(driverEvents, [
|
||||||
|
{ socketId, roverId, action: 'remove' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -135,6 +135,20 @@ function buildUserEntry(socket) {
|
|||||||
const role = getRole(socket);
|
const role = getRole(socket);
|
||||||
const assignment = assignmentService.describeAssignment(socket.id);
|
const assignment = assignmentService.describeAssignment(socket.id);
|
||||||
const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id);
|
const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id);
|
||||||
|
/*
|
||||||
|
assignmentService owns automatic placement policy, while roverManager owns
|
||||||
|
actual control membership. Validate both candidate indexes before exposing
|
||||||
|
presence because neither cached direction is authoritative without the
|
||||||
|
physical rover record agreeing that this socket is one of its drivers.
|
||||||
|
*/
|
||||||
|
const verifiedPrimaryRover = primaryRover
|
||||||
|
&& roverManager.isDriver(primaryRover, socket)
|
||||||
|
? primaryRover
|
||||||
|
: null;
|
||||||
|
const verifiedAssignmentRover = assignment?.roverId
|
||||||
|
&& roverManager.isDriver(assignment.roverId, socket)
|
||||||
|
? assignment.roverId
|
||||||
|
: null;
|
||||||
const ptzChatTarget = getPtzChatTargetForSocket(socket.id);
|
const ptzChatTarget = getPtzChatTargetForSocket(socket.id);
|
||||||
return {
|
return {
|
||||||
socketId: socket.id,
|
socketId: socket.id,
|
||||||
@@ -147,7 +161,7 @@ function buildUserEntry(socket) {
|
|||||||
the PTZ chat target while the socket is queued or operating so presence,
|
the PTZ chat target while the socket is queued or operating so presence,
|
||||||
queue lookup, and chat identity all agree.
|
queue lookup, and chat identity all agree.
|
||||||
*/
|
*/
|
||||||
roverId: ptzChatTarget?.roverId || primaryRover || assignment?.roverId || null,
|
roverId: ptzChatTarget?.roverId || verifiedPrimaryRover || verifiedAssignmentRover || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +186,17 @@ function buildSession(socket) {
|
|||||||
}));
|
}));
|
||||||
const roster = roverManager.getRosterForSocket(socket);
|
const roster = roverManager.getRosterForSocket(socket);
|
||||||
const assignment = assignmentService.describeAssignment(socket?.id || '');
|
const assignment = assignmentService.describeAssignment(socket?.id || '');
|
||||||
const assignmentRoverId = filterVisibleRoverId(socket, assignment?.roverId);
|
/*
|
||||||
|
Visibility alone is insufficient here: a reconnected rover can be visible
|
||||||
|
before a stale assignment map has recreated actual driver membership. The
|
||||||
|
session contract consumed by every UI surface must require both visibility
|
||||||
|
and roverManager's authoritative membership check.
|
||||||
|
*/
|
||||||
|
const verifiedAssignmentRover = assignment?.roverId
|
||||||
|
&& roverManager.isDriver(assignment.roverId, socket)
|
||||||
|
? assignment.roverId
|
||||||
|
: null;
|
||||||
|
const assignmentRoverId = filterVisibleRoverId(socket, verifiedAssignmentRover);
|
||||||
const activeDrivers = filterActiveDriversForSocket(getActiveDrivers(), socket);
|
const activeDrivers = filterActiveDriversForSocket(getActiveDrivers(), socket);
|
||||||
const turnQueues = filterTurnQueuesForSocket(getTurnQueues(), socket);
|
const turnQueues = filterTurnQueuesForSocket(getTurnQueues(), socket);
|
||||||
const socials = features.socials && configuredSocials?.length ? configuredSocials : [];
|
const socials = features.socials && configuredSocials?.length ? configuredSocials : [];
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 337 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.8 MiB |
@@ -18,42 +18,69 @@ import ExpansionPanel from '../CornerPods/ExpansionPanel.jsx';
|
|||||||
import usePodVisibility from '../CornerPods/usePodVisibility.js';
|
import usePodVisibility from '../CornerPods/usePodVisibility.js';
|
||||||
|
|
||||||
function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndock }) {
|
function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndock }) {
|
||||||
|
const [hidden, setHidden] = useState(false);
|
||||||
const waitingForTurn = controlsDisabled && !pending;
|
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 (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center p-6">
|
<>
|
||||||
<button
|
{/* The docked shield is owned by the dismissible action so hiding the
|
||||||
type="button"
|
prompt also reveals the video and ordinary HUD instead of leaving an
|
||||||
disabled={pending || controlsDisabled}
|
unexplained dark, input-blocking layer behind. */}
|
||||||
onClick={onUndock}
|
<div className="pointer-events-auto absolute inset-0 z-[25] bg-black/75" aria-hidden="true" />
|
||||||
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 ${
|
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center p-6">
|
||||||
waitingForTurn
|
<div className="relative w-[min(32rem,80%)]">
|
||||||
? 'cursor-not-allowed bg-slate-950/95 ring-slate-400/70'
|
<button
|
||||||
: 'bg-emerald-950/90 ring-emerald-300/80 hover:bg-emerald-900/95 focus-visible:ring-emerald-200 disabled:cursor-wait disabled:opacity-75'
|
type="button"
|
||||||
}`}
|
disabled={pending || controlsDisabled}
|
||||||
>
|
onClick={onUndock}
|
||||||
<strong className="text-3xl leading-tight">{pending ? 'Undocking…' : 'Your rover is docked'}</strong>
|
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}`}
|
||||||
{pending ? (
|
>
|
||||||
null
|
<strong className="text-3xl leading-tight">{pending ? 'Undocking…' : 'Your rover is docked'}</strong>
|
||||||
) : waitingForTurn ? (
|
{pending ? (
|
||||||
/* A disabled action must explain the ownership constraint instead of
|
null
|
||||||
continuing to advertise a click and keybind that cannot succeed. */
|
) : waitingForTurn ? (
|
||||||
<span className="text-lg font-semibold leading-snug text-slate-300">
|
/* A disabled action must explain the ownership constraint instead of
|
||||||
Wait for your turn to undock.
|
continuing to advertise a click and keybind that cannot succeed. */
|
||||||
</span>
|
<span className="text-lg font-semibold leading-snug text-slate-300">
|
||||||
) : (
|
Wait for your turn to undock.
|
||||||
<span className="text-lg font-semibold leading-snug text-emerald-50">
|
</span>
|
||||||
Click here
|
) : (
|
||||||
{driveKeyLabel ? (
|
<span className="text-lg font-semibold leading-snug text-emerald-50">
|
||||||
<>
|
Click here
|
||||||
{' '}or press <KeyPill label={driveKeyLabel} />
|
{driveKeyLabel ? (
|
||||||
</>
|
<>
|
||||||
) : null}
|
{' '}or press <KeyPill label={driveKeyLabel} />
|
||||||
{' '}to undock and drive the rover
|
</>
|
||||||
</span>
|
) : null}
|
||||||
)}
|
{' '}to undock and drive the rover
|
||||||
{error ? <span className="text-sm font-semibold text-red-200">{error}</span> : null}
|
</span>
|
||||||
</button>
|
)}
|
||||||
</div>
|
{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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,15 +345,12 @@ export default function DockingHud({ roverId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* This single layer both dims and blocks the ordinary rover HUD. A passive
|
{/* Automatic docking keeps its own lighter blocking shield. The ordinary
|
||||||
undocked rover is still moving autonomously, so it gets a lighter but equally
|
docked shield lives inside DockedAction because the new Hide control
|
||||||
blocking shield. Explicit pending state keeps the correct shield mounted until
|
must dismiss the prompt and its dimming as one coherent surface. */}
|
||||||
the complete drive sequence finishes even as telemetry changes underneath it. */}
|
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 z-[25] transition-all duration-300 ${
|
className={`absolute inset-0 z-[25] transition-all duration-300 ${
|
||||||
docked || pendingAction === 'undocking'
|
autoDocking || pendingAction === 'resuming'
|
||||||
? 'pointer-events-auto bg-black/75 opacity-100'
|
|
||||||
: autoDocking || pendingAction === 'resuming'
|
|
||||||
? 'pointer-events-auto bg-black/55 opacity-100'
|
? 'pointer-events-auto bg-black/55 opacity-100'
|
||||||
: 'pointer-events-none opacity-0'
|
: 'pointer-events-none opacity-0'
|
||||||
}`}
|
}`}
|
||||||
@@ -335,6 +359,7 @@ export default function DockingHud({ roverId }) {
|
|||||||
|
|
||||||
{docked || pendingAction === 'undocking' ? (
|
{docked || pendingAction === 'undocking' ? (
|
||||||
<DockedAction
|
<DockedAction
|
||||||
|
key={roverId}
|
||||||
driveKeyLabel={driveKeyLabel}
|
driveKeyLabel={driveKeyLabel}
|
||||||
pending={pendingAction === 'undocking'}
|
pending={pendingAction === 'undocking'}
|
||||||
controlsDisabled={!canControl}
|
controlsDisabled={!canControl}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Initial Session Overlay
|
||||||
|
// Purpose: Hides incomplete driver-page placeholders until the first authoritative session snapshot arrives.
|
||||||
|
// Scope: Provides startup presentation only; it deliberately does not delay or alter page initialization underneath.
|
||||||
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
import spinnerImage from '../../assets/spinner.png';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
export default function InitialSessionOverlay() {
|
||||||
|
const connected = useSessionSelector((state) => Boolean(state.connected));
|
||||||
|
const sessionReady = useSessionSelector((state) => state.session !== null);
|
||||||
|
|
||||||
|
if (sessionReady) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black text-slate-200"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={connected ? 'Loading session' : 'Connecting'}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
{/* The source image is intentionally constrained to a small fixed box;
|
||||||
|
its intrinsic pixel dimensions must never determine overlay layout.
|
||||||
|
Reduced-motion users still see the identifying image without either
|
||||||
|
the rotation or continuously changing color. */}
|
||||||
|
<div className="initial-session-spinner-rotation h-20 w-20" aria-hidden="true">
|
||||||
|
<img
|
||||||
|
src={spinnerImage}
|
||||||
|
alt=""
|
||||||
|
draggable="false"
|
||||||
|
className="initial-session-spinner-image h-full w-full select-none object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-slate-300">
|
||||||
|
{connected ? 'Loading session…' : 'Connecting…'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
These animations belong only to InitialSessionOverlay. Keeping them beside
|
||||||
|
the component avoids adding feature-specific behavior to the global styles.
|
||||||
|
Rotation and hue use separate elements so their independent infinite cycles
|
||||||
|
cannot overwrite one another's transform or filter properties.
|
||||||
|
*/
|
||||||
|
@keyframes initial-session-spinner-rotate {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes initial-session-spinner-hue {
|
||||||
|
from {
|
||||||
|
filter: hue-rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
filter: hue-rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.initial-session-spinner-rotation {
|
||||||
|
animation: initial-session-spinner-rotate 1.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initial-session-spinner-image {
|
||||||
|
animation: initial-session-spinner-hue 2.8s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,9 +19,9 @@ function ControlRow({ label, keyLabel }) {
|
|||||||
function DesktopQuickstart({ keymap }) {
|
function DesktopQuickstart({ keymap }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<p className="text-sm text-slate-200">1. Press "Start Driving" put your rover into driving mode.</p>
|
<p className="text-sm text-slate-200">1. Click "Your rover is docked" to undock.</p>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<p className="text-sm text-slate-200">2. Use the drive controls to move your rover:</p>
|
<p className="text-sm text-slate-200">2. Drive with these keybindings:</p>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<ControlRow label="Forward" keyLabel={formatKeyLabel(keymap?.driveForward?.[0])} />
|
<ControlRow label="Forward" keyLabel={formatKeyLabel(keymap?.driveForward?.[0])} />
|
||||||
<ControlRow label="Backward" keyLabel={formatKeyLabel(keymap?.driveBackward?.[0])} />
|
<ControlRow label="Backward" keyLabel={formatKeyLabel(keymap?.driveBackward?.[0])} />
|
||||||
@@ -31,7 +31,8 @@ function DesktopQuickstart({ keymap }) {
|
|||||||
<ControlRow label="Move slower" keyLabel={formatKeyLabel(keymap?.slowModifier?.[0])} />
|
<ControlRow label="Move slower" keyLabel={formatKeyLabel(keymap?.slowModifier?.[0])} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-200">3. When done, enter "Docking Assist" and line up the front sensor with the dock sensor.</p>
|
<p className="text-sm text-slate-200">3. Use the video HUD for rover controls and information.</p>
|
||||||
|
<p className="text-sm text-slate-200">4. Click "Dock rover" when finished.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -39,10 +40,11 @@ function DesktopQuickstart({ keymap }) {
|
|||||||
function MobileQuickstart() {
|
function MobileQuickstart() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5 text-sm text-slate-200">
|
<div className="space-y-0.5 text-sm text-slate-200">
|
||||||
<p>1. Press "Start Driving" put your rover into driving mode.</p>
|
<p>1. Tap "Your rover is docked" to undock.</p>
|
||||||
<p>2. Touch and hold in Joystick area to move.</p>
|
<p>2. Hold and drag on the drive pad.</p>
|
||||||
<p>3. Use the other column for motor, horn, and camera controls.</p>
|
<p>3. Choose Precision, Normal, or Turbo above the drive pad.</p>
|
||||||
<p>4. When done, enter "Docking Assist" and line up the front sensor with the dock sensor.</p>
|
<p>4. Use the other column for rover controls.</p>
|
||||||
|
<p>5. Tap "Dock and charge" when finished.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -71,7 +73,6 @@ export default function QuickstartOverlay({
|
|||||||
layout,
|
layout,
|
||||||
showOnLoad,
|
showOnLoad,
|
||||||
onToggleShowOnLoad,
|
onToggleShowOnLoad,
|
||||||
onOpenHelp,
|
|
||||||
onClose,
|
onClose,
|
||||||
}) {
|
}) {
|
||||||
const rawKeymap = useControlSelector((control) => control.state.keymap);
|
const rawKeymap = useControlSelector((control) => control.state.keymap);
|
||||||
|
|||||||
@@ -87,10 +87,6 @@ function DriverPageContent({ layout, oldDesktop }) {
|
|||||||
},
|
},
|
||||||
[saveQuickstartSettings],
|
[saveQuickstartSettings],
|
||||||
);
|
);
|
||||||
const openHelpFromQuickstart = useCallback(() => {
|
|
||||||
setQuickstartVisible(false);
|
|
||||||
setHelpVisible(true);
|
|
||||||
}, []);
|
|
||||||
return (
|
return (
|
||||||
<ControlSystemProvider>
|
<ControlSystemProvider>
|
||||||
{/*
|
{/*
|
||||||
@@ -127,7 +123,6 @@ function DriverPageContent({ layout, oldDesktop }) {
|
|||||||
layout={layout}
|
layout={layout}
|
||||||
showOnLoad={quickstartSettings?.showOnLoad !== false}
|
showOnLoad={quickstartSettings?.showOnLoad !== false}
|
||||||
onToggleShowOnLoad={setQuickstartShowOnLoad}
|
onToggleShowOnLoad={setQuickstartShowOnLoad}
|
||||||
onOpenHelp={openHelpFromQuickstart}
|
|
||||||
onClose={closeQuickstart}
|
onClose={closeQuickstart}
|
||||||
/>
|
/>
|
||||||
</ControlSystemProvider>
|
</ControlSystemProvider>
|
||||||
|
|||||||
+60
-27
@@ -11,26 +11,44 @@ export const HELP_CONTENT = {
|
|||||||
type: 'list',
|
type: 'list',
|
||||||
title: 'Chat and nicknames',
|
title: 'Chat and nicknames',
|
||||||
items: [
|
items: [
|
||||||
'Set a nickname in the user list panel, on the bottom left of the page below the rover video.',
|
'Chat and nickname controls are in the Chat/Rovers tab.',
|
||||||
{ segments: ['Toggle chat focus with ', { action: 'chatFocus' }, '. Press ', { action: 'chatFocus'}, ' again to send.'] },
|
{ segments: ['Open the HUD chat composer with ', { action: 'chatFocus' }, '. Press ', { action: 'chatFocus'}, ' again to send.'] },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'list',
|
type: 'list',
|
||||||
title: 'Driving the rover',
|
title: 'Driving the rover',
|
||||||
items: [
|
items: [
|
||||||
{ segments: ['Press the "Start Driving" button onscreen, or press ' , { action: 'driveMacro' }, ' on your keyboard to put the rover into driving mode.'] },
|
{ segments: ['Click "Your rover is docked", or press ', { action: 'driveMacro' }, ', to undock.'] },
|
||||||
'Refer to the controls for the controls for the rover.'
|
'Drive with the movement keybindings.',
|
||||||
|
'Rover controls dim while another person has the turn.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
title: 'Video HUD',
|
||||||
|
items: [
|
||||||
|
'Top left shows the rover name and turns.',
|
||||||
|
'Top right shows battery and rover status.',
|
||||||
|
'Bottom left contains horn, headlight, and laser controls.',
|
||||||
|
'Bottom right contains camera tilt and chat.',
|
||||||
|
'Use the arrows to open and close pods and their expansions.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
title: 'Page layout',
|
||||||
|
items: [
|
||||||
|
'Room, Activities, and VIP are in the left sidebar.',
|
||||||
|
'Chat/Rovers, Help, and Settings are in the right sidebar.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'list',
|
type: 'list',
|
||||||
title: 'Docking the rover',
|
title: 'Docking the rover',
|
||||||
items: [
|
items: [
|
||||||
'If the rover shows "Docking in Progress", it is already auto-seeking the dock.',
|
{ segments: ['Click "Dock rover", or press ', { action: 'dockMacro' }, '.'] },
|
||||||
'To dock manually, enter Docking Assist from the drive panel.',
|
'Click "Rover is docking itself" to resume driving.',
|
||||||
{ segments: ['Press "Enter Docking Assist", or press ', { action: 'dockMacro' }, '.'] },
|
|
||||||
'In assist mode, camera tilts down and driving speed is limited for precise alignment.',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -56,8 +74,8 @@ export const HELP_CONTENT = {
|
|||||||
id: 'macros',
|
id: 'macros',
|
||||||
title: 'Rover modes & chat',
|
title: 'Rover modes & chat',
|
||||||
items: [
|
items: [
|
||||||
{ action: 'driveMacro', label: 'Drive macro' },
|
{ action: 'driveMacro', label: 'Undock / resume driving' },
|
||||||
{ action: 'dockMacro', label: 'Docking assist toggle' },
|
{ action: 'dockMacro', label: 'Dock rover' },
|
||||||
{ action: 'chatFocus', label: 'Chat focus' },
|
{ action: 'chatFocus', label: 'Chat focus' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -119,27 +137,35 @@ export const HELP_CONTENT = {
|
|||||||
type: 'list',
|
type: 'list',
|
||||||
title: 'Chat and nicknames',
|
title: 'Chat and nicknames',
|
||||||
items: [
|
items: [
|
||||||
'Set a nickname in the user list panel below.',
|
'Chat and nickname controls are in the Chat tab.',
|
||||||
'Tap in the chat box to send messages in the chat.'
|
'Tap the chat box to send a message.',
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'list',
|
type: 'list',
|
||||||
title: 'Driving the rover',
|
title: 'Driving the rover',
|
||||||
items: [
|
items: [
|
||||||
{ segments: ['Press the "Start Driving" button onscreen to put the rover into driving mode.'] },
|
'Tap "Your rover is docked" to undock.',
|
||||||
'Look below the rover video. Use the joystick column to move the rover, and hold the aux buttons in the other control column.'
|
'Hold and drag on the drive pad.',
|
||||||
|
'Choose Precision, Normal, or Turbo above the drive pad.',
|
||||||
|
'Use the other column for rover controls.',
|
||||||
|
'Rover controls dim while another person has the turn.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'list',
|
type: 'list',
|
||||||
title: 'Docking the rover',
|
title: 'Docking the rover',
|
||||||
items: [
|
items: [
|
||||||
'If you see "Docking in Progress", the rover is currently auto-seeking the dock.',
|
'Tap "Dock and charge" when finished.',
|
||||||
{ segments: ['For manual docking, press "Enter Docking Assist".'] },
|
|
||||||
'Assist mode tilts camera down and limits speed for precise alignment.',
|
|
||||||
],
|
],
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
title: 'More controls',
|
||||||
|
items: [
|
||||||
|
'Chat, Activities, VIP, Room Controls, Help, and Settings are below the rover controls.',
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'mobile-landscape': {
|
'mobile-landscape': {
|
||||||
@@ -148,28 +174,35 @@ export const HELP_CONTENT = {
|
|||||||
type: 'list',
|
type: 'list',
|
||||||
title: 'Chat and nicknames',
|
title: 'Chat and nicknames',
|
||||||
items: [
|
items: [
|
||||||
'Scroll down to see more of the page.',
|
'Chat and nickname controls are in the Chat tab.',
|
||||||
'Set a nickname in the user list panel below.',
|
'Tap the chat box to send a message.',
|
||||||
'Tap in the chat box to send messages in the chat.'
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'list',
|
type: 'list',
|
||||||
title: 'Driving the rover',
|
title: 'Driving the rover',
|
||||||
items: [
|
items: [
|
||||||
{ segments: ['Press the "Start Driving" button, or the "Drive" button to put the rover into driving mode.'] },
|
'Tap "Your rover is docked" to undock.',
|
||||||
'Use the joystick column beside the video feed to move the rover, and hold the aux buttons in the other control column.'
|
'Hold and drag on the drive pad.',
|
||||||
|
'Choose Precision, Normal, or Turbo above the drive pad.',
|
||||||
|
'Use the other column for rover controls.',
|
||||||
|
'Rover controls dim while another person has the turn.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'list',
|
type: 'list',
|
||||||
title: 'Docking the rover',
|
title: 'Docking the rover',
|
||||||
items: [
|
items: [
|
||||||
'If you see "Docking in Progress", the rover is currently auto-seeking the dock.',
|
'Tap "Dock and charge" when finished.',
|
||||||
{ segments: ['For manual docking, press "Enter Docking Assist" (or "Dock" button).'] },
|
|
||||||
'Assist mode tilts camera down and limits speed for precise alignment.',
|
|
||||||
],
|
],
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
title: 'More controls',
|
||||||
|
items: [
|
||||||
|
'Chat, Activities, VIP, Room Controls, Help, and Settings are below the rover controls.',
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// aside: [
|
// aside: [
|
||||||
// {
|
// {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { SettingsProvider } from './settings/index.js'
|
|||||||
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
|
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
|
||||||
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
|
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
|
||||||
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
|
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
|
||||||
|
import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx'
|
||||||
|
|
||||||
// The reporting route includes the charting and CSV libraries. Loading that
|
// The reporting route includes the charting and CSV libraries. Loading that
|
||||||
// bundle only when `/reports` is visited keeps ordinary rover-control sessions
|
// bundle only when `/reports` is visited keeps ordinary rover-control sessions
|
||||||
@@ -31,6 +32,11 @@ createRoot(document.getElementById('root')).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<SocketProvider>
|
<SocketProvider>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
|
{/* Every route depends on the first authoritative session snapshot.
|
||||||
|
Mounting this opaque layer at the shared provider boundary prevents
|
||||||
|
incomplete route-specific placeholders from flashing while still
|
||||||
|
allowing every application tree to initialize underneath it. */}
|
||||||
|
<InitialSessionOverlay />
|
||||||
<TelemetryProvider>
|
<TelemetryProvider>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<ChatProvider>
|
<ChatProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user