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
+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]);
}