mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
/display page woooo
This commit is contained in:
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
@@ -11,8 +11,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" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BkTL8xYG.js"></script>
|
<script type="module" crossorigin src="/assets/index-DVCbMND6.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Des_xuUP.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D3sOXuVW.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function ChatPanel({
|
|||||||
fillHeight = false,
|
fillHeight = false,
|
||||||
allowSpectatorInput = false,
|
allowSpectatorInput = false,
|
||||||
title = 'Chat and TTS',
|
title = 'Chat and TTS',
|
||||||
nicknameLayout = 'inline',
|
minimal = false,
|
||||||
}) {
|
}) {
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||||
@@ -67,6 +67,8 @@ export default function ChatPanel({
|
|||||||
[currentRoverId, roster],
|
[currentRoverId, roster],
|
||||||
);
|
);
|
||||||
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
|
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
|
||||||
|
const effectiveHideInput = minimal || hideInput;
|
||||||
|
const effectiveTitle = minimal ? '' : title;
|
||||||
|
|
||||||
const sorted = useMemo(() => messages.slice(-200), [messages]);
|
const sorted = useMemo(() => messages.slice(-200), [messages]);
|
||||||
const typingRows = useMemo(() => typing || [], [typing]);
|
const typingRows = useMemo(() => typing || [], [typing]);
|
||||||
@@ -125,7 +127,7 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
async function handleSend(event) {
|
async function handleSend(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!canChat || hideInput) return;
|
if (!canChat || effectiveHideInput) return;
|
||||||
// Allow users to type "\n" to represent a newline in messages
|
// Allow users to type "\n" to represent a newline in messages
|
||||||
const normalizedDraft = draft.replace(/\\n/g, '\n');
|
const normalizedDraft = draft.replace(/\\n/g, '\n');
|
||||||
const clean = normalizedDraft.trim();
|
const clean = normalizedDraft.trim();
|
||||||
@@ -156,9 +158,9 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame
|
<CardFrame
|
||||||
title={title}
|
title={effectiveTitle}
|
||||||
|
|
||||||
hideHeader={!title}
|
hideHeader={minimal || !effectiveTitle}
|
||||||
fillHeight={fillHeight}
|
fillHeight={fillHeight}
|
||||||
bodyClassName="space-y-0.5 text-base"
|
bodyClassName="space-y-0.5 text-base"
|
||||||
>
|
>
|
||||||
@@ -172,7 +174,7 @@ export default function ChatPanel({
|
|||||||
<ChatTypingRow key={`typing-${entry.typingId || entry.id}`} message={entry} />
|
<ChatTypingRow key={`typing-${entry.typingId || entry.id}`} message={entry} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!hideInput && (
|
{!effectiveHideInput && (
|
||||||
<form className={composerClass} onSubmit={handleSend}>
|
<form className={composerClass} onSubmit={handleSend}>
|
||||||
<div className="chat-composer-nickname">
|
<div className="chat-composer-nickname">
|
||||||
<NicknameForm compact />
|
<NicknameForm compact />
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Server Display App Root
|
||||||
|
// Purpose: Defines the route root for the physical-room display page.
|
||||||
|
// Scope: Keeps this passive spectator-style surface separate from the main driver UI.
|
||||||
|
import ServerDisplayContent from './ServerDisplayContent.jsx';
|
||||||
|
|
||||||
|
export default function ServerDisplayAppRoot() {
|
||||||
|
return <ServerDisplayContent />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Server Display Content
|
||||||
|
// Purpose: Composes the passive room display route used as a third spectator-style page.
|
||||||
|
// Scope: Shows online names, rover driver/battery status, chat, replay overlays, reward overlays, and hidden audio.
|
||||||
|
import { useSession, useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
|
||||||
|
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
|
||||||
|
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
|
||||||
|
import ReplayReadyPopup from '../../components/ReplaySourcesPanel/ReplayReadyPopup.jsx';
|
||||||
|
import RewardRunOverlay from '../../components/RewardRunOverlay/index.jsx';
|
||||||
|
import OnlinePeopleStrip from './components/OnlinePeopleStrip.jsx';
|
||||||
|
import DisplayRoverGrid from './components/DisplayRoverGrid.jsx';
|
||||||
|
import DisplayChatFeed from './components/DisplayChatFeed.jsx';
|
||||||
|
|
||||||
|
export default function ServerDisplayContent() {
|
||||||
|
const { session } = useSession();
|
||||||
|
const latestReplay = useSessionSelector((state) => state.latestReplay);
|
||||||
|
const { clearLatestReplay } = useSessionActions();
|
||||||
|
const inLockdown = session?.mode === 'lockdown';
|
||||||
|
|
||||||
|
useDefaultNickname();
|
||||||
|
// This page is a passive room endpoint, but it still needs the same identity
|
||||||
|
// heartbeat as /spectate and /mini so nickname cookies and spectator role sync
|
||||||
|
// stay current when this laptop is left running for a long session.
|
||||||
|
useUserIdentitySync();
|
||||||
|
useSpectatorMode();
|
||||||
|
|
||||||
|
if (inLockdown) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center bg-black text-[clamp(2.5rem,8vh,8rem)] font-black text-slate-200">
|
||||||
|
Lockdown
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="display-page flex h-screen w-screen flex-col overflow-hidden bg-black text-slate-100">
|
||||||
|
<div className="h-[8vh] min-h-[4rem] shrink-0">
|
||||||
|
<OnlinePeopleStrip users={session?.users || []} />
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-[0.72]">
|
||||||
|
<DisplayRoverGrid roster={session?.roster || []} session={session} />
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-[1.28]">
|
||||||
|
<DisplayChatFeed />
|
||||||
|
</div>
|
||||||
|
<RewardRunOverlay />
|
||||||
|
{/* Display is spectator-like: every Discord-hosted replay should take over
|
||||||
|
this physical-room board, not only replays requested by this browser. */}
|
||||||
|
<ReplayReadyPopup replay={latestReplay} onClose={clearLatestReplay} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Display Chat Feed
|
||||||
|
// Purpose: Shows the existing chat panel scaled for distance viewing.
|
||||||
|
// Scope: Uses ChatPanel minimal mode so the display route reuses normal chat behavior without input chrome.
|
||||||
|
import ChatPanel from '../../../components/ChatPanel/index.jsx';
|
||||||
|
|
||||||
|
export default function DisplayChatFeed() {
|
||||||
|
const displayChatScale = 2.35;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="h-full min-h-0 overflow-hidden border-t border-slate-800 bg-black p-[0.45vw]">
|
||||||
|
<div
|
||||||
|
className="origin-top-left"
|
||||||
|
style={{
|
||||||
|
// ChatPanel is shared with the normal UI and intentionally compact.
|
||||||
|
// Scaling the whole panel keeps message rendering, scrolling, typing
|
||||||
|
// rows, and future chat behavior in one place while making the display
|
||||||
|
// readable. Width and height compensation reserve the unscaled layout
|
||||||
|
// space that becomes exactly one full chat band after transform.
|
||||||
|
width: `calc(100% / ${displayChatScale})`,
|
||||||
|
height: `calc(100% / ${displayChatScale})`,
|
||||||
|
transform: `scale(${displayChatScale})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChatPanel minimal fillHeight hideSpectatorNotice />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Display Rover Cell
|
||||||
|
// Purpose: Shows one rover's room-readable driver and battery status.
|
||||||
|
// Scope: Reuses shared rover/battery presentation primitives while avoiding controls, queues, and video.
|
||||||
|
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
||||||
|
import BatteryBar from '../../../components/BatteryBar/index.jsx';
|
||||||
|
import RoverLabel from '../../../components/RoverLabel/index.jsx';
|
||||||
|
import AutoFitText from '../../../mini/MiniSummaryApp/components/AutoFitText.jsx';
|
||||||
|
import {
|
||||||
|
buildRoverStateText,
|
||||||
|
findDriverForRover,
|
||||||
|
formatBatteryText,
|
||||||
|
getDisplayBatteryVisual,
|
||||||
|
} from '../utils.js';
|
||||||
|
|
||||||
|
function classNames(...values) {
|
||||||
|
return values.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DisplayRoverCell({ rover, session }) {
|
||||||
|
const frame = useTelemetryFrame(rover?.id);
|
||||||
|
const visual = getDisplayBatteryVisual({ rover, frame });
|
||||||
|
const driver = findDriverForRover({ roverId: rover?.id, session });
|
||||||
|
const stateText = buildRoverStateText(rover, visual);
|
||||||
|
const batteryText = formatBatteryText(visual);
|
||||||
|
const active = Boolean(driver);
|
||||||
|
const urgent = Boolean(visual?.urgentActive);
|
||||||
|
const warn = Boolean(visual?.warnActive);
|
||||||
|
const locked = Boolean(rover?.locked);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={classNames(
|
||||||
|
'relative min-h-0 overflow-hidden border border-slate-800 bg-black',
|
||||||
|
locked ? 'border-red-400 ring-4 ring-red-500/80' : '',
|
||||||
|
!locked && active ? 'ring-2 ring-sky-400/80' : '',
|
||||||
|
!locked && urgent ? 'ring-4 ring-red-500/90' : !locked && warn ? 'ring-2 ring-amber-300/80' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BatteryBar visual={visual} variant="background" orientation="vertical" />
|
||||||
|
<div className="relative z-10 grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] gap-[0.55vh] p-[0.85vw] text-center">
|
||||||
|
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-[0.8vw]">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<RoverLabel
|
||||||
|
rover={rover}
|
||||||
|
fallback={rover?.id}
|
||||||
|
as={AutoFitText}
|
||||||
|
className="leading-none"
|
||||||
|
maxSize={76}
|
||||||
|
minSize={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{locked ? (
|
||||||
|
// Keep the lock warning adjacent to the rover identity so the cell
|
||||||
|
// remains readable while still making the locked state impossible
|
||||||
|
// to miss at a glance.
|
||||||
|
<div className="max-w-[36vw] border-4 border-red-100 bg-red-700 px-[0.9vw] py-[0.45vh] text-center text-[clamp(1.6rem,4.2vh,4.4rem)] font-black leading-none text-white">
|
||||||
|
<div>LOCKED</div>
|
||||||
|
{rover?.lockReason ? (
|
||||||
|
<div className="mt-[0.3vh] text-[clamp(1rem,2.4vh,2.5rem)] leading-none text-red-50">
|
||||||
|
{rover.lockReason}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid min-h-0 min-w-0 grid-cols-[minmax(0,1.45fr)_minmax(0,0.85fr)] items-center gap-[1vw]">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<AutoFitText
|
||||||
|
className={classNames(
|
||||||
|
'font-black leading-none',
|
||||||
|
active ? 'text-white' : 'text-slate-400',
|
||||||
|
)}
|
||||||
|
maxSize={128}
|
||||||
|
minSize={28}
|
||||||
|
>
|
||||||
|
{driver?.label || 'Idle'}
|
||||||
|
</AutoFitText>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<AutoFitText
|
||||||
|
className={classNames(
|
||||||
|
'font-black leading-none',
|
||||||
|
urgent ? 'text-red-100' : warn ? 'text-amber-100' : 'text-slate-100',
|
||||||
|
)}
|
||||||
|
maxSize={100}
|
||||||
|
minSize={24}
|
||||||
|
>
|
||||||
|
{batteryText}
|
||||||
|
</AutoFitText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{stateText ? (
|
||||||
|
<div className="min-w-0 text-[clamp(0.95rem,1.8vh,1.8rem)] font-black leading-none text-amber-100">
|
||||||
|
{stateText}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// This empty line keeps cells with and without exceptional states the
|
||||||
|
// same height. The grid should not jump just because one rover becomes
|
||||||
|
// locked or low battery while people are reading the board.
|
||||||
|
<div aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Display Rover Grid
|
||||||
|
// Purpose: Fills the central 16:10 display band with rover driver/battery cells.
|
||||||
|
// Scope: Keeps rover status presentation dense and label-free for room readability.
|
||||||
|
import DisplayRoverCell from './DisplayRoverCell.jsx';
|
||||||
|
import { gridClassForRoverCount } from '../utils.js';
|
||||||
|
|
||||||
|
export default function DisplayRoverGrid({ roster = [], session }) {
|
||||||
|
if (!roster.length) {
|
||||||
|
return (
|
||||||
|
<section className="flex h-full min-h-0 items-center justify-center border-b border-slate-800 bg-black text-[clamp(2.5rem,7vh,7rem)] font-black text-slate-500">
|
||||||
|
No rovers
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`grid h-full min-h-0 ${gridClassForRoverCount(roster.length)} auto-rows-fr gap-0.5 bg-slate-950 p-0.5`}>
|
||||||
|
{roster.map((rover) => (
|
||||||
|
<DisplayRoverCell key={rover.id} rover={rover} session={session} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Online People Strip
|
||||||
|
// Purpose: Renders a one-line, label-free list of online people for the room display.
|
||||||
|
// Scope: Owns overflow detection and marquee-style motion without changing shared user-list components.
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { formatUserName } from '../utils.js';
|
||||||
|
|
||||||
|
function classNames(...values) {
|
||||||
|
return values.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OnlinePeopleStrip({ users = [] }) {
|
||||||
|
const viewportRef = useRef(null);
|
||||||
|
const trackRef = useRef(null);
|
||||||
|
const contentRef = useRef(null);
|
||||||
|
const [overflowing, setOverflowing] = useState(false);
|
||||||
|
|
||||||
|
const names = useMemo(
|
||||||
|
() =>
|
||||||
|
users
|
||||||
|
// The display page itself enters spectator mode, and other passive
|
||||||
|
// spectators are usually not relevant to people physically in the room.
|
||||||
|
// Filtering them keeps the top strip focused on active participants.
|
||||||
|
.filter((user) => user?.role !== 'spectator')
|
||||||
|
.map(formatUserName)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.localeCompare(b)),
|
||||||
|
[users],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (!viewport || !content) return undefined;
|
||||||
|
|
||||||
|
const updateOverflow = () => {
|
||||||
|
// The strip only moves when it needs to. A still row is easier to read
|
||||||
|
// when the current online set already fits on the 16:10 display.
|
||||||
|
setOverflowing(content.scrollWidth > viewport.clientWidth + 4);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateOverflow();
|
||||||
|
const resizeObserver = new ResizeObserver(updateOverflow);
|
||||||
|
resizeObserver.observe(viewport);
|
||||||
|
resizeObserver.observe(content);
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, [names]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const track = trackRef.current;
|
||||||
|
if (!track || !overflowing) return undefined;
|
||||||
|
|
||||||
|
// This animation is local to the display route, so it is created directly
|
||||||
|
// on the strip element instead of adding route-specific keyframes to the
|
||||||
|
// global stylesheet. The duplicated name row means -50% lands exactly at
|
||||||
|
// the start of the second copy, producing a continuous readable loop.
|
||||||
|
const animation = track.animate(
|
||||||
|
[{ transform: 'translateX(0)' }, { transform: 'translateX(-50%)' }],
|
||||||
|
{ duration: 42000, iterations: Infinity, easing: 'linear' },
|
||||||
|
);
|
||||||
|
return () => animation.cancel();
|
||||||
|
}, [overflowing, names]);
|
||||||
|
|
||||||
|
const renderedNames = names.length ? names : ['No one online'];
|
||||||
|
const itemNodes = renderedNames.map((name, index) => (
|
||||||
|
<span key={`${name}-${index}`} className="shrink-0 px-[1.6vw] font-black tracking-normal text-slate-100">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={viewportRef} className="relative h-full min-w-0 overflow-hidden border-b border-slate-800/80 bg-black">
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className={classNames(
|
||||||
|
'flex h-full w-max items-center whitespace-nowrap text-[clamp(2rem,4.2vh,4.6rem)] leading-none',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div ref={contentRef} className="flex h-full items-center">
|
||||||
|
{itemNodes}
|
||||||
|
</div>
|
||||||
|
{overflowing ? (
|
||||||
|
// The duplicate row makes the loop continuous. It is hidden from assistive
|
||||||
|
// tech because it is purely mechanical animation, not extra information.
|
||||||
|
<div className="flex h-full items-center" aria-hidden="true">
|
||||||
|
{itemNodes}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Server Display Utilities
|
||||||
|
// Purpose: Supplies small formatting helpers shared by the room display components.
|
||||||
|
// Scope: Keeps the display route focused on presentation while still using the same
|
||||||
|
// session, roster, and battery data shapes as the rest of the web UI.
|
||||||
|
import { buildBatteryVisual } from '../../lib/battery.js';
|
||||||
|
|
||||||
|
export function formatUserName(user) {
|
||||||
|
// The room display is read from a distance, so every user needs a short stable
|
||||||
|
// label even when they have not set a nickname. Socket ids are noisy, but the
|
||||||
|
// first six characters match existing UI behavior and are enough to distinguish
|
||||||
|
// temporary visitors in the room.
|
||||||
|
return String(user?.nickname || user?.socketId?.slice(0, 6) || 'unknown').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findDriverForRover({ roverId, session }) {
|
||||||
|
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
||||||
|
if (!activeDriverId) return null;
|
||||||
|
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
|
||||||
|
return {
|
||||||
|
socketId: activeDriverId,
|
||||||
|
label: formatUserName(user || { socketId: activeDriverId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayBatteryVisual({ rover, frame }) {
|
||||||
|
// Roster battery state is the normal fast path because the server already
|
||||||
|
// classifies warning/urgent thresholds. Telemetry charge is included as a
|
||||||
|
// fallback so the display still gets a useful percentage if the roster state
|
||||||
|
// arrives before the richer battery snapshot is populated.
|
||||||
|
return buildBatteryVisual({
|
||||||
|
batteryState: rover?.batteryState ?? null,
|
||||||
|
charge: frame?.sensors?.batteryChargeMah ?? null,
|
||||||
|
config: rover?.battery ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBatteryText(visual) {
|
||||||
|
if (!visual?.available) return '--';
|
||||||
|
if (visual.urgentActive) return `${visual.percentDisplay}% Low`;
|
||||||
|
if (visual.warnActive) return `${visual.percentDisplay}% Low`;
|
||||||
|
return `${visual.percentDisplay}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRoverStateText(rover, visual) {
|
||||||
|
const states = [];
|
||||||
|
if (rover?.private?.enabled && rover?.private?.open) states.push('Private');
|
||||||
|
if (visual?.urgentActive || visual?.warnActive) states.push('Low');
|
||||||
|
// Only exceptional state belongs here. The normal driver/id/battery lines
|
||||||
|
// already consume the big readable space, and extra routine labels make this
|
||||||
|
// utility board harder to scan from across the room.
|
||||||
|
return states.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gridClassForRoverCount(count) {
|
||||||
|
if (count <= 1) return 'grid-cols-1';
|
||||||
|
if (count === 2) return 'grid-cols-2';
|
||||||
|
if (count === 4) return 'grid-cols-2';
|
||||||
|
return 'grid-cols-3';
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { TelemetryProvider } from './context/TelemetryContext.jsx'
|
|||||||
import { ChatProvider } from './context/ChatContext.jsx'
|
import { ChatProvider } from './context/ChatContext.jsx'
|
||||||
import SpectatorApp from './spectate/SpectatorApp/SpectatorAppRoot.jsx'
|
import SpectatorApp from './spectate/SpectatorApp/SpectatorAppRoot.jsx'
|
||||||
import MiniSummaryApp from './mini/MiniSummaryApp/MiniSummaryAppRoot.jsx'
|
import MiniSummaryApp from './mini/MiniSummaryApp/MiniSummaryAppRoot.jsx'
|
||||||
|
import ServerDisplayApp from './display/ServerDisplayApp/ServerDisplayAppRoot.jsx'
|
||||||
import { SettingsProvider } from './settings/index.js'
|
import { SettingsProvider } from './settings/index.js'
|
||||||
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
|
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ createRoot(document.getElementById('root')).render(
|
|||||||
<Route path="/" element={<App />} />
|
<Route path="/" element={<App />} />
|
||||||
<Route path="/spectate" element={<SpectatorApp />} />
|
<Route path="/spectate" element={<SpectatorApp />} />
|
||||||
<Route path="/mini" element={<MiniSummaryApp />} />
|
<Route path="/mini" element={<MiniSummaryApp />} />
|
||||||
|
<Route path="/display" element={<ServerDisplayApp />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ChatProvider>
|
</ChatProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user