mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -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
|
||||
|
||||
@@ -130,7 +130,7 @@ export function useOvercurrentLimiter(roverId, options = {}) {
|
||||
return { motors, groups };
|
||||
}, [overcurrentFlags]);
|
||||
|
||||
const adminImmune = role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
|
||||
const adminImmune = role === 'admin' || role === 'lockdown';
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Database Admin App
|
||||
// Purpose: Provides the dedicated /database route for lockdown-admin identity database management.
|
||||
// Purpose: Provides the dedicated /database route for lockdown admin identity database management.
|
||||
// Scope: Handles route-level identity sync, access gating, and composition of the self-contained database panel.
|
||||
import AuthPanel from '../components/AuthPanel/index.jsx';
|
||||
import CardFrame from '../components/CardFrame/index.jsx';
|
||||
@@ -10,7 +10,7 @@ import { pageBackgroundClass } from '../themeFlags.js';
|
||||
import IdentityDatabasePanel from './IdentityDatabasePanel.jsx';
|
||||
|
||||
function isLockdownAdminRole(role) {
|
||||
return role === 'lockdown' || role === 'lockdown-admin';
|
||||
return role === 'lockdown';
|
||||
}
|
||||
|
||||
export default function DatabaseAdminApp() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Identity Database Panel
|
||||
// Purpose: Implements the lockdown-admin identity database editor UI for the /database route.
|
||||
// Purpose: Implements the lockdown admin identity database editor UI for the /database route.
|
||||
// Scope: Keeps list, detail, signal, status, feature-state, and raw JSON editing local to this feature.
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import CardFrame from '../components/CardFrame/index.jsx';
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
// Hook: useDockIr
|
||||
// Purpose: Tracks dock IR telemetry/status values for docking-related UI indicators. Scope: Converts telemetry feed updates into component-friendly reactive state.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const HOLD_MS = 650;
|
||||
|
||||
const DOCK_CODES = {
|
||||
161: { red: true }, // Red buoy
|
||||
164: { green: true }, // Green buoy
|
||||
165: { force: true }, // Force field only
|
||||
168: { red: true, green: true }, // Red + green
|
||||
169: { red: true, force: true }, // Red + force
|
||||
172: { green: true, force: true }, // Green + force
|
||||
173: { red: true, green: true, force: true }, // Red + green + force
|
||||
};
|
||||
|
||||
function decodeDockBits(code) {
|
||||
if (typeof code !== 'number' || code <= 0) return null;
|
||||
const bits = DOCK_CODES[code];
|
||||
if (!bits) return null;
|
||||
return { code, ...bits };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a smoothed view of dock IR signals. Keeps the last seen code alive for HOLD_MS to mask blinks.
|
||||
*/
|
||||
export function useDockIr(sensors, options = {}) {
|
||||
const holdMs = options.holdMs || HOLD_MS;
|
||||
const [state, setState] = useState({
|
||||
left: null,
|
||||
right: null,
|
||||
omni: null,
|
||||
});
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick(Date.now()), Math.max(holdMs / 2, 100));
|
||||
return () => clearInterval(id);
|
||||
}, [holdMs]);
|
||||
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
const left = decodeDockBits(sensors?.infraredCharacterLeft);
|
||||
const right = decodeDockBits(sensors?.infraredCharacterRight);
|
||||
const omni = decodeDockBits(sensors?.infraredCharacterOmni);
|
||||
if (!left && !right && !omni) {
|
||||
return;
|
||||
}
|
||||
setState((prev) => ({
|
||||
left: left ? { ...left, ts: now } : prev.left,
|
||||
right: right ? { ...right, ts: now } : prev.right,
|
||||
omni: omni ? { ...omni, ts: now } : prev.omni,
|
||||
}));
|
||||
}, [sensors?.infraredCharacterLeft, sensors?.infraredCharacterRight, sensors?.infraredCharacterOmni]);
|
||||
|
||||
return useMemo(() => {
|
||||
const now = Date.now();
|
||||
const withWindow = (entry) => {
|
||||
if (!entry) return { active: false, red: false, green: false, force: false, age: null, code: null };
|
||||
const age = now - entry.ts;
|
||||
const active = age <= holdMs;
|
||||
return {
|
||||
active,
|
||||
age,
|
||||
code: entry.code,
|
||||
red: Boolean(entry.red && active),
|
||||
green: Boolean(entry.green && active),
|
||||
force: Boolean(entry.force && active),
|
||||
};
|
||||
};
|
||||
|
||||
const left = withWindow(state.left);
|
||||
const right = withWindow(state.right);
|
||||
const omni = withWindow(state.omni);
|
||||
const forceDetected = left.force || right.force || omni.force;
|
||||
const visible = left.active || right.active || omni.active;
|
||||
const bias =
|
||||
left.active && !right.active
|
||||
? 'right'
|
||||
: right.active && !left.active
|
||||
? 'left'
|
||||
: 'center';
|
||||
const balance = (right.active ? 1 : 0) - (left.active ? 1 : 0); // positive means steer left-to-right
|
||||
|
||||
return {
|
||||
visible,
|
||||
forceDetected,
|
||||
left,
|
||||
right,
|
||||
omni,
|
||||
bias, // left/right/center for steering cue
|
||||
balance: Math.max(-1, Math.min(1, balance)),
|
||||
};
|
||||
}, [state.left, state.right, state.omni, holdMs, tick]);
|
||||
}
|
||||
Reference in New Issue
Block a user