mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
topdown reworkie
This commit is contained in:
+1
-1
@@ -87,7 +87,7 @@ function DesktopLayout({ layout, onOpenHelpOverlay }) {
|
||||
<div className={`flex min-w-0 flex-[1.22] flex-col ${themeGapClass} overflow-y-auto pr-0`}>
|
||||
<DriverVideo />
|
||||
<PiHostStatsCard />
|
||||
<TelemetryPanel />
|
||||
{/* <TelemetryPanel /> */}
|
||||
</div>
|
||||
<div className={`flex min-w-0 flex-1 flex-col ${themeGapClass} overflow-y-auto`}>
|
||||
<GlobalObjectiveBanner layout={layout} />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import TopDownMap from '../../TopDownMap/index.jsx';
|
||||
|
||||
export default function HudMapOverlay({
|
||||
roverId = null,
|
||||
sensors,
|
||||
mapTelemetry = null,
|
||||
show = true,
|
||||
mapPosition = 'top-center',
|
||||
layoutFormat = 'desktop',
|
||||
@@ -33,7 +33,7 @@ export default function HudMapOverlay({
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute rounded" style={mapStyle}>
|
||||
<TopDownMap sensors={sensors} mapTelemetry={mapTelemetry} size={240} overlay />
|
||||
<TopDownMap roverId={roverId} sensors={sensors} size={240} overlay />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ import { useHudMapSetting } from '../../../hooks/useHudMapSetting.js';
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
||||
import {
|
||||
mapTelemetryEqual,
|
||||
selectSpectatorTelemetry,
|
||||
selectVisualMapTelemetry,
|
||||
spectatorTelemetryEqual,
|
||||
} from '../../../context/telemetryViews.js';
|
||||
import RoverLabelOverlay from './RoverLabelOverlay.jsx';
|
||||
@@ -30,7 +28,6 @@ function HudOverlay({
|
||||
}) {
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||
const mapTelemetry = useVisualTelemetrySelector(effectiveRoverId, selectVisualMapTelemetry, mapTelemetryEqual);
|
||||
const spectatorTelemetry = useVisualTelemetrySelector(effectiveRoverId, selectSpectatorTelemetry, spectatorTelemetryEqual);
|
||||
const rosterInfo = useSessionSelector((state) => {
|
||||
if (!effectiveRoverId) return { label: null, roverColor: null };
|
||||
@@ -48,7 +45,6 @@ function HudOverlay({
|
||||
const match = users.find((u) => String(u.socketId || '') === String(activeId || ''));
|
||||
return match?.nickname || match?.name || null;
|
||||
});
|
||||
const resolvedMapTelemetry = sensors ? null : mapTelemetry;
|
||||
const resolvedSensors = sensors ?? null;
|
||||
const resolvedLabel = label ?? rosterInfo.label ?? null;
|
||||
const resolvedRoverColor = roverColor ?? rosterInfo.roverColor ?? null;
|
||||
@@ -85,8 +81,8 @@ function HudOverlay({
|
||||
/>
|
||||
</div>
|
||||
<HudMapOverlay
|
||||
roverId={effectiveRoverId}
|
||||
sensors={resolvedSensors}
|
||||
mapTelemetry={resolvedMapTelemetry}
|
||||
show={resolvedShowTopDown}
|
||||
mapPosition={resolvedMapPosition}
|
||||
layoutFormat={layoutFormat}
|
||||
@@ -106,8 +102,8 @@ function HudOverlay({
|
||||
labelScale={labelScale}
|
||||
/>
|
||||
<HudMapOverlay
|
||||
roverId={effectiveRoverId}
|
||||
sensors={resolvedSensors}
|
||||
mapTelemetry={resolvedMapTelemetry}
|
||||
show={resolvedShowTopDown && variant !== 'spectator'}
|
||||
mapPosition={resolvedMapPosition}
|
||||
layoutFormat={layoutFormat}
|
||||
|
||||
@@ -13,8 +13,6 @@ import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../Tabs/index.jsx';
|
||||
import TopDownMap from '../TopDownMap/index.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';
|
||||
@@ -44,14 +42,13 @@ const CHAT_DOCK_BOTTOM_INSET = 8;
|
||||
|
||||
function TopDownMapPanel() {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const mapTelemetry = useVisualTelemetrySelector(roverId, selectVisualMapTelemetry, mapTelemetryEqual);
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title = "Roomba sensor view"
|
||||
>
|
||||
<div className="aspect-square w-full">
|
||||
<TopDownMap mapTelemetry={mapTelemetry} />
|
||||
<TopDownMap roverId={roverId} />
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
// 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, { useMemo } from 'react';
|
||||
import { selectVisualMapTelemetry } from '../../context/telemetryViews.js';
|
||||
import { buildSegments, cliffColor, lightBumpColor } from './helpers.js';
|
||||
import {
|
||||
ArcSegment,
|
||||
ConeSegment,
|
||||
WheelVisual,
|
||||
SideBrushVisual,
|
||||
MainBrushVisual,
|
||||
} from './visuals.jsx';
|
||||
import { buildSegments } from './helpers.js';
|
||||
import BumpLayer from './layers/BumpLayer/index.jsx';
|
||||
import CliffLayer from './layers/CliffLayer/index.jsx';
|
||||
import ElectricalReadoutLayer from './layers/ElectricalReadoutLayer/index.jsx';
|
||||
import LightBumpLayer from './layers/LightBumpLayer/index.jsx';
|
||||
import MainBrushLayer from './layers/MainBrushLayer/index.jsx';
|
||||
// import RawFrameStripLayer from './layers/RawFrameStripLayer/index.jsx';
|
||||
import SideBrushLayer from './layers/SideBrushLayer/index.jsx';
|
||||
import WheelLayer from './layers/WheelLayer/index.jsx';
|
||||
|
||||
const LIGHT_LABELS = ['L', 'FL', 'CL', 'CR', 'FR', 'R'];
|
||||
|
||||
@@ -26,6 +26,18 @@ function buildGeometry(size, variant) {
|
||||
const cliffRingOuter = innerCircle - 6;
|
||||
const wheelLineOffset = innerCircle * 0.65;
|
||||
const lightAngles = buildSegments({ count: 6, totalSpan: 140, gap: 6, startAngle: -70 });
|
||||
// The body circle consumes almost the whole square, especially in the HUD
|
||||
// overlay where the map is scaled down. The bottom corners are the only
|
||||
// reliable empty spaces, so new telemetry is anchored there instead of adding
|
||||
// separate HUD/non-HUD layouts that would drift apart over time.
|
||||
const maxCornerReadoutWidth = size * 0.46;
|
||||
const readoutWidth = Math.min(maxCornerReadoutWidth, Math.max(96, size * 0.42));
|
||||
const readoutHeight = Math.max(18, size * 0.08);
|
||||
const readoutInset = Math.max(6, size * 0.03);
|
||||
const bottomReadoutY = size - readoutInset - readoutHeight;
|
||||
const rawFrameStripWidth = innerCircle * 1.05;
|
||||
const rawFrameStripHeight = Math.max(13, size * 0.055);
|
||||
const rawFrameStripY = centerY + innerCircle * 0.38;
|
||||
|
||||
return {
|
||||
size,
|
||||
@@ -38,48 +50,42 @@ function buildGeometry(size, variant) {
|
||||
cliffRingInner,
|
||||
cliffRingOuter,
|
||||
wheelLineOffset,
|
||||
voltageReadout: {
|
||||
x: readoutInset,
|
||||
y: bottomReadoutY,
|
||||
width: readoutWidth,
|
||||
height: readoutHeight,
|
||||
},
|
||||
currentReadout: {
|
||||
x: size - readoutInset - readoutWidth,
|
||||
y: bottomReadoutY,
|
||||
width: readoutWidth,
|
||||
height: readoutHeight,
|
||||
},
|
||||
// rawFrameStrip: {
|
||||
// x: centerX - rawFrameStripWidth / 2,
|
||||
// y: rawFrameStripY,
|
||||
// width: rawFrameStripWidth,
|
||||
// height: rawFrameStripHeight,
|
||||
// },
|
||||
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' },
|
||||
{ label: 'Cliff L', start: -60, end: -46, valueKey: 'cliffLeftSignal', activeKey: 'cliffLeft', fillFromEnd: true },
|
||||
{ label: 'Cliff FL', start: -32, end: -16, valueKey: 'cliffFrontLeftSignal', activeKey: 'cliffFrontLeft', fillFromEnd: true },
|
||||
{ label: 'Cliff FR', start: 16, end: 32, valueKey: 'cliffFrontRightSignal', activeKey: 'cliffFrontRight', fillFromEnd: false },
|
||||
{ label: 'Cliff R', start: 46, end: 60, valueKey: 'cliffRightSignal', activeKey: 'cliffRight', fillFromEnd: false },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function TopDownMapContent({ sensors = {}, mapTelemetry = null, variant = 'full', size: overrideSize, overlay = false }) {
|
||||
function TopDownMapContent({ roverId = null, sensors = 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);
|
||||
const maxLight = lightMaxSamples.length ? Math.max(...lightMaxSamples, 1200) : 1200;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${overlay ? 'relative' : 'relative p-1'}`}
|
||||
@@ -87,72 +93,14 @@ function TopDownMapContent({ sensors = {}, mapTelemetry = null, variant = 'full'
|
||||
>
|
||||
<svg width="100%" height="100%" viewBox={`0 0 ${size} ${size}`} preserveAspectRatio="xMidYMid meet" className="mx-auto block">
|
||||
<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={geometry.centerX}
|
||||
cy={geometry.centerY}
|
||||
current={mainBrushCurrent}
|
||||
overcurrent={telemetry.mainBrushOvercurrent}
|
||||
variant={variant}
|
||||
/>
|
||||
{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={geometry.centerX}
|
||||
cy={geometry.centerY}
|
||||
rBase={baseR}
|
||||
rTip={tipR}
|
||||
startDeg={seg.start}
|
||||
endDeg={seg.end}
|
||||
color={color}
|
||||
value={value}
|
||||
max={maxLight}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{geometry.cliffSegments.map((seg) => (
|
||||
<ArcSegment
|
||||
key={seg.label}
|
||||
cx={geometry.centerX}
|
||||
cy={geometry.centerY}
|
||||
rInner={geometry.cliffRingInner}
|
||||
rOuter={geometry.cliffRingOuter}
|
||||
startDeg={seg.start}
|
||||
endDeg={seg.end}
|
||||
color={cliffColor(telemetry[seg.valueKey], telemetry[seg.activeKey])}
|
||||
opacity={1}
|
||||
pulse={Boolean(telemetry[seg.activeKey])}
|
||||
/>
|
||||
))}
|
||||
<ArcSegment
|
||||
cx={geometry.centerX}
|
||||
cy={geometry.centerY}
|
||||
rInner={geometry.lightRingInner - bumpLeftOffset}
|
||||
rOuter={geometry.lightRingOuter - bumpLeftOffset}
|
||||
startDeg={-70}
|
||||
endDeg={-6}
|
||||
color={telemetry.bumpLeft ? '#ef4444' : '#475569'}
|
||||
opacity={1}
|
||||
pulse={Boolean(telemetry.bumpLeft)}
|
||||
/>
|
||||
<ArcSegment
|
||||
cx={geometry.centerX}
|
||||
cy={geometry.centerY}
|
||||
rInner={geometry.lightRingInner - bumpRightOffset}
|
||||
rOuter={geometry.lightRingOuter - bumpRightOffset}
|
||||
startDeg={6}
|
||||
endDeg={70}
|
||||
color={telemetry.bumpRight ? '#ef4444' : '#475569'}
|
||||
opacity={1}
|
||||
pulse={Boolean(telemetry.bumpRight)}
|
||||
/>
|
||||
<WheelLayer roverId={roverId} sensors={sensors} geometry={geometry} />
|
||||
<SideBrushLayer roverId={roverId} sensors={sensors} geometry={geometry} />
|
||||
<MainBrushLayer roverId={roverId} sensors={sensors} geometry={geometry} variant={variant} />
|
||||
{/* <RawFrameStripLayer roverId={sensors ? null : roverId} geometry={geometry} /> */}
|
||||
<LightBumpLayer roverId={roverId} sensors={sensors} geometry={geometry} />
|
||||
<CliffLayer roverId={roverId} sensors={sensors} geometry={geometry} />
|
||||
<BumpLayer roverId={roverId} sensors={sensors} geometry={geometry} />
|
||||
<ElectricalReadoutLayer roverId={roverId} sensors={sensors} geometry={geometry} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -44,15 +44,6 @@ export function lightBumpColor(value, max) {
|
||||
return `hsl(${hue} 100% 60%)`;
|
||||
}
|
||||
|
||||
export function cliffColor(value, active) {
|
||||
if (active) return '#ef4444';
|
||||
const t = clamp01(value == null ? 0 : value / 4095);
|
||||
const start = [47, 55, 69];
|
||||
const end = [245, 158, 11];
|
||||
const [r, g, b] = start.map((s, i) => Math.round(s + (end[i] - s) * t));
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
export function buildSegments({ count, totalSpan, gap, startAngle }) {
|
||||
const usable = totalSpan - gap * (count - 1);
|
||||
const width = usable / count;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { shallowObjectEqual } from '../../../../context/TelemetryContext.jsx';
|
||||
import { ArcSegment } from '../../visuals.jsx';
|
||||
import { useTopDownTelemetry } from '../layerTelemetry.js';
|
||||
|
||||
const EMPTY_BUMP_TELEMETRY = Object.freeze({
|
||||
bumpLeft: false,
|
||||
bumpRight: false,
|
||||
});
|
||||
|
||||
function selectBumpTelemetry(frame) {
|
||||
const sensors = frame?.sensors;
|
||||
if (!sensors) return EMPTY_BUMP_TELEMETRY;
|
||||
const bumps = sensors.bumpsAndWheelDrops || {};
|
||||
return {
|
||||
bumpLeft: Boolean(bumps.bumpLeft),
|
||||
bumpRight: Boolean(bumps.bumpRight),
|
||||
};
|
||||
}
|
||||
|
||||
function BumpLayer({ roverId, sensors, geometry }) {
|
||||
const telemetry = useTopDownTelemetry(roverId, sensors, selectBumpTelemetry, shallowObjectEqual);
|
||||
const bumpDepress = 6;
|
||||
const bumpLeftOffset = telemetry.bumpLeft ? bumpDepress : 0;
|
||||
const bumpRightOffset = telemetry.bumpRight ? bumpDepress : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArcSegment
|
||||
cx={geometry.centerX}
|
||||
cy={geometry.centerY}
|
||||
rInner={geometry.lightRingInner - bumpLeftOffset}
|
||||
rOuter={geometry.lightRingOuter - bumpLeftOffset}
|
||||
startDeg={-70}
|
||||
endDeg={-6}
|
||||
color={telemetry.bumpLeft ? '#ef4444' : '#475569'}
|
||||
opacity={1}
|
||||
pulse={Boolean(telemetry.bumpLeft)}
|
||||
/>
|
||||
<ArcSegment
|
||||
cx={geometry.centerX}
|
||||
cy={geometry.centerY}
|
||||
rInner={geometry.lightRingInner - bumpRightOffset}
|
||||
rOuter={geometry.lightRingOuter - bumpRightOffset}
|
||||
startDeg={6}
|
||||
endDeg={70}
|
||||
color={telemetry.bumpRight ? '#ef4444' : '#475569'}
|
||||
opacity={1}
|
||||
pulse={Boolean(telemetry.bumpRight)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(BumpLayer);
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { shallowObjectEqual } from '../../../../context/TelemetryContext.jsx';
|
||||
import { CurvedArcBar } from '../../visuals.jsx';
|
||||
import { rawNumber, useTopDownTelemetry } from '../layerTelemetry.js';
|
||||
|
||||
const EMPTY_CLIFF_TELEMETRY = Object.freeze({
|
||||
cliffLeftSignal: null,
|
||||
cliffFrontLeftSignal: null,
|
||||
cliffFrontRightSignal: null,
|
||||
cliffRightSignal: null,
|
||||
cliffLeft: false,
|
||||
cliffFrontLeft: false,
|
||||
cliffFrontRight: false,
|
||||
cliffRight: false,
|
||||
});
|
||||
|
||||
function selectCliffTelemetry(frame) {
|
||||
const sensors = frame?.sensors;
|
||||
if (!sensors) return EMPTY_CLIFF_TELEMETRY;
|
||||
return {
|
||||
cliffLeftSignal: rawNumber(sensors.cliffLeftSignal),
|
||||
cliffFrontLeftSignal: rawNumber(sensors.cliffFrontLeftSignal),
|
||||
cliffFrontRightSignal: rawNumber(sensors.cliffFrontRightSignal),
|
||||
cliffRightSignal: rawNumber(sensors.cliffRightSignal),
|
||||
cliffLeft: Boolean(sensors.cliffLeft),
|
||||
cliffFrontLeft: Boolean(sensors.cliffFrontLeft),
|
||||
cliffFrontRight: Boolean(sensors.cliffFrontRight),
|
||||
cliffRight: Boolean(sensors.cliffRight),
|
||||
};
|
||||
}
|
||||
|
||||
function cliffSignalPercent(value) {
|
||||
// Create cliff signals are unsigned 12-bit-ish analog values in this stream.
|
||||
// The curved bar shows the raw signal magnitude as fill amount, while the
|
||||
// cliff boolean controls the danger background separately.
|
||||
if (value == null || !Number.isFinite(Number(value))) return 0;
|
||||
return Math.max(0, Math.min(1, Number(value) / 4095));
|
||||
}
|
||||
|
||||
function CliffLayer({ roverId, sensors, geometry }) {
|
||||
const telemetry = useTopDownTelemetry(roverId, sensors, selectCliffTelemetry, shallowObjectEqual);
|
||||
|
||||
return (
|
||||
<>
|
||||
{geometry.cliffSegments.map((seg) => (
|
||||
<CurvedArcBar
|
||||
key={seg.label}
|
||||
cx={geometry.centerX}
|
||||
cy={geometry.centerY}
|
||||
rInner={geometry.cliffRingInner}
|
||||
rOuter={geometry.cliffRingOuter}
|
||||
startDeg={seg.start}
|
||||
endDeg={seg.end}
|
||||
percent={cliffSignalPercent(telemetry[seg.valueKey])}
|
||||
backgroundColor={telemetry[seg.activeKey] ? '#334155' : '#7f1d1d'}
|
||||
fillColor={telemetry[seg.activeKey] ? '#f59e0b' : '#fbbf24'}
|
||||
fillFromEnd={seg.fillFromEnd}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(CliffLayer);
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { shallowObjectEqual } from '../../../../context/TelemetryContext.jsx';
|
||||
import { ReadoutBar } from '../../visuals.jsx';
|
||||
import { rawNumber, useTopDownTelemetry } from '../layerTelemetry.js';
|
||||
|
||||
const EMPTY_ELECTRICAL_TELEMETRY = Object.freeze({
|
||||
voltageMv: null,
|
||||
currentMa: null,
|
||||
chargingStateLabel: '',
|
||||
});
|
||||
|
||||
function selectElectricalTelemetry(frame) {
|
||||
const sensors = frame?.sensors;
|
||||
if (!sensors) return EMPTY_ELECTRICAL_TELEMETRY;
|
||||
return {
|
||||
voltageMv: rawNumber(sensors.voltageMv),
|
||||
currentMa: rawNumber(sensors.currentMa),
|
||||
chargingStateLabel: sensors.chargingState?.label || '',
|
||||
};
|
||||
}
|
||||
|
||||
function formatVoltage(voltageMv) {
|
||||
if (voltageMv == null || !Number.isFinite(Number(voltageMv))) return '--.-V';
|
||||
return `${(Number(voltageMv) / 1000).toFixed(1)}V`;
|
||||
}
|
||||
|
||||
function formatCurrent(currentMa) {
|
||||
if (currentMa == null || !Number.isFinite(Number(currentMa))) return '---mA';
|
||||
const current = Number(currentMa);
|
||||
const prefix = current > 0 ? '+' : '';
|
||||
return `${prefix}${Math.round(current)}mA`;
|
||||
}
|
||||
|
||||
function voltagePercent(voltageMv) {
|
||||
// This is intentionally a display range, not battery-health logic. A Create
|
||||
// 2-class pack usually lives around the mid-teens in volts, so 12-17V gives
|
||||
// the tiny map bar useful movement without pretending to be a precise fuel
|
||||
// gauge. The real battery percent remains handled by the dedicated battery UI.
|
||||
if (voltageMv == null || !Number.isFinite(Number(voltageMv))) return 0;
|
||||
return clampDisplay((Number(voltageMv) - 12000) / 5000);
|
||||
}
|
||||
|
||||
function currentPercent(currentMa) {
|
||||
// Current changes sign depending on charge/discharge direction, but the bar
|
||||
// is meant to show load magnitude. The signed text beside it preserves the
|
||||
// actual direction while the fill gives a quick "how much is happening" cue.
|
||||
if (currentMa == null || !Number.isFinite(Number(currentMa))) return 0;
|
||||
return clampDisplay(Math.abs(Number(currentMa)) / 2500);
|
||||
}
|
||||
|
||||
function clampDisplay(value) {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function ElectricalReadoutLayer({ roverId, sensors, geometry }) {
|
||||
const telemetry = useTopDownTelemetry(roverId, sensors, selectElectricalTelemetry, shallowObjectEqual);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReadoutBar
|
||||
x={geometry.voltageReadout.x}
|
||||
y={geometry.voltageReadout.y}
|
||||
width={geometry.voltageReadout.width}
|
||||
height={geometry.voltageReadout.height}
|
||||
label={telemetry.chargingStateLabel || 'unknown'}
|
||||
valueText={formatVoltage(telemetry.voltageMv)}
|
||||
percent={voltagePercent(telemetry.voltageMv)}
|
||||
color="#38bdf8"
|
||||
missing={telemetry.voltageMv == null}
|
||||
/>
|
||||
<ReadoutBar
|
||||
x={geometry.currentReadout.x}
|
||||
y={geometry.currentReadout.y}
|
||||
width={geometry.currentReadout.width}
|
||||
height={geometry.currentReadout.height}
|
||||
label="current"
|
||||
valueText={formatCurrent(telemetry.currentMa)}
|
||||
percent={currentPercent(telemetry.currentMa)}
|
||||
color={Number(telemetry.currentMa) < 0 ? '#f59e0b' : '#22c55e'}
|
||||
missing={telemetry.currentMa == null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ElectricalReadoutLayer);
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { shallowArrayEqual } from '../../../../context/TelemetryContext.jsx';
|
||||
import { lightBumpColor } from '../../helpers.js';
|
||||
import { ConeSegment } from '../../visuals.jsx';
|
||||
import { rawNumber, useTopDownTelemetry } from '../layerTelemetry.js';
|
||||
|
||||
const EMPTY_LIGHT_BUMP_TELEMETRY = Object.freeze([null, null, null, null, null, null]);
|
||||
|
||||
function selectLightBumpTelemetry(frame) {
|
||||
const sensors = frame?.sensors;
|
||||
if (!sensors) return EMPTY_LIGHT_BUMP_TELEMETRY;
|
||||
return [
|
||||
rawNumber(sensors.lightBumpLeftSignal),
|
||||
rawNumber(sensors.lightBumpFrontLeftSignal),
|
||||
rawNumber(sensors.lightBumpCenterLeftSignal),
|
||||
rawNumber(sensors.lightBumpCenterRightSignal),
|
||||
rawNumber(sensors.lightBumpFrontRightSignal),
|
||||
rawNumber(sensors.lightBumpRightSignal),
|
||||
];
|
||||
}
|
||||
|
||||
function LightBumpLayer({ roverId, sensors, geometry }) {
|
||||
const lightValues = useTopDownTelemetry(roverId, sensors, selectLightBumpTelemetry, shallowArrayEqual);
|
||||
const lightMaxSamples = lightValues.filter((value) => value != null);
|
||||
const maxLight = lightMaxSamples.length ? Math.max(...lightMaxSamples, 1200) : 1200;
|
||||
|
||||
return (
|
||||
<>
|
||||
{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={geometry.centerX}
|
||||
cy={geometry.centerY}
|
||||
rBase={baseR}
|
||||
rTip={tipR}
|
||||
startDeg={seg.start}
|
||||
endDeg={seg.end}
|
||||
color={color}
|
||||
value={value}
|
||||
max={maxLight}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LightBumpLayer);
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { shallowObjectEqual } from '../../../../context/TelemetryContext.jsx';
|
||||
import { MainBrushVisual } from '../../visuals.jsx';
|
||||
import { rawNumber, useTopDownTelemetry } from '../layerTelemetry.js';
|
||||
|
||||
const EMPTY_MAIN_BRUSH_TELEMETRY = Object.freeze({
|
||||
mainBrushCurrentMa: 0,
|
||||
mainBrushOvercurrent: false,
|
||||
dirtDetectLeft: null,
|
||||
dirtDetect: null,
|
||||
});
|
||||
|
||||
function selectMainBrushTelemetry(frame) {
|
||||
const sensors = frame?.sensors;
|
||||
if (!sensors) return EMPTY_MAIN_BRUSH_TELEMETRY;
|
||||
return {
|
||||
mainBrushCurrentMa: rawNumber(sensors.mainBrushCurrentMa, 0),
|
||||
mainBrushOvercurrent: Boolean(sensors.wheelOvercurrents?.mainBrush),
|
||||
dirtDetectLeft: rawNumber(sensors.dirtDetectLeft),
|
||||
dirtDetect: rawNumber(sensors.dirtDetect),
|
||||
};
|
||||
}
|
||||
|
||||
function MainBrushLayer({ roverId, sensors, geometry, variant }) {
|
||||
const telemetry = useTopDownTelemetry(roverId, sensors, selectMainBrushTelemetry, shallowObjectEqual);
|
||||
|
||||
return (
|
||||
<MainBrushVisual
|
||||
cx={geometry.centerX}
|
||||
cy={geometry.centerY}
|
||||
current={telemetry.mainBrushCurrentMa}
|
||||
overcurrent={telemetry.mainBrushOvercurrent}
|
||||
variant={variant}
|
||||
dirtLeft={telemetry.dirtDetectLeft}
|
||||
dirtRight={telemetry.dirtDetect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(MainBrushLayer);
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useVisualTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
|
||||
import { RawFrameStrip } from '../../visuals.jsx';
|
||||
|
||||
const EMPTY_RAW_FRAME_BYTES = Object.freeze([]);
|
||||
|
||||
function selectRawSensorFrame(frame) {
|
||||
return frame?.raw || null;
|
||||
}
|
||||
|
||||
function decodeRawFrameBytes(rawFrame) {
|
||||
if (!rawFrame || typeof rawFrame !== 'string' || typeof globalThis.atob !== 'function') {
|
||||
return EMPTY_RAW_FRAME_BYTES;
|
||||
}
|
||||
|
||||
try {
|
||||
const binary = globalThis.atob(rawFrame);
|
||||
const bytes = [];
|
||||
for (let idx = 0; idx < binary.length; idx += 1) {
|
||||
// atob returns a binary string where each character code is the original
|
||||
// byte value. Masking with 0xff keeps the visualization tied to the raw
|
||||
// byte stream even if a browser exposes a wider internal string code unit.
|
||||
bytes.push(binary.charCodeAt(idx) & 0xff);
|
||||
}
|
||||
return bytes;
|
||||
} catch {
|
||||
return EMPTY_RAW_FRAME_BYTES;
|
||||
}
|
||||
}
|
||||
|
||||
function RawFrameStripLayer({ roverId, geometry }) {
|
||||
const rawFrame = useVisualTelemetrySelector(roverId, selectRawSensorFrame, Object.is);
|
||||
const bytes = useMemo(() => decodeRawFrameBytes(rawFrame), [rawFrame]);
|
||||
|
||||
return (
|
||||
<RawFrameStrip
|
||||
x={geometry.rawFrameStrip.x}
|
||||
y={geometry.rawFrameStrip.y}
|
||||
width={geometry.rawFrameStrip.width}
|
||||
height={geometry.rawFrameStrip.height}
|
||||
bytes={bytes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(RawFrameStripLayer);
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { shallowObjectEqual } from '../../../../context/TelemetryContext.jsx';
|
||||
import { SideBrushVisual } from '../../visuals.jsx';
|
||||
import { rawNumber, useTopDownTelemetry } from '../layerTelemetry.js';
|
||||
|
||||
const EMPTY_SIDE_BRUSH_TELEMETRY = Object.freeze({
|
||||
sideBrushCurrentMa: 0,
|
||||
sideBrushOvercurrent: false,
|
||||
});
|
||||
|
||||
function selectSideBrushTelemetry(frame) {
|
||||
const sensors = frame?.sensors;
|
||||
if (!sensors) return EMPTY_SIDE_BRUSH_TELEMETRY;
|
||||
return {
|
||||
sideBrushCurrentMa: rawNumber(sensors.sideBrushCurrentMa, 0),
|
||||
sideBrushOvercurrent: Boolean(sensors.wheelOvercurrents?.sideBrush),
|
||||
};
|
||||
}
|
||||
|
||||
function SideBrushLayer({ roverId, sensors, geometry }) {
|
||||
const telemetry = useTopDownTelemetry(roverId, sensors, selectSideBrushTelemetry, shallowObjectEqual);
|
||||
|
||||
return (
|
||||
<SideBrushVisual
|
||||
cx={geometry.centerX + geometry.innerCircle * 0.65}
|
||||
cy={geometry.centerY - geometry.innerCircle * 0.55}
|
||||
current={telemetry.sideBrushCurrentMa}
|
||||
overcurrent={telemetry.sideBrushOvercurrent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(SideBrushLayer);
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { shallowObjectEqual } from '../../../../context/TelemetryContext.jsx';
|
||||
import { WheelVisual } from '../../visuals.jsx';
|
||||
import { rawNumber, useTopDownTelemetry } from '../layerTelemetry.js';
|
||||
|
||||
const EMPTY_WHEEL_TELEMETRY = Object.freeze({
|
||||
wheelDropLeft: false,
|
||||
wheelDropRight: false,
|
||||
leftWheelOvercurrent: false,
|
||||
rightWheelOvercurrent: false,
|
||||
wheelLeftCurrentMa: 0,
|
||||
wheelRightCurrentMa: 0,
|
||||
});
|
||||
|
||||
function selectWheelTelemetry(frame) {
|
||||
const sensors = frame?.sensors;
|
||||
if (!sensors) return EMPTY_WHEEL_TELEMETRY;
|
||||
const bumps = sensors.bumpsAndWheelDrops || {};
|
||||
const wheelOver = sensors.wheelOvercurrents || {};
|
||||
return {
|
||||
wheelDropLeft: Boolean(bumps.wheelDropLeft),
|
||||
wheelDropRight: Boolean(bumps.wheelDropRight),
|
||||
leftWheelOvercurrent: Boolean(wheelOver.leftWheel),
|
||||
rightWheelOvercurrent: Boolean(wheelOver.rightWheel),
|
||||
wheelLeftCurrentMa: rawNumber(sensors.wheelLeftCurrentMa, 0),
|
||||
wheelRightCurrentMa: rawNumber(sensors.wheelRightCurrentMa, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function WheelLayer({ roverId, sensors, geometry }) {
|
||||
const telemetry = useTopDownTelemetry(roverId, sensors, selectWheelTelemetry, shallowObjectEqual);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WheelVisual
|
||||
cx={geometry.centerX - geometry.wheelLineOffset}
|
||||
cy={geometry.centerY}
|
||||
current={telemetry.wheelLeftCurrentMa}
|
||||
drop={telemetry.wheelDropLeft}
|
||||
overcurrent={telemetry.leftWheelOvercurrent}
|
||||
label="L"
|
||||
/>
|
||||
<WheelVisual
|
||||
cx={geometry.centerX + geometry.wheelLineOffset}
|
||||
cy={geometry.centerY}
|
||||
current={telemetry.wheelRightCurrentMa}
|
||||
drop={telemetry.wheelDropRight}
|
||||
overcurrent={telemetry.rightWheelOvercurrent}
|
||||
label="R"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(WheelLayer);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
||||
|
||||
export function rawNumber(value, fallback = null) {
|
||||
// Top-down visual layers intentionally consume raw sensor resolution. The
|
||||
// visual subscription can still throttle notification cadence for mobile or
|
||||
// spectator layouts, but this helper never rounds or buckets the value.
|
||||
if (value == null || !Number.isFinite(Number(value))) return fallback;
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
export function useTopDownTelemetry(roverId, sensors, selector, equalityFn) {
|
||||
// Raw sensor snapshots are kept for fallback/static call sites. Live map
|
||||
// rendering should subscribe by rover id, so passing null while raw sensors
|
||||
// are supplied avoids a wasted live subscription and keeps hook order stable.
|
||||
const selected = useVisualTelemetrySelector(sensors ? null : roverId, selector, equalityFn);
|
||||
return sensors ? selector({ sensors }) : selected;
|
||||
}
|
||||
@@ -29,6 +29,46 @@ export const ArcSegment = React.memo(function ArcSegment({ cx, cy, rInner, rOute
|
||||
);
|
||||
});
|
||||
|
||||
export const CurvedArcBar = React.memo(function CurvedArcBar({
|
||||
cx,
|
||||
cy,
|
||||
rInner,
|
||||
rOuter,
|
||||
startDeg,
|
||||
endDeg,
|
||||
percent,
|
||||
backgroundColor,
|
||||
fillColor,
|
||||
fillFromEnd = false,
|
||||
}) {
|
||||
const safePercent = clamp01(percent ?? 0);
|
||||
const rMid = (rInner + rOuter) / 2;
|
||||
const strokeWidth = rOuter - rInner;
|
||||
const backgroundPath = useMemo(
|
||||
() => describeArc(cx, cy, rMid, startDeg, endDeg),
|
||||
[cx, cy, endDeg, rMid, startDeg],
|
||||
);
|
||||
const fillPath = useMemo(() => {
|
||||
// The foreground bar uses the same arc geometry as the background pill.
|
||||
// Mirroring is done by anchoring the fill to the opposite end of the arc,
|
||||
// which keeps left/right cliff sensors visually symmetric around the robot.
|
||||
const span = endDeg - startDeg;
|
||||
const fillSpan = span * safePercent;
|
||||
const fillStart = fillFromEnd ? endDeg - fillSpan : startDeg;
|
||||
const fillEnd = fillFromEnd ? endDeg : startDeg + fillSpan;
|
||||
return describeArc(cx, cy, rMid, fillStart, fillEnd);
|
||||
}, [cx, cy, endDeg, fillFromEnd, rMid, safePercent, startDeg]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<path d={backgroundPath} stroke={backgroundColor} strokeWidth={strokeWidth} strokeLinecap="round" fill="none" opacity="0.95" />
|
||||
{safePercent > 0 ? (
|
||||
<path d={fillPath} stroke={fillColor} strokeWidth={strokeWidth} strokeLinecap="round" fill="none" opacity="1" />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const ConeSegment = React.memo(function ConeSegment({ cx, cy, rBase, rTip, startDeg, endDeg, color, value, max }) {
|
||||
const mid = (startDeg + endDeg) / 2;
|
||||
const norm = clamp01(value != null ? value / (max || 1) : 0);
|
||||
@@ -60,6 +100,7 @@ export const WheelVisual = React.memo(function WheelVisual({ cx, cy, current, dr
|
||||
const sign = label === 'L' ? -1 : 1;
|
||||
const currentCenterX = sign * (-dropW / 2 - gap / 2);
|
||||
const dropCenterX = sign * (currentW / 2 + gap / 2);
|
||||
const dropLabelRotation = label === 'L' ? -90 : 90;
|
||||
const groupWidth = currentW + dropW + gap + 2;
|
||||
const groupHeight = barH + 4;
|
||||
return (
|
||||
@@ -68,6 +109,18 @@ export const WheelVisual = React.memo(function WheelVisual({ cx, cy, current, dr
|
||||
<rect x={currentCenterX - currentW / 2} y={-barH / 2} width={currentW} height={barH} fill="#0f172a" stroke="#0f172a" strokeWidth="1" rx="2" />
|
||||
<rect x={currentCenterX - currentW / 2} y={barH / 2 - currentFill} width={currentW} height={currentFill} fill={color} className={overcurrent ? 'animate-pulse' : ''} />
|
||||
<rect x={dropCenterX - dropW / 2} y={-barH / 2} width={dropW} height={barH} fill={drop ? '#ef4444' : '#475569'} className={drop ? 'animate-pulse' : ''} rx="2" />
|
||||
{drop ? (
|
||||
<text
|
||||
x={dropCenterX}
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
transform={`rotate(${dropLabelRotation} ${dropCenterX} 0)`}
|
||||
className="pointer-events-none fill-white text-[0.48rem] font-bold"
|
||||
>
|
||||
Dropped
|
||||
</text>
|
||||
) : null}
|
||||
<text x={0} y={barH / 2 + 10} textAnchor="middle" className="fill-slate-200 text-[0.7rem]">{label}</text>
|
||||
</g>
|
||||
);
|
||||
@@ -101,7 +154,67 @@ export const SideBrushVisual = React.memo(function SideBrushVisual({ cx, cy, cur
|
||||
);
|
||||
});
|
||||
|
||||
export const MainBrushVisual = React.memo(function MainBrushVisual({ cx, cy, current, overcurrent, variant }) {
|
||||
export const ReadoutBar = React.memo(function ReadoutBar({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
label,
|
||||
valueText,
|
||||
percent,
|
||||
color = '#38bdf8',
|
||||
missing = false,
|
||||
}) {
|
||||
const safePercent = clamp01(percent ?? 0);
|
||||
const fillWidth = width * safePercent;
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<rect width={width} height={height} rx="4" fill="#020617" opacity="0.9" stroke="#334155" strokeWidth="1" />
|
||||
<rect x="2" y={height - 5} width={width - 4} height="3" rx="1.5" fill="#1e293b" />
|
||||
<rect x="2" y={height - 5} width={Math.max(0, fillWidth - 4)} height="3" rx="1.5" fill={missing ? '#475569' : color} />
|
||||
<text x="5" y="9" className="fill-slate-400 text-[0.48rem] font-semibold">{label}</text>
|
||||
<text x={width - 5} y="10" textAnchor="end" className="fill-slate-100 text-[0.55rem] font-semibold">{valueText}</text>
|
||||
</g>
|
||||
);
|
||||
});
|
||||
|
||||
export const RawFrameStrip = React.memo(function RawFrameStrip({ x, y, width, height, bytes }) {
|
||||
const safeBytes = Array.isArray(bytes) ? bytes : [];
|
||||
const count = safeBytes.length;
|
||||
const gap = 0;
|
||||
const cellWidth = count > 0 ? Math.max(0.8, (width - gap * (count - 1)) / count) : width;
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`} opacity="0.72">
|
||||
{safeBytes.map((byte, idx) => {
|
||||
// This strip intentionally visualizes the raw decoded frame bytes
|
||||
// instead of decoded sensor meanings. Hue makes byte identity visible,
|
||||
// while bar height makes quiet/low and loud/high byte values distinct.
|
||||
const value = Number.isFinite(byte) ? Math.max(0, Math.min(255, byte)) : 0;
|
||||
const normalized = value / 255;
|
||||
const barHeight = 2 + normalized * (height - 5);
|
||||
const hue = Math.round(normalized * 300 + 35);
|
||||
const barX = idx * (cellWidth + gap);
|
||||
const barY = height - 2 - barHeight;
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={idx}
|
||||
x={barX + 1}
|
||||
y={barY}
|
||||
width={Math.max(0.6, cellWidth)}
|
||||
height={barHeight}
|
||||
rx="0.8"
|
||||
fill={`hsl(${hue} 95% 58%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
});
|
||||
|
||||
export const MainBrushVisual = React.memo(function MainBrushVisual({ cx, cy, current, overcurrent, variant, dirtLeft, dirtRight }) {
|
||||
const mag = Math.abs(current);
|
||||
const color = currentColor(current, overcurrent);
|
||||
const opacity = 1;
|
||||
@@ -111,6 +224,30 @@ export const MainBrushVisual = React.memo(function MainBrushVisual({ cx, cy, cur
|
||||
const patternB = `main-brush-pattern-b-${variant}`;
|
||||
const dur = mag > 0 ? 0.6 : null;
|
||||
const dir = current >= 0 ? 1 : -1;
|
||||
|
||||
const renderDirtDot = (dotCx, dotCy, value) => {
|
||||
// The Create dirt packets are impulse-style 0-255 counters rather than a
|
||||
// calibrated percentage. A low divisor keeps small real hits visible, while
|
||||
// clamp01 prevents rare large values from growing beyond the brush layout.
|
||||
const numericValue = Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0;
|
||||
const strength = clamp01(numericValue / 80);
|
||||
const radius = 2.5 + strength * 5.5;
|
||||
const fill = numericValue > 0 ? '#fbbf24' : '#475569';
|
||||
const stroke = numericValue > 0 ? '#fde68a' : '#1e293b';
|
||||
return (
|
||||
<circle
|
||||
cx={dotCx}
|
||||
cy={dotCy}
|
||||
r={radius}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth="1"
|
||||
opacity={numericValue > 0 ? 0.95 : 0.45}
|
||||
className={numericValue > 0 ? 'animate-pulse' : ''}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<g>
|
||||
<defs>
|
||||
@@ -126,8 +263,8 @@ export const MainBrushVisual = React.memo(function MainBrushVisual({ cx, cy, cur
|
||||
</defs>
|
||||
<rect x={cx - rollerWidth / 2} y={cy - 14} width={rollerWidth} height={rollerHeight} rx="3" fill={`url(#${patternA})`} stroke="#64748b" strokeWidth="1" />
|
||||
<rect x={cx - rollerWidth / 2} y={cy + 4} width={rollerWidth} height={rollerHeight} rx="3" fill={`url(#${patternB})`} stroke="#64748b" strokeWidth="1" />
|
||||
<circle cx={cx - rollerWidth / 4} cy={cy - 8} r={3.5} fill="#fbbf24" />
|
||||
<circle cx={cx + rollerWidth / 4} cy={cy + 8} r={3.5} fill="#fbbf24" />
|
||||
{renderDirtDot(cx - rollerWidth / 4, cy - 8, dirtLeft)}
|
||||
{renderDirtDot(cx + rollerWidth / 4, cy + 8, dirtRight)}
|
||||
{overcurrent ? (
|
||||
<rect
|
||||
x={cx - rollerWidth / 2 - 4}
|
||||
|
||||
@@ -4,37 +4,6 @@
|
||||
|
||||
import { shallowArrayEqual, shallowObjectEqual } from './TelemetryContext.jsx';
|
||||
|
||||
export const EMPTY_MAP_TELEMETRY = Object.freeze({
|
||||
bumpLeft: false,
|
||||
bumpRight: false,
|
||||
wheelDropLeft: false,
|
||||
wheelDropRight: false,
|
||||
leftWheelOvercurrent: false,
|
||||
rightWheelOvercurrent: false,
|
||||
sideBrushOvercurrent: false,
|
||||
mainBrushOvercurrent: false,
|
||||
wheelLeftCurrentMa: 0,
|
||||
wheelRightCurrentMa: 0,
|
||||
sideBrushCurrentMa: 0,
|
||||
mainBrushCurrentMa: 0,
|
||||
lightBumpLeftSignal: null,
|
||||
lightBumpFrontLeftSignal: null,
|
||||
lightBumpCenterLeftSignal: null,
|
||||
lightBumpCenterRightSignal: null,
|
||||
lightBumpFrontRightSignal: null,
|
||||
lightBumpRightSignal: null,
|
||||
cliffLeftSignal: null,
|
||||
cliffFrontLeftSignal: null,
|
||||
cliffFrontRightSignal: null,
|
||||
cliffRightSignal: null,
|
||||
cliffLeft: false,
|
||||
cliffFrontLeft: false,
|
||||
cliffFrontRight: false,
|
||||
cliffRight: false,
|
||||
dirtDetectLeft: null,
|
||||
dirtDetect: null,
|
||||
});
|
||||
|
||||
const EMPTY_BATTERY_TELEMETRY = Object.freeze({
|
||||
batteryChargeMah: null,
|
||||
batteryCapacityMah: null,
|
||||
@@ -84,10 +53,6 @@ function sensorNumber(value) {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
export function mapTelemetryEqual(left, right) {
|
||||
return shallowObjectEqual(left, right);
|
||||
}
|
||||
|
||||
export function batteryTelemetryEqual(left, right) {
|
||||
return shallowObjectEqual(left, right);
|
||||
}
|
||||
@@ -100,43 +65,6 @@ export function hostStatsEqual(left, right) {
|
||||
return shallowObjectEqual(left, right);
|
||||
}
|
||||
|
||||
export function selectVisualMapTelemetry(frame) {
|
||||
const sensors = frame?.sensors;
|
||||
if (!sensors) return EMPTY_MAP_TELEMETRY;
|
||||
const bumps = sensors.bumpsAndWheelDrops || {};
|
||||
const wheelOver = sensors.wheelOvercurrents || {};
|
||||
return {
|
||||
bumpLeft: Boolean(bumps.bumpLeft),
|
||||
bumpRight: Boolean(bumps.bumpRight),
|
||||
wheelDropLeft: Boolean(bumps.wheelDropLeft),
|
||||
wheelDropRight: Boolean(bumps.wheelDropRight),
|
||||
leftWheelOvercurrent: Boolean(wheelOver.leftWheel),
|
||||
rightWheelOvercurrent: Boolean(wheelOver.rightWheel),
|
||||
sideBrushOvercurrent: Boolean(wheelOver.sideBrush),
|
||||
mainBrushOvercurrent: Boolean(wheelOver.mainBrush),
|
||||
wheelLeftCurrentMa: bucketNumber(sensors.wheelLeftCurrentMa ?? 0, 5),
|
||||
wheelRightCurrentMa: bucketNumber(sensors.wheelRightCurrentMa ?? 0, 5),
|
||||
sideBrushCurrentMa: bucketNumber(sensors.sideBrushCurrentMa ?? 0, 5),
|
||||
mainBrushCurrentMa: bucketNumber(sensors.mainBrushCurrentMa ?? 0, 5),
|
||||
lightBumpLeftSignal: sensorNumber(sensors.lightBumpLeftSignal),
|
||||
lightBumpFrontLeftSignal: sensorNumber(sensors.lightBumpFrontLeftSignal),
|
||||
lightBumpCenterLeftSignal: sensorNumber(sensors.lightBumpCenterLeftSignal),
|
||||
lightBumpCenterRightSignal: sensorNumber(sensors.lightBumpCenterRightSignal),
|
||||
lightBumpFrontRightSignal: sensorNumber(sensors.lightBumpFrontRightSignal),
|
||||
lightBumpRightSignal: sensorNumber(sensors.lightBumpRightSignal),
|
||||
cliffLeftSignal: bucketNumber(sensors.cliffLeftSignal, 5),
|
||||
cliffFrontLeftSignal: bucketNumber(sensors.cliffFrontLeftSignal, 5),
|
||||
cliffFrontRightSignal: bucketNumber(sensors.cliffFrontRightSignal, 5),
|
||||
cliffRightSignal: bucketNumber(sensors.cliffRightSignal, 5),
|
||||
cliffLeft: Boolean(sensors.cliffLeft),
|
||||
cliffFrontLeft: Boolean(sensors.cliffFrontLeft),
|
||||
cliffFrontRight: Boolean(sensors.cliffFrontRight),
|
||||
cliffRight: Boolean(sensors.cliffRight),
|
||||
dirtDetectLeft: bucketNumber(sensors.dirtDetectLeft, 1),
|
||||
dirtDetect: bucketNumber(sensors.dirtDetect, 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectLightBumpTelemetry(frame) {
|
||||
const sensors = frame?.sensors;
|
||||
if (!sensors) return [];
|
||||
|
||||
Reference in New Issue
Block a user