feat(rotor dial): circular scale and a beam, from EC1KD's revision
The square ring put the ticks in the corners where the room is, and made every distance from the centre depend on direction — a marker at 45 sat further out than one at north. A dial is read by angle, so the ring it is read against is the same distance away all the way round now, and the markers need one radius instead of a per-direction one. The antenna is a sector that fades outwards rather than an arrow: an antenna does not look along a line, it looks through a lobe, and the arrow claimed a precision the beamwidth does not have. Where the mouse would send it is drawn the same way, and its azimuth cross-fades into the big readout while aiming — the figure is read at the moment the beam is, so it belongs in the same place rather than somewhere to look away to. Three colours, three meanings, kept apart: green where the antenna IS (as everywhere else in OpsLog), orange where a click would send it, yellow what was ORDERED. His revision drew all of them orange and yellow, which loses the distinction the dial exists for — and drew the second lobe of a bidirectional Ultrabeam at full strength, indistinguishable from the main one. It is dimmed again; the dashed boom, the REV/BI badge and the compact form are untouched.
This commit is contained in:
+4
-2
@@ -10,7 +10,8 @@
|
||||
"Icom over the network: when CI-V goes quiet while the experimental RX audio stream is still delivering, the log now says so and names the switch to try. The two share the rig’s session, and the shape in the field is exactly that — hundreds of audio packets arriving, not one CI-V reply, the watchdog tearing the session down, and the whole thing starting again. The silence report also lists the last eight CI-V commands sent: a rig that answers at connect and then never again has usually been sent something it does not like, and a count of unanswered commands never said which one.",
|
||||
"Club Log uploads are no longer refused as “not configured”. The check added for services with no credentials demanded a Club Log API key, which nobody has ever set — OpsLog carries its own application key — so an operator whose live upload had worked for months was turned away when sending QSOs by hand. Each service’s requirements now live beside the uploader that enforces them, and the message names the fields that are actually missing.",
|
||||
"Audio: the Listening device now says when it cannot be opened. A device unplugged, renamed by Windows or unable to run at 16 kHz failed silently while everything upstream reported success — the stream up, the packets arriving, the monitor started — which is the whole of “I turned the sound on and nothing comes out”. The log also says, once, whether the network RX audio is reaching the speakers or arriving with nobody listening.",
|
||||
"Icom network audio starts at once instead of half a minute later. The message that authorises the stream is sent during the login, before the audio socket exists — so the rig was told to send audio to a port nothing was bound to, got a port-unreachable back, and only resumed when its own retry timer came round. It is sent once more as soon as the port is listening."
|
||||
"Icom network audio starts at once instead of half a minute later. The message that authorises the stream is sent during the login, before the audio socket exists — so the rig was told to send audio to a port nothing was bound to, got a port-unreachable back, and only resumed when its own retry timer came round. It is sent once more as soon as the port is listening.",
|
||||
"Rotor dial: a circular scale and a beam instead of an arrow. The square ring made a marker at 45° sit further from the centre than one at north — a dial is read by angle, so the ring it is read against is now the same distance away all the way round. The antenna is drawn as a sector that fades outwards, which is the shape of the thing it stands for; where the mouse would send it appears in the same shape in orange, and its azimuth in place of the current heading while you aim. Green for where the antenna is, orange for where it would go, yellow for what was ordered — the second lobe of a bidirectional Ultrabeam and the dashed boom are unchanged. Design from EC1KD again."
|
||||
],
|
||||
"fr": [
|
||||
"Carte FTx : l’indicatif, le locator et le report réapparaissent au survol. Le cercle invisible qui capte les clics est au-dessus du point, donc il capte aussi le survol — et l’étiquette n’était liée qu’au point du dessous, ce qui rendait la carte muette dès que les stations sont devenues cliquables.",
|
||||
@@ -20,7 +21,8 @@
|
||||
"Icom en réseau : quand le CI-V devient muet alors que le flux audio expérimental continue d’arriver, le journal le dit et nomme l’option à essayer. Les deux partagent la session du poste, et c’est exactement la forme observée en vrai — des centaines de paquets audio, pas une réponse CI-V, le chien de garde qui coupe la session, et tout qui recommence. Le rapport de silence liste aussi les huit dernières commandes CI-V envoyées : un poste qui répond à la connexion puis plus jamais s’est en général vu envoyer quelque chose qu’il n’aime pas, et un compteur de commandes sans réponse n’a jamais dit laquelle.",
|
||||
"Les envois vers Club Log ne sont plus refusés comme « non configuré ». Le contrôle ajouté pour les services sans identifiants réclamait une clé API Club Log que personne n’a jamais saisie — OpsLog embarque la sienne — et un opérateur dont l’envoi automatique fonctionnait depuis des mois se voyait éconduit au moment d’envoyer des QSO à la main. Les exigences de chaque service vivent désormais à côté du code qui les applique, et le message nomme les champs réellement manquants.",
|
||||
"Audio : le périphérique d’écoute signale désormais quand il ne peut pas s’ouvrir. Un périphérique débranché, renommé par Windows ou incapable de fonctionner en 16 kHz échouait en silence pendant que tout en amont annonçait le succès — flux ouvert, paquets reçus, moniteur démarré — ce qui est exactement le « j’ai remis le son et rien ne sort ». Le journal dit aussi, une fois, si l’audio réseau atteint les haut-parleurs ou arrive sans que personne n’écoute.",
|
||||
"L’audio réseau Icom démarre tout de suite au lieu d’une demi-minute plus tard. Le message qui autorise le flux part pendant la connexion, avant que la prise audio n’existe : le poste se voyait donc demander d’émettre vers un port où personne n’écoutait, recevait un « port injoignable » en retour, et ne reprenait qu’au tour suivant de son propre minuteur. Il est renvoyé dès que le port écoute."
|
||||
"L’audio réseau Icom démarre tout de suite au lieu d’une demi-minute plus tard. Le message qui autorise le flux part pendant la connexion, avant que la prise audio n’existe : le poste se voyait donc demander d’émettre vers un port où personne n’écoutait, recevait un « port injoignable » en retour, et ne reprenait qu’au tour suivant de son propre minuteur. Il est renvoyé dès que le port écoute.",
|
||||
"Cadran rotor : échelle circulaire et faisceau au lieu d’une flèche. L’anneau carré plaçait un repère à 45° plus loin du centre qu’un repère au nord — un cadran se lit par l’angle, donc l’anneau qui sert de référence est désormais à la même distance tout autour. L’antenne est dessinée comme un secteur qui s’estompe vers l’extérieur, ce qui est la forme de ce qu’il représente ; là où la souris l’enverrait apparaît dans la même forme en orange, et son azimut à la place du cap courant pendant qu’on vise. Vert pour où l’antenne est, orange pour où elle irait, jaune pour ce qui a été demandé — le deuxième lobe d’un Ultrabeam bidirectionnel et le boom en pointillés sont inchangés. Design d’EC1KD, encore."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -70,6 +70,12 @@ const TARGET_FADE_MS = 500;
|
||||
// in four colour schemes stops being readable. The chrome around it — card,
|
||||
// borders, buttons — follows the theme as everything else does.
|
||||
const COMPASS_ORANGE = '#F97316';
|
||||
// Three things are drawn on this dial and they must never be mistaken for one
|
||||
// another: where the antenna IS (green, as everywhere else in OpsLog), where
|
||||
// the mouse would send it (orange, the dial's own colour), and where it has
|
||||
// been ORDERED to go (yellow, the same yellow as the figure under the readout).
|
||||
const BEAM_GREEN = '#22C55E';
|
||||
type BeamKind = 'antenna' | 'hover';
|
||||
// The target is yellow in BOTH places it appears: the figure under the current
|
||||
// azimuth and the dot on the dial. One idea, one colour.
|
||||
const TARGET_YELLOW = '#FBBF24';
|
||||
@@ -118,7 +124,7 @@ function unwrapRotation(nextAngle: number, previousRotation: number | null): num
|
||||
|
||||
function RotorCompassDial({
|
||||
azimuth, secondary, boom, targetAzimuth, targetFading,
|
||||
shortPath, longPath, centerLat, centerLon, onGoto,
|
||||
shortPath, longPath, centerLat, centerLon, onGoto, onHoverAzimuth,
|
||||
}: {
|
||||
azimuth: number | null;
|
||||
// The second lobe of a bidirectional Ultrabeam, and the mechanical boom when
|
||||
@@ -132,6 +138,10 @@ function RotorCompassDial({
|
||||
centerLat?: number | null;
|
||||
centerLon?: number | null;
|
||||
onGoto?: (az: number) => void;
|
||||
// What the mouse is over, so the readout can show it. The figure belongs
|
||||
// beside the current heading, not on the map: a number floating over a beam
|
||||
// is read by moving the eye, and this one is read while aiming.
|
||||
onHoverAzimuth?: (az: number | null) => void;
|
||||
}) {
|
||||
// Gradient and mask ids must be unique per instance: two compasses on one
|
||||
// screen (docked widget + Station Control) would otherwise share the first
|
||||
@@ -144,16 +154,19 @@ function RotorCompassDial({
|
||||
const SIZE = 320;
|
||||
const CENTER = SIZE / 2;
|
||||
|
||||
// The scale is a SQUARE ring, not a circle: it puts the tick marks at the
|
||||
// edge of the panel, which is where the room is.
|
||||
const SCALE_HALF = 108;
|
||||
const CARDINAL_TICK_OUTER_HALF = SCALE_HALF + 7;
|
||||
const MAJOR_TICK_INNER_HALF = SCALE_HALF - 17;
|
||||
// A CIRCLE, not a square ring. The square put the ticks in the corners of
|
||||
// the panel, which is where the room is — and made every distance from the
|
||||
// centre depend on the direction, so a marker at 45° sat further out than one
|
||||
// at north. A dial is read by angle; the ring it is read against has to be
|
||||
// the same distance away all the way round.
|
||||
const SCALE_RADIUS = 112;
|
||||
const CARDINAL_TICK_OUTER_RADIUS = SCALE_RADIUS + 7;
|
||||
const MAJOR_TICK_INNER_RADIUS = SCALE_RADIUS - 17;
|
||||
const POINTER_TIP_RADIUS = 72;
|
||||
const CARDINAL_LABEL_INSET = 23;
|
||||
const CARDINAL_LABEL_RADIUS = SCALE_RADIUS + 24;
|
||||
const CENTER_DOT_RADIUS = 4.2;
|
||||
const MAP_RADIUS = CENTER - 1;
|
||||
// Around the centre the cursor's direction is meaningless — a pixel either
|
||||
// Around the centre the cursor’s direction is meaningless — a pixel either
|
||||
// way is forty degrees — so no heading is derived there.
|
||||
const HOVER_DEAD_ZONE = 14;
|
||||
|
||||
@@ -171,34 +184,15 @@ function RotorCompassDial({
|
||||
return geoPath(projection as any)(LAND as any) || '';
|
||||
}, [centerLat, centerLon]);
|
||||
|
||||
// A point on the square scale at a given azimuth.
|
||||
const squarePoint = (angle: number, halfExtent: number) => {
|
||||
const radians = (normalizeAzimuth(angle) * Math.PI) / 180;
|
||||
const dx = Math.sin(radians);
|
||||
const dy = -Math.cos(radians);
|
||||
const divisor = Math.max(Math.abs(dx), Math.abs(dy), 0.0001);
|
||||
const scale = halfExtent / divisor;
|
||||
return { x: CENTER + dx * scale, y: CENTER + dy * scale };
|
||||
};
|
||||
|
||||
const radialPoint = (angle: number, radius: number) => {
|
||||
const radians = (normalizeAzimuth(angle) * Math.PI) / 180;
|
||||
return { x: CENTER + Math.sin(radians) * radius, y: CENTER - Math.cos(radians) * radius };
|
||||
};
|
||||
|
||||
const radialDistanceToSquare = (angle: number, halfExtent: number) => {
|
||||
const radians = (normalizeAzimuth(angle) * Math.PI) / 180;
|
||||
const dx = Math.sin(radians);
|
||||
const dy = -Math.cos(radians);
|
||||
return halfExtent / Math.max(Math.abs(dx), Math.abs(dy), 0.0001);
|
||||
};
|
||||
|
||||
// Markers sit halfway between the pointer's tip and the scale, so they stay
|
||||
// clear of both whatever direction they are in — the ring is a square.
|
||||
const markerRadius = (angle: number) => {
|
||||
const tickInnerRadius = radialDistanceToSquare(angle, MAJOR_TICK_INNER_HALF);
|
||||
return POINTER_TIP_RADIUS + (tickInnerRadius - POINTER_TIP_RADIUS) / 2;
|
||||
};
|
||||
// Markers sit halfway between the beam’s tip and the scale, clear of both.
|
||||
// One number now: on a circular ring the answer no longer depends on which
|
||||
// way the marker lies.
|
||||
const markerRadius = POINTER_TIP_RADIUS + (MAJOR_TICK_INNER_RADIUS - POINTER_TIP_RADIUS) / 2;
|
||||
|
||||
const azimuthFromMouseEvent = (event: ReactMouseEvent<SVGSVGElement>): number | null => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
@@ -214,6 +208,7 @@ function RotorCompassDial({
|
||||
|
||||
const clearHover = () => {
|
||||
setHoverAzimuth(null);
|
||||
onHoverAzimuth?.(null);
|
||||
setHoverRotation(null);
|
||||
hoverRotationRef.current = null;
|
||||
};
|
||||
@@ -225,6 +220,7 @@ function RotorCompassDial({
|
||||
const nextRotation = unwrapRotation(nextAzimuth, hoverRotationRef.current);
|
||||
hoverRotationRef.current = nextRotation;
|
||||
setHoverAzimuth(nextAzimuth);
|
||||
onHoverAzimuth?.(nextAzimuth);
|
||||
setHoverRotation(nextRotation);
|
||||
};
|
||||
|
||||
@@ -239,7 +235,7 @@ function RotorCompassDial({
|
||||
const degreeLabels = [30, 60, 120, 150, 210, 240, 300, 330];
|
||||
|
||||
const renderPathDot = (angle: number, type: 'sp' | 'lp') => {
|
||||
const point = radialPoint(angle, markerRadius(angle));
|
||||
const point = radialPoint(angle, markerRadius);
|
||||
return (
|
||||
<circle cx={point.x} cy={point.y} r="4.2" fill="currentColor"
|
||||
className={type === 'sp' ? 'text-success' : 'text-destructive'}
|
||||
@@ -248,36 +244,46 @@ function RotorCompassDial({
|
||||
};
|
||||
|
||||
const renderTargetDot = (angle: number) => {
|
||||
const point = radialPoint(angle, markerRadius(angle));
|
||||
const point = radialPoint(angle, markerRadius);
|
||||
return (
|
||||
<circle cx={point.x} cy={point.y} r="4.2" fill={TARGET_YELLOW} pointerEvents="none"
|
||||
style={{ opacity: targetFading ? 0 : 0.95, transition: `opacity ${TARGET_FADE_MS}ms ease-out` }} />
|
||||
);
|
||||
};
|
||||
|
||||
// The pointer is drawn once and rotated, so the browser animates the turn
|
||||
// instead of the component redrawing a triangle every telemetry read.
|
||||
const renderPointer = (rotation: number, colour: string | null, opacity: number, animated: boolean) => {
|
||||
const tipY = 88;
|
||||
const baseY = 110;
|
||||
const innerTipY = 98;
|
||||
// A BEAM, not an arrow. The dial answers "where is the antenna looking", and
|
||||
// an antenna does not look along a line — it looks through a lobe. Drawn as a
|
||||
// sector that fades outwards, which is also the shape of the thing it stands
|
||||
// for; the arrow said a precision the beamwidth does not have.
|
||||
//
|
||||
// Drawn pointing north and rotated as a whole, so the browser animates the
|
||||
// turn instead of the component recomputing an arc on every telemetry read —
|
||||
// and rotation is the ONLY source of angle here, which is what keeps the
|
||||
// mouse preview exactly under the cursor.
|
||||
const BEAM_HALF_ANGLE = 17.5;
|
||||
const renderBeam = (rotation: number, kind: BeamKind, opacity: number, animated: boolean) => {
|
||||
const left = radialPoint(-BEAM_HALF_ANGLE, SCALE_RADIUS);
|
||||
const right = radialPoint(BEAM_HALF_ANGLE, SCALE_RADIUS);
|
||||
return (
|
||||
<g
|
||||
className={colour ? undefined : 'text-success'}
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: `${CENTER}px ${CENTER}px`,
|
||||
opacity,
|
||||
transition: animated ? 'transform 350ms ease-out' : 'transform 70ms linear, opacity 120ms ease-out',
|
||||
transition: animated
|
||||
? 'transform 500ms cubic-bezier(0.16,1,0.3,1), opacity 500ms ease-out'
|
||||
: 'transform 70ms linear, opacity 120ms ease-out',
|
||||
}}
|
||||
>
|
||||
<path d={`M ${CENTER - 13} ${baseY} L ${CENTER} ${tipY} L ${CENTER + 13} ${baseY}`}
|
||||
fill="none" stroke={colour ?? 'currentColor'} strokeWidth="5.5"
|
||||
strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d={`M ${CENTER - 4.5} ${baseY - 2} L ${CENTER} ${innerTipY} L ${CENTER + 4.5} ${baseY - 2}`}
|
||||
fill="none" stroke={colour ?? 'currentColor'} strokeWidth="2"
|
||||
strokeLinecap="round" strokeLinejoin="round" opacity="0.6" />
|
||||
<path
|
||||
d={`M ${CENTER} ${CENTER} L ${left.x} ${left.y} A ${SCALE_RADIUS} ${SCALE_RADIUS} 0 0 1 ${right.x} ${right.y} Z`}
|
||||
fill={`url(#beam-${kind}-${uid})`}
|
||||
/>
|
||||
{/* The axis: the heading itself, to the rim, fading outwards so the
|
||||
eye is drawn to where it starts rather than where it ends. */}
|
||||
<line x1={CENTER} y1={CENTER} x2={CENTER} y2={CENTER - SCALE_RADIUS}
|
||||
stroke={`url(#axis-${kind}-${uid})`} strokeWidth="2" strokeLinecap="round" />
|
||||
</g>
|
||||
);
|
||||
};
|
||||
@@ -312,6 +318,24 @@ function RotorCompassDial({
|
||||
<mask id={mapFadeMaskId}>
|
||||
<rect x="0" y="0" width={SIZE} height={SIZE} fill={`url(#${mapFadeGradientId})`} />
|
||||
</mask>
|
||||
{/* One pair per beam kind: the sector's wash and its axis. Both fade
|
||||
outwards — a lobe has no edge, and drawing one would claim a
|
||||
beamwidth the antenna does not have. */}
|
||||
{([['antenna', BEAM_GREEN], ['hover', COMPASS_ORANGE]] as const).map(([kind, colour]) => (
|
||||
<g key={kind}>
|
||||
<linearGradient id={`beam-${kind}-${uid}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={colour} stopOpacity="0.45" />
|
||||
<stop offset="55%" stopColor={colour} stopOpacity="0.22" />
|
||||
<stop offset="100%" stopColor={colour} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id={`axis-${kind}-${uid}`} x1="0" y1={CENTER} x2="0" y2={CENTER - SCALE_RADIUS}
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor={colour} stopOpacity="1" />
|
||||
<stop offset="55%" stopColor={colour} stopOpacity="0.45" />
|
||||
<stop offset="100%" stopColor={colour} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</g>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
<rect x="0" y="0" width={SIZE} height={SIZE} fill={`url(#${bgGradientId})`} />
|
||||
@@ -329,8 +353,8 @@ function RotorCompassDial({
|
||||
const cardinal = angle % 90 === 0;
|
||||
const major = angle % 30 === 0;
|
||||
const medium = !major && angle % 10 === 0;
|
||||
const outer = squarePoint(angle, cardinal ? CARDINAL_TICK_OUTER_HALF : SCALE_HALF);
|
||||
const inner = squarePoint(angle, major ? MAJOR_TICK_INNER_HALF : medium ? SCALE_HALF - 11 : SCALE_HALF - 6);
|
||||
const outer = radialPoint(angle, cardinal ? CARDINAL_TICK_OUTER_RADIUS : SCALE_RADIUS);
|
||||
const inner = radialPoint(angle, major ? MAJOR_TICK_INNER_RADIUS : medium ? SCALE_RADIUS - 11 : SCALE_RADIUS - 6);
|
||||
return (
|
||||
<line key={`tick-${angle}`} x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y}
|
||||
stroke="currentColor" strokeLinecap="round"
|
||||
@@ -343,7 +367,7 @@ function RotorCompassDial({
|
||||
|
||||
<g pointerEvents="none">
|
||||
{degreeLabels.map((angle) => {
|
||||
const position = squarePoint(angle, SCALE_HALF + 20);
|
||||
const position = radialPoint(angle, SCALE_RADIUS + 22);
|
||||
return (
|
||||
<text key={`degree-${angle}`} x={position.x} y={position.y}
|
||||
textAnchor="middle" dominantBaseline="middle" fill="currentColor"
|
||||
@@ -354,22 +378,25 @@ function RotorCompassDial({
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* The cardinals sit on the same circle as everything else. Each carries
|
||||
a small outward nudge: the letters are not the same height, and set
|
||||
on a true circle S and W read as if they had slipped inwards. */}
|
||||
<g pointerEvents="none">
|
||||
{([['N', CENTER, CARDINAL_LABEL_INSET], ['E', SIZE - CARDINAL_LABEL_INSET, CENTER],
|
||||
['S', CENTER, SIZE - CARDINAL_LABEL_INSET], ['W', CARDINAL_LABEL_INSET, CENTER]] as const).map(
|
||||
([label, x, y]) => (
|
||||
<text key={label} x={x} y={y} textAnchor="middle" dominantBaseline="middle"
|
||||
{([['N', 0, 0], ['E', 90, 2], ['S', 180, 4], ['W', 270, 3]] as const).map(([label, angle, nudge]) => {
|
||||
const position = radialPoint(angle, CARDINAL_LABEL_RADIUS + nudge);
|
||||
return (
|
||||
<text key={label} x={position.x} y={position.y} textAnchor="middle" dominantBaseline="middle"
|
||||
fill={COMPASS_ORANGE} className="text-[27px] font-black">
|
||||
{label}
|
||||
</text>
|
||||
),
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Where the mouse is pointing, in the same shape as the antenna's own
|
||||
pointer: the click sends the antenna there, so the preview should
|
||||
look like what it will produce. */}
|
||||
{hoverAzimuth != null && hoverRotation != null && renderPointer(hoverRotation, COMPASS_ORANGE, 0.78, false)}
|
||||
{hoverAzimuth != null && hoverRotation != null && renderBeam(hoverRotation, 'hover', 1, false)}
|
||||
|
||||
{shortPath != null && renderPathDot(shortPath, 'sp')}
|
||||
{longPath != null && renderPathDot(longPath, 'lp')}
|
||||
@@ -391,9 +418,10 @@ function RotorCompassDial({
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* The second lobe of a bidirectional antenna: same pointer, dimmed. */}
|
||||
{secondary != null && renderPointer(normalizeAzimuth(secondary), null, 0.42, true)}
|
||||
{azimuth != null && renderPointer(normalizeAzimuth(azimuth), null, 0.96, true)}
|
||||
{/* 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)}
|
||||
|
||||
<circle cx={CENTER} cy={CENTER} r={CENTER_DOT_RADIUS} fill={COMPASS_ORANGE} pointerEvents="none" />
|
||||
</svg>
|
||||
@@ -435,6 +463,8 @@ export function RotorCompass({
|
||||
() => rememberedTargets.get(rotorKey) ?? null,
|
||||
);
|
||||
const [targetFading, setTargetFading] = useState(false);
|
||||
// Where the mouse is aiming, while it is over the dial.
|
||||
const [hoverAzimuth, setHoverAzimuth] = useState<number | null>(null);
|
||||
|
||||
const flashTimerRef = useRef<number | undefined>(undefined);
|
||||
const movementTimerRef = useRef<number | undefined>(undefined);
|
||||
@@ -723,13 +753,29 @@ export function RotorCompass({
|
||||
{/* Where the antenna is, and under it — smaller, yellow, and only while it
|
||||
matters — where it was told to go. */}
|
||||
<div className="h-full min-h-0 rounded-md border border-border bg-background/30 px-1 text-center relative overflow-hidden">
|
||||
{/* Two readings in ONE place, cross-faded: where the antenna is, and —
|
||||
while the mouse is over the dial — where a click would send it. The
|
||||
aiming figure is what the operator is reading at that moment, and
|
||||
putting it somewhere else means looking away from the beam to find
|
||||
it. The green one does not move, so nothing jumps when the mouse
|
||||
leaves the dial. */}
|
||||
<div className={cn(
|
||||
'absolute left-1/2 top-1/2 -translate-x-1/2 font-mono text-[30px] leading-none font-bold tabular-nums whitespace-nowrap transition-all duration-300 ease-out',
|
||||
targetAzimuth != null ? '-translate-y-[72%]' : '-translate-y-1/2',
|
||||
hoverAzimuth != null ? 'opacity-0 scale-95' : 'opacity-100 scale-100',
|
||||
displayAzimuth != null ? 'text-success' : 'text-muted-foreground',
|
||||
)}>
|
||||
{displayAzimuth != null ? `${displayAzimuth}°` : '—'}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
'absolute left-1/2 top-1/2 -translate-x-1/2 font-mono text-[30px] leading-none font-bold tabular-nums whitespace-nowrap transition-all duration-300 ease-out',
|
||||
targetAzimuth != null ? '-translate-y-[72%]' : '-translate-y-1/2',
|
||||
hoverAzimuth != null ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none',
|
||||
)}
|
||||
style={{ color: COMPASS_ORANGE }}>
|
||||
{hoverAzimuth != null ? `${hoverAzimuth}°` : ''}
|
||||
</div>
|
||||
{targetAzimuth != null && (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -812,6 +858,7 @@ export function RotorCompass({
|
||||
centerLat={centerLat}
|
||||
centerLon={centerLon}
|
||||
onGoto={onGoto ? gotoAzimuth : undefined}
|
||||
onHoverAzimuth={setHoverAzimuth}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user