Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/app/new-contact/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,15 @@ import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { frequencyToBand, AMATEUR_BANDS } from '@/lib/bands';
import { AMATEUR_MODES, defaultRstForMode } from '@/lib/modes';
import { gridToLatLon, distanceKm, bearingDeg, compassPoint, kmToMiles } from '@/lib/grid';
import {
gridToLatLon,
distanceKm,
bearingDeg,
compassPoint,
kmToMiles,
longPathBearingDeg,
longPathKm,
} from '@/lib/grid';

void _PageHeader;

Expand Down Expand Up @@ -972,6 +980,9 @@ export default function NewContactPage() {
{Math.round(kmToMiles(pathInfo.km)).toLocaleString()} mi
</span>
</dd>
<div className="font-mono text-xs text-muted-foreground tabular-nums">
LP {Math.round(longPathKm(pathInfo.km)).toLocaleString()} km
</div>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-xs uppercase tracking-wide text-muted-foreground">
Expand All @@ -983,6 +994,10 @@ export default function NewContactPage() {
{compassPoint(pathInfo.bearing)}
</span>
</dd>
<div className="font-mono text-xs text-muted-foreground tabular-nums">
LP {Math.round(longPathBearingDeg(pathInfo.bearing))}°{' '}
{compassPoint(longPathBearingDeg(pathInfo.bearing))}
</div>
</div>
</dl>
) : null}
Expand Down
35 changes: 33 additions & 2 deletions src/lib/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ export function gridToLatLon(grid: string): LatLon | null {
return { lat, lon };
}

const EARTH_RADIUS_KM = 6371;

// Length of a full great circle around the Earth (2πR). The short path between
// two points and its long-path complement always sum to this, so long-path
// distance is just `EARTH_CIRCUMFERENCE_KM - shortPathKm`.
export const EARTH_CIRCUMFERENCE_KM = 2 * Math.PI * EARTH_RADIUS_KM;

const toRad = (deg: number): number => (deg * Math.PI) / 180;
const toDeg = (rad: number): number => (rad * 180) / Math.PI;

Expand All @@ -75,7 +82,7 @@ export function distanceKm(
lat2: number,
lon2: number
): number {
const R = 6371;
const R = EARTH_RADIUS_KM;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Expand Down Expand Up @@ -113,10 +120,29 @@ export function compassPoint(bearing: number): string {
return COMPASS_POINTS[idx];
}

// Long-path bearing: the heading around the *other* side of the globe, exactly
// 180° opposed to the short-path bearing. HF DX frequently arrives long-path, so
// operators swing the beam this way when the short path is dead. Normalized into
// [0, 360) so it feeds compassPoint like any other bearing.
export function longPathBearingDeg(shortBearing: number): number {
return ((shortBearing + 180) % 360 + 360) % 360;
}

// Long-path distance: the rest of the great circle once the short path is
// removed. short + long always sums to the Earth's circumference.
export function longPathKm(shortKm: number): number {
return EARTH_CIRCUMFERENCE_KM - shortKm;
}

export interface PathInfo {
distanceKm: number;
bearingDeg: number;
compass: string;
// Long-path complement — the beam heading and arc the other way around the
// globe, for the HF openings that favor the long path.
longPathKm: number;
longPathBearingDeg: number;
longPathCompass: string;
}

// Distance/bearing between two Maidenhead locators. Returns null unless both
Expand All @@ -127,10 +153,15 @@ export function gridPath(from: string, to: string): PathInfo | null {
const b = gridToLatLon(to);
if (!a || !b) return null;
const bearing = bearingDeg(a.lat, a.lon, b.lat, b.lon);
const dist = distanceKm(a.lat, a.lon, b.lat, b.lon);
const lpBearing = longPathBearingDeg(bearing);
return {
distanceKm: distanceKm(a.lat, a.lon, b.lat, b.lon),
distanceKm: dist,
bearingDeg: bearing,
compass: compassPoint(bearing),
longPathKm: longPathKm(dist),
longPathBearingDeg: lpBearing,
longPathCompass: compassPoint(lpBearing),
};
}

Expand Down
51 changes: 51 additions & 0 deletions tests/grid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import {
compassPoint,
gridPath,
kmToMiles,
longPathBearingDeg,
longPathKm,
EARTH_CIRCUMFERENCE_KM,
} from '@/lib/grid';

// Pure-function tests for the Maidenhead grid / great-circle math that powers
Expand Down Expand Up @@ -131,6 +134,36 @@ test.describe('compassPoint', () => {
});
});

test.describe('longPathBearingDeg', () => {
test('is 180° opposed to the short-path bearing', () => {
expect(longPathBearingDeg(0)).toBeCloseTo(180, 6);
expect(longPathBearingDeg(90)).toBeCloseTo(270, 6);
expect(longPathBearingDeg(200)).toBeCloseTo(20, 6); // wraps past 360
expect(longPathBearingDeg(359)).toBeCloseTo(179, 6);
});

test('always returns a value in [0, 360)', () => {
for (const b of [0, 45, 179, 180, 270, 359.9]) {
const lp = longPathBearingDeg(b);
expect(lp).toBeGreaterThanOrEqual(0);
expect(lp).toBeLessThan(360);
}
});
});

test.describe('longPathKm', () => {
test('is the remainder of the great circle after the short path', () => {
expect(longPathKm(0)).toBeCloseTo(EARTH_CIRCUMFERENCE_KM, 6);
// A short + long path always sums to the Earth's circumference.
const shortKm = 5500;
expect(shortKm + longPathKm(shortKm)).toBeCloseTo(EARTH_CIRCUMFERENCE_KM, 6);
});

test('EARTH_CIRCUMFERENCE_KM is ~40030 km (2πR, R = 6371 km)', () => {
expect(EARTH_CIRCUMFERENCE_KM).toBeCloseTo(40030.17, 1);
});
});

test.describe('gridPath', () => {
test('reports the transatlantic hop from Connecticut to London', () => {
// FN31 → IO91 is a classic east-coast-US to England path, ~5500 km NE.
Expand All @@ -141,6 +174,24 @@ test.describe('gridPath', () => {
expect(path!.compass).toBe('NE');
});

test('reports the long path as the opposite beam heading and the far arc', () => {
// The short path from Connecticut to London is NE; the long path leaves the
// antenna pointed SW, around the other side of the globe, and covers the
// rest of the great circle (~34500 km).
const path = gridPath('FN31', 'IO91');
expect(path).not.toBeNull();
expect(path!.longPathBearingDeg).toBeCloseTo(
(path!.bearingDeg + 180) % 360,
6,
);
expect(path!.longPathCompass).toBe('SW');
expect(path!.distanceKm + path!.longPathKm).toBeCloseTo(
EARTH_CIRCUMFERENCE_KM,
6,
);
expect(path!.longPathKm).toBeGreaterThan(path!.distanceKm);
});

test('returns null when either locator is invalid', () => {
expect(gridPath('FN31', 'nope')).toBeNull();
expect(gridPath('', 'IO91')).toBeNull();
Expand Down
Loading