topdown reworkie

This commit is contained in:
legop3
2026-06-28 21:42:18 -04:00
parent 78fc891cb9
commit 8f2a4dbb8e
23 changed files with 799 additions and 353 deletions
+1 -1
View File
@@ -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}
+1 -4
View File
@@ -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;
}
+140 -3
View File
@@ -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}
-72
View File
@@ -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 [];