Files
OpsLog/frontend/src/components/RotorCompass.tsx
T

167 lines
8.0 KiB
TypeScript

// 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<SVGSVGElement>) {
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 (
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
{/* Header — matches the WinKeyer / Voice keyer panels. */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
<Compass className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')}
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
<div className="flex-1" />
{pattern && (
<span
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
pattern === 'reverse' ? 'bg-amber-200 text-amber-900'
: pattern === 'bi' ? 'bg-sky-200 text-sky-900'
: 'bg-emerald-200 text-emerald-900')}
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
: 'Ultrabeam normal'}>
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
</span>
)}
<span className="font-mono text-sm font-bold text-emerald-700 tabular-nums">
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
</span>
{onClose && (
<button className="text-muted-foreground hover:text-foreground" title="Hide rotor" onClick={onClose}>
<X className="size-3.5" />
</button>
)}
</div>
<div className="flex items-center justify-center p-2 min-h-0">
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
style={{ width: SIZE, height: SIZE }}
onClick={handleClick}
>
<defs>
<clipPath id="rotorDial"><circle cx={C} cy={C} r={MAP_R} /></clipPath>
</defs>
{/* water + world map, clipped to the dial */}
<circle cx={C} cy={C} r={MAP_R} fill="#d3e7f1" />
<g clipPath="url(#rotorDial)">
{grat && <path d={grat} fill="none" stroke="#9cc0d6" strokeWidth={0.4} opacity={0.7} />}
{land && <path d={land} fill="#dfe2cf" stroke="#9aa589" strokeWidth={0.4} />}
</g>
{/* green azimuth bezel */}
<circle cx={C} cy={C} r={R} fill="none" stroke="#16a34a" strokeWidth={5} />
{/* 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 <line key={d} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#475569" strokeWidth={major ? 1 : 0.6} opacity={0.7} />;
})}
{/* cardinal labels + degree numbers at 45° */}
{cardinals.map(({ d, l }) => {
const [x, y] = pt(d, MAP_R - 13);
return <text key={l} x={x} y={y} textAnchor="middle" dominantBaseline="central" className="fill-slate-700" style={{ fontSize: l.length > 1 ? 7 : 9, fontWeight: 700 }}>{l}</text>;
})}
{/* DX short-path bearing → small red marker on the bezel */}
{bearing != null && (() => { const [x, y] = pt(bearing, MAP_R); return (
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
); })()}
{/* 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 (
<g>
<title>Boom (rotor) {Math.round(boomHeading)}°</title>
<line x1={C} y1={C} x2={x} y2={y} stroke="#64748b" strokeWidth={2} strokeDasharray="3 3" strokeLinecap="round" />
<circle cx={x} cy={y} r={3} fill="#64748b" stroke="#fff" strokeWidth={1} />
</g>
);
})()}
{/* radiating heading needle(s) — green; two when bidirectional */}
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
<g key={i}>
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
<polygon points={`${x},${y} ${pt(h - 5, MAP_R - 12).join(',')} ${pt(h + 5, MAP_R - 12).join(',')}`} fill="#15803d" opacity={i === 0 ? 1 : 0.55} />
</g>
); })}
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
</svg>
</div>
</section>
);
}