moveing stuffs arounds

This commit is contained in:
legop3
2025-12-21 13:47:13 -05:00
parent 0be105e9dd
commit e69fd18256
10 changed files with 715 additions and 524 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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
View File
@@ -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 />
+255
View File
@@ -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)}°`;
}
+2 -4
View File
@@ -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">
+112 -503
View File
@@ -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>
<WheelVisual
cx={center + wheelLineOffset} <DetailCard title="Cliff sensors">
cy={center} {cliffSignals.map((entry) => (
current={wheelCurrentRight} <ValueRow key={entry.label} label={entry.label} value={`${formatNumber(entry.value)} · ${entry.active ? 'bool: true' : 'bool: false'}`} />
drop={bumps.wheelDropRight} ))}
overcurrent={wheelOver.rightWheel} </DetailCard>
label="R"
/> <DetailCard title="Currents">
<SideBrushVisual {currents.map((entry) => (
cx={center + innerCircle * 0.65} <ValueRow
cy={center - innerCircle * 0.55} key={entry.label}
current={sideBrushCurrent} label={entry.label}
overcurrent={wheelOver.sideBrush} value={
/> <span className="flex items-center gap-0.5">
<MainBrushVisual <span>{formatNumber(entry.value, 'mA')}</span>
cx={center} {entry.over ? <Pill active tone="red" label="over" /> : null}
cy={center} </span>
current={mainBrushCurrent} }
overcurrent={wheelOver.mainBrush} />
variant={variant} ))}
dirtLeft={sensors?.dirtDetectLeft} </DetailCard>
dirtRight={sensors?.dirtDetect}
/> <DetailCard title="Dirt detect">
{lightSegments.map((seg) => { <ValueRow label="Left" value={formatNumber(sensors?.dirtDetectLeft)} />
const color = lightBumpColor(seg.value); <ValueRow label="Right" value={formatNumber(sensors?.dirtDetect)} />
return ( </DetailCard>
<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})`;
} }
+329
View File
@@ -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;