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:
2026-09-06 18:31:52 +02:00
parent 9614e3498a
commit 07ee48e20c
2 changed files with 111 additions and 62 deletions
+107 -60
View File
@@ -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 cursors 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 beams 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}
/>
);