mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
improvements and bug fixes
This commit is contained in:
@@ -61,9 +61,8 @@ export default function AdminPanelContent() {
|
||||
|
||||
const isAdmin =
|
||||
session?.role === 'admin' ||
|
||||
session?.role === 'lockdown' ||
|
||||
session?.role === 'lockdown-admin';
|
||||
const isLockdownAdmin = session?.role === 'lockdown' || session?.role === 'lockdown-admin';
|
||||
session?.role === 'lockdown';
|
||||
const isLockdownAdmin = session?.role === 'lockdown';
|
||||
|
||||
const currentMode = session?.mode ?? 'open';
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ function roleColors(role) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
case 'lockdown':
|
||||
case 'lockdown-admin':
|
||||
return 'text-amber-300';
|
||||
case 'spectator':
|
||||
return 'text-slate-400';
|
||||
@@ -151,7 +150,7 @@ function chatRowClass(message, variant = 'message') {
|
||||
return 'flex flex-col gap-0.5 rounded-md border border-emerald-500/40 bg-emerald-950 px-0.5 py-0.5 text-sm text-neutral-100';
|
||||
}
|
||||
const isAdmin =
|
||||
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
|
||||
message.role === 'admin' || message.role === 'lockdown';
|
||||
return `flex flex-col gap-0.5 rounded-md bg-neutral-800 px-0.5 py-0.5 text-sm text-neutral-100 ${
|
||||
isAdmin
|
||||
? 'border border-amber-400/30'
|
||||
|
||||
@@ -8,8 +8,8 @@ import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
import ChatPanel from '../ChatPanel/index.jsx';
|
||||
|
||||
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
|
||||
const LOCKDOWN_ROLES = new Set(['lockdown', 'lockdown-admin']);
|
||||
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown']);
|
||||
const LOCKDOWN_ROLES = new Set(['lockdown']);
|
||||
const RESTRICTED_MODES = new Set(['admin', 'lockdown']);
|
||||
|
||||
function getModeDetails(mode = 'admin') {
|
||||
|
||||
@@ -11,7 +11,6 @@ function roleColors(role) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
case 'lockdown':
|
||||
case 'lockdown-admin':
|
||||
return 'text-amber-300';
|
||||
case 'spectator':
|
||||
return 'text-slate-400';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Room Camera Panel
|
||||
// Purpose: Defines the Room Camera Panel 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 { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js';
|
||||
@@ -18,6 +18,7 @@ function EmptyState() {
|
||||
}
|
||||
|
||||
const ORIENTATIONS = ['horizontal', 'vertical'];
|
||||
const CAMERA_VISIBILITY_ROOT_MARGIN = '0px';
|
||||
|
||||
function normalizeOrientation(value, fallback) {
|
||||
if (ORIENTATIONS.includes(value)) {
|
||||
@@ -26,6 +27,82 @@ function normalizeOrientation(value, fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function useCameraPanelSubscriptionGate() {
|
||||
const panelRef = useRef(null);
|
||||
const [isPanelVisible, setIsPanelVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
Browser visibility can only be measured after React has mounted a real DOM
|
||||
node. Starting closed avoids a short subscribe/unsubscribe burst for room
|
||||
camera panels that mount below the fold.
|
||||
*/
|
||||
const panel = panelRef.current;
|
||||
if (!panel || typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let intersectsViewport = true;
|
||||
|
||||
const publishVisibility = () => {
|
||||
/*
|
||||
Server upload bandwidth is only saved when the socket subscription is
|
||||
actually disabled. Combining viewport intersection with page visibility
|
||||
means a scrolled-away panel and a hidden browser tab both stop receiving
|
||||
room-camera frame uploads from socketGateway.js.
|
||||
*/
|
||||
setIsPanelVisible(intersectsViewport && document.visibilityState !== 'hidden');
|
||||
};
|
||||
|
||||
const handlePageVisibilityChange = () => {
|
||||
/*
|
||||
IntersectionObserver is about layout visibility, while the Page
|
||||
Visibility API is about whether the tab itself can be seen. The latter
|
||||
matters here because a background tab can still keep a mounted panel and
|
||||
socket alive unless we explicitly close the subscription gate.
|
||||
*/
|
||||
publishVisibility();
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handlePageVisibilityChange);
|
||||
|
||||
if (typeof IntersectionObserver !== 'function') {
|
||||
/*
|
||||
Without IntersectionObserver we cannot cheaply know whether the panel is
|
||||
clipped by a scroll container. Fall back to page visibility so browsers
|
||||
without the observer still behave correctly, just without scroll-based
|
||||
bandwidth savings.
|
||||
*/
|
||||
publishVisibility();
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handlePageVisibilityChange);
|
||||
};
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
/*
|
||||
A zero root margin keeps the bandwidth gate strict: the browser only
|
||||
subscribes once the panel intersects the viewport. If the first frame
|
||||
feels too delayed in real use, this constant can be widened later.
|
||||
*/
|
||||
intersectsViewport = Boolean(entry?.isIntersecting);
|
||||
publishVisibility();
|
||||
},
|
||||
{ root: null, rootMargin: CAMERA_VISIBILITY_ROOT_MARGIN, threshold: 0 },
|
||||
);
|
||||
|
||||
observer.observe(panel);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
document.removeEventListener('visibilitychange', handlePageVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { panelRef, isPanelVisible };
|
||||
}
|
||||
|
||||
export default function RoomCameraPanel({
|
||||
defaultOrientation = 'horizontal',
|
||||
orientation: forcedOrientation,
|
||||
@@ -34,7 +111,12 @@ export default function RoomCameraPanel({
|
||||
panelId = null,
|
||||
}) {
|
||||
const cameras = useSessionSelector((state) => state.session?.roomCameras || []);
|
||||
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
|
||||
const cameraIds = useMemo(
|
||||
() => cameras.map((camera) => camera.id),
|
||||
[cameras],
|
||||
);
|
||||
const { panelRef, isPanelVisible } = useCameraPanelSubscriptionGate();
|
||||
const feedMap = useRoomCameraSnapshots(cameraIds, { enabled: isPanelVisible });
|
||||
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
|
||||
const [orientation, setOrientation] = useState(() =>
|
||||
normalizeOrientation(
|
||||
@@ -42,17 +124,19 @@ export default function RoomCameraPanel({
|
||||
'horizontal',
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panelId) return;
|
||||
const stored = orientationSettings?.[panelId];
|
||||
if (!stored) return;
|
||||
setOrientation(normalizeOrientation(stored, 'horizontal'));
|
||||
// only respond to changes for this panel id
|
||||
}, [panelId, orientationSettings?.[panelId]]);
|
||||
const storedOrientation = panelId ? orientationSettings?.[panelId] : null;
|
||||
const effectiveOrientation = forcedOrientation
|
||||
? normalizeOrientation(forcedOrientation, 'horizontal')
|
||||
: orientation;
|
||||
: normalizeOrientation(
|
||||
/*
|
||||
Settings are external state, so derive from them during render instead
|
||||
of mirroring them into local state from an effect. Local state remains
|
||||
useful as the immediate value after clicking the layout toggle, while
|
||||
stored settings win once the settings provider has loaded or saved.
|
||||
*/
|
||||
storedOrientation || orientation,
|
||||
'horizontal',
|
||||
);
|
||||
const containerClass =
|
||||
effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2';
|
||||
const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameras.length > 0;
|
||||
@@ -64,7 +148,11 @@ export default function RoomCameraPanel({
|
||||
};
|
||||
|
||||
if (cameras.length === 0) {
|
||||
return <EmptyState />;
|
||||
return (
|
||||
<div ref={panelRef}>
|
||||
<EmptyState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const actions = showLayoutToggle ? (
|
||||
@@ -86,27 +174,28 @@ export default function RoomCameraPanel({
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title="Room cameras"
|
||||
|
||||
actions={actions}
|
||||
hideHeader={hideHeader}
|
||||
bodyClassName="space-y-0.5 text-base"
|
||||
>
|
||||
<div className={containerClass}>
|
||||
{cameras.map((camera) => {
|
||||
const feed = feedMap[camera.id] || null;
|
||||
return (
|
||||
<article key={camera.id} className="w-full space-y-0.5 p-0.5">
|
||||
{/* <header className="space-y-0.5">
|
||||
<p className="text-lg font-semibold text-white">{camera.name || camera.id}</p>
|
||||
{camera.description && <p className="text-xs text-slate-500">{camera.description}</p>}
|
||||
</header> */}
|
||||
<RoomCameraFeed feed={feed} label={camera.name || camera.id} />
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardFrame>
|
||||
<div ref={panelRef}>
|
||||
<CardFrame
|
||||
title="Room cameras"
|
||||
actions={actions}
|
||||
hideHeader={hideHeader}
|
||||
bodyClassName="space-y-0.5 text-base"
|
||||
>
|
||||
<div className={containerClass}>
|
||||
{cameras.map((camera) => {
|
||||
const feed = feedMap[camera.id] || null;
|
||||
return (
|
||||
<article key={camera.id} className="w-full space-y-0.5 p-0.5">
|
||||
{/* <header className="space-y-0.5">
|
||||
<p className="text-lg font-semibold text-white">{camera.name || camera.id}</p>
|
||||
{camera.description && <p className="text-xs text-slate-500">{camera.description}</p>}
|
||||
</header> */}
|
||||
<RoomCameraFeed feed={feed} label={camera.name || camera.id} />
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ function roleColors(role) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
case 'lockdown':
|
||||
case 'lockdown-admin':
|
||||
return 'text-amber-300';
|
||||
case 'spectator':
|
||||
return 'text-slate-400';
|
||||
@@ -66,7 +65,7 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||
|
||||
const canRequest = useMemo(() => role && role !== 'spectator', [role]);
|
||||
const adminCapable = useMemo(
|
||||
() => role === 'admin' || role === 'lockdown' || role === 'lockdown-admin',
|
||||
() => role === 'admin' || role === 'lockdown',
|
||||
[role],
|
||||
);
|
||||
const hasDeadlines = useMemo(
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useVisualTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import { selectFrameForDisplay } from '../../context/telemetryViews.js';
|
||||
import { useDockIr } from '../../hooks/useDockIr.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
export default function TelemetryPanel() {
|
||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const frame = useVisualTelemetrySelector(roverId, selectFrameForDisplay);
|
||||
const sensors = frame?.sensors || {};
|
||||
const dockIr = useDockIr(sensors);
|
||||
const voltage = sensors.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : null;
|
||||
const current = sensors.currentMa != null ? `${sensors.currentMa} mA` : null;
|
||||
const batteryTemp = sensors.batteryTemperatureC != null ? `${sensors.batteryTemperatureC} °C` : null;
|
||||
@@ -35,7 +33,7 @@ export default function TelemetryPanel() {
|
||||
) : (
|
||||
<>
|
||||
<TelemetrySummary sensors={sensors} voltage={voltage} current={current} batteryTemp={batteryTemp} charge={charge} capacity={capacity} />
|
||||
<SensorDetails sensors={sensors} dockState={dockIr} />
|
||||
<SensorDetails sensors={sensors} />
|
||||
</>
|
||||
)}
|
||||
{rawSnippet && (
|
||||
@@ -75,7 +73,7 @@ function Metric({ label, value }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SensorDetails({ sensors, dockState }) {
|
||||
function SensorDetails({ sensors }) {
|
||||
const bumps = sensors?.bumpsAndWheelDrops || {};
|
||||
const over = sensors?.wheelOvercurrents || {};
|
||||
|
||||
@@ -104,11 +102,6 @@ function SensorDetails({ sensors, dockState }) {
|
||||
|
||||
return (
|
||||
<div className="grid gap-0.5 md:grid-cols-2">
|
||||
<DetailCard title="Dock IR">
|
||||
<DockMiniStatus sensors={sensors} />
|
||||
<DockDebug state={dockState} />
|
||||
</DetailCard>
|
||||
|
||||
<DetailCard title="Bumps & drops">
|
||||
<ValueRow label="Bump L" value={<Pill active={bumps.bumpLeft} />} />
|
||||
<ValueRow label="Bump R" value={<Pill active={bumps.bumpRight} />} />
|
||||
@@ -190,49 +183,3 @@ function formatNumber(value, unit) {
|
||||
if (value == null || Number.isNaN(value)) return '--';
|
||||
return unit ? `${value} ${unit}` : value;
|
||||
}
|
||||
|
||||
function DockMiniStatus({ sensors }) {
|
||||
const left = sensors?.infraredCharacterLeft;
|
||||
const right = sensors?.infraredCharacterRight;
|
||||
const omni = sensors?.infraredCharacterOmni;
|
||||
const badge = (code) => (
|
||||
<span className={`rounded px-1 py-0.25 text-[0.7rem] font-semibold ${code ? 'bg-emerald-600 text-white' : 'bg-slate-700 text-slate-300'}`}>
|
||||
{code || '--'}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-0.5 text-xs text-slate-200">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-slate-400">L</span>
|
||||
{badge(left)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-slate-400">O</span>
|
||||
{badge(omni)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-slate-400">R</span>
|
||||
{badge(right)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DockDebug({ state }) {
|
||||
if (!state) return null;
|
||||
const label = (entry) => {
|
||||
const parts = [];
|
||||
if (entry?.red) parts.push('R');
|
||||
if (entry?.green) parts.push('G');
|
||||
if (entry?.force) parts.push('F');
|
||||
return parts.length ? parts.join('') : 'none';
|
||||
};
|
||||
const age = (entry) => (entry?.age != null ? `${entry.age}ms` : '--');
|
||||
return (
|
||||
<div className="text-[0.7rem] text-slate-400">
|
||||
<div>{`Left: ${state.left.code ?? '--'} (${label(state.left)}) · age ${age(state.left)}`}</div>
|
||||
<div>{`Omni: ${state.omni.code ?? '--'} (${label(state.omni)}) · age ${age(state.omni)}`}</div>
|
||||
<div>{`Right: ${state.right.code ?? '--'} (${label(state.right)}) · age ${age(state.right)}`}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ function roleColors(role) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
case 'lockdown':
|
||||
case 'lockdown-admin':
|
||||
return 'text-amber-300';
|
||||
case 'spectator':
|
||||
return 'text-slate-400';
|
||||
@@ -120,7 +119,7 @@ export default function UserListPanel({
|
||||
) : (
|
||||
sorted.map((user) => {
|
||||
const isAdmin =
|
||||
user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin';
|
||||
user.role === 'admin' || user.role === 'lockdown';
|
||||
return (
|
||||
<div
|
||||
key={user.socketId}
|
||||
@@ -229,7 +228,7 @@ export default function UserListPanel({
|
||||
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
|
||||
const isSelf = Boolean(selfId && socketId === selfId);
|
||||
const isAdmin =
|
||||
user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin';
|
||||
user.role === 'admin' || user.role === 'lockdown';
|
||||
const highlightClass = isCurrent
|
||||
? 'bg-sky-600 text-white ring-2 ring-amber-300 animate-pulse'
|
||||
: isNext
|
||||
|
||||
Reference in New Issue
Block a user