mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
moveing stuffs arounds
This commit is contained in:
@@ -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