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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DZ5ZQHIL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CFB9Hv1w.css">
<script type="module" crossorigin src="/assets/index-Cpz_jzKr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C6I0gKp1.css">
</head>
<body>
<div id="root"></div>
+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 [];