mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
awa
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user