From 1c66299d443c69d1eb14c0f0632346071bb0af3e Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Fri, 24 Jul 2026 12:06:11 +0000 Subject: [PATCH] feat(logging): show long-path distance & bearing alongside short path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HF DX often arrives via the long path (around the far side of the globe), where the beam heading is 180° opposed to the short path and the arc covers the rest of the great circle. The logging form's distance/bearing readout only showed the short path, so operators had to compute the long-path heading in their head during an opening. Add long-path helpers to the shared grid module (longPathBearingDeg, longPathKm, EARTH_CIRCUMFERENCE_KM), extend PathInfo/gridPath to carry the complement, and render a subtle "LP …" line under each of the Distance and Bearing readouts on the new-contact page. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/new-contact/page.tsx | 17 +++++++++++- src/lib/grid.ts | 35 +++++++++++++++++++++++-- tests/grid.spec.ts | 51 ++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/app/new-contact/page.tsx b/src/app/new-contact/page.tsx index b03c799..2970bdc 100644 --- a/src/app/new-contact/page.tsx +++ b/src/app/new-contact/page.tsx @@ -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; @@ -972,6 +980,9 @@ export default function NewContactPage() { {Math.round(kmToMiles(pathInfo.km)).toLocaleString()} mi +
+ LP {Math.round(longPathKm(pathInfo.km)).toLocaleString()} km +
@@ -983,6 +994,10 @@ export default function NewContactPage() { {compassPoint(pathInfo.bearing)} +
+ LP {Math.round(longPathBearingDeg(pathInfo.bearing))}°{' '} + {compassPoint(longPathBearingDeg(pathInfo.bearing))} +
) : null} diff --git a/src/lib/grid.ts b/src/lib/grid.ts index 4b2aee3..83ed5e1 100644 --- a/src/lib/grid.ts +++ b/src/lib/grid.ts @@ -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; @@ -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 = @@ -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 @@ -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), }; } diff --git a/tests/grid.spec.ts b/tests/grid.spec.ts index 85c69c7..d1b7474 100644 --- a/tests/grid.spec.ts +++ b/tests/grid.spec.ts @@ -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 @@ -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. @@ -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();