This commit is contained in:
legop3
2026-05-13 19:55:55 -04:00
parent b38a81d002
commit 36a6ee012c
32 changed files with 898 additions and 933 deletions
@@ -0,0 +1,31 @@
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import LightBumpBars from '../LightBumpBars/index.jsx';
import { buildBatteryVisual } from '../../../lib/battery.js';
import BatteryBar from '../../BatteryBar/index.jsx';
export default function DriverBottomStrip({ roverId = null, mobileHud = false }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const sensors = frame?.sensors ?? null;
const batteryConfig = useSessionSelector((state) => {
if (!effectiveRoverId) return null;
const roster = state.session?.roster || [];
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
return rover?.battery ?? null;
});
const batteryVisual = buildBatteryVisual({
charge: sensors?.batteryChargeMah ?? null,
config: batteryConfig,
});
return (
<div className="space-y-0.5">
<LightBumpBars roverId={effectiveRoverId} />
<div className="panel-section space-y-0.5 text-sm">
<BatteryBar visual={batteryVisual} compact={mobileHud} />
</div>
</div>
);
}
@@ -0,0 +1,103 @@
// Hud Chat Input
// Purpose: Defines the Hud Chat Input module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { memo, useMemo, useState } from 'react';
import { useChat } from '../../../context/ChatContext.jsx';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../../settings/index.js';
function HudChatInput({ compact = false }) {
const role = useSessionSelector((state) => state.session?.role || null);
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
const roverRoster = useSessionSelector((state) => state.session?.roster || []);
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChat();
const { value: ttsSettings } = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 });
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const canChat = role !== 'spectator';
const hideHudChat = role === 'spectator';
const rover = useMemo(
() => roverRoster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, roverRoster],
);
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
const ttsPayload = useMemo(() => {
if (!ttsSupported) return null;
const engine = ttsSettings?.engine === 'espeak' ? 'espeak' : 'flite';
if (engine === 'espeak') {
let pitch = Number.isFinite(ttsSettings?.pitch) ? Math.round(ttsSettings.pitch) : undefined;
if (typeof pitch === 'number') {
pitch = Math.max(0, Math.min(99, pitch));
}
return { speak: true, engine, pitch };
}
const voice = typeof ttsSettings?.voice === 'string' ? ttsSettings.voice : undefined;
return { speak: true, engine, voice };
}, [ttsSettings?.engine, ttsSettings?.pitch, ttsSettings?.voice, ttsSupported]);
const containerClass = compact
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.4 py-0.2'
: 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25';
const inputClass = compact
? 'min-w-0 flex-1 bg-transparent text-[0.55rem] text-slate-100 placeholder:text-slate-400 focus:outline-none'
: 'min-w-0 flex-1 bg-transparent text-[0.7rem] text-slate-100 placeholder:text-slate-400 focus:outline-none';
const buttonClass = compact
? 'rounded bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50'
: 'rounded bg-cyan-500/80 px-0.5 py-0.25 text-[0.7rem] font-semibold text-black disabled:opacity-50';
async function handleSend(event) {
event.preventDefault();
if (!canChat) return;
const clean = draft.trim();
if (!clean) return;
setSending(true);
try {
await sendMessage(clean, ttsPayload);
setDraft('');
blurChat();
setTypingActive(false);
} catch (err) {
alert(err.message);
} finally {
setSending(false);
}
}
if (hideHudChat) return null;
return (
<form onSubmit={handleSend} className={containerClass}>
<input
className={inputClass}
value={draft}
onChange={(event) => {
const next = event.target.value;
setDraft(next);
setTypingActive(Boolean(next.trim()));
}}
onFocus={(event) => {
onInputFocus(event);
setTypingActive(Boolean(draft.trim()));
}}
onBlur={(event) => {
onInputBlur(event);
setTypingActive(false);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !draft.trim()) {
event.preventDefault();
blurChat();
setTypingActive(false);
}
}}
ref={(el) => registerInputRef(el, { target: 'hud' })}
placeholder={canChat ? 'Chat (TTS)' : 'Spectator'}
disabled={!canChat}
/>
<button type="submit" disabled={!canChat || sending} className={buttonClass}>
Speak
</button>
</form>
);
}
export default memo(HudChatInput);
@@ -0,0 +1,191 @@
// Hud Overlay
// Purpose: Defines the Hud Overlay module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
import { useHudMapSetting } from '../../../hooks/useHudMapSetting.js';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import TopDownMap from '../../TopDownMap/index.jsx';
import { roverNameChromeStyle } from '../../../lib/roverColor.js';
function HudOverlay({
roverId = null,
sensors,
label,
roverColor = null,
layoutFormat = 'desktop',
variant = 'default',
driverLabel = null,
showTopDown = undefined,
mobileHud = false,
mapPosition = null,
labelScale = 1,
}) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const rosterInfo = useSessionSelector((state) => {
if (!effectiveRoverId) return { label: null, roverColor: null };
const roster = state.session?.roster || [];
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
return {
label: rover?.name || null,
roverColor: rover?.color || null,
};
});
const derivedDriverLabel = useSessionSelector((state) => {
if (!effectiveRoverId || variant !== 'spectator') return null;
const activeId = state.session?.activeDrivers?.[effectiveRoverId] || null;
const users = state.session?.users || [];
const match = users.find((u) => String(u.socketId || '') === String(activeId || ''));
return match?.nickname || match?.name || null;
});
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const resolvedLabel = label ?? rosterInfo.label ?? null;
const resolvedRoverColor = roverColor ?? rosterInfo.roverColor ?? null;
const resolvedDriverLabel = driverLabel ?? derivedDriverLabel;
const isMobile = mobileHud;
const [showHudMapDesktop] = useHudMapSetting();
const resolvedShowTopDown =
typeof showTopDown === 'boolean'
? showTopDown
: variant === 'spectator'
? true
: isMobile
? true
: showHudMapDesktop;
const resolvedMapPosition =
mapPosition || (variant === 'spectator' ? 'top-center' : isMobile ? 'top-right' : 'top-center');
const portraitMobile = layoutFormat === 'mobile-portrait';
const statusPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-1 py-0.5';
const labelPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-0.5 py-0.5';
const labelTextClass = isMobile ? 'text-[0.55rem]' : 'text-[0.8rem]';
const telemetryPosClass = isMobile ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
const labelPosClass = isMobile ? 'bottom-0.5' : 'bottom-0.5';
const labelWrapperStyle = {
transform: `translateX(-50%) scale(${labelScale})`,
transformOrigin: 'center bottom',
};
const mapSize = '240px';
const mapScale = portraitMobile ? 0.3 : isMobile ? 0.33 : 0.7;
const mapOpacity = isMobile ? 0.6 : 0.7;
const mapStyle = {
width: mapSize,
height: mapSize,
opacity: mapOpacity,
transform: resolvedMapPosition === 'top-center' ? `translateX(-50%) scale(${mapScale})` : `scale(${mapScale})`,
transformOrigin:
resolvedMapPosition === 'bottom-left'
? 'bottom left'
: resolvedMapPosition === 'top-center'
? 'top center'
: 'top right',
...(resolvedMapPosition === 'bottom-left'
? { left: '0.25rem', bottom: '0.25rem' }
: resolvedMapPosition === 'top-center'
? { left: '50%', top: '0.25rem' }
: { right: '0.25rem', top: '0.25rem' }),
};
if (variant === 'none') {
return null;
}
if (variant === 'spectator') {
const telemetryEntries = [
['Voltage', resolvedSensors?.voltageMv != null ? `${(resolvedSensors.voltageMv / 1000).toFixed(2)} V` : '--'],
['Current', resolvedSensors?.currentMa != null ? `${resolvedSensors.currentMa} mA` : '--'],
['Charge', resolvedSensors?.batteryChargeMah != null ? `${resolvedSensors.batteryChargeMah}` : '--'],
['OI', resolvedSensors?.oiMode?.label || '--'],
];
const docked = Boolean(resolvedSensors?.chargingSources?.homeBase);
const chargingLabel = resolvedSensors?.chargingState?.label || '';
const charging = Boolean(chargingLabel && chargingLabel.toLowerCase() !== 'not charging');
const oiLabel = resolvedSensors?.oiMode?.label || 'Unknown';
const oiNormalized = oiLabel.toLowerCase();
const oiTone =
oiNormalized === 'full'
? 'bg-emerald-500/80 text-emerald-50'
: oiNormalized === 'safe'
? 'bg-amber-400/80 text-amber-950'
: oiNormalized === 'passive'
? 'bg-slate-700/80 text-slate-100'
: 'bg-slate-700/60 text-slate-200';
const dockTone = docked ? 'bg-emerald-500/80 text-emerald-50' : 'bg-slate-700/70 text-slate-200';
const chargingTone = charging
? 'bg-emerald-500/80 text-emerald-50'
: docked
? 'bg-amber-400/80 text-amber-950'
: 'bg-slate-700/70 text-slate-200';
return (
<>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.5 bg-black/70 text-slate-100 ${isMobile ? 'text-[0.45rem]' : 'text-[0.65rem]'} ${statusPadClass}`}
>
<div className="space-y-0.5 leading-tight">
<div className="flex flex-col gap-0.5 text-[0.75rem] font-semibold uppercase tracking-wide">
<span className={`rounded px-1.5 py-0.5 ${dockTone}`}>{docked ? 'Docked' : 'Undocked'}</span>
<span className={`rounded px-1.5 py-0.5 ${chargingTone}`}>
{charging ? 'Charging' : docked ? 'Not charging' : 'Not charging'}
</span>
<span className={`rounded px-1.5 py-0.5 ${oiTone}`}>OI: {oiLabel}</span>
</div>
{telemetryEntries.map(([labelText, value]) => (
<span key={labelText} className="flex items-center justify-between gap-0.5">
<span className="text-slate-400">{labelText}</span>
<span className="font-semibold text-white">{value}</span>
</span>
))}
</div>
</div>
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
<div
className={`flex items-center gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
>
<span
className="font-semibold text-white rounded px-1 py-[1px] border border-transparent"
style={roverNameChromeStyle(resolvedRoverColor, 0.18)}
>
{resolvedLabel || 'Unnamed Rover'}
</span>
{resolvedDriverLabel ? <span className="text-slate-300"> {resolvedDriverLabel}</span> : null}
</div>
</div>
</div>
{resolvedShowTopDown ? (
<div className="pointer-events-none absolute rounded" style={{ ...mapStyle }}>
<TopDownMap sensors={resolvedSensors} size={240} overlay />
</div>
) : null}
</>
);
}
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
<div className={`flex gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
<span>
Rover:{' '}
<span
className="rounded px-1 py-[1px] border border-transparent"
style={roverNameChromeStyle(resolvedRoverColor, 0.18)}
>
"{resolvedLabel || 'Unnamed Rover'}"
</span>
</span>
</div>
</div>
{resolvedShowTopDown && variant !== 'spectator' ? (
<div className="pointer-events-none absolute rounded" style={{ ...mapStyle }}>
<TopDownMap sensors={resolvedSensors} size={240} overlay />
</div>
) : null}
</div>
);
}
export default React.memo(HudOverlay);
@@ -0,0 +1,57 @@
// Light Bump Bars
// Purpose: Defines the Light Bump Bars module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
function LightBumpBars({ roverId = null, sensors }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const values = [
resolvedSensors?.lightBumpLeftSignal,
resolvedSensors?.lightBumpFrontLeftSignal,
resolvedSensors?.lightBumpCenterLeftSignal,
resolvedSensors?.lightBumpCenterRightSignal,
resolvedSensors?.lightBumpFrontRightSignal,
resolvedSensors?.lightBumpRightSignal,
];
const max = values.filter((v) => v != null).reduce((acc, v) => Math.max(acc, v), 1200);
const eased = (v) => Math.pow(Math.max(0, Math.min(1, (v ?? 0) / max)), 0.35);
const hueFor = (v) => {
if (v == null || v <= 0) return 'hsl(200 60% 18%)';
const h = (200 + eased(v) * 360) % 360;
return `hsl(${h} 100% 60%)`;
};
const segments = 6;
const barHeight = 12;
return (
<div className="flex w-full items-center justify-center gap-0.5">
{values.map((v, idx) => {
const t = eased(v);
const dir = idx < segments / 2 ? -1 : 1;
const fill = `${t * 100}%`;
const color = hueFor(v);
return (
<div key={idx} className="relative flex-1 min-w-[0]" style={{ height: `${barHeight}px` }}>
<div className="h-full w-full overflow-hidden bg-slate-800" style={{ borderRadius: 0 }}>
<div
className="h-full"
style={{
width: fill,
background: color,
float: dir === -1 ? 'right' : 'left',
}}
/>
</div>
</div>
);
})}
</div>
);
}
export default React.memo(LightBumpBars);
@@ -0,0 +1,46 @@
// Low Battery Overlay
// Purpose: Defines the Low Battery Overlay module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import { buildBatteryVisual } from '../../../lib/battery.js';
function LowBatteryOverlay({ roverId = null, sensors, batteryConfig, compact = false }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const rosterBatteryConfig = useSessionSelector((state) => {
if (!effectiveRoverId) return null;
const roster = state.session?.roster || [];
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
return rover?.battery ?? null;
});
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const resolvedBatteryConfig = batteryConfig ?? rosterBatteryConfig;
const battery = buildBatteryVisual({
charge: resolvedSensors?.batteryChargeMah ?? null,
config: resolvedBatteryConfig,
});
if (!battery?.available) return null;
if (!battery.warnActive && !battery.urgentActive) return null;
const message = battery.urgentActive
? 'BATTERY VERY LOW, DOCK THE ROVER AND CHARGE IMMEDIATELY!!'
: 'Battery low! please dock and charge the rover soon.';
const containerClass = compact ? 'p-2 top-6' : 'p-4 top-10';
const textClass = compact ? 'text-sm' : 'text-2xl';
return (
<div
className={`pointer-events-none absolute flex items-center justify-center bg-amber-900/60 left-1/2 -translate-x-1/2 ${containerClass}`}
>
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
<div>{message}</div>
</div>
</div>
);
}
export default React.memo(LowBatteryOverlay);
@@ -0,0 +1,7 @@
export const OVERCURRENT_LABELS = {
leftWheel: 'Left wheel',
rightWheel: 'Right wheel',
mainBrush: 'Main brush',
sideBrush: 'Side brush',
limiter: 'Overcurrent limit',
};
@@ -0,0 +1,65 @@
// Overcurrent Overlay
// Purpose: Defines the Overcurrent Overlay module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
import { useMemo } from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import { OVERCURRENT_LABELS } from './constants.js';
function OvercurrentOverlay({ roverId = null, sensors, overcurrentLimiter = null, compact = false }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const resolvedOvercurrentLimiter = overcurrentLimiter ?? null;
const wheelOvercurrents = resolvedSensors?.wheelOvercurrents || null;
const overcurrentMotors = useMemo(
() =>
wheelOvercurrents == null
? []
: Object.entries(wheelOvercurrents)
.filter(([, active]) => Boolean(active))
.map(([key]) => key),
[wheelOvercurrents],
);
const limiterCaps = resolvedOvercurrentLimiter?.caps || null;
const limiterFill = useMemo(() => {
if (!limiterCaps) return null;
const driveCap = Number.isFinite(limiterCaps?.drive?.cap) ? limiterCaps.drive.cap : 1;
const auxCap = Number.isFinite(limiterCaps?.aux?.cap) ? limiterCaps.aux.cap : 1;
return Math.max(0, Math.min(1, 1 - Math.min(driveCap, auxCap)));
}, [limiterCaps]);
const limiterActive = Boolean(resolvedOvercurrentLimiter?.isActive);
const motors = useMemo(
() => (overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : []),
[overcurrentMotors, limiterActive],
);
const fill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
if (!motors?.length) return null;
const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]';
const padClass = compact ? 'px-2 py-1' : 'px-4 py-2';
const textClass = compact ? 'text-lg' : 'text-4xl';
const subTextClass = compact ? 'text-xs' : 'text-xl';
const safeFill = Math.max(0, Math.min(1, fill));
const fillWidth = `${Math.round(safeFill * 100)}%`;
return (
<div
className={`pointer-events-none absolute flex items-center justify-center bg-red-900/50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`}
>
<div className="relative h-full w-full">
<div className="absolute inset-0 overflow-hidden">
<div className="h-full bg-red-700/60" style={{ width: fillWidth }} />
</div>
<div className={`relative z-10 flex h-full w-full flex-col items-center justify-center text-center font-semibold text-white animate-pulse ${textClass} ${padClass}`}>
<div>OVERCURRENT</div>
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
</div>
</div>
</div>
);
}
export default React.memo(OvercurrentOverlay);
@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
const DISMISS_AFTER_INPUT_MS = 5000;
const LARGE_FADE_MS = 700;
export default function RoverDescriptionOverlay({
roverId = null,
description,
variant = 'default',
mobileHud = false,
displayKey = '',
controlIntentAt,
}) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const rosterDescription = useSessionSelector((state) => {
if (!effectiveRoverId) return null;
const roster = state.session?.roster || [];
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
return rover?.description || null;
});
const resolvedDescription = description ?? rosterDescription;
const resolvedControlIntentAt =
typeof controlIntentAt === 'number' ? controlIntentAt : 0;
const resolvedDisplayKey =
displayKey || `${effectiveRoverId || ''}::${resolvedDescription || ''}`;
const [largeVisible, setLargeVisible] = useState(false);
const [largeFading, setLargeFading] = useState(false);
const baselineIntentRef = useRef(0);
const fadeTimerRef = useRef(null);
const hideTimerRef = useRef(null);
useEffect(
() => () => {
clearTimeout(fadeTimerRef.current);
clearTimeout(hideTimerRef.current);
},
[],
);
useEffect(() => {
if (variant !== 'default' || !resolvedDescription) {
setLargeVisible(false);
setLargeFading(false);
clearTimeout(fadeTimerRef.current);
clearTimeout(hideTimerRef.current);
return undefined;
}
clearTimeout(fadeTimerRef.current);
clearTimeout(hideTimerRef.current);
setLargeVisible(true);
setLargeFading(false);
baselineIntentRef.current = Number(resolvedControlIntentAt) || 0;
return undefined;
}, [resolvedDescription, resolvedDisplayKey, resolvedControlIntentAt, variant]);
useEffect(() => {
if (variant !== 'default' || !resolvedDescription || !largeVisible || largeFading) return;
const nextIntent = Number(resolvedControlIntentAt) || 0;
if (nextIntent <= baselineIntentRef.current) return;
if (fadeTimerRef.current || hideTimerRef.current) return;
fadeTimerRef.current = setTimeout(() => {
setLargeFading(true);
fadeTimerRef.current = null;
}, DISMISS_AFTER_INPUT_MS);
hideTimerRef.current = setTimeout(() => {
setLargeVisible(false);
hideTimerRef.current = null;
}, DISMISS_AFTER_INPUT_MS + LARGE_FADE_MS);
}, [resolvedControlIntentAt, resolvedDescription, largeFading, largeVisible, variant]);
if (!resolvedDescription) return null;
if (variant === 'spectator') {
return (
<div className="pointer-events-none absolute inset-x-0 top-1 z-50 flex justify-center">
<div className="surface max-w-[92%] border border-slate-600/80 px-1 py-0.5 text-center text-[0.62rem] leading-tight text-slate-100 shadow-lg">
{resolvedDescription}
</div>
</div>
);
}
if (variant !== 'default' || !largeVisible) return null;
return (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center">
<div
className={`surface max-w-[94%] border border-slate-400 bg-neutral-900 px-2 py-1 text-center font-semibold text-slate-100 shadow-xl transition-opacity duration-700 ${
largeFading ? 'opacity-0' : 'opacity-100'
} ${mobileHud ? 'text-[1rem] leading-tight' : 'text-[1.5rem] leading-tight'}`}
>
<p>Just so you know, this rover</p>
<p>{resolvedDescription}</p>
<p className={`${mobileHud ? 'text-[0.58rem]' : 'text-[0.72rem]'} mt-0.5 font-normal text-slate-300`}>
This fades 5 seconds after your first control input.
</p>
</div>
</div>
);
}
@@ -0,0 +1,230 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useControlSystem } from '../../../controls/index.js';
import SocialButton from '../../SocialButton/index.jsx';
function TurnsOverlay({
roverId = null,
mobileHud = false,
discordUrl: discordUrlProp = null,
}) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const mode = useSessionSelector((state) => state.session?.mode || null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const users = useSessionSelector((state) => state.session?.users ?? []);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
const discordUrl = useSessionSelector((state) => {
const socials = state.session?.socials || [];
const socialUrl =
socials.find((entry) => {
const key = String(entry?.id || entry?.label || '').toLowerCase();
return key === 'discord';
})?.url || null;
return socialUrl || state.session?.discord?.invite || null;
});
const {
state: { lastControlIntentAt },
} = useControlSystem();
const effectiveDiscordUrl = discordUrlProp || discordUrl;
const [now, setNow] = useState(() => Date.now());
const [showTurnCue, setShowTurnCue] = useState(false);
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
const [noticeFlashActive, setNoticeFlashActive] = useState(false);
const [notTurnFlashAt, setNotTurnFlashAt] = useState(0);
const lastTurnRef = useRef({ initialized: false, roverId: null, activeDriverId: null });
const lastIntentRef = useRef(lastControlIntentAt || 0);
const timerTextClass = mobileHud ? 'text-[0.5rem]' : 'text-[0.7rem]';
const timerPadClass = mobileHud ? 'px-0.5 py-0.25' : 'px-1 py-0.5';
const titleClass = mobileHud ? 'text-3xl' : 'text-5xl';
const subClass = mobileHud ? 'text-xs' : 'text-sm';
const cueTimerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
const cuePadClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
const turnInfo = effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
const activeDriverId = effectiveRoverId ? activeDrivers?.[effectiveRoverId] : null;
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
const nextDriverId = useMemo(() => {
const queue = turnInfo?.queue || [];
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
const idx = queue.findIndex((id) => id === turnInfo.current);
if (idx === -1) return queue[0] || null;
return queue[(idx + 1) % queue.length] || null;
}, [turnInfo?.queue, turnInfo?.current]);
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
const deadline = turnInfo?.deadline || null;
const idleDeadline = turnInfo?.idleDeadline || null;
const msUntilTurn = deadline ? deadline - now : null;
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
const isTurnsMode = mode === 'turns';
const totalRovers = roster.length;
const totalDrivers = useMemo(() => {
const unique = new Set();
users.forEach((entry) => {
const role = String(entry?.role || '');
if (role === 'spectator') return;
const turnRoverId = String(entry?.roverId || '').trim();
const turnSocketId = String(entry?.socketId || '').trim();
if (!turnRoverId || !turnSocketId) return;
unique.add(turnSocketId);
});
return unique.size;
}, [users]);
const shouldUsePreviewByLoad = isTurnsMode && totalDrivers > totalRovers;
const isPreSwitchWindow =
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
const showPreviewReason = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
const turnSeconds =
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
const idleSkipSeconds =
msUntilIdleSkip != null && Number.isFinite(msUntilIdleSkip)
? Math.max(0, Math.ceil(msUntilIdleSkip / 1000))
: null;
const turnTimerText = useMemo(() => {
if (!isTurnsMode || !isActiveDriver) return null;
return turnSeconds != null ? `${turnSeconds}s left` : 'Your turn';
}, [isTurnsMode, isActiveDriver, turnSeconds]);
const notTurnCountdownText = useMemo(() => {
if (!showNotTurnNotice || !isNextDriver || turnSeconds == null) return null;
return `${turnSeconds} seconds until your turn.`;
}, [showNotTurnNotice, isNextDriver, turnSeconds]);
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
const turnTimerFlashActive = noticeFlashActive;
useEffect(() => {
if (mode !== 'turns') return undefined;
const timer = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(timer);
}, [mode]);
useEffect(() => {
if (mode !== 'turns') {
setShowTurnCue(false);
setTurnCueStartAt(null);
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
return;
}
const lastTurn = lastTurnRef.current;
const nextActiveDriverId = activeDriverId || null;
if (!socketId || !effectiveRoverId) {
setShowTurnCue(false);
setTurnCueStartAt(null);
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
return;
}
if (!lastTurn.initialized || lastTurn.roverId !== effectiveRoverId) {
lastTurnRef.current = { initialized: true, roverId: effectiveRoverId, activeDriverId: nextActiveDriverId };
return;
}
const becameActive =
Boolean(lastTurn.activeDriverId) &&
lastTurn.activeDriverId !== socketId &&
nextActiveDriverId === socketId;
if (becameActive) {
setShowTurnCue(true);
setTurnCueStartAt(Date.now());
} else if (nextActiveDriverId !== socketId && showTurnCue) {
setShowTurnCue(false);
setTurnCueStartAt(null);
}
lastTurnRef.current = { initialized: true, roverId: effectiveRoverId, activeDriverId: nextActiveDriverId };
}, [activeDriverId, mode, effectiveRoverId, socketId, showTurnCue]);
useEffect(() => {
if (!showTurnCue || !turnCueStartAt) return;
if (lastControlIntentAt > turnCueStartAt) {
setShowTurnCue(false);
}
}, [lastControlIntentAt, showTurnCue, turnCueStartAt]);
useEffect(() => {
const lastIntent = Number(lastIntentRef.current) || 0;
const nextIntent = Number(lastControlIntentAt) || 0;
if (nextIntent > lastIntent && showNotTurnNotice) {
setNotTurnFlashAt(Date.now());
}
lastIntentRef.current = nextIntent;
}, [lastControlIntentAt, showNotTurnNotice]);
useEffect(() => {
if (!showNotTurnNotice || !notTurnFlashAt) return undefined;
setNoticeFlashActive(true);
const timer = setTimeout(() => setNoticeFlashActive(false), 650);
return () => clearTimeout(timer);
}, [showNotTurnNotice, notTurnFlashAt]);
return (
<>
{showTurnCue ? (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/55">
<div
className={`flex flex-col items-center gap-0.5 rounded border border-amber-300/80 bg-black/70 ${cuePadClass}`}
>
<div className={`font-semibold text-amber-200 ${titleClass}`}>IT IS YOUR TURN!</div>
<div className={`text-amber-200/80 ${subClass}`}>Start driving!</div>
{showCountdown ? (
<div className={`text-red-100/90 ${cueTimerClass}`}>
Idle skip in {idleSkipSeconds}s
</div>
) : null}
</div>
</div>
) : null}
{turnTimerText ? (
<div
className={`pointer-events-none absolute bottom-1 left-1 rounded border ${
turnTimerFlashActive
? 'border-red-300/90 bg-red-900/80 text-red-100'
: 'border-amber-300/80 bg-black/75 text-amber-200'
} ${timerPadClass} ${timerTextClass}`}
>
{turnTimerText}
</div>
) : null}
{showNotTurnNotice ? (
<div className="pointer-events-none absolute bottom-1 left-1 z-40">
<div
className={`w-fit rounded border ${
noticeFlashActive
? 'border-red-300/90 bg-red-900/80 text-red-100'
: 'border-amber-300/80 bg-black/75 text-amber-200'
} ${mobileHud ? 'px-2 py-1 text-[0.6rem]' : 'px-3 py-1.5 text-sm'}`}
>
<div
className={
noticeFlashActive
? 'text-[0.82rem] font-semibold text-red-50'
: 'text-[0.82rem] font-semibold text-white'
}
>
Not your turn to drive!
</div>
{notTurnCountdownText ? (
<div className={noticeFlashActive ? 'text-red-100/95' : 'text-amber-100'}>
{notTurnCountdownText}
</div>
) : null}
{showPreviewReason ? (
<div className={noticeFlashActive ? 'text-red-100/90' : 'text-amber-200/85'}>
Video switched to preview mode to save bandwidth.
</div>
) : null}
<div className="pointer-events-auto mt-0.5">
<SocialButton
id="discord"
label="Join our Discord while you wait!"
url={effectiveDiscordUrl}
/>
</div>
</div>
</div>
) : null}
</>
);
}
export default React.memo(TurnsOverlay);
@@ -0,0 +1,36 @@
import React from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import { buildBatteryVisual } from '../../../lib/battery.js';
import BatteryBar from '../../BatteryBar/index.jsx';
function VerticalBatteryOverlay({ show = false, roverId = null, sensors, batteryConfig, mobileHud = false }) {
const frame = useTelemetryFrame(roverId);
const rosterBatteryConfig = useSessionSelector((state) => {
if (!roverId) return null;
const roster = state.session?.roster || [];
const rover = roster.find((entry) => String(entry.id) === String(roverId));
return rover?.battery ?? null;
});
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const resolvedBatteryConfig = batteryConfig ?? rosterBatteryConfig;
const batteryVisual = buildBatteryVisual({
charge: resolvedSensors?.batteryChargeMah ?? null,
config: resolvedBatteryConfig,
});
if (!show || !batteryVisual?.available) return null;
return (
<div className="pointer-events-none absolute right-1 top-1/2 flex h-[70%] -translate-y-1/2 flex-col items-center justify-center rounded bg-black/60 px-0.5 pb-1 pt-1">
<BatteryBar
visual={batteryVisual}
orientation="vertical"
variant="inline"
compact={mobileHud}
className="h-full w-4"
/>
</div>
);
}
export default React.memo(VerticalBatteryOverlay);