mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
slopfixing / issue 004
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
// Drive Dock State
|
||||
// Purpose: Selects only dock-related telemetry fields needed by drive/dock controls.
|
||||
// Scope: Keeps the action component focused on rendering while this hook owns telemetry subscription details.
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||
|
||||
export function deriveDriveDockStateFromTelemetry(dockTelemetry) {
|
||||
const oiLabel = dockTelemetry?.oiModeLabel || 'Unknown';
|
||||
const oiNormalized = oiLabel.toLowerCase();
|
||||
const chargingLabel = dockTelemetry?.chargingStateLabel || '';
|
||||
const docked = Boolean(dockTelemetry?.homeBase);
|
||||
const charging = docked && chargingLabel.toLowerCase() !== 'not charging' && chargingLabel !== '';
|
||||
const driving = oiNormalized === 'full';
|
||||
const dockedNotCharging = docked && !charging;
|
||||
const dockingInProgress = !docked && !charging && oiNormalized === 'passive';
|
||||
return { driving, docked, charging, dockedNotCharging, dockingInProgress, oiLabel, chargingLabel };
|
||||
}
|
||||
|
||||
export function useDriveDockState(roverId) {
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
return useMemo(() => deriveDriveDockStateFromTelemetry(dockTelemetry), [dockTelemetry]);
|
||||
}
|
||||
@@ -1,31 +1,15 @@
|
||||
// Drive Dock Action
|
||||
// Purpose: Defines the Drive Dock Action 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 { useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
|
||||
|
||||
export function deriveDriveDockState(frame) {
|
||||
const sensors = frame?.sensors || {};
|
||||
const oiLabel = sensors.oiMode?.label || 'Unknown';
|
||||
const oiNormalized = oiLabel.toLowerCase();
|
||||
const chargingLabel = sensors.chargingState?.label || '';
|
||||
const docked = Boolean(sensors.chargingSources?.homeBase);
|
||||
const charging = docked && chargingLabel.toLowerCase() !== 'not charging' && chargingLabel !== '';
|
||||
const driving = oiNormalized === 'full';
|
||||
const dockedNotCharging = docked && !charging;
|
||||
const dockingInProgress = !docked && !charging && oiNormalized === 'passive';
|
||||
return { driving, docked, charging, dockedNotCharging, dockingInProgress, oiLabel, chargingLabel };
|
||||
}
|
||||
|
||||
export function useDriveDockState(roverId) {
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
return useMemo(() => deriveDriveDockState(frame), [frame]);
|
||||
}
|
||||
|
||||
function StatusRow({ label, value, tone = 'neutral' }) {
|
||||
function StatusRow({ value, tone = 'neutral' }) {
|
||||
const toneClasses =
|
||||
tone === 'good'
|
||||
? 'border-emerald-200 bg-emerald-600 text-white'
|
||||
@@ -107,8 +91,8 @@ export default function DriveDockAction({
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const actions = useControlActions();
|
||||
const dockAssist = useManualDockAssist();
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const state = driveDockState ?? deriveDriveDockState(frame);
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
const state = driveDockState ?? deriveDriveDockStateFromTelemetry(dockTelemetry);
|
||||
const { driving, docked, charging, dockingInProgress } = state;
|
||||
const [pending, setPending] = useState(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
||||
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
||||
import { batteryTelemetryEqual, selectBatteryTelemetry } from '../../../context/telemetryViews.js';
|
||||
import LightBumpBars from '../LightBumpBars/index.jsx';
|
||||
import { buildBatteryVisual } from '../../../lib/battery.js';
|
||||
import BatteryBar from '../../BatteryBar/index.jsx';
|
||||
@@ -7,8 +8,7 @@ import BatteryBar from '../../BatteryBar/index.jsx';
|
||||
export default function DriverBottomStrip({ roverId = null, mobileHud = false }) {
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||
const frame = useTelemetryFrame(effectiveRoverId);
|
||||
const sensors = frame?.sensors ?? null;
|
||||
const batteryTelemetry = useVisualTelemetrySelector(effectiveRoverId, selectBatteryTelemetry, batteryTelemetryEqual);
|
||||
const batteryConfig = useSessionSelector((state) => {
|
||||
if (!effectiveRoverId) return null;
|
||||
const roster = state.session?.roster || [];
|
||||
@@ -16,7 +16,7 @@ export default function DriverBottomStrip({ roverId = null, mobileHud = false })
|
||||
return rover?.battery ?? null;
|
||||
});
|
||||
const batteryVisual = buildBatteryVisual({
|
||||
charge: sensors?.batteryChargeMah ?? null,
|
||||
charge: batteryTelemetry?.batteryChargeMah ?? null,
|
||||
config: batteryConfig,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import TopDownMap from '../../TopDownMap/index.jsx';
|
||||
|
||||
export default function HudMapOverlay({
|
||||
sensors,
|
||||
mapTelemetry = null,
|
||||
show = true,
|
||||
mapPosition = 'top-center',
|
||||
layoutFormat = 'desktop',
|
||||
@@ -32,7 +33,7 @@ export default function HudMapOverlay({
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute rounded" style={mapStyle}>
|
||||
<TopDownMap sensors={sensors} size={240} overlay />
|
||||
<TopDownMap sensors={sensors} mapTelemetry={mapTelemetry} size={240} overlay />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
export default function SpectatorTelemetryOverlay({ sensors, mobileHud = false }) {
|
||||
export default function SpectatorTelemetryOverlay({ sensors, telemetry = null, mobileHud = false }) {
|
||||
const voltageMv = telemetry?.voltageMv ?? sensors?.voltageMv ?? null;
|
||||
const currentMa = telemetry?.currentMa ?? sensors?.currentMa ?? null;
|
||||
const batteryChargeMah = telemetry?.batteryChargeMah ?? sensors?.batteryChargeMah ?? null;
|
||||
const oiLabel = telemetry?.oiModeLabel ?? sensors?.oiMode?.label ?? 'Unknown';
|
||||
const docked = telemetry?.homeBase ?? Boolean(sensors?.chargingSources?.homeBase);
|
||||
const chargingLabel = telemetry?.chargingStateLabel ?? sensors?.chargingState?.label ?? '';
|
||||
const statusPadClass = mobileHud ? 'px-0.25 py-0.25' : 'px-1 py-0.5';
|
||||
const telemetryPosClass = mobileHud ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
|
||||
const telemetryTextClass = mobileHud ? 'text-[0.45rem]' : 'text-[0.65rem]';
|
||||
const telemetryEntries = [
|
||||
['Voltage', sensors?.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'],
|
||||
['Current', sensors?.currentMa != null ? `${sensors.currentMa} mA` : '--'],
|
||||
['Charge', sensors?.batteryChargeMah != null ? `${sensors.batteryChargeMah}` : '--'],
|
||||
['OI', sensors?.oiMode?.label || '--'],
|
||||
['Voltage', voltageMv != null ? `${(voltageMv / 1000).toFixed(2)} V` : '--'],
|
||||
['Current', currentMa != null ? `${currentMa} mA` : '--'],
|
||||
['Charge', batteryChargeMah != null ? `${batteryChargeMah}` : '--'],
|
||||
['Oi', oiLabel || '--'],
|
||||
];
|
||||
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||
const chargingLabel = sensors?.chargingState?.label || '';
|
||||
const charging = Boolean(chargingLabel && chargingLabel.toLowerCase() !== 'not charging');
|
||||
const oiLabel = sensors?.oiMode?.label || 'Unknown';
|
||||
const oiNormalized = oiLabel.toLowerCase();
|
||||
const oiTone =
|
||||
oiNormalized === 'full'
|
||||
@@ -38,7 +41,7 @@ export default function SpectatorTelemetryOverlay({ sensors, mobileHud = false }
|
||||
<span className={`rounded px-1.5 py-0.5 ${chargingTone}`}>
|
||||
{charging ? 'Charging' : docked ? 'Not charging' : 'Not charging'}
|
||||
</span>
|
||||
<span className={`rounded px-1.5 py-0.5 ${oiTone}`}>OI: {oiLabel}</span>
|
||||
<span className={`rounded px-1.5 py-0.5 ${oiTone}`}>Oi: {oiLabel}</span>
|
||||
</div>
|
||||
{telemetryEntries.map(([labelText, value]) => (
|
||||
<span key={labelText} className="flex items-center justify-between gap-0.5">
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
import React from 'react';
|
||||
import { useHudMapSetting } from '../../../hooks/useHudMapSetting.js';
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
||||
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
||||
import {
|
||||
mapTelemetryEqual,
|
||||
selectSpectatorTelemetry,
|
||||
selectVisualMapTelemetry,
|
||||
spectatorTelemetryEqual,
|
||||
} from '../../../context/telemetryViews.js';
|
||||
import RoverLabelOverlay from './RoverLabelOverlay.jsx';
|
||||
import SpectatorTelemetryOverlay from './SpectatorTelemetryOverlay.jsx';
|
||||
import HudMapOverlay from './HudMapOverlay.jsx';
|
||||
@@ -24,7 +30,8 @@ function HudOverlay({
|
||||
}) {
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||
const frame = useTelemetryFrame(effectiveRoverId);
|
||||
const mapTelemetry = useVisualTelemetrySelector(effectiveRoverId, selectVisualMapTelemetry, mapTelemetryEqual);
|
||||
const spectatorTelemetry = useVisualTelemetrySelector(effectiveRoverId, selectSpectatorTelemetry, spectatorTelemetryEqual);
|
||||
const rosterInfo = useSessionSelector((state) => {
|
||||
if (!effectiveRoverId) return { label: null, roverColor: null };
|
||||
const roster = state.session?.roster || [];
|
||||
@@ -41,7 +48,8 @@ function HudOverlay({
|
||||
const match = users.find((u) => String(u.socketId || '') === String(activeId || ''));
|
||||
return match?.nickname || match?.name || null;
|
||||
});
|
||||
const resolvedSensors = sensors ?? frame?.sensors ?? null;
|
||||
const resolvedMapTelemetry = sensors ? null : mapTelemetry;
|
||||
const resolvedSensors = sensors ?? null;
|
||||
const resolvedLabel = label ?? rosterInfo.label ?? null;
|
||||
const resolvedRoverColor = roverColor ?? rosterInfo.roverColor ?? null;
|
||||
const resolvedDriverLabel = driverLabel ?? derivedDriverLabel;
|
||||
@@ -66,7 +74,7 @@ function HudOverlay({
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<SpectatorTelemetryOverlay sensors={resolvedSensors} mobileHud={isMobile} />
|
||||
<SpectatorTelemetryOverlay sensors={resolvedSensors} telemetry={sensors ? null : spectatorTelemetry} mobileHud={isMobile} />
|
||||
<RoverLabelOverlay
|
||||
variant="spectator"
|
||||
label={resolvedLabel}
|
||||
@@ -78,6 +86,7 @@ function HudOverlay({
|
||||
</div>
|
||||
<HudMapOverlay
|
||||
sensors={resolvedSensors}
|
||||
mapTelemetry={resolvedMapTelemetry}
|
||||
show={resolvedShowTopDown}
|
||||
mapPosition={resolvedMapPosition}
|
||||
layoutFormat={layoutFormat}
|
||||
@@ -98,6 +107,7 @@ function HudOverlay({
|
||||
/>
|
||||
<HudMapOverlay
|
||||
sensors={resolvedSensors}
|
||||
mapTelemetry={resolvedMapTelemetry}
|
||||
show={resolvedShowTopDown && variant !== 'spectator'}
|
||||
mapPosition={resolvedMapPosition}
|
||||
layoutFormat={layoutFormat}
|
||||
|
||||
@@ -3,21 +3,23 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import React from 'react';
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
||||
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
||||
import { lightBumpTelemetryEqual, selectLightBumpTelemetry } from '../../../context/telemetryViews.js';
|
||||
|
||||
function LightBumpBars({ roverId = null, sensors }) {
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||
const frame = useTelemetryFrame(effectiveRoverId);
|
||||
const resolvedSensors = sensors ?? frame?.sensors ?? null;
|
||||
const values = [
|
||||
resolvedSensors?.lightBumpLeftSignal,
|
||||
resolvedSensors?.lightBumpFrontLeftSignal,
|
||||
resolvedSensors?.lightBumpCenterLeftSignal,
|
||||
resolvedSensors?.lightBumpCenterRightSignal,
|
||||
resolvedSensors?.lightBumpFrontRightSignal,
|
||||
resolvedSensors?.lightBumpRightSignal,
|
||||
];
|
||||
const selectedValues = useVisualTelemetrySelector(effectiveRoverId, selectLightBumpTelemetry, lightBumpTelemetryEqual);
|
||||
const values = sensors
|
||||
? [
|
||||
sensors?.lightBumpLeftSignal,
|
||||
sensors?.lightBumpFrontLeftSignal,
|
||||
sensors?.lightBumpCenterLeftSignal,
|
||||
sensors?.lightBumpCenterRightSignal,
|
||||
sensors?.lightBumpFrontRightSignal,
|
||||
sensors?.lightBumpRightSignal,
|
||||
]
|
||||
: selectedValues;
|
||||
const max = values.filter((v) => v != null).reduce((acc, v) => Math.max(acc, v), 1200);
|
||||
const eased = (v) => Math.pow(Math.max(0, Math.min(1, (v ?? 0) / max)), 0.35);
|
||||
const hueFor = (v) => {
|
||||
|
||||
@@ -3,30 +3,36 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import React from 'react';
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
||||
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
||||
import { batteryTelemetryEqual, selectBatteryTelemetry } from '../../../context/telemetryViews.js';
|
||||
import { buildBatteryVisual } from '../../../lib/battery.js';
|
||||
|
||||
function LowBatteryOverlay({ roverId = null, sensors, batteryConfig, compact = false }) {
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||
const frame = useTelemetryFrame(effectiveRoverId);
|
||||
const batteryTelemetry = useVisualTelemetrySelector(effectiveRoverId, selectBatteryTelemetry, batteryTelemetryEqual);
|
||||
const rosterBatteryConfig = useSessionSelector((state) => {
|
||||
if (!effectiveRoverId) return null;
|
||||
const roster = state.session?.roster || [];
|
||||
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
|
||||
return rover?.battery ?? null;
|
||||
});
|
||||
const resolvedSensors = sensors ?? frame?.sensors ?? null;
|
||||
const resolvedBatteryTelemetry = sensors
|
||||
? {
|
||||
batteryChargeMah: sensors?.batteryChargeMah ?? null,
|
||||
batteryCapacityMah: sensors?.batteryCapacityMah ?? null,
|
||||
}
|
||||
: batteryTelemetry;
|
||||
const resolvedBatteryConfig = batteryConfig ?? rosterBatteryConfig;
|
||||
const battery = buildBatteryVisual({
|
||||
charge: resolvedSensors?.batteryChargeMah ?? null,
|
||||
charge: resolvedBatteryTelemetry?.batteryChargeMah ?? null,
|
||||
config: resolvedBatteryConfig,
|
||||
});
|
||||
if (!battery?.available) return null;
|
||||
if (!battery.warnActive && !battery.urgentActive) return null;
|
||||
|
||||
const message = battery.urgentActive
|
||||
? 'BATTERY VERY LOW, DOCK THE ROVER AND CHARGE IMMEDIATELY!!'
|
||||
? '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';
|
||||
|
||||
@@ -4,26 +4,26 @@
|
||||
import React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
||||
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
||||
import { overcurrentFlagsEqual, selectOvercurrentFlags } from '../../../context/telemetryViews.js';
|
||||
import { useOvercurrentLimiter } from '../../../controls/index.js';
|
||||
import { OVERCURRENT_LABELS } from './constants.js';
|
||||
|
||||
function OvercurrentOverlay({ roverId = null, sensors, overcurrentLimiter = null, compact = false }) {
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||
const frame = useTelemetryFrame(effectiveRoverId);
|
||||
const selectedOvercurrents = useVisualTelemetrySelector(effectiveRoverId, selectOvercurrentFlags, overcurrentFlagsEqual);
|
||||
const internalLimiter = useOvercurrentLimiter(effectiveRoverId);
|
||||
const resolvedSensors = sensors ?? frame?.sensors ?? null;
|
||||
const resolvedOvercurrents = sensors?.wheelOvercurrents ?? selectedOvercurrents;
|
||||
const resolvedOvercurrentLimiter = overcurrentLimiter ?? internalLimiter ?? null;
|
||||
const wheelOvercurrents = resolvedSensors?.wheelOvercurrents || null;
|
||||
const overcurrentMotors = useMemo(
|
||||
() =>
|
||||
wheelOvercurrents == null
|
||||
resolvedOvercurrents == null
|
||||
? []
|
||||
: Object.entries(wheelOvercurrents)
|
||||
: Object.entries(resolvedOvercurrents)
|
||||
.filter(([, active]) => Boolean(active))
|
||||
.map(([key]) => key),
|
||||
[wheelOvercurrents],
|
||||
[resolvedOvercurrents],
|
||||
);
|
||||
const limiterCaps = resolvedOvercurrentLimiter?.caps || null;
|
||||
const limiterFill = useMemo(() => {
|
||||
@@ -56,7 +56,7 @@ function OvercurrentOverlay({ roverId = null, sensors, overcurrentLimiter = null
|
||||
<div className="h-full bg-red-700/60" style={{ width: fillWidth }} />
|
||||
</div>
|
||||
<div className={`relative z-10 flex h-full w-full flex-col items-center justify-center text-center font-semibold text-white animate-pulse ${textClass} ${padClass}`}>
|
||||
<div>OVERCURRENT</div>
|
||||
<div>Overcurrent</div>
|
||||
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import React from 'react';
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
||||
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
||||
import { batteryTelemetryEqual, selectBatteryTelemetry } from '../../../context/telemetryViews.js';
|
||||
import { buildBatteryVisual } from '../../../lib/battery.js';
|
||||
import BatteryBar from '../../BatteryBar/index.jsx';
|
||||
|
||||
function VerticalBatteryOverlay({ show = false, roverId = null, sensors, batteryConfig, mobileHud = false }) {
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const batteryTelemetry = useVisualTelemetrySelector(roverId, selectBatteryTelemetry, batteryTelemetryEqual);
|
||||
const rosterBatteryConfig = useSessionSelector((state) => {
|
||||
if (!roverId) return null;
|
||||
const roster = state.session?.roster || [];
|
||||
const rover = roster.find((entry) => String(entry.id) === String(roverId));
|
||||
return rover?.battery ?? null;
|
||||
});
|
||||
const resolvedSensors = sensors ?? frame?.sensors ?? null;
|
||||
const resolvedBatteryTelemetry = sensors
|
||||
? {
|
||||
batteryChargeMah: sensors?.batteryChargeMah ?? null,
|
||||
batteryCapacityMah: sensors?.batteryCapacityMah ?? null,
|
||||
}
|
||||
: batteryTelemetry;
|
||||
const resolvedBatteryConfig = batteryConfig ?? rosterBatteryConfig;
|
||||
const batteryVisual = buildBatteryVisual({
|
||||
charge: resolvedSensors?.batteryChargeMah ?? null,
|
||||
charge: resolvedBatteryTelemetry?.batteryChargeMah ?? null,
|
||||
config: resolvedBatteryConfig,
|
||||
});
|
||||
if (!show || !batteryVisual?.available) return null;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// Purpose: Assembles the mobile movement column, which is the right column by default.
|
||||
// Scope: Integrates drive/dock actions with the mobile control pad without hiding that dependency inside the pad.
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import DriveDockAction, { useDriveDockState } from '../DriveDockAction/index.jsx';
|
||||
import DriveDockAction from '../DriveDockAction/index.jsx';
|
||||
import { useDriveDockState } from '../DriveDockAction/driveDockState.js';
|
||||
import ControlPadPanel from './ControlPadPanel.jsx';
|
||||
|
||||
function MovementColumnContent({ layout }) {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// Purpose: Renders Raspberry Pi host health for the assigned rover. Scope: Uses the separate roverHostStats stream, not Roomba sensorFrame data.
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import { hostStatsEqual, selectHostStats } from '../../context/telemetryViews.js';
|
||||
|
||||
const EMPTY_STATS = Object.freeze({});
|
||||
const EMPTY_WIFI = Object.freeze({});
|
||||
@@ -249,8 +250,7 @@ function warningMessages(stats) {
|
||||
|
||||
export default function PiHostStatsCard() {
|
||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const stats = frame?.hostStats || EMPTY_STATS;
|
||||
const stats = useTelemetrySelector(roverId, selectHostStats, hostStatsEqual) || EMPTY_STATS;
|
||||
const wifi = stats.wifi || EMPTY_WIFI;
|
||||
const warnings = warningMessages(stats);
|
||||
const wifiQuality = wifiQualityPercent(wifi);
|
||||
|
||||
@@ -11,8 +11,10 @@ import { LinkButtonsPanel } from '../UserListPanel/index.jsx';
|
||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../Tabs/index.jsx';
|
||||
import TopDownMap from '../TopDownMap/index.jsx';
|
||||
import DriveDockAction, { useDriveDockState } from '../DriveDockAction/index.jsx';
|
||||
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
||||
import DriveDockAction from '../DriveDockAction/index.jsx';
|
||||
import { useDriveDockState } from '../DriveDockAction/driveDockState.js';
|
||||
import { useVisualTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import { mapTelemetryEqual, selectVisualMapTelemetry } from '../../context/telemetryViews.js';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
|
||||
import RawUserPilePanel from '../RawUserPilePanel/index.jsx';
|
||||
@@ -37,15 +39,14 @@ const CHAT_DOCK_BOTTOM_INSET = 8;
|
||||
|
||||
function TopDownMapPanel() {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const sensors = frame?.sensors || {};
|
||||
const mapTelemetry = useVisualTelemetrySelector(roverId, selectVisualMapTelemetry, mapTelemetryEqual);
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title = "Roomba sensor view"
|
||||
>
|
||||
<div className="aspect-square w-full">
|
||||
<TopDownMap sensors={sensors} />
|
||||
<TopDownMap mapTelemetry={mapTelemetry} />
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { WhepPlayer } from '../../lib/whepPlayer.js';
|
||||
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import {
|
||||
mainBrushAudioTelemetryEqual,
|
||||
selectMainBrushAudioTelemetry,
|
||||
} from '../../context/telemetryViews.js';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
||||
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
|
||||
@@ -82,8 +86,17 @@ export default function RoverMediaPlayer({
|
||||
snapshotFeed ?? (effectiveRoverId ? autoSnapshots[effectiveRoverId] || null : null);
|
||||
const resolvedLabel =
|
||||
label || rosterEntry?.name || (effectiveRoverId ? `Rover ${effectiveRoverId}` : 'Rover');
|
||||
const frame = useTelemetryFrame(effectiveRoverId);
|
||||
const resolvedSensors = sensors ?? frame?.sensors ?? null;
|
||||
const mainBrushTelemetry = useTelemetrySelector(
|
||||
effectiveRoverId,
|
||||
selectMainBrushAudioTelemetry,
|
||||
mainBrushAudioTelemetryEqual,
|
||||
);
|
||||
const resolvedMainBrushTelemetry = sensors
|
||||
? {
|
||||
mainBrushCurrentMa: sensors?.mainBrushCurrentMa ?? 0,
|
||||
mainBrushOvercurrent: Boolean(sensors?.wheelOvercurrents?.mainBrush),
|
||||
}
|
||||
: mainBrushTelemetry;
|
||||
const videoRef = useRef(null);
|
||||
const audioRef = useRef(null);
|
||||
const restartTimer = useRef(null);
|
||||
@@ -129,8 +142,8 @@ export default function RoverMediaPlayer({
|
||||
: AUDIO_SETTINGS_DEFAULTS.mainBrushDuckAmount;
|
||||
const baseRoverGain = Math.max(0, Math.min(1, masterVolume * roverVolume));
|
||||
const mainBrushActive = Boolean(
|
||||
(Number(resolvedSensors?.mainBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA ||
|
||||
resolvedSensors?.wheelOvercurrents?.mainBrush,
|
||||
(Number(resolvedMainBrushTelemetry?.mainBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA ||
|
||||
resolvedMainBrushTelemetry?.mainBrushOvercurrent,
|
||||
);
|
||||
const duckGain = mainBrushDuckEnabled && mainBrushActive ? 1 - mainBrushDuckAmount : 1;
|
||||
const effectiveRoverGain = Math.max(0, Math.min(1, baseRoverGain * duckGain));
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
// Telemetry Panel
|
||||
// Purpose: Defines the Telemetry 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 { useMemo } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../../context/TelemetryContext.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';
|
||||
|
||||
function formatMetric(value, fallback = '--') {
|
||||
if (value == null || value === '') return fallback;
|
||||
return value;
|
||||
}
|
||||
|
||||
export default function TelemetryPanel() {
|
||||
const connected = useSessionSelector((state) => state.connected);
|
||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const activeDriverId = useSessionSelector((state) => {
|
||||
const id = state.session?.assignment?.roverId;
|
||||
return id ? state.session?.activeDrivers?.[id] || null : null;
|
||||
});
|
||||
const selfSocketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
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;
|
||||
@@ -29,15 +17,7 @@ export default function TelemetryPanel() {
|
||||
const batteryTemp = sensors.batteryTemperatureC != null ? `${sensors.batteryTemperatureC} °C` : null;
|
||||
const charge = sensors.batteryChargeMah;
|
||||
const capacity = sensors.batteryCapacityMah;
|
||||
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
|
||||
const rawSnippet = frame?.raw ? frame.raw : null;
|
||||
const driverLabel = useMemo(() => {
|
||||
if (!roverId) return 'n/a';
|
||||
if (!activeDriverId) return 'Available';
|
||||
if (activeDriverId === selfSocketId) return 'You';
|
||||
const user = users.find((entry) => entry.socketId === activeDriverId);
|
||||
return user?.nickname || activeDriverId.slice(0, 6);
|
||||
}, [activeDriverId, roverId, selfSocketId, users]);
|
||||
|
||||
return (
|
||||
<CardFrame title='Roomba sensor values' clipOverflow={false} bodyClassName="space-y-0.5 text-base text-slate-100">
|
||||
@@ -79,7 +59,7 @@ function TelemetrySummary({ sensors, voltage, current, batteryTemp, charge, capa
|
||||
<Metric label="Charge" value={charge != null ? `${charge} mAh` : '--'} />
|
||||
<Metric label="Capacity" value={capacity != null ? `${capacity} mAh` : '--'} />
|
||||
<Metric label="Charge" value={chargePct} />
|
||||
<Metric label="OI mode" value={oiMode} />
|
||||
<Metric label="Oi mode" value={oiMode} />
|
||||
<Metric label="Docked" value={docked} />
|
||||
<Metric label="Charging state" value={charging} />
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
// Top Down Map Content
|
||||
// Purpose: Defines the Top Down Map Content 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 React from 'react';
|
||||
import { buildSegments } from './helpers.js';
|
||||
import React, { useMemo } from 'react';
|
||||
import { selectVisualMapTelemetry } from '../../context/telemetryViews.js';
|
||||
import { buildSegments, cliffColor, lightBumpColor } from './helpers.js';
|
||||
import {
|
||||
ArcSegment,
|
||||
ConeSegment,
|
||||
WheelVisual,
|
||||
SideBrushVisual,
|
||||
MainBrushVisual,
|
||||
lightBumpColor,
|
||||
cliffColor,
|
||||
} from './visuals.jsx';
|
||||
|
||||
function TopDownMapContent({ sensors = {}, variant = 'full', size: overrideSize, overlay = false }) {
|
||||
const size = overrideSize || (variant === 'mini' ? 190 : 260);
|
||||
const LIGHT_LABELS = ['L', 'FL', 'CL', 'CR', 'FR', 'R'];
|
||||
|
||||
function buildGeometry(size, variant) {
|
||||
const center = size / 2;
|
||||
const offsetY = size * 0.05;
|
||||
const centerX = center;
|
||||
@@ -25,39 +25,56 @@ function TopDownMapContent({ sensors = {}, variant = 'full', size: overrideSize,
|
||||
const cliffRingInner = innerCircle - 14;
|
||||
const cliffRingOuter = innerCircle - 6;
|
||||
const wheelLineOffset = innerCircle * 0.65;
|
||||
|
||||
const bumps = sensors?.bumpsAndWheelDrops || {};
|
||||
const wheelOver = sensors?.wheelOvercurrents || {};
|
||||
const wheelCurrentLeft = sensors?.wheelLeftCurrentMa ?? 0;
|
||||
const wheelCurrentRight = sensors?.wheelRightCurrentMa ?? 0;
|
||||
const sideBrushCurrent = sensors?.sideBrushCurrentMa ?? 0;
|
||||
const mainBrushCurrent = sensors?.mainBrushCurrentMa ?? 0;
|
||||
const bumpDepress = 6;
|
||||
const bumpLeftOffset = bumps.bumpLeft ? bumpDepress : 0;
|
||||
const bumpRightOffset = bumps.bumpRight ? bumpDepress : 0;
|
||||
|
||||
const lightAngles = buildSegments({ count: 6, totalSpan: 140, gap: 6, startAngle: -70 });
|
||||
const lightLabels = ['L', 'FL', 'CL', 'CR', 'FR', 'R'];
|
||||
const lightValues = [
|
||||
sensors?.lightBumpLeftSignal,
|
||||
sensors?.lightBumpFrontLeftSignal,
|
||||
sensors?.lightBumpCenterLeftSignal,
|
||||
sensors?.lightBumpCenterRightSignal,
|
||||
sensors?.lightBumpFrontRightSignal,
|
||||
sensors?.lightBumpRightSignal,
|
||||
];
|
||||
const lightSegments = lightAngles.map((ang, idx) => ({
|
||||
label: lightLabels[idx],
|
||||
start: ang.start,
|
||||
end: ang.end,
|
||||
value: lightValues[idx],
|
||||
}));
|
||||
|
||||
const cliffSegments = [
|
||||
{ label: 'Cliff L', start: -60, end: -46, value: sensors?.cliffLeftSignal, active: sensors?.cliffLeft },
|
||||
{ label: 'Cliff FL', start: -32, end: -16, value: sensors?.cliffFrontLeftSignal, active: sensors?.cliffFrontLeft },
|
||||
{ label: 'Cliff FR', start: 16, end: 32, value: sensors?.cliffFrontRightSignal, active: sensors?.cliffFrontRight },
|
||||
{ label: 'Cliff R', start: 46, end: 60, value: sensors?.cliffRightSignal, active: sensors?.cliffRight },
|
||||
return {
|
||||
size,
|
||||
variant,
|
||||
centerX,
|
||||
centerY,
|
||||
innerCircle,
|
||||
lightRingInner,
|
||||
lightRingOuter,
|
||||
cliffRingInner,
|
||||
cliffRingOuter,
|
||||
wheelLineOffset,
|
||||
lightSegments: lightAngles.map((angle, idx) => ({
|
||||
label: LIGHT_LABELS[idx],
|
||||
start: angle.start,
|
||||
end: angle.end,
|
||||
})),
|
||||
cliffSegments: [
|
||||
{ label: 'Cliff L', start: -60, end: -46, valueKey: 'cliffLeftSignal', activeKey: 'cliffLeft' },
|
||||
{ label: 'Cliff FL', start: -32, end: -16, valueKey: 'cliffFrontLeftSignal', activeKey: 'cliffFrontLeft' },
|
||||
{ label: 'Cliff FR', start: 16, end: 32, valueKey: 'cliffFrontRightSignal', activeKey: 'cliffFrontRight' },
|
||||
{ label: 'Cliff R', start: 46, end: 60, valueKey: 'cliffRightSignal', activeKey: 'cliffRight' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function TopDownMapContent({ sensors = {}, mapTelemetry = null, variant = 'full', size: overrideSize, overlay = false }) {
|
||||
const size = overrideSize || (variant === 'mini' ? 190 : 260);
|
||||
const geometry = useMemo(() => buildGeometry(size, variant), [size, variant]);
|
||||
|
||||
// The map accepts either the new selector-produced telemetry shape or the old
|
||||
// raw sensors prop. Keeping this translation local lets existing non-hot paths
|
||||
// continue to render while high-frequency callers move to targeted selectors.
|
||||
const telemetry = mapTelemetry ?? selectVisualMapTelemetry({ sensors });
|
||||
const wheelCurrentLeft = telemetry.wheelLeftCurrentMa ?? 0;
|
||||
const wheelCurrentRight = telemetry.wheelRightCurrentMa ?? 0;
|
||||
const sideBrushCurrent = telemetry.sideBrushCurrentMa ?? 0;
|
||||
const mainBrushCurrent = telemetry.mainBrushCurrentMa ?? 0;
|
||||
const bumpDepress = 6;
|
||||
const bumpLeftOffset = telemetry.bumpLeft ? bumpDepress : 0;
|
||||
const bumpRightOffset = telemetry.bumpRight ? bumpDepress : 0;
|
||||
|
||||
const lightValues = [
|
||||
telemetry.lightBumpLeftSignal,
|
||||
telemetry.lightBumpFrontLeftSignal,
|
||||
telemetry.lightBumpCenterLeftSignal,
|
||||
telemetry.lightBumpCenterRightSignal,
|
||||
telemetry.lightBumpFrontRightSignal,
|
||||
telemetry.lightBumpRightSignal,
|
||||
];
|
||||
|
||||
const lightMaxSamples = lightValues.filter((v) => v != null);
|
||||
@@ -69,73 +86,72 @@ function TopDownMapContent({ sensors = {}, variant = 'full', size: overrideSize,
|
||||
style={overlay ? { width: `${size}px`, height: `${size}px` } : { height: '100%', width: '100%', aspectRatio: '1 / 1' }}
|
||||
>
|
||||
<svg width="100%" height="100%" viewBox={`0 0 ${size} ${size}`} preserveAspectRatio="xMidYMid meet" className="mx-auto block">
|
||||
<circle cx={centerX} cy={centerY} r={innerCircle} fill="#0f172a" stroke="#334155" strokeWidth="2" />
|
||||
<WheelVisual cx={centerX - wheelLineOffset} cy={centerY} current={wheelCurrentLeft} drop={bumps.wheelDropLeft} overcurrent={wheelOver.leftWheel} label="L" />
|
||||
<WheelVisual cx={centerX + wheelLineOffset} cy={centerY} current={wheelCurrentRight} drop={bumps.wheelDropRight} overcurrent={wheelOver.rightWheel} label="R" />
|
||||
<SideBrushVisual cx={centerX + innerCircle * 0.65} cy={centerY - innerCircle * 0.55} current={sideBrushCurrent} overcurrent={wheelOver.sideBrush} />
|
||||
<circle cx={geometry.centerX} cy={geometry.centerY} r={geometry.innerCircle} fill="#0f172a" stroke="#334155" strokeWidth="2" />
|
||||
<WheelVisual cx={geometry.centerX - geometry.wheelLineOffset} cy={geometry.centerY} current={wheelCurrentLeft} drop={telemetry.wheelDropLeft} overcurrent={telemetry.leftWheelOvercurrent} label="L" />
|
||||
<WheelVisual cx={geometry.centerX + geometry.wheelLineOffset} cy={geometry.centerY} current={wheelCurrentRight} drop={telemetry.wheelDropRight} overcurrent={telemetry.rightWheelOvercurrent} label="R" />
|
||||
<SideBrushVisual cx={geometry.centerX + geometry.innerCircle * 0.65} cy={geometry.centerY - geometry.innerCircle * 0.55} current={sideBrushCurrent} overcurrent={telemetry.sideBrushOvercurrent} />
|
||||
<MainBrushVisual
|
||||
cx={centerX}
|
||||
cy={centerY}
|
||||
cx={geometry.centerX}
|
||||
cy={geometry.centerY}
|
||||
current={mainBrushCurrent}
|
||||
overcurrent={wheelOver.mainBrush}
|
||||
overcurrent={telemetry.mainBrushOvercurrent}
|
||||
variant={variant}
|
||||
dirtLeft={sensors?.dirtDetectLeft}
|
||||
dirtRight={sensors?.dirtDetect}
|
||||
/>
|
||||
{lightSegments.map((seg) => {
|
||||
const color = lightBumpColor(seg.value, maxLight);
|
||||
const tipR = lightRingOuter + 4;
|
||||
{geometry.lightSegments.map((seg, idx) => {
|
||||
const value = lightValues[idx];
|
||||
const color = lightBumpColor(value, maxLight);
|
||||
const tipR = geometry.lightRingOuter + 4;
|
||||
const baseR = tipR + 28;
|
||||
return (
|
||||
<ConeSegment
|
||||
key={seg.label}
|
||||
cx={centerX}
|
||||
cy={centerY}
|
||||
cx={geometry.centerX}
|
||||
cy={geometry.centerY}
|
||||
rBase={baseR}
|
||||
rTip={tipR}
|
||||
startDeg={seg.start}
|
||||
endDeg={seg.end}
|
||||
color={color}
|
||||
value={seg.value}
|
||||
value={value}
|
||||
max={maxLight}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{cliffSegments.map((seg) => (
|
||||
{geometry.cliffSegments.map((seg) => (
|
||||
<ArcSegment
|
||||
key={seg.label}
|
||||
cx={centerX}
|
||||
cy={centerY}
|
||||
rInner={cliffRingInner}
|
||||
rOuter={cliffRingOuter}
|
||||
cx={geometry.centerX}
|
||||
cy={geometry.centerY}
|
||||
rInner={geometry.cliffRingInner}
|
||||
rOuter={geometry.cliffRingOuter}
|
||||
startDeg={seg.start}
|
||||
endDeg={seg.end}
|
||||
color={cliffColor(seg.value, seg.active)}
|
||||
color={cliffColor(telemetry[seg.valueKey], telemetry[seg.activeKey])}
|
||||
opacity={1}
|
||||
pulse={Boolean(seg.active)}
|
||||
pulse={Boolean(telemetry[seg.activeKey])}
|
||||
/>
|
||||
))}
|
||||
<ArcSegment
|
||||
cx={centerX}
|
||||
cy={centerY}
|
||||
rInner={lightRingInner - bumpLeftOffset}
|
||||
rOuter={lightRingOuter - bumpLeftOffset}
|
||||
cx={geometry.centerX}
|
||||
cy={geometry.centerY}
|
||||
rInner={geometry.lightRingInner - bumpLeftOffset}
|
||||
rOuter={geometry.lightRingOuter - bumpLeftOffset}
|
||||
startDeg={-70}
|
||||
endDeg={-6}
|
||||
color={bumps.bumpLeft ? '#ef4444' : '#475569'}
|
||||
color={telemetry.bumpLeft ? '#ef4444' : '#475569'}
|
||||
opacity={1}
|
||||
pulse={Boolean(bumps.bumpLeft)}
|
||||
pulse={Boolean(telemetry.bumpLeft)}
|
||||
/>
|
||||
<ArcSegment
|
||||
cx={centerX}
|
||||
cy={centerY}
|
||||
rInner={lightRingInner - bumpRightOffset}
|
||||
rOuter={lightRingOuter - bumpRightOffset}
|
||||
cx={geometry.centerX}
|
||||
cy={geometry.centerY}
|
||||
rInner={geometry.lightRingInner - bumpRightOffset}
|
||||
rOuter={geometry.lightRingOuter - bumpRightOffset}
|
||||
startDeg={6}
|
||||
endDeg={70}
|
||||
color={bumps.bumpRight ? '#ef4444' : '#475569'}
|
||||
color={telemetry.bumpRight ? '#ef4444' : '#475569'}
|
||||
opacity={1}
|
||||
pulse={Boolean(bumps.bumpRight)}
|
||||
pulse={Boolean(telemetry.bumpRight)}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// visuals
|
||||
// Purpose: Defines the visuals 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 React from 'react';
|
||||
import { clamp01, currentColor, describeArc, lightBumpColor, cliffColor, polarToCartesian, toRad } from './helpers.js';
|
||||
import React, { useMemo } from 'react';
|
||||
import { clamp01, currentColor, describeArc, polarToCartesian, toRad } from './helpers.js';
|
||||
|
||||
export function ArcSegment({ cx, cy, rInner, rOuter, startDeg, endDeg, color, pulse = false, opacity = 1 }) {
|
||||
export const ArcSegment = React.memo(function ArcSegment({ cx, cy, rInner, rOuter, startDeg, endDeg, color, pulse = false, opacity = 1 }) {
|
||||
const rMid = (rInner + rOuter) / 2;
|
||||
const strokeWidth = rOuter - rInner;
|
||||
const path = describeArc(cx, cy, rMid, startDeg, endDeg);
|
||||
const path = useMemo(
|
||||
() => describeArc(cx, cy, rMid, startDeg, endDeg),
|
||||
[cx, cy, endDeg, rMid, startDeg],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<path d={path} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" fill="none" opacity={opacity} />
|
||||
@@ -24,23 +27,28 @@ export function ArcSegment({ cx, cy, rInner, rOuter, startDeg, endDeg, color, pu
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function ConeSegment({ cx, cy, rBase, rTip, startDeg, endDeg, color, value, max }) {
|
||||
export const ConeSegment = React.memo(function ConeSegment({ cx, cy, rBase, rTip, startDeg, endDeg, color, value, max }) {
|
||||
const mid = (startDeg + endDeg) / 2;
|
||||
const tip = polarToCartesian(cx, cy, rTip, mid);
|
||||
const norm = clamp01(value != null ? value / (max || 1) : 0);
|
||||
const eased = Math.pow(norm, 0.35);
|
||||
const filledR = rBase - (rBase - rTip) * eased;
|
||||
const barR = Math.max(rTip, Math.min(filledR, rBase));
|
||||
const filledA = polarToCartesian(cx, cy, barR, startDeg);
|
||||
const filledB = polarToCartesian(cx, cy, barR, endDeg);
|
||||
const fg = `M ${tip.x} ${tip.y} L ${filledA.x} ${filledA.y} L ${filledB.x} ${filledB.y} Z`;
|
||||
const fg = useMemo(() => {
|
||||
// The cone geometry is still dynamic because the filled radius changes
|
||||
// with light-bump strength, but the memo prevents unrelated parent renders
|
||||
// from rebuilding the SVG path string for every cone.
|
||||
const tip = polarToCartesian(cx, cy, rTip, mid);
|
||||
const filledA = polarToCartesian(cx, cy, barR, startDeg);
|
||||
const filledB = polarToCartesian(cx, cy, barR, endDeg);
|
||||
return `M ${tip.x} ${tip.y} L ${filledA.x} ${filledA.y} L ${filledB.x} ${filledB.y} Z`;
|
||||
}, [barR, cx, cy, endDeg, mid, rTip, startDeg]);
|
||||
|
||||
return <path d={fg} fill={color} opacity={1} stroke="none" />;
|
||||
}
|
||||
});
|
||||
|
||||
export function WheelVisual({ cx, cy, current, drop, overcurrent, label }) {
|
||||
export const WheelVisual = React.memo(function WheelVisual({ cx, cy, current, drop, overcurrent, label }) {
|
||||
const mag = Math.abs(current);
|
||||
const pct = clamp01(mag / 1200);
|
||||
const color = currentColor(current, overcurrent);
|
||||
@@ -63,9 +71,9 @@ export function WheelVisual({ cx, cy, current, drop, overcurrent, label }) {
|
||||
<text x={0} y={barH / 2 + 10} textAnchor="middle" className="fill-slate-200 text-[0.7rem]">{label}</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function SideBrushVisual({ cx, cy, current, overcurrent }) {
|
||||
export const SideBrushVisual = React.memo(function SideBrushVisual({ cx, cy, current, overcurrent }) {
|
||||
let mag = Math.abs(current);
|
||||
if (mag < 10) mag = 0;
|
||||
const color = currentColor(current * 3, overcurrent);
|
||||
@@ -91,9 +99,9 @@ export function SideBrushVisual({ cx, cy, current, overcurrent }) {
|
||||
{overcurrent ? <circle cx={cx} cy={cy} r={armLength + 8} stroke="#ef4444" strokeWidth="3" fill="none" className="animate-pulse" /> : null}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function MainBrushVisual({ cx, cy, current, overcurrent, variant, dirtLeft, dirtRight }) {
|
||||
export const MainBrushVisual = React.memo(function MainBrushVisual({ cx, cy, current, overcurrent, variant }) {
|
||||
const mag = Math.abs(current);
|
||||
const color = currentColor(current, overcurrent);
|
||||
const opacity = 1;
|
||||
@@ -135,6 +143,4 @@ export function MainBrushVisual({ cx, cy, current, overcurrent, variant, dirtLef
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export { lightBumpColor, cliffColor };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user