fix(rotor): the beam stops taking the long way round north
A G-2800 sitting at 020° turned anticlockwise reports 020, 010, 000, 359, 358 … 340. The dial fed those straight into a CSS rotate, so the transition travelled from 20deg to 359deg the long way: a complete revolution on screen while the mast moved forty degrees in the opposite direction. On a rotator with an overlap that happens on every pass through north, which is where the antenna spends much of its time. unwrapRotation was already there and already did the right thing — it was only wired to the HOVER beam, where the mouse crossing north had made it obvious. The antenna's own beam used the raw azimuth. So the antenna angle is accumulated across renders too: 020 → 000 → −001 → −020, which is the way the mast is moving. Both lobes of a bidirectional antenna get their own accumulator, since they cross north at different moments, and the ref only advances when the input changes so a re-render for any other reason cannot make the beam creep. The classic dial is unaffected: it draws its needle from SVG coordinates, with no transition to send anywhere. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -124,6 +124,35 @@ function unwrapRotation(nextAngle: number, previousRotation: number | null): num
|
||||
return previousRotation + delta;
|
||||
}
|
||||
|
||||
// useUnwrappedRotation is unwrapRotation kept across renders, for a beam whose
|
||||
// angle comes from the ANTENNA rather than from the mouse.
|
||||
//
|
||||
// This is what was missing, and on a rotator with an overlap it is unmissable:
|
||||
// a G-2800 sitting at 020° turned anticlockwise reports 020, 010, 000, 359,
|
||||
// 358 … 340, and the CSS transition from 20deg to 359deg travels the long way —
|
||||
// the beam whips a full turn round the dial while the antenna moves forty
|
||||
// degrees the other way. Reported from the air.
|
||||
//
|
||||
// The angle is therefore accumulated rather than reset: 020 → 000 → −001 →
|
||||
// −020, which is the way the mast is actually moving. The ref is advanced only
|
||||
// when the input changes, so a re-render for any other reason cannot make the
|
||||
// beam creep.
|
||||
function useUnwrappedRotation(angle: number | null): number | null {
|
||||
const rotation = useRef<number | null>(null);
|
||||
const lastInput = useRef<number | null>(null);
|
||||
if (angle == null) {
|
||||
rotation.current = null;
|
||||
lastInput.current = null;
|
||||
return null;
|
||||
}
|
||||
const a = normalizeAzimuth(angle);
|
||||
if (lastInput.current !== a || rotation.current == null) {
|
||||
rotation.current = unwrapRotation(a, rotation.current);
|
||||
lastInput.current = a;
|
||||
}
|
||||
return rotation.current;
|
||||
}
|
||||
|
||||
// ── The dial ───────────────────────────────────────────────────────────────
|
||||
|
||||
function RotorCompassDial({
|
||||
@@ -147,6 +176,12 @@ function RotorCompassDial({
|
||||
// is read by moving the eye, and this one is read while aiming.
|
||||
onHoverAzimuth?: (az: number | null) => void;
|
||||
}) {
|
||||
// The beams animate on an accumulated angle, so crossing north never sends
|
||||
// them the long way round the dial. Both lobes of a bidirectional antenna get
|
||||
// their own, because they cross north at different moments.
|
||||
const antennaRotation = useUnwrappedRotation(azimuth ?? null);
|
||||
const secondaryRotation = useUnwrappedRotation(secondary ?? null);
|
||||
|
||||
// Gradient and mask ids must be unique per instance: two compasses on one
|
||||
// screen (docked widget + Station Control) would otherwise share the first
|
||||
// one's definitions.
|
||||
@@ -428,8 +463,8 @@ function RotorCompassDial({
|
||||
|
||||
{/* The second lobe of a bidirectional antenna: the same beam, dimmed —
|
||||
it radiates as much, and it is not where the operator aimed. */}
|
||||
{secondary != null && renderBeam(normalizeAzimuth(secondary), 'antenna', 0.45, true)}
|
||||
{azimuth != null && renderBeam(normalizeAzimuth(azimuth), 'antenna', 1, true)}
|
||||
{secondaryRotation != null && renderBeam(secondaryRotation, 'antenna', 0.45, true)}
|
||||
{antennaRotation != null && renderBeam(antennaRotation, 'antenna', 1, true)}
|
||||
|
||||
<circle cx={CENTER} cy={CENTER} r={CENTER_DOT_RADIUS} fill={COMPASS_ORANGE} pointerEvents="none" />
|
||||
</svg>
|
||||
|
||||
Reference in New Issue
Block a user