mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
moveing stuffs arounds
This commit is contained in:
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
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-jkAqWJYz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DpzaTbCt.css">
|
||||
<script type="module" crossorigin src="/assets/index-cJvNDfdb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BJwrnf9g.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import TelemetryPanel from './components/TelemetryPanel.jsx';
|
||||
import DrivePanel from './components/DrivePanel.jsx';
|
||||
import ControlSummary from './components/ControlSummary.jsx';
|
||||
import AlertFeed from './components/AlertFeed.jsx';
|
||||
import AdminPanel from './components/AdminPanel.jsx';
|
||||
import MobileControls, {
|
||||
@@ -83,9 +83,9 @@ function MobilePortraitLayout() {
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<DriverVideoPanel layoutFormat='mobile'/>
|
||||
<MobileControls />
|
||||
<ControlSummary />
|
||||
<ChatPanel />
|
||||
<UserListPanel />
|
||||
<DrivePanel />
|
||||
<TelemetryPanel />
|
||||
<AuthPanel />
|
||||
<AdminPanel />
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||
import { useControlSystem } from '../controls/index.js';
|
||||
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
||||
import TopDownMap from './TopDownMap.jsx';
|
||||
import RoverRoster from './RoverRoster.jsx';
|
||||
|
||||
export default function ControlSummary() {
|
||||
const { session, requestControl } = useSession();
|
||||
const {
|
||||
state: { roverId, keymap },
|
||||
actions,
|
||||
} = useControlSystem();
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const sensors = frame?.sensors || {};
|
||||
const roster = session?.roster ?? [];
|
||||
const [pending, setPending] = useState({});
|
||||
|
||||
const canRequest = useMemo(() => session?.role && session.role !== 'spectator', [session?.role]);
|
||||
|
||||
const driving = useMemo(
|
||||
() => (sensors.oiMode?.label || '').toLowerCase() === 'full',
|
||||
[sensors.oiMode?.label],
|
||||
);
|
||||
const docked = Boolean(sensors.chargingSources?.homeBase);
|
||||
const charging = Boolean(
|
||||
sensors.chargingState?.label && sensors.chargingState.label.toLowerCase() !== 'not charging',
|
||||
);
|
||||
|
||||
const handleStartDrive = () => {
|
||||
if (!roverId) return;
|
||||
actions.setMode('drive');
|
||||
actions.runMacro('drive-sequence');
|
||||
};
|
||||
|
||||
const handleDock = () => {
|
||||
if (!roverId) return;
|
||||
actions.setMode('dock');
|
||||
actions.runMacro('seek-dock');
|
||||
};
|
||||
|
||||
async function handleRequest(targetRoverId) {
|
||||
if (!targetRoverId) return;
|
||||
setPending((prev) => ({ ...prev, [targetRoverId]: true }));
|
||||
try {
|
||||
await requestControl(targetRoverId);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending((prev) => ({ ...prev, [targetRoverId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel-section">
|
||||
<div className="grid items-stretch gap-1 md:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] md:min-h-[22rem]">
|
||||
<div className="flex h-full w-full items-stretch justify-center">
|
||||
<div className="aspect-square h-full w-full">
|
||||
<TopDownMap sensors={sensors} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid h-full grid-rows-[1fr_1fr_auto] gap-0.75">
|
||||
<ActionButton
|
||||
title="Start Driving"
|
||||
description="Enable driving mode, then move the rover."
|
||||
statuses={[{ label: driving ? 'Ready' : 'Not ready', active: driving }]}
|
||||
tone="emerald"
|
||||
onClick={handleStartDrive}
|
||||
disabled={!roverId}
|
||||
keyAction="driveMacro"
|
||||
keymap={keymap}
|
||||
/>
|
||||
<ActionButton
|
||||
title="Dock and Charge"
|
||||
description="Trigger an automatic docking attempt."
|
||||
statuses={[
|
||||
{ label: docked ? 'Docked' : 'Not docked', active: docked },
|
||||
{ label: charging ? 'Charging' : 'Not charging', active: charging },
|
||||
]}
|
||||
tone="indigo"
|
||||
onClick={handleDock}
|
||||
disabled={!roverId}
|
||||
keyAction="dockMacro"
|
||||
keymap={keymap}
|
||||
/>
|
||||
<InlineCameraTilt keymap={keymap} />
|
||||
</div>
|
||||
</div>
|
||||
<RoverRoster
|
||||
title="Rovers"
|
||||
roster={roster}
|
||||
renderActions={(rover) =>
|
||||
canRequest ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRequest(rover.id)}
|
||||
disabled={pending[rover.id]}
|
||||
className="button-dark disabled:opacity-40"
|
||||
>
|
||||
{pending[rover.id] ? '...' : 'request'}
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({ title, description, statuses, onClick, disabled, tone, keyAction, keymap }) {
|
||||
const colors =
|
||||
tone === 'indigo'
|
||||
? 'bg-indigo-700 hover:bg-indigo-600 focus-visible:ring-indigo-400'
|
||||
: 'bg-emerald-700 hover:bg-emerald-600 focus-visible:ring-emerald-400';
|
||||
const keyLabel = keyAction ? formatKeyLabel(keymap?.[keyAction]?.[0]) : '';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`flex h-full w-full flex-col items-center justify-center gap-0.5 border border-slate-700 px-1 py-1 text-center text-white transition focus-visible:outline-none focus-visible:ring-1 ${colors} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="text-base font-semibold text-slate-100">{title}</span>
|
||||
{keyLabel ? <KeyPill label={keyLabel} /> : null}
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 text-center">{description}</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||
{statuses.map((status) => (
|
||||
<StatusPill key={status.label} active={status.active} label={status.label} />
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyPill({ label }) {
|
||||
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
|
||||
}
|
||||
|
||||
function StatusPill({ label, active }) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[0.7rem] font-semibold ${
|
||||
active ? 'bg-emerald-600 text-white' : 'bg-slate-700 text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineCameraTilt({ keymap }) {
|
||||
const {
|
||||
state: { roverId, camera },
|
||||
actions: { setServoAngle },
|
||||
} = useControlSystem();
|
||||
|
||||
const config = camera?.config;
|
||||
const enabled = Boolean(roverId && camera?.enabled && config);
|
||||
const min = typeof config?.minAngle === 'number' ? config.minAngle : -30;
|
||||
const max = typeof config?.maxAngle === 'number' ? config.maxAngle : 30;
|
||||
const value =
|
||||
typeof camera?.angle === 'number'
|
||||
? camera.angle
|
||||
: typeof config?.homeAngle === 'number'
|
||||
? config.homeAngle
|
||||
: (min + max) / 2;
|
||||
|
||||
const [pendingAngle, setPendingAngle] = useState(value);
|
||||
const throttleRef = useRef(null);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingRef.current) {
|
||||
setPendingAngle(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const scheduleSend = (next) => {
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
}
|
||||
throttleRef.current = setTimeout(() => {
|
||||
setServoAngle(next);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleSlider = (event) => {
|
||||
const next = Number.parseFloat(event.target.value);
|
||||
if (Number.isNaN(next)) return;
|
||||
draggingRef.current = true;
|
||||
setPendingAngle(next);
|
||||
scheduleSend(next);
|
||||
};
|
||||
|
||||
const commitSlider = () => {
|
||||
draggingRef.current = false;
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
}
|
||||
setServoAngle(pendingAngle);
|
||||
};
|
||||
|
||||
if (!enabled) return null;
|
||||
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
||||
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
||||
|
||||
return (
|
||||
<div className="surface space-y-0.5 px-1 py-1 text-sm text-slate-200">
|
||||
<div className="flex items-center justify-between text-xs text-slate-300">
|
||||
<span>Camera tilt</span>
|
||||
<span className="font-mono text-slate-100">{formatDegrees(value)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
className="w-full accent-emerald-400"
|
||||
min={min}
|
||||
max={max}
|
||||
step={0.5}
|
||||
value={pendingAngle}
|
||||
onChange={handleSlider}
|
||||
onMouseUp={commitSlider}
|
||||
onTouchEnd={commitSlider}
|
||||
onPointerUp={commitSlider}
|
||||
/>
|
||||
<div className="mt-0.25 flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||
<span className="flex items-center gap-0.25">
|
||||
{downLabel ? <KeyPill label={downLabel} /> : null}
|
||||
{formatDegrees(min)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.25">
|
||||
{formatDegrees(max)}
|
||||
{upLabel ? <KeyPill label={upLabel} /> : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDegrees(value) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}°`;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import TelemetryPanel from './TelemetryPanel.jsx';
|
||||
import DrivePanel from './DrivePanel.jsx';
|
||||
import CameraServoPanel from './CameraServoPanel.jsx';
|
||||
import ControlSummary from './ControlSummary.jsx';
|
||||
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
||||
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
||||
import SettingsPanel from './SettingsPanel.jsx';
|
||||
@@ -19,11 +18,10 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
<TabPanels>
|
||||
<TabPanel id="telemetry">
|
||||
<div className="space-y-0.5">
|
||||
<DrivePanel />
|
||||
<ControlSummary />
|
||||
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
|
||||
<HomeAssistantControls />
|
||||
<TelemetryPanel />
|
||||
<CameraServoPanel />
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="help">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||
import RoverRoster from './RoverRoster.jsx';
|
||||
|
||||
function formatMetric(value, fallback = '--') {
|
||||
if (value == null || value === '') return fallback;
|
||||
@@ -9,7 +8,7 @@ function formatMetric(value, fallback = '--') {
|
||||
}
|
||||
|
||||
export default function TelemetryPanel() {
|
||||
const { connected, session, requestControl } = useSession();
|
||||
const { connected, session } = useSession();
|
||||
const roverId = session?.assignment?.roverId;
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const sensors = frame?.sensors || {};
|
||||
@@ -19,9 +18,7 @@ export default function TelemetryPanel() {
|
||||
const charge = sensors.batteryChargeMah;
|
||||
const capacity = sensors.batteryCapacityMah;
|
||||
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
|
||||
// const rawSnippet = frame?.raw ? (frame.raw.length > 80 ? `${frame.raw.slice(0, 80)}…` : frame.raw) : null;
|
||||
const rawSnippet = frame?.raw ? frame.raw : null;
|
||||
const roster = session?.roster ?? [];
|
||||
const activeDriverId = roverId ? session?.activeDrivers?.[roverId] : null;
|
||||
const driverLabel = useMemo(() => {
|
||||
if (!roverId) return 'n/a';
|
||||
@@ -30,20 +27,6 @@ export default function TelemetryPanel() {
|
||||
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
|
||||
return user?.nickname || activeDriverId.slice(0, 6);
|
||||
}, [activeDriverId, roverId, session?.socketId, session?.users]);
|
||||
const canRequest = useMemo(() => session?.role && session.role !== 'spectator', [session?.role]);
|
||||
const [pending, setPending] = useState({});
|
||||
|
||||
async function handleRequest(roverId) {
|
||||
if (!roverId) return;
|
||||
setPending((prev) => ({ ...prev, [roverId]: true }));
|
||||
try {
|
||||
await requestControl(roverId);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending((prev) => ({ ...prev, [roverId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-base text-slate-100">
|
||||
@@ -60,528 +43,154 @@ export default function TelemetryPanel() {
|
||||
<p className="text-sm text-slate-500">Waiting for sensor frames…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-0.5 text-sm">
|
||||
<Metric label="Charge" value={formatMetric(charge != null && capacity != null ? `${charge}/${capacity}` : null)} />
|
||||
<Metric label="Charging" value={formatMetric(sensors.chargingState?.label)} />
|
||||
<Metric label="OI mode" value={formatMetric(sensors.oiMode?.label)} />
|
||||
<Metric label="Voltage" value={formatMetric(voltage)} />
|
||||
<Metric label="Current" value={formatMetric(current)} />
|
||||
<Metric label="Temp" value={formatMetric(batteryTemp)} />
|
||||
<Metric label="Left Encoder" value={formatMetric(sensors.encoderCountsLeft)} />
|
||||
<Metric label="Right Encoder" value={formatMetric(sensors.encoderCountsRight)} />
|
||||
</div>
|
||||
<SituationalMap sensors={sensors} variant="full" />
|
||||
|
||||
<div className="surface flex gap-0.5 text-sm">
|
||||
<IrPill label="IR Omni" value={sensors.infraredCharacterOmni} />
|
||||
<IrPill label="IR Left" value={sensors.infraredCharacterLeft} />
|
||||
<IrPill label="IR Right" value={sensors.infraredCharacterRight} />
|
||||
</div>
|
||||
<TelemetrySummary sensors={sensors} voltage={voltage} current={current} batteryTemp={batteryTemp} charge={charge} capacity={capacity} />
|
||||
<SensorDetails sensors={sensors} />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{rawSnippet && (
|
||||
// console.log('rawSnippet:', rawSnippet),
|
||||
<pre className="surface whitespace-pre-wrap break-words text-xs text-lime-300">
|
||||
{rawSnippet}
|
||||
</pre>
|
||||
<pre className="surface whitespace-pre-wrap break-words text-xs text-lime-300">{rawSnippet}</pre>
|
||||
)}
|
||||
<RoverRoster
|
||||
title="Rovers"
|
||||
roster={roster}
|
||||
renderActions={(rover) =>
|
||||
canRequest ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRequest(rover.id)}
|
||||
disabled={pending[rover.id]}
|
||||
className="button-dark disabled:opacity-40"
|
||||
>
|
||||
{pending[rover.id] ? '...' : 'request'}
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CliffBar({ value }) {
|
||||
// Render a small vertical bar; value is clamped to 0-100 for display
|
||||
// const pct = value == null ? 0 : Math.max(0, Math.min(100, value));
|
||||
// goes from 0 to 4095. calculate percentage for height
|
||||
const pct = value == null ? 0 : Math.max(0, Math.min(100, (value / 4095) * 100));
|
||||
const height = `${pct}%`;
|
||||
function TelemetrySummary({ sensors, voltage, current, batteryTemp, charge, capacity }) {
|
||||
const chargePct = charge != null && capacity ? `${Math.round((charge / capacity) * 100)}%` : '--';
|
||||
const oiMode = sensors?.oiMode?.label || '--';
|
||||
const docked = sensors?.chargingSources?.homeBase ? 'Yes' : 'No';
|
||||
const charging = sensors?.chargingState?.label || '--';
|
||||
|
||||
return (
|
||||
// <div className="surface-muted h-6 w-1/4 relative">
|
||||
// <div className="w-full bg-amber-500 absolute inset-0" style={{ height }} />
|
||||
// </div>
|
||||
|
||||
<div className="flex flex-col items-center gap-0.5 w-1/4">
|
||||
{/* <span className="text-sm text-slate-200">{label}</span> */}
|
||||
<div className="surface-muted h-6 w-full relative">
|
||||
<div className={`absolute top-0 left-0 right-0 bg-amber-500`} style={{ height }} />
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-white">
|
||||
{value != null ? `${value}` : '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-0.5 md:grid-cols-3">
|
||||
<Metric label="Voltage" value={voltage ?? '--'} />
|
||||
<Metric label="Current" value={current ?? '--'} />
|
||||
<Metric label="Battery temp" value={batteryTemp ?? '--'} />
|
||||
<Metric label="Charge" value={charge != null ? `${charge} mAh` : '--'} />
|
||||
<Metric label="Capacity" value={capacity != null ? `${capacity} mAh` : '--'} />
|
||||
<Metric label="Charge %" value={chargePct} />
|
||||
<Metric label="OI mode" value={oiMode} />
|
||||
<Metric label="Docked" value={docked} />
|
||||
<Metric label="Charging state" value={charging} />
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }) {
|
||||
return (
|
||||
<div className="surface text-sm">
|
||||
{label}: {value}
|
||||
<div className="surface flex items-center justify-between gap-0.5 px-1 py-0.5 text-sm">
|
||||
<span className="text-slate-400">{label}</span>
|
||||
<span className="text-slate-100">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// a reusable motor current bar, with overcurrent coloring. Use the overcurrent from sensors for each motor.
|
||||
// include a label to indicate which motor it is, with the label to the left of the bar
|
||||
function MotorCurrentBar({ label, value, overcurrent }) {
|
||||
const pct = value == null ? 0 : Math.max(0, Math.min(100, (value / 1000) * 100));
|
||||
const height = `${pct}%`;
|
||||
const barColor = overcurrent ? 'bg-red-500' : 'bg-emerald-500';
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5 w-1/4">
|
||||
<span className="text-sm text-slate-200">{label}</span>
|
||||
<div className="surface-muted h-6 w-full relative">
|
||||
<div className={`${barColor} absolute bottom-0 left-0 right-0`} style={{ height }} />
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-white">
|
||||
{value != null ? `${value} mA` : '--'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DirtMeter({ label, value }) {
|
||||
const pct = value == null ? 0 : Math.max(0, Math.min(100, (value / 255) * 100));
|
||||
const width = `${pct}%`;
|
||||
return (
|
||||
<div className="surface-muted relative flex-1 overflow-hidden px-0.5 py-0.25">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="bg-amber-500 h-full" style={{ width }} />
|
||||
</div>
|
||||
<div className="relative flex items-center justify-between text-xs text-white">
|
||||
<span>{label}</span>
|
||||
<span>{value != null ? value : '--'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IrPill({ label, value }) {
|
||||
return (
|
||||
<div className="surface px-1 py-0.5 text-xs text-white">
|
||||
{label}: {value != null && value !== 0 ? value : '--'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SituationalMap({ sensors, variant = 'full' }) {
|
||||
const size = variant === 'mini' ? 190 : 240;
|
||||
const center = size / 2;
|
||||
const innerCircle = variant === 'mini' ? 78 : 96;
|
||||
const lightRingInner = variant === 'mini' ? 78 : 92;
|
||||
const lightRingOuter = variant === 'mini' ? 86 : 100;
|
||||
const cliffRingInner = variant === 'mini' ? 62 : 76;
|
||||
const cliffRingOuter = variant === 'mini' ? 70 : 84;
|
||||
const bumpRingInner = variant === 'mini' ? 82 : 104;
|
||||
const bumpRingOuter = variant === 'mini' ? 90 : 114;
|
||||
const wheelLineOffset = variant === 'mini' ? 50 : 68;
|
||||
function SensorDetails({ sensors }) {
|
||||
const bumps = sensors?.bumpsAndWheelDrops || {};
|
||||
const over = sensors?.wheelOvercurrents || {};
|
||||
|
||||
const lightSignals = [
|
||||
sensors?.lightBumpLeftSignal,
|
||||
sensors?.lightBumpFrontLeftSignal,
|
||||
sensors?.lightBumpCenterLeftSignal,
|
||||
sensors?.lightBumpCenterRightSignal,
|
||||
sensors?.lightBumpFrontRightSignal,
|
||||
sensors?.lightBumpRightSignal,
|
||||
].filter((v) => v != null);
|
||||
const observedLightMax = lightSignals.length ? Math.max(...lightSignals) : 0;
|
||||
const effectiveLightMax = Math.max(observedLightMax, 1200); // adaptive gain; these rarely hit 4095
|
||||
const bumps = sensors?.bumpsAndWheelDrops || {};
|
||||
const wheelCurrentLeft = sensors?.wheelLeftCurrentMa ?? 0;
|
||||
const wheelCurrentRight = sensors?.wheelRightCurrentMa ?? 0;
|
||||
const sideBrushCurrent = sensors?.sideBrushCurrentMa ?? 0;
|
||||
const mainBrushCurrent = sensors?.mainBrushCurrentMa ?? 0;
|
||||
const wheelOver = sensors?.wheelOvercurrents || {};
|
||||
|
||||
const lightAngles = buildSegments({
|
||||
count: 6,
|
||||
totalSpan: 140,
|
||||
gap: 6,
|
||||
startAngle: -70,
|
||||
});
|
||||
const lightLabels = ['L', 'FL', 'CL', 'CR', 'FR', 'R'];
|
||||
const lightValues = [
|
||||
sensors?.lightBumpLeftSignal,
|
||||
sensors?.lightBumpFrontLeftSignal,
|
||||
sensors?.lightBumpCenterLeftSignal,
|
||||
sensors?.lightBumpCenterRightSignal,
|
||||
sensors?.lightBumpFrontRightSignal,
|
||||
sensors?.lightBumpRightSignal,
|
||||
{ label: 'Left', value: sensors?.lightBumpLeftSignal },
|
||||
{ label: 'Front Left', value: sensors?.lightBumpFrontLeftSignal },
|
||||
{ label: 'Center Left', value: sensors?.lightBumpCenterLeftSignal },
|
||||
{ label: 'Center Right', value: sensors?.lightBumpCenterRightSignal },
|
||||
{ label: 'Front Right', value: sensors?.lightBumpFrontRightSignal },
|
||||
{ label: 'Right', value: sensors?.lightBumpRightSignal },
|
||||
];
|
||||
const lightActive = [
|
||||
sensors?.lightBumper?.left,
|
||||
sensors?.lightBumper?.frontLeft,
|
||||
sensors?.lightBumper?.centerLeft,
|
||||
sensors?.lightBumper?.centerRight,
|
||||
sensors?.lightBumper?.frontRight,
|
||||
sensors?.lightBumper?.right,
|
||||
];
|
||||
const lightSegments = lightAngles.map((ang, idx) => ({
|
||||
label: lightLabels[idx],
|
||||
start: ang.start,
|
||||
end: ang.end,
|
||||
value: lightValues[idx],
|
||||
active: lightActive[idx],
|
||||
}));
|
||||
|
||||
const cliffSegments = [
|
||||
{ label: 'Cliff L', start: -65, end: -45, value: sensors?.cliffLeftSignal, active: sensors?.cliffLeft },
|
||||
{ label: 'Cliff FL', start: -25, end: -5, value: sensors?.cliffFrontLeftSignal, active: sensors?.cliffFrontLeft },
|
||||
{ label: 'Cliff FR', start: 5, end: 25, value: sensors?.cliffFrontRightSignal, active: sensors?.cliffFrontRight },
|
||||
{ label: 'Cliff R', start: 45, end: 65, value: sensors?.cliffRightSignal, active: sensors?.cliffRight },
|
||||
const cliffSignals = [
|
||||
{ label: 'Left', value: sensors?.cliffLeftSignal, active: sensors?.cliffLeft },
|
||||
{ label: 'Front Left', value: sensors?.cliffFrontLeftSignal, active: sensors?.cliffFrontLeft },
|
||||
{ label: 'Front Right', value: sensors?.cliffFrontRightSignal, active: sensors?.cliffFrontRight },
|
||||
{ label: 'Right', value: sensors?.cliffRightSignal, active: sensors?.cliffRight },
|
||||
];
|
||||
|
||||
const currents = [
|
||||
{ label: 'Wheel L', value: sensors?.wheelLeftCurrentMa, over: over.leftWheel },
|
||||
{ label: 'Wheel R', value: sensors?.wheelRightCurrentMa, over: over.rightWheel },
|
||||
{ label: 'Side brush', value: sensors?.sideBrushCurrentMa, over: over.sideBrush },
|
||||
{ label: 'Main brush', value: sensors?.mainBrushCurrentMa, over: over.mainBrush },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="surface relative p-1" style={{ height: variant === 'mini' ? '12rem' : '15rem' }}>
|
||||
<div className="absolute left-1 top-1 text-xs text-slate-400">Top-down</div>
|
||||
<div className="absolute right-1 top-1 text-[0.65rem] text-slate-500">0–{effectiveLightMax}</div>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="mx-auto">
|
||||
<circle cx={center} cy={center} r={innerCircle} fill="#0f172a" stroke="#334155" strokeWidth="2" />
|
||||
<WheelVisual
|
||||
cx={center - wheelLineOffset}
|
||||
cy={center}
|
||||
current={wheelCurrentLeft}
|
||||
drop={bumps.wheelDropLeft}
|
||||
overcurrent={wheelOver.leftWheel}
|
||||
label="L"
|
||||
/>
|
||||
<WheelVisual
|
||||
cx={center + wheelLineOffset}
|
||||
cy={center}
|
||||
current={wheelCurrentRight}
|
||||
drop={bumps.wheelDropRight}
|
||||
overcurrent={wheelOver.rightWheel}
|
||||
label="R"
|
||||
/>
|
||||
<SideBrushVisual
|
||||
cx={center + innerCircle * 0.65}
|
||||
cy={center - innerCircle * 0.55}
|
||||
current={sideBrushCurrent}
|
||||
overcurrent={wheelOver.sideBrush}
|
||||
/>
|
||||
<MainBrushVisual
|
||||
cx={center}
|
||||
cy={center}
|
||||
current={mainBrushCurrent}
|
||||
overcurrent={wheelOver.mainBrush}
|
||||
variant={variant}
|
||||
dirtLeft={sensors?.dirtDetectLeft}
|
||||
dirtRight={sensors?.dirtDetect}
|
||||
/>
|
||||
{lightSegments.map((seg) => {
|
||||
const color = lightBumpColor(seg.value);
|
||||
return (
|
||||
<ArcSegment
|
||||
key={seg.label}
|
||||
cx={center}
|
||||
cy={center}
|
||||
rInner={lightRingInner}
|
||||
rOuter={lightRingOuter}
|
||||
startDeg={seg.start}
|
||||
endDeg={seg.end}
|
||||
color={color}
|
||||
opacity={1}
|
||||
pulse={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{cliffSegments.map((seg) => {
|
||||
const color = cliffColor(seg.value, seg.active);
|
||||
return (
|
||||
<ArcSegment
|
||||
key={seg.label}
|
||||
cx={center}
|
||||
cy={center}
|
||||
rInner={cliffRingInner}
|
||||
rOuter={cliffRingOuter}
|
||||
startDeg={seg.start}
|
||||
endDeg={seg.end}
|
||||
color={seg.value == null ? '#475569' : color}
|
||||
opacity={1}
|
||||
pulse={Boolean(seg.active)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<ArcSegment
|
||||
cx={center}
|
||||
cy={center}
|
||||
rInner={lightRingOuter + 2}
|
||||
rOuter={lightRingOuter + 8}
|
||||
startDeg={-70}
|
||||
endDeg={-6}
|
||||
color={bumps.bumpLeft ? '#ef4444' : '#475569'}
|
||||
opacity={1}
|
||||
pulse={Boolean(bumps.bumpLeft)}
|
||||
/>
|
||||
<ArcSegment
|
||||
cx={center}
|
||||
cy={center}
|
||||
rInner={lightRingOuter + 2}
|
||||
rOuter={lightRingOuter + 8}
|
||||
startDeg={6}
|
||||
endDeg={70}
|
||||
color={bumps.bumpRight ? '#ef4444' : '#475569'}
|
||||
opacity={1}
|
||||
pulse={Boolean(bumps.bumpRight)}
|
||||
/>
|
||||
</svg>
|
||||
<div className="grid gap-0.5 md:grid-cols-2">
|
||||
<DetailCard title="Bumps & drops">
|
||||
<ValueRow label="Bump L" value={<Pill active={bumps.bumpLeft} />} />
|
||||
<ValueRow label="Bump R" value={<Pill active={bumps.bumpRight} />} />
|
||||
<ValueRow label="Wheel drop L" value={<Pill active={bumps.wheelDropLeft} tone="amber" />} />
|
||||
<ValueRow label="Wheel drop R" value={<Pill active={bumps.wheelDropRight} tone="amber" />} />
|
||||
</DetailCard>
|
||||
|
||||
<DetailCard title="Light bumps (raw)">
|
||||
{lightSignals.map((entry) => (
|
||||
<ValueRow key={entry.label} label={entry.label} value={formatNumber(entry.value)} />
|
||||
))}
|
||||
</DetailCard>
|
||||
|
||||
<DetailCard title="Cliff sensors">
|
||||
{cliffSignals.map((entry) => (
|
||||
<ValueRow key={entry.label} label={entry.label} value={`${formatNumber(entry.value)} · ${entry.active ? 'bool: true' : 'bool: false'}`} />
|
||||
))}
|
||||
</DetailCard>
|
||||
|
||||
<DetailCard title="Currents">
|
||||
{currents.map((entry) => (
|
||||
<ValueRow
|
||||
key={entry.label}
|
||||
label={entry.label}
|
||||
value={
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span>{formatNumber(entry.value, 'mA')}</span>
|
||||
{entry.over ? <Pill active tone="red" label="over" /> : null}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</DetailCard>
|
||||
|
||||
<DetailCard title="Dirt detect">
|
||||
<ValueRow label="Left" value={formatNumber(sensors?.dirtDetectLeft)} />
|
||||
<ValueRow label="Right" value={formatNumber(sensors?.dirtDetect)} />
|
||||
</DetailCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArcSegment({ cx, cy, rInner, rOuter, startDeg, endDeg, color, pulse = false, opacity = 1 }) {
|
||||
const rMid = (rInner + rOuter) / 2;
|
||||
const strokeWidth = rOuter - rInner;
|
||||
const path = describeArc(cx, cy, rMid, startDeg, endDeg);
|
||||
function DetailCard({ title, children }) {
|
||||
return (
|
||||
<>
|
||||
<path d={path} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" fill="none" opacity={opacity} />
|
||||
{pulse ? (
|
||||
<path
|
||||
d={path}
|
||||
stroke="#ef4444"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
className="animate-pulse"
|
||||
opacity={1}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
<div className="surface space-y-0.5 p-1 text-sm">
|
||||
<div className="text-[0.8rem] uppercase tracking-wide text-slate-400">{title}</div>
|
||||
<div className="space-y-0.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function describeArc(cx, cy, r, startDeg, endDeg) {
|
||||
let start = startDeg;
|
||||
let end = endDeg;
|
||||
if (end <= start) end += 360;
|
||||
const startPt = polarToCartesian(cx, cy, r, start);
|
||||
const endPt = polarToCartesian(cx, cy, r, end);
|
||||
return ['M', startPt.x, startPt.y, 'A', r, r, 0, 0, 1, endPt.x, endPt.y].join(' ');
|
||||
}
|
||||
|
||||
function polarToCartesian(cx, cy, r, angleDeg) {
|
||||
const rad = toRad(angleDeg);
|
||||
return {
|
||||
x: cx + r * Math.cos(rad),
|
||||
y: cy + r * Math.sin(rad),
|
||||
};
|
||||
}
|
||||
|
||||
function toRad(angleDeg) {
|
||||
return ((angleDeg - 90) * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function currentColor(value, overcurrent) {
|
||||
if (overcurrent) return '#ef4444';
|
||||
const mag = Math.abs(value);
|
||||
if (mag > 900) return '#f59e0b';
|
||||
if (mag > 300) return '#22c55e';
|
||||
return '#64748b'; // unified idle tone for wheels/brushes
|
||||
}
|
||||
|
||||
function clamp01(value) {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function WheelVisual({ cx, cy, current, drop, overcurrent, label }) {
|
||||
const mag = Math.abs(current);
|
||||
const pct = clamp01(mag / 1200);
|
||||
const color = currentColor(current, overcurrent);
|
||||
const barH = 56;
|
||||
const currentW = 14;
|
||||
const dropW = 9;
|
||||
const gap = 3;
|
||||
const currentFill = barH * pct;
|
||||
const sign = label === 'L' ? -1 : 1;
|
||||
const currentCenterX = sign * (-dropW / 2 - gap / 2);
|
||||
const dropCenterX = sign * (currentW / 2 + gap / 2);
|
||||
const groupWidth = currentW + dropW + gap + 2;
|
||||
const groupHeight = barH + 4;
|
||||
function ValueRow({ label, value }) {
|
||||
return (
|
||||
<g transform={`translate(${cx},${cy})`}>
|
||||
<rect
|
||||
x={-groupWidth / 2}
|
||||
y={-groupHeight / 2}
|
||||
width={groupWidth}
|
||||
height={groupHeight}
|
||||
rx="4"
|
||||
fill="none"
|
||||
stroke="#64748b"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<text x={0} y={barH / 2 + 10} textAnchor="middle" className="fill-slate-200 text-[0.7rem]">
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-slate-300">{label}</span>
|
||||
<span className="text-slate-100">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SideBrushVisual({ cx, cy, current, overcurrent }) {
|
||||
const mag = Math.abs(current);
|
||||
const color = currentColor(current * 3, overcurrent); // more sensitive; side brush draws little current
|
||||
const armLength = 42;
|
||||
const spinDuration = mag > 0 ? 0.65 : null; // spin only when drawing current
|
||||
return (
|
||||
<g
|
||||
style={{
|
||||
transformOrigin: `${cx}px ${cy}px`,
|
||||
animation: spinDuration ? `spin ${spinDuration}s linear infinite` : 'none',
|
||||
}}
|
||||
>
|
||||
<circle cx={cx} cy={cy} r={10} fill="#64748b" stroke="#64748b" strokeWidth="1" />
|
||||
{[0, 120, 240].map((deg) => {
|
||||
const rad = toRad(deg);
|
||||
const x2 = cx + armLength * Math.cos(rad);
|
||||
const y2 = cy + armLength * Math.sin(rad);
|
||||
return <line key={deg} x1={cx} y1={cy} x2={x2} y2={y2} stroke={color} strokeWidth="4" strokeLinecap="round" />;
|
||||
})}
|
||||
{overcurrent ? <circle cx={cx} cy={cy} r={armLength + 8} stroke="#ef4444" strokeWidth="3" fill="none" className="animate-pulse" /> : null}
|
||||
</g>
|
||||
);
|
||||
function Pill({ active, tone = 'green', label }) {
|
||||
const base = 'rounded px-1.5 py-0.5 text-[0.7rem] font-semibold';
|
||||
const colors =
|
||||
tone === 'red'
|
||||
? active
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-slate-700 text-slate-300'
|
||||
: tone === 'amber'
|
||||
? active
|
||||
? 'bg-amber-500 text-slate-900'
|
||||
: 'bg-slate-700 text-slate-300'
|
||||
: active
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-slate-700 text-slate-300';
|
||||
return <span className={`${base} ${colors}`}>{label || (active ? 'true' : 'false')}</span>;
|
||||
}
|
||||
|
||||
function MainBrushVisual({ cx, cy, current, overcurrent, variant, dirtLeft, dirtRight }) {
|
||||
const mag = Math.abs(current);
|
||||
const color = currentColor(current, overcurrent);
|
||||
const opacity = 1;
|
||||
const rollerWidth = 96;
|
||||
const rollerHeight = 12;
|
||||
const patternA = `main-brush-pattern-a-${variant}`;
|
||||
const patternB = `main-brush-pattern-b-${variant}`;
|
||||
const dur = mag > 0 ? 0.6 : null; // only scroll when drawing current
|
||||
const dir = current >= 0 ? 1 : -1;
|
||||
return (
|
||||
<g>
|
||||
<defs>
|
||||
{[patternA, patternB].map((id, idx) => (
|
||||
<pattern key={id} id={id} patternUnits="userSpaceOnUse" width="6" height="6">
|
||||
<rect width="6" height="6" fill={color} opacity={opacity} />
|
||||
<path d="M0 6 L6 0" stroke="#0f172a" strokeWidth="1" />
|
||||
{dur ? (
|
||||
<animateTransform
|
||||
attributeName="patternTransform"
|
||||
type="translate"
|
||||
from="0 0"
|
||||
to={`0 ${dir * (idx === 0 ? 6 : -6)}`}
|
||||
dur={`${dur}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
) : null}
|
||||
</pattern>
|
||||
))}
|
||||
</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" />
|
||||
{overcurrent ? (
|
||||
<rect
|
||||
x={cx - rollerWidth / 2 - 4}
|
||||
y={cy - 16}
|
||||
width={rollerWidth + 8}
|
||||
height={rollerHeight * 2 + 16}
|
||||
rx="6"
|
||||
stroke="#ef4444"
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
className="animate-pulse"
|
||||
opacity={0.6}
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function buildSegments({ count, totalSpan, gap, startAngle }) {
|
||||
const usable = totalSpan - gap * (count - 1);
|
||||
const width = usable / count;
|
||||
const segments = [];
|
||||
let cursor = startAngle;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const end = cursor + width;
|
||||
segments.push({ start: cursor, end });
|
||||
cursor = end + gap;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
const LIGHT_BUMP_SENSITIVITY = 1; // increase to spin through hues faster
|
||||
function lightBumpColor(value) {
|
||||
if (value == null || value <= 0) return '#000000';
|
||||
const hue = (value * LIGHT_BUMP_SENSITIVITY) % 360; // cycles through the whole spectrum
|
||||
return `hsl(${hue} 100% 60%)`; // fully saturated, bright
|
||||
}
|
||||
|
||||
function cliffColor(value, active) {
|
||||
if (active) return '#ef4444';
|
||||
const t = clamp01(value == null ? 0 : value / 4095);
|
||||
const start = [47, 55, 69]; // #2f3745-ish dark gray
|
||||
const end = [245, 158, 11]; // amber
|
||||
const [r, g, b] = start.map((s, i) => Math.round(s + (end[i] - s) * t));
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
function formatNumber(value, unit) {
|
||||
if (value == null || Number.isNaN(value)) return '--';
|
||||
return unit ? `${value} ${unit}` : value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import React from 'react';
|
||||
|
||||
function TopDownMap({ sensors = {}, variant = 'full', size: overrideSize }) {
|
||||
const size = overrideSize || (variant === 'mini' ? 190 : 260);
|
||||
const center = size / 2;
|
||||
const innerCircle = center * 0.8; // keep proportions consistent as size changes
|
||||
const lightRingInner = innerCircle - 4;
|
||||
const lightRingOuter = innerCircle + 4;
|
||||
const cliffRingInner = innerCircle - 14;
|
||||
const cliffRingOuter = innerCircle - 6;
|
||||
const wheelLineOffset = innerCircle * 0.65;
|
||||
|
||||
const bumps = sensors?.bumpsAndWheelDrops || {};
|
||||
const wheelOver = sensors?.wheelOvercurrents || {};
|
||||
const wheelCurrentLeft = sensors?.wheelLeftCurrentMa ?? 0;
|
||||
const wheelCurrentRight = sensors?.wheelRightCurrentMa ?? 0;
|
||||
const sideBrushCurrent = sensors?.sideBrushCurrentMa ?? 0;
|
||||
const mainBrushCurrent = sensors?.mainBrushCurrentMa ?? 0;
|
||||
|
||||
const lightAngles = buildSegments({
|
||||
count: 6,
|
||||
totalSpan: 140,
|
||||
gap: 6,
|
||||
startAngle: -70,
|
||||
});
|
||||
const lightLabels = ['L', 'FL', 'CL', 'CR', 'FR', 'R'];
|
||||
const lightValues = [
|
||||
sensors?.lightBumpLeftSignal,
|
||||
sensors?.lightBumpFrontLeftSignal,
|
||||
sensors?.lightBumpCenterLeftSignal,
|
||||
sensors?.lightBumpCenterRightSignal,
|
||||
sensors?.lightBumpFrontRightSignal,
|
||||
sensors?.lightBumpRightSignal,
|
||||
];
|
||||
const lightSegments = lightAngles.map((ang, idx) => ({
|
||||
label: lightLabels[idx],
|
||||
start: ang.start,
|
||||
end: ang.end,
|
||||
value: lightValues[idx],
|
||||
}));
|
||||
|
||||
const cliffSegments = [
|
||||
{ label: 'Cliff L', start: -60, end: -46, value: sensors?.cliffLeftSignal, active: sensors?.cliffLeft },
|
||||
{ label: 'Cliff FL', start: -32, end: -16, value: sensors?.cliffFrontLeftSignal, active: sensors?.cliffFrontLeft },
|
||||
{ label: 'Cliff FR', start: 16, end: 32, value: sensors?.cliffFrontRightSignal, active: sensors?.cliffFrontRight },
|
||||
{ label: 'Cliff R', start: 46, end: 60, value: sensors?.cliffRightSignal, active: sensors?.cliffRight },
|
||||
];
|
||||
|
||||
const lightMaxSamples = lightValues.filter((v) => v != null);
|
||||
const maxLight = lightMaxSamples.length ? Math.max(...lightMaxSamples, 1200) : 1200;
|
||||
|
||||
return (
|
||||
<div className="surface relative p-1" style={{ height: '100%', width: '100%', aspectRatio: '1 / 1' }}>
|
||||
<div className="absolute left-1 top-1 text-xs text-slate-400">Top-down</div>
|
||||
<div className="absolute right-1 top-1 text-[0.65rem] text-slate-500">0–{maxLight}</div>
|
||||
<svg width="100%" height="100%" viewBox={`0 0 ${size} ${size}`} preserveAspectRatio="xMidYMid meet" className="mx-auto block">
|
||||
<circle cx={center} cy={center} r={innerCircle} fill="#0f172a" stroke="#334155" strokeWidth="2" />
|
||||
<WheelVisual
|
||||
cx={center - wheelLineOffset}
|
||||
cy={center}
|
||||
current={wheelCurrentLeft}
|
||||
drop={bumps.wheelDropLeft}
|
||||
overcurrent={wheelOver.leftWheel}
|
||||
label="L"
|
||||
/>
|
||||
<WheelVisual
|
||||
cx={center + wheelLineOffset}
|
||||
cy={center}
|
||||
current={wheelCurrentRight}
|
||||
drop={bumps.wheelDropRight}
|
||||
overcurrent={wheelOver.rightWheel}
|
||||
label="R"
|
||||
/>
|
||||
<SideBrushVisual
|
||||
cx={center + innerCircle * 0.65}
|
||||
cy={center - innerCircle * 0.55}
|
||||
current={sideBrushCurrent}
|
||||
overcurrent={wheelOver.sideBrush}
|
||||
/>
|
||||
<MainBrushVisual
|
||||
cx={center}
|
||||
cy={center}
|
||||
current={mainBrushCurrent}
|
||||
overcurrent={wheelOver.mainBrush}
|
||||
variant={variant}
|
||||
dirtLeft={sensors?.dirtDetectLeft}
|
||||
dirtRight={sensors?.dirtDetect}
|
||||
/>
|
||||
{lightSegments.map((seg) => {
|
||||
const color = lightBumpColor(seg.value);
|
||||
return (
|
||||
<ArcSegment
|
||||
key={seg.label}
|
||||
cx={center}
|
||||
cy={center}
|
||||
rInner={lightRingInner}
|
||||
rOuter={lightRingOuter}
|
||||
startDeg={seg.start}
|
||||
endDeg={seg.end}
|
||||
color={color}
|
||||
opacity={1}
|
||||
pulse={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{cliffSegments.map((seg) => {
|
||||
const color = cliffColor(seg.value, seg.active);
|
||||
return (
|
||||
<ArcSegment
|
||||
key={seg.label}
|
||||
cx={center}
|
||||
cy={center}
|
||||
rInner={cliffRingInner}
|
||||
rOuter={cliffRingOuter}
|
||||
startDeg={seg.start}
|
||||
endDeg={seg.end}
|
||||
color={color}
|
||||
opacity={1}
|
||||
pulse={Boolean(seg.active)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<ArcSegment
|
||||
cx={center}
|
||||
cy={center}
|
||||
rInner={lightRingOuter + 2}
|
||||
rOuter={lightRingOuter + 8}
|
||||
startDeg={-70}
|
||||
endDeg={-6}
|
||||
color={bumps.bumpLeft ? '#ef4444' : '#475569'}
|
||||
opacity={1}
|
||||
pulse={Boolean(bumps.bumpLeft)}
|
||||
/>
|
||||
<ArcSegment
|
||||
cx={center}
|
||||
cy={center}
|
||||
rInner={lightRingOuter + 2}
|
||||
rOuter={lightRingOuter + 8}
|
||||
startDeg={6}
|
||||
endDeg={70}
|
||||
color={bumps.bumpRight ? '#ef4444' : '#475569'}
|
||||
opacity={1}
|
||||
pulse={Boolean(bumps.bumpRight)}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArcSegment({ cx, cy, rInner, rOuter, startDeg, endDeg, color, pulse = false, opacity = 1 }) {
|
||||
const rMid = (rInner + rOuter) / 2;
|
||||
const strokeWidth = rOuter - rInner;
|
||||
const path = describeArc(cx, cy, rMid, startDeg, endDeg);
|
||||
return (
|
||||
<>
|
||||
<path d={path} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" fill="none" opacity={opacity} />
|
||||
{pulse ? (
|
||||
<path
|
||||
d={path}
|
||||
stroke="#ef4444"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
className="animate-pulse"
|
||||
opacity={1}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function describeArc(cx, cy, r, startDeg, endDeg) {
|
||||
let start = startDeg;
|
||||
let end = endDeg;
|
||||
if (end <= start) end += 360;
|
||||
const startPt = polarToCartesian(cx, cy, r, start);
|
||||
const endPt = polarToCartesian(cx, cy, r, end);
|
||||
return ['M', startPt.x, startPt.y, 'A', r, r, 0, 0, 1, endPt.x, endPt.y].join(' ');
|
||||
}
|
||||
|
||||
function polarToCartesian(cx, cy, r, angleDeg) {
|
||||
const rad = toRad(angleDeg);
|
||||
return {
|
||||
x: cx + r * Math.cos(rad),
|
||||
y: cy + r * Math.sin(rad),
|
||||
};
|
||||
}
|
||||
|
||||
function toRad(angleDeg) {
|
||||
return ((angleDeg - 90) * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function currentColor(value, overcurrent) {
|
||||
if (overcurrent) return '#ef4444';
|
||||
const mag = Math.abs(value);
|
||||
if (mag > 900) return '#f59e0b';
|
||||
if (mag > 300) return '#22c55e';
|
||||
return '#64748b';
|
||||
}
|
||||
|
||||
function clamp01(value) {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function WheelVisual({ cx, cy, current, drop, overcurrent, label }) {
|
||||
const mag = Math.abs(current);
|
||||
const pct = clamp01(mag / 1200);
|
||||
const color = currentColor(current, overcurrent);
|
||||
const barH = 56;
|
||||
const currentW = 14;
|
||||
const dropW = 9;
|
||||
const gap = 3;
|
||||
const currentFill = barH * pct;
|
||||
const sign = label === 'L' ? -1 : 1;
|
||||
const currentCenterX = sign * (-dropW / 2 - gap / 2);
|
||||
const dropCenterX = sign * (currentW / 2 + gap / 2);
|
||||
const groupWidth = currentW + dropW + gap + 2;
|
||||
const groupHeight = barH + 4;
|
||||
return (
|
||||
<g transform={`translate(${cx},${cy})`}>
|
||||
<rect x={-groupWidth / 2} y={-groupHeight / 2} width={groupWidth} height={groupHeight} rx="4" fill="none" stroke="#64748b" strokeWidth="1" />
|
||||
<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" />
|
||||
<text x={0} y={barH / 2 + 10} textAnchor="middle" className="fill-slate-200 text-[0.7rem]">
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function SideBrushVisual({ cx, cy, current, overcurrent }) {
|
||||
const mag = Math.abs(current);
|
||||
const color = currentColor(current * 3, overcurrent);
|
||||
const armLength = 42;
|
||||
const spinDuration = mag > 0 ? 0.65 : null;
|
||||
return (
|
||||
<g
|
||||
style={{
|
||||
transformOrigin: `${cx}px ${cy}px`,
|
||||
animation: spinDuration ? `spin ${spinDuration}s linear infinite` : 'none',
|
||||
}}
|
||||
>
|
||||
<circle cx={cx} cy={cy} r={10} fill="#64748b" stroke="#64748b" strokeWidth="1" />
|
||||
{[0, 120, 240].map((deg) => {
|
||||
const rad = toRad(deg);
|
||||
const x2 = cx + armLength * Math.cos(rad);
|
||||
const y2 = cy + armLength * Math.sin(rad);
|
||||
return <line key={deg} x1={cx} y1={cy} x2={x2} y2={y2} stroke={color} strokeWidth="4" strokeLinecap="round" />;
|
||||
})}
|
||||
{overcurrent ? <circle cx={cx} cy={cy} r={armLength + 8} stroke="#ef4444" strokeWidth="3" fill="none" className="animate-pulse" /> : null}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function MainBrushVisual({ cx, cy, current, overcurrent, variant, dirtLeft, dirtRight }) {
|
||||
const mag = Math.abs(current);
|
||||
const color = currentColor(current, overcurrent);
|
||||
const opacity = 1;
|
||||
const rollerWidth = 96;
|
||||
const rollerHeight = 12;
|
||||
const patternA = `main-brush-pattern-a-${variant}`;
|
||||
const patternB = `main-brush-pattern-b-${variant}`;
|
||||
const dur = mag > 0 ? 0.6 : null;
|
||||
const dir = current >= 0 ? 1 : -1;
|
||||
return (
|
||||
<g>
|
||||
<defs>
|
||||
{[patternA, patternB].map((id, idx) => (
|
||||
<pattern key={id} id={id} patternUnits="userSpaceOnUse" width="6" height="6">
|
||||
<rect width="6" height="6" fill={color} opacity={opacity} />
|
||||
<path d="M0 6 L6 0" stroke="#0f172a" strokeWidth="1" />
|
||||
{dur ? (
|
||||
<animateTransform attributeName="patternTransform" type="translate" from="0 0" to={`0 ${dir * (idx === 0 ? 6 : -6)}`} dur={`${dur}s`} repeatCount="indefinite" />
|
||||
) : null}
|
||||
</pattern>
|
||||
))}
|
||||
</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" />
|
||||
{overcurrent ? (
|
||||
<rect
|
||||
x={cx - rollerWidth / 2 - 4}
|
||||
y={cy - 16}
|
||||
width={rollerWidth + 8}
|
||||
height={rollerHeight * 2 + 16}
|
||||
rx="6"
|
||||
stroke="#ef4444"
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
className="animate-pulse"
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
const LIGHT_BUMP_SENSITIVITY = 0.18;
|
||||
function lightBumpColor(value) {
|
||||
if (value == null || value <= 0) return '#000000';
|
||||
const hue = (value * LIGHT_BUMP_SENSITIVITY) % 360;
|
||||
return `hsl(${hue} 100% 60%)`;
|
||||
}
|
||||
|
||||
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})`;
|
||||
}
|
||||
|
||||
function buildSegments({ count, totalSpan, gap, startAngle }) {
|
||||
const usable = totalSpan - gap * (count - 1);
|
||||
const width = usable / count;
|
||||
const segments = [];
|
||||
let cursor = startAngle;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const end = cursor + width;
|
||||
segments.push({ start: cursor, end });
|
||||
cursor = end + gap;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export default TopDownMap;
|
||||
Reference in New Issue
Block a user