mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
dock assist HUD
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-CL1kOB9m.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D2GpvMU5.css">
|
||||
<script type="module" crossorigin src="/assets/index-BTIDGQND.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CvzdH92u.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useDockIr } from '../hooks/useDockIr.js';
|
||||
|
||||
const SVG_SIZE = 150;
|
||||
const CENTER_X = SVG_SIZE / 2;
|
||||
const DOCK_Y = 26;
|
||||
const ROVER_Y = 120;
|
||||
|
||||
function lobePath(side = 'left') {
|
||||
if (side === 'left') {
|
||||
return `M ${CENTER_X} ${DOCK_Y} C ${CENTER_X - 28} ${DOCK_Y + 24} ${CENTER_X - 36} ${ROVER_Y - 18} ${CENTER_X - 10} ${ROVER_Y} L ${CENTER_X} ${ROVER_Y} Z`;
|
||||
}
|
||||
return `M ${CENTER_X} ${DOCK_Y} C ${CENTER_X + 28} ${DOCK_Y + 24} ${CENTER_X + 36} ${ROVER_Y - 18} ${CENTER_X + 10} ${ROVER_Y} L ${CENTER_X} ${ROVER_Y} Z`;
|
||||
}
|
||||
|
||||
export default function DockAssistHUD({ sensors }) {
|
||||
const state = useDockIr(sensors);
|
||||
const lobe = useMemo(
|
||||
() => ({
|
||||
left: {
|
||||
base: 'rgba(16, 185, 129, 0.15)',
|
||||
active: 'rgba(16, 185, 129, 0.55)',
|
||||
outline: 'rgba(52, 211, 153, 0.9)',
|
||||
},
|
||||
right: {
|
||||
base: 'rgba(239, 68, 68, 0.15)',
|
||||
active: 'rgba(239, 68, 68, 0.55)',
|
||||
outline: 'rgba(248, 113, 113, 0.9)',
|
||||
},
|
||||
force: {
|
||||
halo: 'rgba(59, 130, 246, 0.15)',
|
||||
haloStrong: 'rgba(59, 130, 246, 0.35)',
|
||||
outline: 'rgba(191, 219, 254, 0.8)',
|
||||
},
|
||||
rover: {
|
||||
body: '#111827',
|
||||
fill: '#e5e7eb',
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
if (!state.visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nudge = state.balance * 8; // pixels to offset rover cue
|
||||
const leftGlow = state.left.red || state.left.green || state.left.force;
|
||||
const rightGlow = state.right.red || state.right.green || state.right.force;
|
||||
const aligned = state.left.red && state.left.green && state.right.red && state.right.green && state.forceDetected;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 flex justify-center p-1">
|
||||
<div className="rounded bg-black/60 px-1 py-0.5 shadow-lg shadow-black/50 ring-1 ring-white/10">
|
||||
<svg width={SVG_SIZE} height={SVG_SIZE * 0.78} viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE * 0.78}`} role="img" aria-label="Docking assist">
|
||||
{/* Force field halo */}
|
||||
<circle
|
||||
cx={CENTER_X}
|
||||
cy={DOCK_Y + 6}
|
||||
r={26}
|
||||
fill={state.forceDetected ? lobe.force.haloStrong : lobe.force.halo}
|
||||
stroke={state.forceDetected ? lobe.force.outline : 'none'}
|
||||
strokeWidth={state.forceDetected ? 2 : 0}
|
||||
/>
|
||||
|
||||
{/* Left lobe */}
|
||||
<path d={lobePath('left')} fill={leftGlow ? lobe.left.active : lobe.left.base} />
|
||||
{aligned && <path d={lobePath('left')} fill="none" stroke={lobe.left.outline} strokeWidth={1.5} />}
|
||||
|
||||
{/* Right lobe */}
|
||||
<path d={lobePath('right')} fill={rightGlow ? lobe.right.active : lobe.right.base} />
|
||||
{aligned && <path d={lobePath('right')} fill="none" stroke={lobe.right.outline} strokeWidth={1.5} />}
|
||||
|
||||
{/* Dock icon */}
|
||||
<rect x={CENTER_X - 16} y={DOCK_Y - 10} width="32" height="12" rx="2" fill="#1f2937" stroke="#6b7280" strokeWidth="1" />
|
||||
<circle cx={CENTER_X - 6} cy={DOCK_Y - 4} r="1.8" fill="#9ca3af" />
|
||||
<circle cx={CENTER_X + 6} cy={DOCK_Y - 4} r="1.8" fill="#9ca3af" />
|
||||
|
||||
{/* Rover cue */}
|
||||
<g transform={`translate(${CENTER_X + nudge}, ${ROVER_Y})`}>
|
||||
<circle r="8" fill={lobe.rover.body} stroke="#6b7280" strokeWidth="1.5" />
|
||||
<path d="M 0 -6 L 4 4 L -4 4 Z" fill={lobe.rover.fill} />
|
||||
</g>
|
||||
|
||||
{/* Nudge arrow */}
|
||||
{state.bias !== 'center' && (
|
||||
<path
|
||||
d={
|
||||
state.bias === 'left'
|
||||
? `M ${CENTER_X - 18} ${ROVER_Y - 6} L ${CENTER_X - 28} ${ROVER_Y} L ${CENTER_X - 18} ${ROVER_Y + 6}`
|
||||
: `M ${CENTER_X + 18} ${ROVER_Y - 6} L ${CENTER_X + 28} ${ROVER_Y} L ${CENTER_X + 18} ${ROVER_Y + 6}`
|
||||
}
|
||||
fill="none"
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Raw code badge for debugging */}
|
||||
<g transform={`translate(${CENTER_X}, ${ROVER_Y + 18})`} className="text-xs">
|
||||
<rect x="-32" y="-10" width="64" height="18" rx="4" fill="rgba(0,0,0,0.6)" stroke="rgba(255,255,255,0.15)" />
|
||||
<text x="0" y="3" fill="#e5e7eb" fontSize="9" textAnchor="middle">
|
||||
{`L:${state.left.code ?? 0} O:${state.omni.code ?? 0} R:${state.right.code ?? 0}`}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -113,6 +113,12 @@ function SensorDetails({ sensors }) {
|
||||
|
||||
return (
|
||||
<div className="grid gap-0.5 md:grid-cols-2">
|
||||
<DetailCard title="Dock IR (raw)">
|
||||
<ValueRow label="Left" value={formatNumber(sensors?.infraredCharacterLeft)} />
|
||||
<ValueRow label="Omni" value={formatNumber(sensors?.infraredCharacterOmni)} />
|
||||
<ValueRow label="Right" value={formatNumber(sensors?.infraredCharacterRight)} />
|
||||
</DetailCard>
|
||||
|
||||
<DetailCard title="Bumps & drops">
|
||||
<ValueRow label="Bump L" value={<Pill active={bumps.bumpLeft} />} />
|
||||
<ValueRow label="Bump R" value={<Pill active={bumps.bumpRight} />} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { WhepPlayer } from '../lib/whepPlayer.js';
|
||||
import TopDownMap from './TopDownMap.jsx';
|
||||
import DockAssistHUD from './DockAssistHUD.jsx';
|
||||
import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
|
||||
|
||||
const RESTART_DELAY_MS = 2000;
|
||||
@@ -527,6 +528,13 @@ function HudOverlay({
|
||||
Song {formatNoteLabel(songNote)} <span className="text-slate-400">({songNote})</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="absolute inset-0 flex items-start justify-center">
|
||||
<DockAssistHUD sensors={sensors} />
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-start justify-center">
|
||||
<DockAssistHUD sensors={sensors} />
|
||||
</div>
|
||||
<div className="absolute bottom-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 px-0.5 py-0.5 text-slate-100">
|
||||
<span>Rover: "{label || 'Unnamed Rover'}"</span>
|
||||
{/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const HOLD_MS = 700;
|
||||
|
||||
const DOCK_CODES = {
|
||||
161: { red: true }, // Red buoy
|
||||
164: { green: true }, // Green buoy
|
||||
165: { force: true }, // Force field only
|
||||
168: { red: true, green: true }, // Red + green
|
||||
169: { red: true, force: true }, // Red + force
|
||||
172: { green: true, force: true }, // Green + force
|
||||
173: { red: true, green: true, force: true }, // Red + green + force
|
||||
};
|
||||
|
||||
function decodeDockBits(code) {
|
||||
if (typeof code !== 'number' || code <= 0) return null;
|
||||
const bits = DOCK_CODES[code];
|
||||
if (!bits) return null;
|
||||
return { code, ...bits };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a smoothed view of dock IR signals. Keeps the last seen code alive for HOLD_MS to mask blinks.
|
||||
*/
|
||||
export function useDockIr(sensors, options = {}) {
|
||||
const holdMs = options.holdMs || HOLD_MS;
|
||||
const [state, setState] = useState({
|
||||
left: null,
|
||||
right: null,
|
||||
omni: null,
|
||||
});
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick(Date.now()), Math.max(holdMs / 2, 100));
|
||||
return () => clearInterval(id);
|
||||
}, [holdMs]);
|
||||
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
const left = decodeDockBits(sensors?.infraredCharacterLeft);
|
||||
const right = decodeDockBits(sensors?.infraredCharacterRight);
|
||||
const omni = decodeDockBits(sensors?.infraredCharacterOmni);
|
||||
if (!left && !right && !omni) {
|
||||
return;
|
||||
}
|
||||
setState((prev) => ({
|
||||
left: left ? { ...left, ts: now } : prev.left,
|
||||
right: right ? { ...right, ts: now } : prev.right,
|
||||
omni: omni ? { ...omni, ts: now } : prev.omni,
|
||||
}));
|
||||
}, [sensors?.infraredCharacterLeft, sensors?.infraredCharacterRight, sensors?.infraredCharacterOmni]);
|
||||
|
||||
useEffect(() => {
|
||||
// Debug: log when new codes appear
|
||||
const { left, right, omni } = state;
|
||||
const haveAny = left?.ts || right?.ts || omni?.ts;
|
||||
if (!haveAny) return;
|
||||
const stamp = new Date().toISOString();
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('[DockIR]', stamp, {
|
||||
left: left?.code ?? 0,
|
||||
omni: omni?.code ?? 0,
|
||||
right: right?.code ?? 0,
|
||||
});
|
||||
}, [state.left?.code, state.right?.code, state.omni?.code]);
|
||||
|
||||
return useMemo(() => {
|
||||
const now = Date.now();
|
||||
const withWindow = (entry) => {
|
||||
if (!entry) return { active: false, red: false, green: false, force: false, age: null, code: null };
|
||||
const age = now - entry.ts;
|
||||
const active = age <= holdMs;
|
||||
return {
|
||||
active,
|
||||
age,
|
||||
code: entry.code,
|
||||
red: Boolean(entry.red && active),
|
||||
green: Boolean(entry.green && active),
|
||||
force: Boolean(entry.force && active),
|
||||
};
|
||||
};
|
||||
|
||||
const left = withWindow(state.left);
|
||||
const right = withWindow(state.right);
|
||||
const omni = withWindow(state.omni);
|
||||
const visible = left.active || right.active || omni.active;
|
||||
const forceDetected = left.force || right.force || omni.force;
|
||||
const bias =
|
||||
left.active && !right.active
|
||||
? 'right'
|
||||
: right.active && !left.active
|
||||
? 'left'
|
||||
: 'center';
|
||||
const balance = (right.active ? 1 : 0) - (left.active ? 1 : 0); // positive means steer left-to-right
|
||||
|
||||
return {
|
||||
visible,
|
||||
forceDetected,
|
||||
left,
|
||||
right,
|
||||
omni,
|
||||
bias, // left/right/center for steering cue
|
||||
balance: Math.max(-1, Math.min(1, balance)),
|
||||
};
|
||||
}, [state.left, state.right, state.omni, holdMs, tick]);
|
||||
}
|
||||
Reference in New Issue
Block a user