This commit is contained in:
legop3
2026-09-12 16:16:59 -04:00
parent 99d2a7689f
commit fb31ff52bd
28 changed files with 545 additions and 88 deletions
@@ -0,0 +1,82 @@
// Auto Fit Text
// Purpose: Sizes one unwrapped label to the largest font that fits its container.
// Scope: Supports the existing width-only labels and full-box overlays from one shared implementation.
import { useLayoutEffect, useRef, useState } from 'react';
export default function AutoFitText({
children,
className = '',
containerClassName = '',
maxSize = 1000,
minSize = 14,
fitHeight = false,
style = undefined,
}) {
const containerRef = useRef(null);
const textRef = useRef(null);
const [fontSize, setFontSize] = useState(maxSize);
useLayoutEffect(() => {
const container = containerRef.current;
const textEl = textRef.current;
if (!container || !textEl) return undefined;
let animationFrame = null;
const fit = () => {
const width = container.clientWidth;
const height = container.clientHeight;
if (!width || (fitHeight && !height)) {
scheduleFit();
return;
}
// Binary search avoids stepping through hundreds of possible font sizes.
// Overlay labels test both axes; ordinary rover labels preserve their
// previous width-only behavior so this shared move changes no layout.
let low = minSize;
let high = maxSize;
let best = minSize;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
textEl.style.fontSize = `${middle}px`;
const fitsWidth = textEl.scrollWidth <= width;
const fitsHeight = !fitHeight || textEl.scrollHeight <= height;
if (fitsWidth && fitsHeight) {
best = middle;
low = middle + 1;
} else {
high = middle - 1;
}
}
setFontSize(best);
};
const scheduleFit = () => {
if (animationFrame) cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(fit);
};
scheduleFit();
const observer = new ResizeObserver(scheduleFit);
observer.observe(container);
return () => {
if (animationFrame) cancelAnimationFrame(animationFrame);
observer.disconnect();
};
}, [children, fitHeight, maxSize, minSize]);
return (
<div
ref={containerRef}
className={`${fitHeight ? 'flex h-full items-center justify-center' : ''} w-full min-w-0 ${containerClassName}`}
>
<div
ref={textRef}
className={`whitespace-nowrap ${className}`}
style={{ fontSize: `${fontSize}px`, lineHeight: fitHeight ? 1 : 1.1, ...(style || {}) }}
>
{children}
</div>
</div>
);
}
@@ -3,6 +3,7 @@
// Scope: Owns row chrome, queue chips, timer labels, and row/button event plumbing;
// callers still own target-specific permission checks and request actions.
import RoverLabel from '../RoverLabel/index.jsx';
import RoverHelpOverlay from '../RoverHelpOverlay/index.jsx';
function classNames(...values) {
return values.filter(Boolean).join(' ');
@@ -105,7 +106,7 @@ export default function QueueTargetRow({
return (
<li
className={classNames(
'surface flex flex-wrap items-start justify-between gap-0.5',
'surface relative flex flex-wrap items-start justify-between gap-0.5 overflow-hidden',
canClick && 'cursor-pointer',
locked
? 'bg-red-900/40'
@@ -191,6 +192,9 @@ export default function QueueTargetRow({
{buttonLabel}
</button>
) : null}
{/* Queue rows use the exact overlay component as the large video and
display surfaces; its measured font automatically adapts to this box. */}
<RoverHelpOverlay active={Boolean(target?.needsHelp)} />
</li>
);
}
@@ -0,0 +1,26 @@
// Rover Help Overlay
// Purpose: Gives every rover surface one unmistakable, responsive HELP treatment.
// Scope: Renders only the shared visual; server roster state decides when it is active.
import AutoFitText from '../AutoFitText/index.jsx';
import './styles.css';
export default function RoverHelpOverlay({ active = false }) {
if (!active) return null;
return (
<div
className="rover-help-overlay pointer-events-none absolute inset-0 z-50 overflow-hidden border-[clamp(0.2rem,1.2cqw,1rem)] border-white bg-red-600 p-[clamp(0.2rem,2cqw,1.5rem)] text-white"
role="status"
aria-label="Rover needs help"
>
<AutoFitText
fitHeight
minSize={8}
maxSize={1400}
className="font-black tracking-tight text-white drop-shadow-[0_0_0.08em_rgba(0,0,0,1)]"
>
HELP
</AutoFitText>
</div>
);
}
@@ -0,0 +1,12 @@
/* A one-second stepped cycle is urgent and room-readable without becoming a
rapid strobe. Opacity applies to the entire shared treatment, so its border,
background, and auto-fit word flash together on every rover surface. */
@keyframes rover-help-flash {
0%, 49% { opacity: 0.96; }
50%, 100% { opacity: 0.12; }
}
.rover-help-overlay {
container-type: size;
animation: rover-help-flash 1s steps(1, end) infinite;
}
@@ -4,6 +4,7 @@ import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/inde
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
import VerticalBatteryOverlay from '../HudOverlays/VerticalBatteryOverlay/index.jsx';
import RoverHelpOverlay from '../RoverHelpOverlay/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
export default function SpectateVideo({
@@ -11,6 +12,7 @@ export default function SpectateVideo({
label,
fitParent = false,
layoutFormat = 'desktop',
needsHelp = false,
}) {
const isExternalSpectatorSnapshotOnly = useSessionSelector((state) =>
state.session?.role === 'spectator' &&
@@ -46,6 +48,7 @@ export default function SpectateVideo({
<OvercurrentOverlay roverId={roverId} compact={false} />
<LowBatteryOverlay roverId={roverId} compact={false} />
<VerticalBatteryOverlay show roverId={roverId} mobileHud={false} />
<RoverHelpOverlay active={needsHelp} />
</div>
</div>
);
@@ -5,7 +5,8 @@ import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.js
import { batteryTelemetryEqual, selectBatteryTelemetry } from '../../../context/telemetryViews.js';
import BatteryBar from '../../../components/BatteryBar/index.jsx';
import RoverLabel from '../../../components/RoverLabel/index.jsx';
import AutoFitText from '../../../mini/MiniSummaryApp/components/AutoFitText.jsx';
import AutoFitText from '../../../components/AutoFitText/index.jsx';
import RoverHelpOverlay from '../../../components/RoverHelpOverlay/index.jsx';
import {
buildRoverStateText,
findDriverForRover,
@@ -38,6 +39,7 @@ export default function DisplayRoverCell({ rover, session }) {
!locked && urgent ? 'ring-4 ring-red-500/90' : !locked && warn ? 'ring-2 ring-amber-300/80' : '',
)}
>
<RoverHelpOverlay active={Boolean(rover?.needsHelp)} />
<BatteryBar visual={visual} variant="background" orientation="vertical" />
<div className="relative z-10 grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] gap-[0.55vh] p-[0.85vw] text-center">
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-[0.8vw]">
@@ -11,6 +11,7 @@ import useDefaultNickname from '../../hooks/useDefaultNickname.js';
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
import PtzLiveVideo from '../../components/PtzLiveVideo/index.jsx';
import RoverMediaPlayer from '../../components/RoverMediaPlayer/index.jsx';
import RoverHelpOverlay from '../../components/RoverHelpOverlay/index.jsx';
import FitViewportFrame from './components/FitViewportFrame.jsx';
import InfoColumn from './components/InfoColumn.jsx';
import { ROTATE_MS } from './constants.js';
@@ -352,6 +353,7 @@ export default function MiniSummaryContent() {
/>
)}
</FitViewportFrame>
<RoverHelpOverlay active={Boolean(rover.needsHelp)} />
</div>
);
})}
@@ -1,65 +0,0 @@
// Auto Fit Text
// Purpose: Defines the Auto Fit Text 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 { useLayoutEffect, useRef, useState } from 'react';
export default function AutoFitText({ children, className = '', maxSize = 1000, minSize = 14, style = undefined }) {
const containerRef = useRef(null);
const textRef = useRef(null);
const [fontSize, setFontSize] = useState(maxSize);
useLayoutEffect(() => {
const container = containerRef.current;
const textEl = textRef.current;
if (!container || !textEl) return undefined;
let raf = null;
const fit = () => {
const width = container.clientWidth;
if (!width) {
scheduleFit();
return;
}
let low = minSize;
let high = maxSize;
let best = minSize;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
textEl.style.fontSize = `${mid}px`;
const fits = textEl.scrollWidth <= width;
if (fits) {
best = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
setFontSize(best);
};
const scheduleFit = () => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(fit);
};
scheduleFit();
const ro = new ResizeObserver(scheduleFit);
ro.observe(container);
return () => {
if (raf) cancelAnimationFrame(raf);
ro.disconnect();
};
}, [children, maxSize, minSize]);
return (
<div ref={containerRef} className="w-full min-w-0">
<div
ref={textRef}
className={`whitespace-nowrap ${className}`}
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1, ...(style || {}) }}
>
{children}
</div>
</div>
);
}
@@ -4,8 +4,9 @@
import RoverMediaPlayer from '../../../components/RoverMediaPlayer/index.jsx';
import BatteryBar from '../../../components/BatteryBar/index.jsx';
import RoverLabel from '../../../components/RoverLabel/index.jsx';
import RoverHelpOverlay from '../../../components/RoverHelpOverlay/index.jsx';
import AutoFitText from '../../../components/AutoFitText/index.jsx';
import { getBatteryVisual } from '../utils.js';
import AutoFitText from './AutoFitText.jsx';
export default function InfoColumn({
rover,
@@ -32,6 +33,10 @@ export default function InfoColumn({
orientation={isActiveView ? 'vertical' : 'horizontal'}
variant="background"
/>
{/* In the side-by-side roster this column is the rover's complete tile.
The active carousel already overlays its video pane, so suppressing a
second copy here avoids flashing HELP twice for the same rover. */}
<RoverHelpOverlay active={!isActiveView && Boolean(rover?.needsHelp)} />
{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">
@@ -10,6 +10,7 @@ export default function RoverSpectatorCard({ rover }) {
<SpectateVideo
roverId={rover.id}
label={rover.name}
needsHelp={Boolean(rover.needsHelp)}
/>
</div>
</article>