mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -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-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-jkAqWJYz.js"></script>
|
<script type="module" crossorigin src="/assets/index-cJvNDfdb.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DpzaTbCt.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BJwrnf9g.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import TelemetryPanel from './components/TelemetryPanel.jsx';
|
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 AlertFeed from './components/AlertFeed.jsx';
|
||||||
import AdminPanel from './components/AdminPanel.jsx';
|
import AdminPanel from './components/AdminPanel.jsx';
|
||||||
import MobileControls, {
|
import MobileControls, {
|
||||||
@@ -83,9 +83,9 @@ function MobilePortraitLayout() {
|
|||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<DriverVideoPanel layoutFormat='mobile'/>
|
<DriverVideoPanel layoutFormat='mobile'/>
|
||||||
<MobileControls />
|
<MobileControls />
|
||||||
|
<ControlSummary />
|
||||||
<ChatPanel />
|
<ChatPanel />
|
||||||
<UserListPanel />
|
<UserListPanel />
|
||||||
<DrivePanel />
|
|
||||||
<TelemetryPanel />
|
<TelemetryPanel />
|
||||||
<AuthPanel />
|
<AuthPanel />
|
||||||
<AdminPanel />
|
<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 TelemetryPanel from './TelemetryPanel.jsx';
|
||||||
import DrivePanel from './DrivePanel.jsx';
|
import ControlSummary from './ControlSummary.jsx';
|
||||||
import CameraServoPanel from './CameraServoPanel.jsx';
|
|
||||||
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
||||||
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
||||||
import SettingsPanel from './SettingsPanel.jsx';
|
import SettingsPanel from './SettingsPanel.jsx';
|
||||||
@@ -19,11 +18,10 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
|||||||
<TabPanels>
|
<TabPanels>
|
||||||
<TabPanel id="telemetry">
|
<TabPanel id="telemetry">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<DrivePanel />
|
<ControlSummary />
|
||||||
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
|
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
|
||||||
<HomeAssistantControls />
|
<HomeAssistantControls />
|
||||||
<TelemetryPanel />
|
<TelemetryPanel />
|
||||||
<CameraServoPanel />
|
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="help">
|
<TabPanel id="help">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||||
import RoverRoster from './RoverRoster.jsx';
|
|
||||||
|
|
||||||
function formatMetric(value, fallback = '--') {
|
function formatMetric(value, fallback = '--') {
|
||||||
if (value == null || value === '') return fallback;
|
if (value == null || value === '') return fallback;
|
||||||
@@ -9,7 +8,7 @@ function formatMetric(value, fallback = '--') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TelemetryPanel() {
|
export default function TelemetryPanel() {
|
||||||
const { connected, session, requestControl } = useSession();
|
const { connected, session } = useSession();
|
||||||
const roverId = session?.assignment?.roverId;
|
const roverId = session?.assignment?.roverId;
|
||||||
const frame = useTelemetryFrame(roverId);
|
const frame = useTelemetryFrame(roverId);
|
||||||
const sensors = frame?.sensors || {};
|
const sensors = frame?.sensors || {};
|
||||||
@@ -19,9 +18,7 @@ export default function TelemetryPanel() {
|
|||||||
const charge = sensors.batteryChargeMah;
|
const charge = sensors.batteryChargeMah;
|
||||||
const capacity = sensors.batteryCapacityMah;
|
const capacity = sensors.batteryCapacityMah;
|
||||||
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
|
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 rawSnippet = frame?.raw ? frame.raw : null;
|
||||||
const roster = session?.roster ?? [];
|
|
||||||
const activeDriverId = roverId ? session?.activeDrivers?.[roverId] : null;
|
const activeDriverId = roverId ? session?.activeDrivers?.[roverId] : null;
|
||||||
const driverLabel = useMemo(() => {
|
const driverLabel = useMemo(() => {
|
||||||
if (!roverId) return 'n/a';
|
if (!roverId) return 'n/a';
|
||||||
@@ -30,20 +27,6 @@ export default function TelemetryPanel() {
|
|||||||
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
|
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
|
||||||
return user?.nickname || activeDriverId.slice(0, 6);
|
return user?.nickname || activeDriverId.slice(0, 6);
|
||||||
}, [activeDriverId, roverId, session?.socketId, session?.users]);
|
}, [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 (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base text-slate-100">
|
<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>
|
<p className="text-sm text-slate-500">Waiting for sensor frames…</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap gap-0.5 text-sm">
|
<TelemetrySummary sensors={sensors} voltage={voltage} current={current} batteryTemp={batteryTemp} charge={charge} capacity={capacity} />
|
||||||
<Metric label="Charge" value={formatMetric(charge != null && capacity != null ? `${charge}/${capacity}` : null)} />
|
<SensorDetails sensors={sensors} />
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{rawSnippet && (
|
{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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CliffBar({ value }) {
|
function TelemetrySummary({ sensors, voltage, current, batteryTemp, charge, capacity }) {
|
||||||
// Render a small vertical bar; value is clamped to 0-100 for display
|
const chargePct = charge != null && capacity ? `${Math.round((charge / capacity) * 100)}%` : '--';
|
||||||
// const pct = value == null ? 0 : Math.max(0, Math.min(100, value));
|
const oiMode = sensors?.oiMode?.label || '--';
|
||||||
// goes from 0 to 4095. calculate percentage for height
|
const docked = sensors?.chargingSources?.homeBase ? 'Yes' : 'No';
|
||||||
const pct = value == null ? 0 : Math.max(0, Math.min(100, (value / 4095) * 100));
|
const charging = sensors?.chargingState?.label || '--';
|
||||||
const height = `${pct}%`;
|
|
||||||
return (
|
return (
|
||||||
// <div className="surface-muted h-6 w-1/4 relative">
|
<div className="grid grid-cols-2 gap-0.5 md:grid-cols-3">
|
||||||
// <div className="w-full bg-amber-500 absolute inset-0" style={{ height }} />
|
<Metric label="Voltage" value={voltage ?? '--'} />
|
||||||
// </div>
|
<Metric label="Current" value={current ?? '--'} />
|
||||||
|
<Metric label="Battery temp" value={batteryTemp ?? '--'} />
|
||||||
<div className="flex flex-col items-center gap-0.5 w-1/4">
|
<Metric label="Charge" value={charge != null ? `${charge} mAh` : '--'} />
|
||||||
{/* <span className="text-sm text-slate-200">{label}</span> */}
|
<Metric label="Capacity" value={capacity != null ? `${capacity} mAh` : '--'} />
|
||||||
<div className="surface-muted h-6 w-full relative">
|
<Metric label="Charge %" value={chargePct} />
|
||||||
<div className={`absolute top-0 left-0 right-0 bg-amber-500`} style={{ height }} />
|
<Metric label="OI mode" value={oiMode} />
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-white">
|
<Metric label="Docked" value={docked} />
|
||||||
{value != null ? `${value}` : '--'}
|
<Metric label="Charging state" value={charging} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Metric({ label, value }) {
|
function Metric({ label, value }) {
|
||||||
return (
|
return (
|
||||||
<div className="surface text-sm">
|
<div className="surface flex items-center justify-between gap-0.5 px-1 py-0.5 text-sm">
|
||||||
{label}: {value}
|
<span className="text-slate-400">{label}</span>
|
||||||
|
<span className="text-slate-100">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// a reusable motor current bar, with overcurrent coloring. Use the overcurrent from sensors for each motor.
|
function SensorDetails({ sensors }) {
|
||||||
// include a label to indicate which motor it is, with the label to the left of the bar
|
const bumps = sensors?.bumpsAndWheelDrops || {};
|
||||||
function MotorCurrentBar({ label, value, overcurrent }) {
|
const over = sensors?.wheelOvercurrents || {};
|
||||||
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;
|
|
||||||
|
|
||||||
const lightSignals = [
|
const lightSignals = [
|
||||||
sensors?.lightBumpLeftSignal,
|
{ label: 'Left', value: sensors?.lightBumpLeftSignal },
|
||||||
sensors?.lightBumpFrontLeftSignal,
|
{ label: 'Front Left', value: sensors?.lightBumpFrontLeftSignal },
|
||||||
sensors?.lightBumpCenterLeftSignal,
|
{ label: 'Center Left', value: sensors?.lightBumpCenterLeftSignal },
|
||||||
sensors?.lightBumpCenterRightSignal,
|
{ label: 'Center Right', value: sensors?.lightBumpCenterRightSignal },
|
||||||
sensors?.lightBumpFrontRightSignal,
|
{ label: 'Front Right', value: sensors?.lightBumpFrontRightSignal },
|
||||||
sensors?.lightBumpRightSignal,
|
{ label: 'Right', value: 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,
|
|
||||||
];
|
];
|
||||||
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 = [
|
const cliffSignals = [
|
||||||
{ label: 'Cliff L', start: -65, end: -45, value: sensors?.cliffLeftSignal, active: sensors?.cliffLeft },
|
{ label: 'Left', value: sensors?.cliffLeftSignal, active: sensors?.cliffLeft },
|
||||||
{ label: 'Cliff FL', start: -25, end: -5, value: sensors?.cliffFrontLeftSignal, active: sensors?.cliffFrontLeft },
|
{ label: 'Front Left', value: sensors?.cliffFrontLeftSignal, active: sensors?.cliffFrontLeft },
|
||||||
{ label: 'Cliff FR', start: 5, end: 25, value: sensors?.cliffFrontRightSignal, active: sensors?.cliffFrontRight },
|
{ label: 'Front Right', value: sensors?.cliffFrontRightSignal, active: sensors?.cliffFrontRight },
|
||||||
{ label: 'Cliff R', start: 45, end: 65, value: sensors?.cliffRightSignal, active: sensors?.cliffRight },
|
{ 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 (
|
return (
|
||||||
<div className="surface relative p-1" style={{ height: variant === 'mini' ? '12rem' : '15rem' }}>
|
<div className="grid gap-0.5 md:grid-cols-2">
|
||||||
<div className="absolute left-1 top-1 text-xs text-slate-400">Top-down</div>
|
<DetailCard title="Bumps & drops">
|
||||||
<div className="absolute right-1 top-1 text-[0.65rem] text-slate-500">0–{effectiveLightMax}</div>
|
<ValueRow label="Bump L" value={<Pill active={bumps.bumpLeft} />} />
|
||||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="mx-auto">
|
<ValueRow label="Bump R" value={<Pill active={bumps.bumpRight} />} />
|
||||||
<circle cx={center} cy={center} r={innerCircle} fill="#0f172a" stroke="#334155" strokeWidth="2" />
|
<ValueRow label="Wheel drop L" value={<Pill active={bumps.wheelDropLeft} tone="amber" />} />
|
||||||
<WheelVisual
|
<ValueRow label="Wheel drop R" value={<Pill active={bumps.wheelDropRight} tone="amber" />} />
|
||||||
cx={center - wheelLineOffset}
|
</DetailCard>
|
||||||
cy={center}
|
|
||||||
current={wheelCurrentLeft}
|
<DetailCard title="Light bumps (raw)">
|
||||||
drop={bumps.wheelDropLeft}
|
{lightSignals.map((entry) => (
|
||||||
overcurrent={wheelOver.leftWheel}
|
<ValueRow key={entry.label} label={entry.label} value={formatNumber(entry.value)} />
|
||||||
label="L"
|
))}
|
||||||
|
</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>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<WheelVisual
|
))}
|
||||||
cx={center + wheelLineOffset}
|
</DetailCard>
|
||||||
cy={center}
|
|
||||||
current={wheelCurrentRight}
|
<DetailCard title="Dirt detect">
|
||||||
drop={bumps.wheelDropRight}
|
<ValueRow label="Left" value={formatNumber(sensors?.dirtDetectLeft)} />
|
||||||
overcurrent={wheelOver.rightWheel}
|
<ValueRow label="Right" value={formatNumber(sensors?.dirtDetect)} />
|
||||||
label="R"
|
</DetailCard>
|
||||||
/>
|
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArcSegment({ cx, cy, rInner, rOuter, startDeg, endDeg, color, pulse = false, opacity = 1 }) {
|
function DetailCard({ title, children }) {
|
||||||
const rMid = (rInner + rOuter) / 2;
|
|
||||||
const strokeWidth = rOuter - rInner;
|
|
||||||
const path = describeArc(cx, cy, rMid, startDeg, endDeg);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="surface space-y-0.5 p-1 text-sm">
|
||||||
<path d={path} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" fill="none" opacity={opacity} />
|
<div className="text-[0.8rem] uppercase tracking-wide text-slate-400">{title}</div>
|
||||||
{pulse ? (
|
<div className="space-y-0.5">{children}</div>
|
||||||
<path
|
</div>
|
||||||
d={path}
|
|
||||||
stroke="#ef4444"
|
|
||||||
strokeWidth={strokeWidth}
|
|
||||||
strokeLinecap="round"
|
|
||||||
fill="none"
|
|
||||||
className="animate-pulse"
|
|
||||||
opacity={1}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeArc(cx, cy, r, startDeg, endDeg) {
|
function ValueRow({ label, value }) {
|
||||||
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;
|
|
||||||
return (
|
return (
|
||||||
<g transform={`translate(${cx},${cy})`}>
|
<div className="flex items-center justify-between gap-1">
|
||||||
<rect
|
<span className="text-slate-300">{label}</span>
|
||||||
x={-groupWidth / 2}
|
<span className="text-slate-100">{value}</span>
|
||||||
y={-groupHeight / 2}
|
</div>
|
||||||
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 }) {
|
function Pill({ active, tone = 'green', label }) {
|
||||||
const mag = Math.abs(current);
|
const base = 'rounded px-1.5 py-0.5 text-[0.7rem] font-semibold';
|
||||||
const color = currentColor(current * 3, overcurrent); // more sensitive; side brush draws little current
|
const colors =
|
||||||
const armLength = 42;
|
tone === 'red'
|
||||||
const spinDuration = mag > 0 ? 0.65 : null; // spin only when drawing current
|
? active
|
||||||
return (
|
? 'bg-red-600 text-white'
|
||||||
<g
|
: 'bg-slate-700 text-slate-300'
|
||||||
style={{
|
: tone === 'amber'
|
||||||
transformOrigin: `${cx}px ${cy}px`,
|
? active
|
||||||
animation: spinDuration ? `spin ${spinDuration}s linear infinite` : 'none',
|
? 'bg-amber-500 text-slate-900'
|
||||||
}}
|
: 'bg-slate-700 text-slate-300'
|
||||||
>
|
: active
|
||||||
<circle cx={cx} cy={cy} r={10} fill="#64748b" stroke="#64748b" strokeWidth="1" />
|
? 'bg-emerald-600 text-white'
|
||||||
{[0, 120, 240].map((deg) => {
|
: 'bg-slate-700 text-slate-300';
|
||||||
const rad = toRad(deg);
|
return <span className={`${base} ${colors}`}>{label || (active ? 'true' : 'false')}</span>;
|
||||||
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 }) {
|
function formatNumber(value, unit) {
|
||||||
const mag = Math.abs(current);
|
if (value == null || Number.isNaN(value)) return '--';
|
||||||
const color = currentColor(current, overcurrent);
|
return unit ? `${value} ${unit}` : value;
|
||||||
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})`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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