mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
/mini new (again)
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="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CVxRGxYn.js"></script>
|
<script type="module" crossorigin src="/assets/index-Cjb70otM.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BhKFQa9T.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C_HmbjrX.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ export default function VideoTile({
|
|||||||
? `${audioStatus} (${audioDetail})`
|
? `${audioStatus} (${audioDetail})`
|
||||||
: audioStatus;
|
: audioStatus;
|
||||||
const showVerticalBattery = hudVariant === 'spectator';
|
const showVerticalBattery = hudVariant === 'spectator';
|
||||||
|
const noHud = hudVariant === 'none';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
|
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
|
||||||
@@ -422,7 +423,7 @@ export default function VideoTile({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<audio ref={audioRef} autoPlay hidden />
|
<audio ref={audioRef} autoPlay hidden />
|
||||||
{showTurnCue ? (
|
{!noHud && showTurnCue ? (
|
||||||
<TurnCueOverlay
|
<TurnCueOverlay
|
||||||
mobileHud={mobileHud}
|
mobileHud={mobileHud}
|
||||||
turnSeconds={turnSeconds}
|
turnSeconds={turnSeconds}
|
||||||
@@ -430,35 +431,37 @@ export default function VideoTile({
|
|||||||
idleSkipSeconds={idleSkipSeconds}
|
idleSkipSeconds={idleSkipSeconds}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<HudOverlay
|
{!noHud ? (
|
||||||
frame={telemetryFrame}
|
<HudOverlay
|
||||||
sensors={sensors}
|
frame={telemetryFrame}
|
||||||
label={label}
|
sensors={sensors}
|
||||||
status={renderedStatus}
|
label={label}
|
||||||
audioStatus={renderedAudioStatus}
|
status={renderedStatus}
|
||||||
desktopLayout={desktopLayout}
|
audioStatus={renderedAudioStatus}
|
||||||
layoutFormat={layoutFormat}
|
desktopLayout={desktopLayout}
|
||||||
variant={hudVariant}
|
layoutFormat={layoutFormat}
|
||||||
driverLabel={driverLabel}
|
variant={hudVariant}
|
||||||
battery={batteryVisual}
|
driverLabel={driverLabel}
|
||||||
showTopDown={showHudMap}
|
battery={batteryVisual}
|
||||||
mobileHud={mobileHud}
|
showTopDown={showHudMap}
|
||||||
mapPosition={hudMapPosition}
|
mobileHud={mobileHud}
|
||||||
turnTimerText={turnTimerText}
|
mapPosition={hudMapPosition}
|
||||||
labelScale={hudLabelScale}
|
turnTimerText={turnTimerText}
|
||||||
/>
|
labelScale={hudLabelScale}
|
||||||
<HudChatInput compact={mobileHud} />
|
/>
|
||||||
{debugHud ? (
|
) : null}
|
||||||
|
{!noHud ? <HudChatInput compact={mobileHud} /> : null}
|
||||||
|
{!noHud && debugHud ? (
|
||||||
<div className="pointer-events-none absolute left-1 top-1 z-40 rounded bg-black/80 px-1 py-0.5 text-[0.6rem] text-lime-200">
|
<div className="pointer-events-none absolute left-1 top-1 z-40 rounded bg-black/80 px-1 py-0.5 text-[0.6rem] text-lime-200">
|
||||||
{`OC vis:${overlayVisible ? 1 : 0} motors:${overlayMotors.length} fill:${Math.round(overlayFill * 100)}%`}
|
{`OC vis:${overlayVisible ? 1 : 0} motors:${overlayMotors.length} fill:${Math.round(overlayFill * 100)}%`}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<OvercurrentOverlay motors={overlayMotors} fill={overlayFill} compact={mobileHud} />
|
{!noHud ? <OvercurrentOverlay motors={overlayMotors} fill={overlayFill} compact={mobileHud} /> : null}
|
||||||
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} compact={mobileHud} />
|
{!noHud ? <LowBatteryOverlay charge={batteryCharge} config={batteryConfig} compact={mobileHud} /> : null}
|
||||||
{showVerticalBattery && batteryVisual.available ? (
|
{!noHud && showVerticalBattery && batteryVisual.available ? (
|
||||||
<BatteryBarVertical visual={batteryVisual} />
|
<BatteryBarVertical visual={batteryVisual} />
|
||||||
) : null}
|
) : null}
|
||||||
{qualityNotice ? (
|
{!noHud && qualityNotice ? (
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2">
|
<div className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2">
|
||||||
<div
|
<div
|
||||||
className={`mx-auto w-fit rounded border border-amber-300/80 bg-black/75 text-amber-200 ${
|
className={`mx-auto w-fit rounded border border-amber-300/80 bg-black/75 text-amber-200 ${
|
||||||
@@ -473,7 +476,7 @@ export default function VideoTile({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!showVerticalBattery && (
|
{!noHud && !showVerticalBattery && (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<LightBumpBars sensors={sensors} />
|
<LightBumpBars sensors={sensors} />
|
||||||
<BatteryBar visual={batteryVisual} />
|
<BatteryBar visual={batteryVisual} />
|
||||||
@@ -626,6 +629,10 @@ function HudOverlay({
|
|||||||
: { right: '0.25rem', top: '0.25rem' }),
|
: { right: '0.25rem', top: '0.25rem' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (variant === 'none') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (variant === 'spectator') {
|
if (variant === 'spectator') {
|
||||||
const telemetryEntries = [
|
const telemetryEntries = [
|
||||||
['Voltage', sensors?.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'],
|
['Voltage', sensors?.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { SettingsProvider } from '../settings/index.js';
|
import { SettingsProvider } from '../settings/index.js';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
|
import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
|
||||||
@@ -6,6 +6,7 @@ import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
|||||||
import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
|
import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
|
||||||
import { useSpectatorMode } from '../hooks/useSpectatorMode.js';
|
import { useSpectatorMode } from '../hooks/useSpectatorMode.js';
|
||||||
import VideoTile from '../components/VideoTile.jsx';
|
import VideoTile from '../components/VideoTile.jsx';
|
||||||
|
import TopDownMap from '../components/TopDownMap.jsx';
|
||||||
import AlertFeed from '../components/AlertFeed.jsx';
|
import AlertFeed from '../components/AlertFeed.jsx';
|
||||||
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
||||||
|
|
||||||
@@ -21,37 +22,259 @@ function formatDriverLabel({ roverId, session }) {
|
|||||||
return mode === 'turns' && turnInfo?.current ? `${label}` : label;
|
return mode === 'turns' && turnInfo?.current ? `${label}` : label;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBatteryVisual({ rover, frame }) {
|
function formatDuration(ms) {
|
||||||
|
if (!Number.isFinite(ms) || ms <= 0) return '0:00';
|
||||||
|
const totalSeconds = Math.ceil(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBatteryPercent({ rover, frame }) {
|
||||||
const percentDisplay = rover?.batteryState?.percentDisplay;
|
const percentDisplay = rover?.batteryState?.percentDisplay;
|
||||||
const urgentActive = rover?.batteryState?.urgentActive ?? false;
|
if (percentDisplay != null && Number.isFinite(percentDisplay)) {
|
||||||
const warnActive = rover?.batteryState?.warnActive ?? false;
|
return Math.round(percentDisplay);
|
||||||
|
}
|
||||||
const config = rover?.battery ?? null;
|
const config = rover?.battery ?? null;
|
||||||
const charge = frame?.sensors?.batteryChargeMah ?? null;
|
const charge = frame?.sensors?.batteryChargeMah ?? null;
|
||||||
const full = config?.Full ?? null;
|
const full = config?.Full ?? null;
|
||||||
const warn = config?.Warn ?? null;
|
const warn = config?.Warn ?? null;
|
||||||
const urgent = config?.Urgent ?? null;
|
if (charge == null || full == null || warn == null) return null;
|
||||||
|
|
||||||
if (percentDisplay != null && Number.isFinite(percentDisplay)) {
|
|
||||||
const percent = Math.max(0, Math.min(1, percentDisplay / 100));
|
|
||||||
const barClass = urgentActive ? 'bg-red-500/40' : warnActive ? 'bg-amber-400/40' : 'bg-emerald-400/40';
|
|
||||||
return { percent, barClass };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (charge == null || full == null || warn == null) {
|
|
||||||
return { percent: 0, barClass: 'bg-slate-700/40' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const span = full - warn;
|
const span = full - warn;
|
||||||
if (span <= 0) {
|
if (span <= 0) return null;
|
||||||
return { percent: 0, barClass: 'bg-slate-700/40' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = (charge - warn) / span;
|
const normalized = (charge - warn) / span;
|
||||||
const percent = Math.max(0, Math.min(1, normalized));
|
return Math.round(Math.max(0, Math.min(1, normalized)) * 100);
|
||||||
const depleted = normalized <= 0;
|
}
|
||||||
const warnTriggered = urgent != null && charge <= urgent;
|
|
||||||
const barClass = depleted ? 'bg-red-500/40' : warnTriggered ? 'bg-amber-400/40' : 'bg-emerald-400/40';
|
function getDockChargeStatus(frame) {
|
||||||
return { percent, barClass };
|
const docked = Boolean(frame?.sensors?.chargingSources?.homeBase);
|
||||||
|
const chargingLabel = frame?.sensors?.chargingState?.label || '';
|
||||||
|
const charging = Boolean(chargingLabel && chargingLabel.toLowerCase() !== 'not charging');
|
||||||
|
const dockTone = docked ? 'bg-emerald-500/80 text-emerald-50' : 'bg-slate-700/80 text-slate-200';
|
||||||
|
const chargeTone = charging
|
||||||
|
? 'bg-emerald-500/80 text-emerald-50'
|
||||||
|
: docked
|
||||||
|
? 'bg-amber-400/80 text-amber-950'
|
||||||
|
: 'bg-slate-700/80 text-slate-200';
|
||||||
|
return {
|
||||||
|
docked,
|
||||||
|
charging,
|
||||||
|
dockLabel: docked ? 'Docked' : 'Undocked',
|
||||||
|
chargeLabel: charging ? 'Charging' : 'Not charging',
|
||||||
|
dockTone,
|
||||||
|
chargeTone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBatteryFillClass({ rover }) {
|
||||||
|
const urgentActive = rover?.batteryState?.urgentActive ?? false;
|
||||||
|
const warnActive = rover?.batteryState?.warnActive ?? false;
|
||||||
|
if (urgentActive) return 'bg-red-500/40';
|
||||||
|
if (warnActive) return 'bg-amber-400/40';
|
||||||
|
return 'bg-emerald-400/40';
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoColumn({
|
||||||
|
rover,
|
||||||
|
frame,
|
||||||
|
driverLabel,
|
||||||
|
snapshotFeed,
|
||||||
|
showCountdown = false,
|
||||||
|
countdownText = '—',
|
||||||
|
withDivider = false,
|
||||||
|
showPreview = true,
|
||||||
|
variant = 'stacked',
|
||||||
|
}) {
|
||||||
|
const batteryPercent = getBatteryPercent({ rover, frame });
|
||||||
|
const batteryFillClass = getBatteryFillClass({ rover });
|
||||||
|
const dockStatus = getDockChargeStatus(frame);
|
||||||
|
const isActiveView = variant === 'active';
|
||||||
|
const mapWrapRef = useRef(null);
|
||||||
|
const [mapScale, setMapScale] = useState(1);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!isActiveView) return undefined;
|
||||||
|
const el = mapWrapRef.current;
|
||||||
|
if (!el) return undefined;
|
||||||
|
const baseSize = 240;
|
||||||
|
let raf = null;
|
||||||
|
|
||||||
|
const fit = () => {
|
||||||
|
const width = el.clientWidth;
|
||||||
|
if (!width) {
|
||||||
|
raf = requestAnimationFrame(fit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMapScale(width / baseSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleFit = () => {
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
raf = requestAnimationFrame(fit);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleFit();
|
||||||
|
const ro = new ResizeObserver(scheduleFit);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => {
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
}, [isActiveView]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex min-w-0 flex-1 flex-col gap-4 px-0 py-0 ${withDivider ? 'border-r border-slate-700/60' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col gap-3 text-center">
|
||||||
|
<div className="min-w-0 rounded-lg border border-slate-800/80 bg-slate-900/70 px-3 py-2">
|
||||||
|
<AutoFitText className="font-semibold leading-tight text-white" maxSize={64} minSize={18}>
|
||||||
|
{rover.name || rover.id}
|
||||||
|
</AutoFitText>
|
||||||
|
</div>
|
||||||
|
{driverLabel ? (
|
||||||
|
<div className="min-w-0 rounded-lg border border-slate-800/80 bg-slate-900/70 px-3 py-2">
|
||||||
|
<AutoFitText className="font-semibold leading-tight text-slate-200" maxSize={52} minSize={16}>
|
||||||
|
{driverLabel}
|
||||||
|
</AutoFitText>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="relative min-w-0 overflow-hidden rounded-lg border border-slate-800/80 bg-slate-900/70 px-3 py-2">
|
||||||
|
<div
|
||||||
|
className={`absolute inset-y-0 left-0 ${batteryFillClass}`}
|
||||||
|
style={{ width: `${batteryPercent == null ? 0 : batteryPercent}%` }}
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<AutoFitText className="font-semibold leading-tight text-slate-200" maxSize={48} minSize={16}>
|
||||||
|
{batteryPercent == null ? 'Battery --%' : `Battery ${batteryPercent}%`}
|
||||||
|
</AutoFitText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-lg border border-slate-800/80">
|
||||||
|
<div className="grid grid-cols-2 text-center text-[clamp(0.85rem,2.4vw,2rem)] font-semibold uppercase tracking-wide">
|
||||||
|
<div className={`${dockStatus.dockTone} px-2 py-2`}>{dockStatus.dockLabel}</div>
|
||||||
|
<div className={`${dockStatus.chargeTone} px-2 py-2`}>{dockStatus.chargeLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||||
|
{isActiveView ? (
|
||||||
|
<div className="relative mt-auto w-full min-w-0">
|
||||||
|
<div ref={mapWrapRef} className="flex w-full items-end justify-center">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
width: '240px',
|
||||||
|
height: '240px',
|
||||||
|
transformOrigin: 'bottom center',
|
||||||
|
transform: `scale(${mapScale})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TopDownMap sensors={frame?.sensors} size={240} overlay />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showCountdown ? (
|
||||||
|
<div className="absolute bottom-0 right-0 pb-1 pr-1 text-right">
|
||||||
|
<AutoFitText className="font-semibold leading-tight text-slate-200" maxSize={72} minSize={18}>
|
||||||
|
{countdownText}
|
||||||
|
</AutoFitText>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full min-w-0 flex-col items-center gap-4">
|
||||||
|
{showPreview ? (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="w-full aspect-[4/3]">
|
||||||
|
<VideoTile
|
||||||
|
sessionInfo={null}
|
||||||
|
videoMode="snapshot"
|
||||||
|
snapshotFeed={snapshotFeed}
|
||||||
|
audioSessionInfo={null}
|
||||||
|
label={rover.name || rover.id}
|
||||||
|
telemetryFrame={frame}
|
||||||
|
batteryConfig={rover.battery}
|
||||||
|
layoutFormat="mobile"
|
||||||
|
hudVariant="none"
|
||||||
|
fitParent
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex w-full items-center justify-center">
|
||||||
|
<div style={{ width: '240px', height: '240px', transform: 'scale(0.78)', transformOrigin: 'center' }}>
|
||||||
|
<TopDownMap sensors={frame?.sensors} size={240} overlay />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showCountdown ? (
|
||||||
|
<div className="flex min-w-0 items-center justify-center text-center">
|
||||||
|
<AutoFitText className="font-semibold leading-tight text-slate-200" maxSize={72} minSize={18}>
|
||||||
|
{countdownText}
|
||||||
|
</AutoFitText>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutoFitText({ children, className = '', maxSize = 64, minSize = 14 }) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const textRef = useRef(null);
|
||||||
|
const [fontSize, setFontSize] = useState(maxSize);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const textEl = textRef.current;
|
||||||
|
if (!container || !textEl) return undefined;
|
||||||
|
|
||||||
|
let raf = null;
|
||||||
|
const fit = () => {
|
||||||
|
const width = container.clientWidth;
|
||||||
|
if (!width) {
|
||||||
|
scheduleFit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let low = minSize;
|
||||||
|
let high = maxSize;
|
||||||
|
let best = minSize;
|
||||||
|
while (low <= high) {
|
||||||
|
const mid = Math.floor((low + high) / 2);
|
||||||
|
textEl.style.fontSize = `${mid}px`;
|
||||||
|
const fits = textEl.scrollWidth <= width;
|
||||||
|
if (fits) {
|
||||||
|
best = mid;
|
||||||
|
low = mid + 1;
|
||||||
|
} else {
|
||||||
|
high = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFontSize(best);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleFit = () => {
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
raf = requestAnimationFrame(fit);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleFit();
|
||||||
|
const ro = new ResizeObserver(scheduleFit);
|
||||||
|
ro.observe(container);
|
||||||
|
return () => {
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
}, [children, maxSize, minSize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="w-full min-w-0">
|
||||||
|
<div ref={textRef} className={`whitespace-nowrap ${className}`} style={{ fontSize: `${fontSize}px` }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MiniSummaryContent() {
|
function MiniSummaryContent() {
|
||||||
@@ -62,14 +285,17 @@ function MiniSummaryContent() {
|
|||||||
const frames = useTelemetryFrames();
|
const frames = useTelemetryFrames();
|
||||||
const roster = session?.roster ?? [];
|
const roster = session?.roster ?? [];
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
|
const [rotationBase, setRotationBase] = useState(Date.now());
|
||||||
|
const [nowTick, setNowTick] = useState(Date.now());
|
||||||
const activeDrivers = session?.activeDrivers || {};
|
const activeDrivers = session?.activeDrivers || {};
|
||||||
const driverRoster = useMemo(
|
const driverRoster = useMemo(
|
||||||
() => roster.filter((rover) => activeDrivers[rover.id]),
|
() => roster.filter((rover) => activeDrivers[rover.id]),
|
||||||
[roster, activeDrivers],
|
[roster, activeDrivers],
|
||||||
);
|
);
|
||||||
|
const snapshotRoster = driverRoster.length ? driverRoster : roster;
|
||||||
|
|
||||||
const snapshotFeeds = useRoverSnapshots(
|
const snapshotFeeds = useRoverSnapshots(
|
||||||
driverRoster.map((rover) => rover.id),
|
snapshotRoster.map((rover) => rover.id),
|
||||||
{ enabled: !inLockdown, version: session?.mode },
|
{ enabled: !inLockdown, version: session?.mode },
|
||||||
);
|
);
|
||||||
const audioEntries = useMemo(
|
const audioEntries = useMemo(
|
||||||
@@ -103,16 +329,25 @@ function MiniSummaryContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIndex(0);
|
setIndex(0);
|
||||||
|
setRotationBase(Date.now());
|
||||||
}, [rotationKey]);
|
}, [rotationKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!rotationPool.length) return undefined;
|
if (!rotationPool.length) return undefined;
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
|
setRotationBase(Date.now());
|
||||||
setIndex((prev) => (prev + 1) % rotationPool.length);
|
setIndex((prev) => (prev + 1) % rotationPool.length);
|
||||||
}, ROTATE_MS);
|
}, ROTATE_MS);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [rotationPool.length, rotationKey]);
|
}, [rotationPool.length, rotationKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setNowTick(Date.now());
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const activeEntry = rotationPool.length ? rotationPool[index % rotationPool.length] : null;
|
const activeEntry = rotationPool.length ? rotationPool[index % rotationPool.length] : null;
|
||||||
const activeRover = activeEntry?.type === 'rover' ? activeEntry.rover : null;
|
const activeRover = activeEntry?.type === 'rover' ? activeEntry.rover : null;
|
||||||
|
|
||||||
@@ -120,6 +355,9 @@ function MiniSummaryContent() {
|
|||||||
const activeAudio = activeRover ? audioSources[`${activeRover.id}-audio`] || null : null;
|
const activeAudio = activeRover ? audioSources[`${activeRover.id}-audio`] || null : null;
|
||||||
const activeFrame = activeRover ? frames[activeRover.id] || null : null;
|
const activeFrame = activeRover ? frames[activeRover.id] || null : null;
|
||||||
const driverLabel = activeRover ? formatDriverLabel({ roverId: activeRover.id, session }) : null;
|
const driverLabel = activeRover ? formatDriverLabel({ roverId: activeRover.id, session }) : null;
|
||||||
|
const rotationRemainingMs =
|
||||||
|
rotationPool.length > 1 ? Math.max(0, rotationBase + ROTATE_MS - nowTick) : null;
|
||||||
|
const rotationCountdown = rotationRemainingMs != null ? formatDuration(rotationRemainingMs) : '—';
|
||||||
|
|
||||||
if (inLockdown) {
|
if (inLockdown) {
|
||||||
return (
|
return (
|
||||||
@@ -132,6 +370,33 @@ function MiniSummaryContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!driverRoster.length) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-screen w-screen overflow-hidden bg-black text-slate-100">
|
||||||
|
<section className="flex h-full w-full gap-x-1">
|
||||||
|
{roster.length ? (
|
||||||
|
roster.map((rover, idx) => (
|
||||||
|
<InfoColumn
|
||||||
|
key={rover.id}
|
||||||
|
rover={rover}
|
||||||
|
frame={frames[rover.id] || null}
|
||||||
|
driverLabel={null}
|
||||||
|
snapshotFeed={snapshotFeeds[rover.id] || null}
|
||||||
|
showCountdown={false}
|
||||||
|
showPreview={true}
|
||||||
|
withDivider={false}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
|
||||||
|
No rovers available.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-screen w-screen overflow-hidden bg-black text-slate-100">
|
<div className="relative flex h-screen w-screen overflow-hidden bg-black text-slate-100">
|
||||||
<section
|
<section
|
||||||
@@ -153,11 +418,7 @@ function MiniSummaryContent() {
|
|||||||
telemetryFrame={activeFrame}
|
telemetryFrame={activeFrame}
|
||||||
batteryConfig={activeRover.battery}
|
batteryConfig={activeRover.battery}
|
||||||
layoutFormat="mobile"
|
layoutFormat="mobile"
|
||||||
hudVariant="spectator"
|
hudVariant="none"
|
||||||
driverLabel={driverLabel}
|
|
||||||
hudLabelScale={7}
|
|
||||||
hudForceMap
|
|
||||||
hudMapPosition="bottom-left"
|
|
||||||
fitParent
|
fitParent
|
||||||
/>
|
/>
|
||||||
</FitViewportFrame>
|
</FitViewportFrame>
|
||||||
@@ -167,38 +428,21 @@ function MiniSummaryContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<aside className="flex h-full min-w-0 flex-1 flex-col border-l border-slate-800/60 bg-slate-950/90">
|
<aside className="flex h-full min-w-0 flex-1 flex-col border-l border-slate-800/60 bg-black">
|
||||||
{roster.length ? (
|
{activeRover ? (
|
||||||
roster.map((rover) => {
|
<InfoColumn
|
||||||
const frame = frames[rover.id] || null;
|
rover={activeRover}
|
||||||
const driverText = formatDriverLabel({ roverId: rover.id, session });
|
frame={activeFrame}
|
||||||
const batteryVisual = getBatteryVisual({ rover, frame });
|
driverLabel={driverLabel}
|
||||||
const isActive = activeRover?.id === rover.id;
|
snapshotFeed={snapshotFeeds[activeRover.id] || null}
|
||||||
return (
|
showCountdown={true}
|
||||||
<div
|
countdownText={rotationCountdown}
|
||||||
key={rover.id}
|
showPreview={false}
|
||||||
className={`relative flex min-h-0 flex-1 flex-col justify-center gap-2 overflow-hidden px-6 py-4 ${
|
variant="active"
|
||||||
isActive ? 'border-4 border-emerald-300/90 bg-emerald-300/10' : 'opacity-60'
|
/>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`absolute inset-y-0 left-0 ${batteryVisual.barClass}`}
|
|
||||||
style={{ width: `${batteryVisual.percent * 100}%` }}
|
|
||||||
/>
|
|
||||||
<div className="relative flex flex-col gap-1">
|
|
||||||
<div className={`text-6xl font-semibold tracking-wide ${isActive ? 'text-white' : 'text-slate-200'}`}>
|
|
||||||
{rover.name || rover.id}
|
|
||||||
</div>
|
|
||||||
<div className={`text-5xl font-semibold ${isActive ? 'text-white' : 'text-slate-300'}`}>
|
|
||||||
{driverText}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
|
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
|
||||||
No rovers available.
|
No active rover.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
Reference in New Issue
Block a user