// RotorCompass — an azimuthal-equidistant rotor display (à la 4O3A RotorGenius). // // A world map centred on the operator's QTH (north up) fills the dial, ringed by // a green azimuth bezel. A green needle shows the antenna heading (two needles // when an Ultrabeam is bidirectional, the opposite one when reversed); a small // red marker on the bezel shows the short-path bearing to the DX. Click the dial // to turn the antenna there. import { useMemo } from 'react'; import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo'; import { feature } from 'topojson-client'; import landTopo from 'world-atlas/land-110m.json'; import { Compass, X } from 'lucide-react'; import { cn } from '@/lib/utils'; // Decode the coastline outline once (≈110 m simplified land polygons). const LAND = feature(landTopo as any, (landTopo as any).objects.land); const GRATICULE = geoGraticule10(); interface Props { bearing?: number | null; // short-path azimuth to DX (deg) headings: number[]; // radiating heading(s) — rotor + Ultrabeam pattern boomHeading?: number | null; // mechanical boom (rotor) azimuth, shown grey when it differs pattern?: 'normal' | 'reverse' | 'bi' | null; // Ultrabeam pattern (for the badge) centerLat?: number | null; // operator latitude (projection centre) centerLon?: number | null; // operator longitude rotorEnabled?: boolean; onGoto?: (az: number) => void; // click-to-turn onClose?: () => void; } const SIZE = 168; const C = SIZE / 2; const R = C - 6; // outer bezel radius const MAP_R = R - 6; // map/clip radius (inside the bezel) function pt(az: number, radius: number): [number, number] { const a = ((az - 90) * Math.PI) / 180; return [C + radius * Math.cos(a), C + radius * Math.sin(a)]; } export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) { const cardinals = useMemo( () => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' }, { d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ], [], ); // Project the world centred on the QTH (north up; antipode at the bezel). const { land, grat } = useMemo(() => { if (centerLat == null || centerLon == null) return { land: '', grat: '' }; const proj = geoAzimuthalEquidistant() .rotate([-centerLon, -centerLat]) .clipAngle(179.9) .scale(MAP_R / Math.PI) .translate([C, C]); const path = geoPath(proj as any); return { land: path(LAND as any) || '', grat: path(GRATICULE as any) || '' }; }, [centerLat, centerLon]); function handleClick(e: React.MouseEvent) { if (!onGoto) return; const rect = e.currentTarget.getBoundingClientRect(); const x = ((e.clientX - rect.left) / rect.width) * SIZE - C; const y = ((e.clientY - rect.top) / rect.height) * SIZE - C; let az = (Math.atan2(y, x) * 180) / Math.PI + 90; az = ((az % 360) + 360) % 360; onGoto(Math.round(az)); } const headLabel = headings.length ? headings[0] : null; return (
{/* Header — matches the WinKeyer / Voice keyer panels. */}
Rotor
{pattern && ( {pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'} )} {headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'} {onClose && ( )}
{/* water + world map, clipped to the dial */} {grat && } {land && } {/* green azimuth bezel */} {/* ticks every 10°, longer at 30° */} {Array.from({ length: 36 }, (_, i) => i * 10).map((d) => { const major = d % 30 === 0; const [x1, y1] = pt(d, MAP_R); const [x2, y2] = pt(d, MAP_R - (major ? 7 : 4)); return ; })} {/* cardinal labels + degree numbers at 45° */} {cardinals.map(({ d, l }) => { const [x, y] = pt(d, MAP_R - 13); return 1 ? 7 : 9, fontWeight: 700 }}>{l}; })} {/* DX short-path bearing → small red marker on the bezel */} {bearing != null && (() => { const [x, y] = pt(bearing, MAP_R); return ( ); })()} {/* mechanical boom (rotor) heading — grey dashed needle, shown when the Ultrabeam radiates somewhere other than the boom (reverse/bi) so the operator sees where the antenna physically points vs where it boom-sits */} {boomHeading != null && pattern && pattern !== 'normal' && (() => { const [x, y] = pt(boomHeading, MAP_R - 2); return ( Boom (rotor) {Math.round(boomHeading)}° ); })()} {/* radiating heading needle(s) — green; two when bidirectional */} {headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return ( ); })}
); }