mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -04:00
new battery bars!!!
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-DLm82-hG.js"></script>
|
<script type="module" crossorigin src="/assets/index-Ds3QBek7.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CnYxiRu2.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Bz1ixYh7.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -196,6 +196,14 @@ function computeBatteryState(record, sensors) {
|
|||||||
if (percent != null) {
|
if (percent != null) {
|
||||||
percent = Math.max(0, Math.min(1, percent));
|
percent = Math.max(0, Math.min(1, percent));
|
||||||
}
|
}
|
||||||
|
const percentDisplay = computeBatteryDisplayPercent({
|
||||||
|
charge,
|
||||||
|
full,
|
||||||
|
warn,
|
||||||
|
urgent,
|
||||||
|
percent,
|
||||||
|
capacity,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
charge,
|
charge,
|
||||||
capacity,
|
capacity,
|
||||||
@@ -203,13 +211,41 @@ function computeBatteryState(record, sensors) {
|
|||||||
warn,
|
warn,
|
||||||
urgent,
|
urgent,
|
||||||
percent,
|
percent,
|
||||||
percentDisplay: percent == null ? null : Math.round(percent * 100),
|
percentDisplay,
|
||||||
warnActive: Boolean(warn != null && charge != null && charge <= warn),
|
warnActive: Boolean(warn != null && charge != null && charge <= warn),
|
||||||
urgentActive: Boolean(urgent != null && charge != null && charge <= urgent),
|
urgentActive: Boolean(urgent != null && charge != null && charge <= urgent),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeBatteryDisplayPercent({ charge, full, warn, urgent, percent, capacity }) {
|
||||||
|
if (
|
||||||
|
charge != null &&
|
||||||
|
full != null &&
|
||||||
|
warn != null &&
|
||||||
|
urgent != null &&
|
||||||
|
full > warn &&
|
||||||
|
warn > urgent
|
||||||
|
) {
|
||||||
|
if (charge <= urgent) return 0;
|
||||||
|
if (charge <= warn) {
|
||||||
|
const t = (charge - urgent) / (warn - urgent);
|
||||||
|
return Math.round(Math.max(0, Math.min(1, t)) * 10);
|
||||||
|
}
|
||||||
|
if (charge >= full) return 100;
|
||||||
|
const t = (charge - warn) / (full - warn);
|
||||||
|
return Math.round((0.1 + Math.max(0, Math.min(1, t)) * 0.9) * 100);
|
||||||
|
}
|
||||||
|
if (percent != null && Number.isFinite(percent)) {
|
||||||
|
return Math.round(Math.max(0, Math.min(1, percent)) * 100);
|
||||||
|
}
|
||||||
|
if (charge != null && capacity != null && capacity > 0) {
|
||||||
|
const fallback = charge / capacity;
|
||||||
|
return Math.round(Math.max(0, Math.min(1, fallback)) * 100);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function handleSensorFrame(roverId, frame) {
|
function handleSensorFrame(roverId, frame) {
|
||||||
const record = rovers.get(roverId);
|
const record = rovers.get(roverId);
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
|
|||||||
@@ -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 { useSession } from '../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
import SocialButton from './SocialButton.jsx';
|
import SocialButton from './SocialButton.jsx';
|
||||||
|
import BatteryBar from './BatteryBar.jsx';
|
||||||
|
import { buildBatteryVisual } from '../lib/battery.js';
|
||||||
|
|
||||||
const RESTART_DELAY_MS = 2000;
|
const RESTART_DELAY_MS = 2000;
|
||||||
const UNMUTE_RETRY_MS = 3000;
|
const UNMUTE_RETRY_MS = 3000;
|
||||||
const AUDIO_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({
|
export default function VideoTile({
|
||||||
sessionInfo,
|
sessionInfo,
|
||||||
audioSessionInfo,
|
audioSessionInfo,
|
||||||
@@ -83,7 +59,7 @@ export default function VideoTile({
|
|||||||
const effectiveHudMapPosition = mobileHud ? 'top-right' : hudMapPosition;
|
const effectiveHudMapPosition = mobileHud ? 'top-right' : hudMapPosition;
|
||||||
const [showHudMapDesktop, setShowHudMapDesktop] = useHudMapSetting();
|
const [showHudMapDesktop, setShowHudMapDesktop] = useHudMapSetting();
|
||||||
const showHudMap = hudForceMap ? true : mobileHud ? true : showHudMapDesktop;
|
const showHudMap = hudForceMap ? true : mobileHud ? true : showHudMapDesktop;
|
||||||
const batteryVisual = buildBatteryVisual(batteryCharge, batteryConfig);
|
const batteryVisual = buildBatteryVisual({ charge: batteryCharge, config: batteryConfig });
|
||||||
// console.log('[BatteryBarDebug]', {
|
// console.log('[BatteryBarDebug]', {
|
||||||
// frameSensors: sensors,
|
// frameSensors: sensors,
|
||||||
// batteryCharge,
|
// batteryCharge,
|
||||||
@@ -466,9 +442,17 @@ export default function VideoTile({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!noHud ? <OvercurrentOverlay motors={overlayMotors} fill={overlayFill} compact={mobileHud} /> : 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 ? (
|
{!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}
|
) : null}
|
||||||
{!noHud && 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">
|
||||||
@@ -492,52 +476,15 @@ export default function VideoTile({
|
|||||||
{!noHud && !showVerticalBattery && (
|
{!noHud && !showVerticalBattery && (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<LightBumpBars sensors={sensors} />
|
<LightBumpBars sensors={sensors} />
|
||||||
<BatteryBar visual={batteryVisual} />
|
<div className="panel-section space-y-0.5 text-sm">
|
||||||
|
<BatteryBar visual={batteryVisual} compact={mobileHud} />
|
||||||
|
</div>
|
||||||
</div>
|
</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 }) {
|
function LightBumpBars({ sensors }) {
|
||||||
const values = [
|
const values = [
|
||||||
sensors?.lightBumpLeftSignal,
|
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.
|
// 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 }) {
|
function LowBatteryOverlay({ battery, compact = false }) {
|
||||||
if (charge == null || config == null) return null;
|
if (!battery?.available) return null;
|
||||||
const full = config.Full;
|
if (!battery.warnActive && !battery.urgentActive) return null;
|
||||||
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;
|
|
||||||
|
|
||||||
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 containerClass = compact ? 'p-2 top-6' : 'p-4 top-10';
|
||||||
const textClass = compact ? 'text-sm' : 'text-2xl';
|
const textClass = compact ? 'text-sm' : 'text-2xl';
|
||||||
|
|||||||
@@ -78,6 +78,36 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@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 {
|
.no-touch-select {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-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 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';
|
||||||
|
import BatteryBar from '../components/BatteryBar.jsx';
|
||||||
|
import { buildBatteryVisual } from '../lib/battery.js';
|
||||||
|
|
||||||
const ROTATE_MS = 20000;
|
const ROTATE_MS = 20000;
|
||||||
const HARD_REFRESH_MS = 1 * 60 * 60 * 1000;
|
const HARD_REFRESH_MS = 1 * 60 * 60 * 1000;
|
||||||
@@ -22,28 +24,10 @@ function formatDriverLabel({ roverId, session }) {
|
|||||||
return mode === 'turns' && turnInfo?.current ? `${label}` : label;
|
return mode === 'turns' && turnInfo?.current ? `${label}` : label;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBatteryPercent({ rover, frame }) {
|
function getBatteryVisual({ rover, frame }) {
|
||||||
const percentDisplay = rover?.batteryState?.percentDisplay;
|
|
||||||
if (percentDisplay != null && Number.isFinite(percentDisplay)) {
|
|
||||||
return Math.round(percentDisplay);
|
|
||||||
}
|
|
||||||
const config = rover?.battery ?? null;
|
|
||||||
const charge = frame?.sensors?.batteryChargeMah ?? null;
|
const charge = frame?.sensors?.batteryChargeMah ?? null;
|
||||||
const full = config?.Full ?? null;
|
const config = rover?.battery ?? null;
|
||||||
const warn = config?.Warn ?? null;
|
return buildBatteryVisual({ batteryState: rover?.batteryState ?? null, charge, config });
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoColumn({
|
function InfoColumn({
|
||||||
@@ -57,22 +41,20 @@ function InfoColumn({
|
|||||||
showPreview = true,
|
showPreview = true,
|
||||||
variant = 'stacked',
|
variant = 'stacked',
|
||||||
}) {
|
}) {
|
||||||
const batteryPercent = getBatteryPercent({ rover, frame });
|
const batteryVisual = getBatteryVisual({ rover, frame });
|
||||||
const batteryFillClass = getBatteryFillClass({ rover });
|
const batteryPercent = batteryVisual?.available ? batteryVisual.percentDisplay : null;
|
||||||
const isActiveView = variant === 'active';
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative flex min-w-0 flex-1 flex-col gap-4 overflow-hidden bg-black px-0 py-0 ${
|
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' : ''
|
withDivider ? 'border-r border-slate-700/60' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={batteryFillClassName} style={batteryFillStyle} />
|
<BatteryBar
|
||||||
|
visual={batteryVisual}
|
||||||
|
orientation="vertical"
|
||||||
|
variant="background"
|
||||||
|
/>
|
||||||
{isActiveView ? (
|
{isActiveView ? (
|
||||||
<div className="relative z-10 flex min-w-0 flex-1 flex-col justify-between text-center">
|
<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">
|
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
|
||||||
|
|||||||
Reference in New Issue
Block a user