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:
@@ -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