dock assist HUD

This commit is contained in:
legop3
2025-12-26 23:05:25 -05:00
parent b9d7a62b0e
commit 9ea29f8e36
9 changed files with 248 additions and 15 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-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>
+112
View File
@@ -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>
);
}
+6
View File
@@ -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} />} />
+8
View File
@@ -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> */}
+107
View File
@@ -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]);
}