mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
new battery bars!!!
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { WARN_DISPLAY_PERCENT } from '../lib/battery.js';
|
||||
|
||||
const WARN_FLASH_MS = 1600;
|
||||
const URGENT_FLASH_MS = 800;
|
||||
|
||||
function classNames(...values) {
|
||||
return values.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export default function BatteryBar({
|
||||
visual,
|
||||
orientation = 'horizontal',
|
||||
variant = 'inline',
|
||||
compact = false,
|
||||
showLabel,
|
||||
className = '',
|
||||
}) {
|
||||
const isBackground = variant === 'background';
|
||||
const isVertical = orientation === 'vertical';
|
||||
const resolvedShowLabel = showLabel ?? !isBackground;
|
||||
|
||||
if (!visual?.available) {
|
||||
if (isBackground) {
|
||||
const warnPercent = WARN_DISPLAY_PERCENT;
|
||||
const markerClass = classNames(
|
||||
'absolute z-10',
|
||||
isVertical ? 'left-0 right-0 h-[2px]' : 'top-0 bottom-0 w-[2px]',
|
||||
'bg-black/70',
|
||||
isWarn && !isUrgent && 'battery-tick-warn',
|
||||
);
|
||||
const markerStyle = isVertical ? { bottom: `${warnPercent}%` } : { left: `${warnPercent}%` };
|
||||
const containerClass = classNames('absolute inset-0 pointer-events-none', className);
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
<div className="absolute inset-0 bg-slate-900/35" />
|
||||
<div className={markerClass} style={markerStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isVertical) return null;
|
||||
return <p className="text-xs text-slate-500">Battery telemetry unavailable</p>;
|
||||
}
|
||||
|
||||
const percentDisplay = visual.percentDisplay ?? 0;
|
||||
const percent = Math.max(0, Math.min(100, percentDisplay));
|
||||
const warnPercent = visual.warnDisplayPercent ?? WARN_DISPLAY_PERCENT;
|
||||
|
||||
const isUrgent = Boolean(visual.urgentActive);
|
||||
const isWarn = Boolean(visual.warnActive);
|
||||
const shouldFlash = isUrgent || isWarn;
|
||||
const animationDuration = isUrgent ? `${URGENT_FLASH_MS}ms` : isWarn ? `${WARN_FLASH_MS}ms` : undefined;
|
||||
|
||||
const fillColor = isUrgent || isWarn ? 'bg-red-500' : 'bg-emerald-500';
|
||||
const fillTone = isBackground
|
||||
? isUrgent || isWarn
|
||||
? 'bg-red-500/45'
|
||||
: 'bg-emerald-500/45'
|
||||
: fillColor;
|
||||
const baseTone = isUrgent || isWarn ? 'bg-red-900/35' : 'bg-slate-900/35';
|
||||
const baseToneStrong = isUrgent || isWarn ? 'bg-red-900/45' : 'bg-slate-900/35';
|
||||
|
||||
const containerClass = classNames(
|
||||
isBackground ? 'absolute inset-0 pointer-events-none' : 'relative pointer-events-auto',
|
||||
isVertical && !isBackground ? 'flex flex-col items-center justify-center' : 'w-full',
|
||||
className,
|
||||
);
|
||||
|
||||
if (isBackground) {
|
||||
const fillStyle = isVertical
|
||||
? { height: `${percent}%` }
|
||||
: { width: `${percent}%` };
|
||||
const fillClass = classNames(
|
||||
'absolute',
|
||||
isVertical ? 'bottom-0 left-0 right-0' : 'left-0 top-0 bottom-0',
|
||||
fillTone,
|
||||
'transition-[width,height]',
|
||||
isUrgent && 'battery-urgent-flash',
|
||||
);
|
||||
const markerClass = classNames(
|
||||
'absolute z-10',
|
||||
isVertical ? 'left-0 right-0 h-[2px]' : 'top-0 bottom-0 w-[2px]',
|
||||
'bg-black/70',
|
||||
isWarn && !isUrgent && 'battery-tick-warn',
|
||||
);
|
||||
const markerStyle = isVertical ? { bottom: `${warnPercent}%` } : { left: `${warnPercent}%` };
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
<div className={classNames('absolute inset-0', baseTone, isUrgent && 'battery-urgent-flash')} />
|
||||
<div className={fillClass} style={{ ...fillStyle, animationDuration }} />
|
||||
<div className={markerClass} style={markerStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const barShellClass = classNames(
|
||||
'relative overflow-hidden rounded-sm border border-slate-800',
|
||||
isUrgent || isWarn ? baseToneStrong : 'bg-zinc-900/90',
|
||||
isUrgent && 'battery-urgent-flash',
|
||||
isVertical
|
||||
? 'w-full flex-1'
|
||||
: compact
|
||||
? 'h-4'
|
||||
: 'h-5',
|
||||
);
|
||||
|
||||
const fillStyle = isVertical
|
||||
? { height: `${percent}%` }
|
||||
: { width: `${percent}%` };
|
||||
|
||||
const fillClass = classNames(
|
||||
'absolute',
|
||||
isVertical ? 'bottom-0 left-0 right-0' : 'left-0 top-0 bottom-0',
|
||||
fillTone,
|
||||
'transition-[width,height]',
|
||||
isUrgent && 'battery-urgent-flash',
|
||||
);
|
||||
|
||||
const markerClass = classNames(
|
||||
'absolute z-10',
|
||||
isVertical ? 'left-0 right-0 h-[2px]' : 'top-0 bottom-0 w-[2px]',
|
||||
'bg-black/70',
|
||||
isWarn && !isUrgent && 'battery-tick-warn',
|
||||
);
|
||||
const markerStyle = isVertical ? { bottom: `${warnPercent}%` } : { left: `${warnPercent}%` };
|
||||
|
||||
const labelText = `Battery ${percentDisplay}%`;
|
||||
const labelClass = classNames(
|
||||
'pointer-events-none absolute inset-0 flex items-center justify-center text-center font-semibold',
|
||||
isVertical ? (compact ? 'text-[0.55rem]' : 'text-[0.65rem]') : 'text-xs',
|
||||
);
|
||||
const labelPillClass = classNames(
|
||||
'rounded px-1.5 py-0.5',
|
||||
isBackground ? 'bg-black/30 text-slate-100' : 'bg-black/50 text-slate-100',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
<div className={barShellClass}>
|
||||
<div className={fillClass} style={{ ...fillStyle, animationDuration }} />
|
||||
<div className={markerClass} style={markerStyle} />
|
||||
</div>
|
||||
{resolvedShowLabel && !isVertical ? (
|
||||
<div className={labelClass}>
|
||||
<span className={labelPillClass}>{labelText}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{resolvedShowLabel && isVertical ? (
|
||||
<div className="mt-0.5 text-[0.65rem] font-semibold text-slate-100">
|
||||
<span className={labelPillClass}>{percentDisplay}%</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,37 +6,13 @@ import { useChat } from '../context/ChatContext.jsx';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import SocialButton from './SocialButton.jsx';
|
||||
import BatteryBar from './BatteryBar.jsx';
|
||||
import { buildBatteryVisual } from '../lib/battery.js';
|
||||
|
||||
const RESTART_DELAY_MS = 2000;
|
||||
const UNMUTE_RETRY_MS = 3000;
|
||||
const AUDIO_RETRY_MS = 3000;
|
||||
|
||||
function buildBatteryVisual(charge, config) {
|
||||
const full = config?.Full;
|
||||
const warn = config?.Warn;
|
||||
const urgent = config?.Urgent ?? null;
|
||||
if (charge == null || full == null || warn == null) {
|
||||
return { available: false };
|
||||
}
|
||||
|
||||
const span = full - warn;
|
||||
if (span <= 0) return { available: false };
|
||||
const normalized = (charge - warn) / span;
|
||||
const percent = Math.min(1, Math.max(0, normalized));
|
||||
const percentDisplay = Math.round(percent * 100);
|
||||
const depleted = normalized <= 0;
|
||||
const warnTriggered = urgent != null && charge <= urgent;
|
||||
const barClass = depleted ? 'bg-red-500 animate-pulse' : warnTriggered ? 'bg-amber-400' : 'bg-emerald-500';
|
||||
|
||||
return {
|
||||
available: true,
|
||||
percentDisplay,
|
||||
depleted,
|
||||
warnTriggered,
|
||||
barClass,
|
||||
};
|
||||
}
|
||||
|
||||
export default function VideoTile({
|
||||
sessionInfo,
|
||||
audioSessionInfo,
|
||||
@@ -83,7 +59,7 @@ export default function VideoTile({
|
||||
const effectiveHudMapPosition = mobileHud ? 'top-right' : hudMapPosition;
|
||||
const [showHudMapDesktop, setShowHudMapDesktop] = useHudMapSetting();
|
||||
const showHudMap = hudForceMap ? true : mobileHud ? true : showHudMapDesktop;
|
||||
const batteryVisual = buildBatteryVisual(batteryCharge, batteryConfig);
|
||||
const batteryVisual = buildBatteryVisual({ charge: batteryCharge, config: batteryConfig });
|
||||
// console.log('[BatteryBarDebug]', {
|
||||
// frameSensors: sensors,
|
||||
// batteryCharge,
|
||||
@@ -466,9 +442,17 @@ export default function VideoTile({
|
||||
</div>
|
||||
) : null}
|
||||
{!noHud ? <OvercurrentOverlay motors={overlayMotors} fill={overlayFill} compact={mobileHud} /> : null}
|
||||
{!noHud ? <LowBatteryOverlay charge={batteryCharge} config={batteryConfig} compact={mobileHud} /> : null}
|
||||
{!noHud ? <LowBatteryOverlay battery={batteryVisual} compact={mobileHud} /> : null}
|
||||
{!noHud && showVerticalBattery && batteryVisual.available ? (
|
||||
<BatteryBarVertical visual={batteryVisual} />
|
||||
<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>
|
||||
) : null}
|
||||
{!noHud && qualityNotice ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2">
|
||||
@@ -492,52 +476,15 @@ export default function VideoTile({
|
||||
{!noHud && !showVerticalBattery && (
|
||||
<div className="space-y-0.5">
|
||||
<LightBumpBars sensors={sensors} />
|
||||
<BatteryBar visual={batteryVisual} />
|
||||
<div className="panel-section space-y-0.5 text-sm">
|
||||
<BatteryBar visual={batteryVisual} compact={mobileHud} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BatteryBar({ visual }) {
|
||||
if (!visual?.available) {
|
||||
return (
|
||||
<div className="panel-section space-y-0.5 text-sm">
|
||||
<p className="text-xs text-slate-500">Battery telemetry unavailable</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const percentText = `${visual.percentDisplay}%`;
|
||||
const barClass = visual.barClass;
|
||||
return (
|
||||
<div className="panel-section space-y-0.5 text-sm">
|
||||
<div className="relative h-4 w-full bg-zinc-900 flex">
|
||||
<div className={`h-full transition-[width] ${barClass}`} style={{ width: `${visual.percentDisplay}%` }}>
|
||||
<span className="inset-0 flex items-center justify-center text-xs font-semibold text-black/80">
|
||||
Battery {percentText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BatteryBarVertical({ visual }) {
|
||||
if (!visual?.available) return null;
|
||||
const percentText = `${visual.percentDisplay}%`;
|
||||
return (
|
||||
<div className="pointer-events-none absolute right-1 top-1/2 flex h-[70%] -translate-y-1/2 flex-col items-center justify-end rounded bg-black/60 px-0.5 pb-1 pt-1">
|
||||
<div className="flex h-full w-4 items-end overflow-hidden rounded bg-zinc-900">
|
||||
<div
|
||||
className={`${visual.barClass} w-full transition-[height]`}
|
||||
style={{ height: `${visual.percentDisplay}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-0 text-[0.65rem] font-semibold text-slate-100">{percentText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LightBumpBars({ sensors }) {
|
||||
const values = [
|
||||
sensors?.lightBumpLeftSignal,
|
||||
@@ -828,20 +775,13 @@ function OvercurrentOverlay({ motors, fill = 0, compact = false }) {
|
||||
}
|
||||
|
||||
// low battery overlay, change text based on warn / urgent. use percentage calculated same as BatteryBar. change text based on warn or urgent.
|
||||
function LowBatteryOverlay({ charge, config, compact = false }) {
|
||||
if (charge == null || config == null) return null;
|
||||
const full = config.Full;
|
||||
const warn = config.Warn;
|
||||
const urgent = config.Urgent ?? null;
|
||||
const span = full - warn;
|
||||
if (span <= 0) return null;
|
||||
const normalized = (charge - warn) / span;
|
||||
const percent = Math.min(1, Math.max(0, normalized));
|
||||
const depleted = normalized <= 0;
|
||||
const warnTriggered = urgent != null && charge <= urgent;
|
||||
if (!warnTriggered && !depleted) return null;
|
||||
function LowBatteryOverlay({ battery, compact = false }) {
|
||||
if (!battery?.available) return null;
|
||||
if (!battery.warnActive && !battery.urgentActive) return null;
|
||||
|
||||
const message = depleted ? 'Battery low! please dock and charge the rover soon.' : 'BATTERY VERY LOW, PLEASE DOCK THE ROVER AND CHARGE IMMEDIATELY!!';
|
||||
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';
|
||||
|
||||
@@ -78,6 +78,36 @@ body {
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@keyframes batteryTickWarn {
|
||||
0%,
|
||||
49% {
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
background-color: rgba(239, 68, 68, 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.battery-tick-warn {
|
||||
animation: batteryTickWarn 0.4s steps(2, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes batteryUrgentFlash {
|
||||
0%,
|
||||
49% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
.battery-urgent-flash {
|
||||
animation: batteryUrgentFlash 0.4s steps(2, end) infinite;
|
||||
}
|
||||
|
||||
.no-touch-select {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
export const WARN_DISPLAY_PERCENT = 10;
|
||||
|
||||
export function buildBatteryVisual({ batteryState = null, charge = null, config = null }) {
|
||||
const percentDisplayFromState =
|
||||
batteryState?.percentDisplay != null && Number.isFinite(batteryState.percentDisplay)
|
||||
? Math.round(batteryState.percentDisplay)
|
||||
: null;
|
||||
const warnActiveFromState = batteryState?.warnActive ?? null;
|
||||
const urgentActiveFromState = batteryState?.urgentActive ?? null;
|
||||
|
||||
const full = config?.Full ?? null;
|
||||
const warn = config?.Warn ?? null;
|
||||
const urgent = config?.Urgent ?? null;
|
||||
const chargeValue = charge ?? batteryState?.charge ?? null;
|
||||
|
||||
let percentDisplay = percentDisplayFromState;
|
||||
if (percentDisplay == null) {
|
||||
percentDisplay = computeDisplayPercent({ charge: chargeValue, full, warn, urgent });
|
||||
}
|
||||
if (percentDisplay == null) {
|
||||
return { available: false };
|
||||
}
|
||||
|
||||
const percent = Math.max(0, Math.min(1, percentDisplay / 100));
|
||||
const warnActive =
|
||||
warnActiveFromState != null
|
||||
? warnActiveFromState
|
||||
: Boolean(warn != null && chargeValue != null && chargeValue <= warn);
|
||||
const urgentActive =
|
||||
urgentActiveFromState != null
|
||||
? urgentActiveFromState
|
||||
: Boolean(urgent != null && chargeValue != null && chargeValue <= urgent);
|
||||
|
||||
return {
|
||||
available: true,
|
||||
percent,
|
||||
percentDisplay,
|
||||
warnActive,
|
||||
urgentActive,
|
||||
warnDisplayPercent: WARN_DISPLAY_PERCENT,
|
||||
};
|
||||
}
|
||||
|
||||
function computeDisplayPercent({ charge, full, warn, urgent }) {
|
||||
if (
|
||||
charge != null &&
|
||||
full != null &&
|
||||
warn != null &&
|
||||
urgent != null &&
|
||||
full > warn &&
|
||||
warn > urgent
|
||||
) {
|
||||
const warnOffset = WARN_DISPLAY_PERCENT / 100;
|
||||
const upperSpan = 1 - warnOffset;
|
||||
if (charge <= urgent) return 0;
|
||||
if (charge <= warn) {
|
||||
const t = (charge - urgent) / (warn - urgent);
|
||||
return Math.round(Math.max(0, Math.min(1, t)) * WARN_DISPLAY_PERCENT);
|
||||
}
|
||||
if (charge >= full) return 100;
|
||||
const t = (charge - warn) / (full - warn);
|
||||
return Math.round((warnOffset + Math.max(0, Math.min(1, t)) * upperSpan) * 100);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import VideoTile from '../components/VideoTile.jsx';
|
||||
import TopDownMap from '../components/TopDownMap.jsx';
|
||||
import AlertFeed from '../components/AlertFeed.jsx';
|
||||
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
||||
import BatteryBar from '../components/BatteryBar.jsx';
|
||||
import { buildBatteryVisual } from '../lib/battery.js';
|
||||
|
||||
const ROTATE_MS = 20000;
|
||||
const HARD_REFRESH_MS = 1 * 60 * 60 * 1000;
|
||||
@@ -22,28 +24,10 @@ function formatDriverLabel({ roverId, session }) {
|
||||
return mode === 'turns' && turnInfo?.current ? `${label}` : label;
|
||||
}
|
||||
|
||||
function getBatteryPercent({ rover, frame }) {
|
||||
const percentDisplay = rover?.batteryState?.percentDisplay;
|
||||
if (percentDisplay != null && Number.isFinite(percentDisplay)) {
|
||||
return Math.round(percentDisplay);
|
||||
}
|
||||
const config = rover?.battery ?? null;
|
||||
function getBatteryVisual({ rover, frame }) {
|
||||
const charge = frame?.sensors?.batteryChargeMah ?? null;
|
||||
const full = config?.Full ?? null;
|
||||
const warn = config?.Warn ?? null;
|
||||
if (charge == null || full == null || warn == null) return null;
|
||||
const span = full - warn;
|
||||
if (span <= 0) return null;
|
||||
const normalized = (charge - warn) / span;
|
||||
return Math.round(Math.max(0, Math.min(1, normalized)) * 100);
|
||||
}
|
||||
|
||||
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';
|
||||
const config = rover?.battery ?? null;
|
||||
return buildBatteryVisual({ batteryState: rover?.batteryState ?? null, charge, config });
|
||||
}
|
||||
|
||||
function InfoColumn({
|
||||
@@ -57,22 +41,20 @@ function InfoColumn({
|
||||
showPreview = true,
|
||||
variant = 'stacked',
|
||||
}) {
|
||||
const batteryPercent = getBatteryPercent({ rover, frame });
|
||||
const batteryFillClass = getBatteryFillClass({ rover });
|
||||
const batteryVisual = getBatteryVisual({ rover, frame });
|
||||
const batteryPercent = batteryVisual?.available ? batteryVisual.percentDisplay : null;
|
||||
const isActiveView = variant === 'active';
|
||||
const batteryFillStyle = isActiveView
|
||||
? { height: `${batteryPercent == null ? 0 : batteryPercent}%`, bottom: 0, top: 'auto' }
|
||||
: { width: `${batteryPercent == null ? 0 : batteryPercent}%` };
|
||||
const batteryFillClassName = isActiveView
|
||||
? `pointer-events-none absolute bottom-0 left-0 right-0 ${batteryFillClass}`
|
||||
: `pointer-events-none absolute left-0 top-0 bottom-0 ${batteryFillClass}`;
|
||||
return (
|
||||
<div
|
||||
className={`relative flex min-w-0 flex-1 flex-col gap-4 overflow-hidden bg-black px-0 py-0 ${
|
||||
withDivider ? 'border-r border-slate-700/60' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={batteryFillClassName} style={batteryFillStyle} />
|
||||
<BatteryBar
|
||||
visual={batteryVisual}
|
||||
orientation="vertical"
|
||||
variant="background"
|
||||
/>
|
||||
{isActiveView ? (
|
||||
<div className="relative z-10 flex min-w-0 flex-1 flex-col justify-between text-center">
|
||||
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
|
||||
|
||||
Reference in New Issue
Block a user