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:
2026-09-10 09:53:30 +02:00
co-authored by Claude Opus 5
parent a88e871640
commit d8c9f05d10
2 changed files with 41 additions and 4 deletions
+4 -2
View File
@@ -10,7 +10,8 @@
"Satellite frequencies are shown to a hundred hertz instead of one. The Doppler moves about sixty hertz a second on 70 cm, so the last two digits changed every tick and the display was a blur of numbers nobody could read and nobody needed. The radio still gets the whole figure — this is only how much of it is worth putting in front of you. The shift beside it now reads \"+9.7 kHz\" rather than \"+9741 Hz\".", "Satellite frequencies are shown to a hundred hertz instead of one. The Doppler moves about sixty hertz a second on 70 cm, so the last two digits changed every tick and the display was a blur of numbers nobody could read and nobody needed. The radio still gets the whole figure — this is only how much of it is worth putting in front of you. The shift beside it now reads \"+9.7 kHz\" rather than \"+9741 Hz\".",
"Rotator Genius: OpsLog no longer clamps a target to 360°, and reads the limits the Genius reports so it can drive an overlap when the controller offers one. In practice a Rotator Genius is a 360° controller — its Limits fields say where the mechanical stop sits within one turn, not how far the mast travels — so an operator with a 450° rotator still gets 360° of it, and that limit is the controllers, not OpsLogs. The rotator range is therefore not offered for it: a setting that can only ever be refused by the box is worse than none.", "Rotator Genius: OpsLog no longer clamps a target to 360°, and reads the limits the Genius reports so it can drive an overlap when the controller offers one. In practice a Rotator Genius is a 360° controller — its Limits fields say where the mechanical stop sits within one turn, not how far the mast travels — so an operator with a 450° rotator still gets 360° of it, and that limit is the controllers, not OpsLogs. The rotator range is therefore not offered for it: a setting that can only ever be refused by the box is worse than none.",
"FlexRadio, satellite: the uplink slice is properly armed. Creating a slice is asynchronous — the radio reports its number afterwards — and OpsLog carried on without waiting, so everything meant for the uplink went nowhere: it was never tuned (it sat at the 435.100 it was created with), never got its sideband, its antenna or its CTCSS tone, and never became the transmitter, leaving the radio transmitting on the DOWNLINK slice. Arming now waits for both slices, adopts one that the radio announces without a reply of its own, and gives a late-arriving uplink everything it was owed.", "FlexRadio, satellite: the uplink slice is properly armed. Creating a slice is asynchronous — the radio reports its number afterwards — and OpsLog carried on without waiting, so everything meant for the uplink went nowhere: it was never tuned (it sat at the 435.100 it was created with), never got its sideband, its antenna or its CTCSS tone, and never became the transmitter, leaving the radio transmitting on the DOWNLINK slice. Arming now waits for both slices, adopts one that the radio announces without a reply of its own, and gives a late-arriving uplink everything it was owed.",
"SteppIR: a Calibrate button, beside Retract. It drives every element to its end stop so the controller re-learns where zero is — the cure for an antenna that tunes to the wrong length after a power cut mid-move, after the elements were pushed by hand, or after a motor slipped. It takes minutes and the antenna is unusable until it finishes, so it asks first. Ultrabeam controllers have no such command and say so rather than pretending. Retracting the elements was already there and now explains itself: it is the storage position, and the next tune brings them back out on its own." "SteppIR: a Calibrate button, beside Retract. It drives every element to its end stop so the controller re-learns where zero is — the cure for an antenna that tunes to the wrong length after a power cut mid-move, after the elements were pushed by hand, or after a motor slipped. It takes minutes and the antenna is unusable until it finishes, so it asks first. Ultrabeam controllers have no such command and say so rather than pretending. Retracting the elements was already there and now explains itself: it is the storage position, and the next tune brings them back out on its own.",
"On the rotor dial, the beam no longer whips a full turn round the compass when the antenna crosses north. A rotator at 020° turned anticlockwise reports 020, 010, 000, 359, 358 … and the animation travelled from 20° to 359° the long way — a complete revolution on screen while the mast moved forty degrees the other way. The beam now follows an accumulated angle, so what is drawn is the way the antenna is actually turning. Both lobes of a bidirectional antenna are handled separately, since they cross north at different moments."
], ],
"fr": [ "fr": [
"Si OpsLog sapprête à créer une base de réglages neuve et vide dans un dossier qui en contient déjà une pleine, il le dit — dans le journal de démarrage et à l’écran — au lieu de souvrir sans bruit comme si rien n’était configuré. Rien nest supprimé et rien nest deviné : le message nomme lautre fichier, là où vos réglages sont toujours.", "Si OpsLog sapprête à créer une base de réglages neuve et vide dans un dossier qui en contient déjà une pleine, il le dit — dans le journal de démarrage et à l’écran — au lieu de souvrir sans bruit comme si rien n’était configuré. Rien nest supprimé et rien nest deviné : le message nomme lautre fichier, là où vos réglages sont toujours.",
@@ -20,7 +21,8 @@
"Les fréquences satellite sont affichées à la centaine de hertz au lieu du hertz. Le Doppler se déplace denviron soixante hertz par seconde en 70 cm : les deux derniers chiffres changeaient à chaque tick et laffichage était une bouillie de chiffres illisible et inutile. La radio reçoit toujours la valeur complète — il ne sagit que de ce qui vaut la peine d’être mis sous vos yeux. Le décalage à côté indique désormais « +9,7 kHz » plutôt que « +9741 Hz ».", "Les fréquences satellite sont affichées à la centaine de hertz au lieu du hertz. Le Doppler se déplace denviron soixante hertz par seconde en 70 cm : les deux derniers chiffres changeaient à chaque tick et laffichage était une bouillie de chiffres illisible et inutile. La radio reçoit toujours la valeur complète — il ne sagit que de ce qui vaut la peine d’être mis sous vos yeux. Le décalage à côté indique désormais « +9,7 kHz » plutôt que « +9741 Hz ».",
"Rotator Genius : OpsLog n’écrête plus une consigne à 360° et lit les limites que le Genius rapporte, de façon à exploiter un recouvrement quand le contrôleur en offre un. Dans les faits, le Rotator Genius est un contrôleur 360° — ses champs Limits indiquent où se trouve la butée mécanique dans un tour, pas la course du pylône — donc un rotor 450° nen donne que 360, et cette limite est celle du contrôleur, pas dOpsLog. Lamplitude du rotor nest donc pas proposée pour lui : un réglage que le boîtier ne pourra que refuser est pire que pas de réglage du tout.", "Rotator Genius : OpsLog n’écrête plus une consigne à 360° et lit les limites que le Genius rapporte, de façon à exploiter un recouvrement quand le contrôleur en offre un. Dans les faits, le Rotator Genius est un contrôleur 360° — ses champs Limits indiquent où se trouve la butée mécanique dans un tour, pas la course du pylône — donc un rotor 450° nen donne que 360, et cette limite est celle du contrôleur, pas dOpsLog. Lamplitude du rotor nest donc pas proposée pour lui : un réglage que le boîtier ne pourra que refuser est pire que pas de réglage du tout.",
"FlexRadio, satellite : la tranche de montée est correctement armée. Créer une tranche est asynchrone — la radio annonce son numéro ensuite — et OpsLog continuait sans attendre : tout ce qui était destiné à la montée partait dans le vide. Elle n’était jamais accordée (elle restait sur le 435,100 de sa création), ne recevait ni sa bande latérale, ni son antenne, ni sa tonalité CTCSS, et ne devenait jamais l’émettrice — la radio émettait donc sur la tranche de DESCENTE. Larmement attend maintenant les deux tranches, adopte celle que la radio annonce sans réponse propre, et donne à une montée arrivée en retard tout ce qui lui était dû.", "FlexRadio, satellite : la tranche de montée est correctement armée. Créer une tranche est asynchrone — la radio annonce son numéro ensuite — et OpsLog continuait sans attendre : tout ce qui était destiné à la montée partait dans le vide. Elle n’était jamais accordée (elle restait sur le 435,100 de sa création), ne recevait ni sa bande latérale, ni son antenne, ni sa tonalité CTCSS, et ne devenait jamais l’émettrice — la radio émettait donc sur la tranche de DESCENTE. Larmement attend maintenant les deux tranches, adopte celle que la radio annonce sans réponse propre, et donne à une montée arrivée en retard tout ce qui lui était dû.",
"SteppIR : un bouton Calibrer, à côté de Rétracter. Il amène chaque élément en butée pour que le contrôleur retrouve son zéro — le remède à une antenne qui saccorde à la mauvaise longueur après une coupure en pleine course, après avoir poussé les éléments à la main, ou après un moteur qui a glissé. Cela prend plusieurs minutes et lantenne est inutilisable jusqu’à la fin : une confirmation est donc demandée. Les contrôleurs Ultrabeam nont pas cette commande et le disent, au lieu de faire semblant. Rétracter les éléments existait déjà et sexplique maintenant : cest la position de rangement, et le prochain accord les fait ressortir tout seuls." "SteppIR : un bouton Calibrer, à côté de Rétracter. Il amène chaque élément en butée pour que le contrôleur retrouve son zéro — le remède à une antenne qui saccorde à la mauvaise longueur après une coupure en pleine course, après avoir poussé les éléments à la main, ou après un moteur qui a glissé. Cela prend plusieurs minutes et lantenne est inutilisable jusqu’à la fin : une confirmation est donc demandée. Les contrôleurs Ultrabeam nont pas cette commande et le disent, au lieu de faire semblant. Rétracter les éléments existait déjà et sexplique maintenant : cest la position de rangement, et le prochain accord les fait ressortir tout seuls.",
"Sur le cadran du rotor, le faisceau ne fait plus un tour complet de la boussole quand lantenne franchit le nord. Un rotor à 020° tourné dans le sens antihoraire annonce 020, 010, 000, 359, 358… et lanimation allait de 20° à 359° par le chemin long — une révolution complète à l’écran pendant que le pylône bougeait de quarante degrés dans lautre sens. Le faisceau suit désormais un angle cumulé : ce qui est dessiné est le mouvement réel de lantenne. Les deux lobes dune antenne bidirectionnelle sont traités séparément, puisquils ne franchissent pas le nord au même moment."
] ]
}, },
{ {
+37 -2
View File
@@ -124,6 +124,35 @@ function unwrapRotation(nextAngle: number, previousRotation: number | null): num
return previousRotation + delta; 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 ─────────────────────────────────────────────────────────────── // ── The dial ───────────────────────────────────────────────────────────────
function RotorCompassDial({ function RotorCompassDial({
@@ -147,6 +176,12 @@ function RotorCompassDial({
// is read by moving the eye, and this one is read while aiming. // is read by moving the eye, and this one is read while aiming.
onHoverAzimuth?: (az: number | null) => void; 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 // Gradient and mask ids must be unique per instance: two compasses on one
// screen (docked widget + Station Control) would otherwise share the first // screen (docked widget + Station Control) would otherwise share the first
// one's definitions. // one's definitions.
@@ -428,8 +463,8 @@ function RotorCompassDial({
{/* The second lobe of a bidirectional antenna: the same beam, dimmed — {/* The second lobe of a bidirectional antenna: the same beam, dimmed —
it radiates as much, and it is not where the operator aimed. */} it radiates as much, and it is not where the operator aimed. */}
{secondary != null && renderBeam(normalizeAzimuth(secondary), 'antenna', 0.45, true)} {secondaryRotation != null && renderBeam(secondaryRotation, 'antenna', 0.45, true)}
{azimuth != null && renderBeam(normalizeAzimuth(azimuth), 'antenna', 1, true)} {antennaRotation != null && renderBeam(antennaRotation, 'antenna', 1, true)}
<circle cx={CENTER} cy={CENTER} r={CENTER_DOT_RADIUS} fill={COMPASS_ORANGE} pointerEvents="none" /> <circle cx={CENTER} cy={CENTER} r={CENTER_DOT_RADIUS} fill={COMPASS_ORANGE} pointerEvents="none" />
</svg> </svg>