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
40 changes: 29 additions & 11 deletions src/lib/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,22 @@ export interface LatLon {
lon: number;
}

const GRID_RE = /^[A-R]{2}[0-9]{2}([A-X]{2})?$/;

// True for a well-formed 4- or 6-character Maidenhead locator (case-insensitive).
// 4-char (field+square), 6-char (+subsquare), or 8-char (+extended square)
// Maidenhead locator. The extended-square pair can only follow a subsquare —
// you can't skip a level (e.g. `FN3155` is invalid).
const GRID_RE = /^[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?$/;

// True for a well-formed 4-, 6-, or 8-character Maidenhead locator
// (case-insensitive). VHF/UHF/microwave and satellite operators log 8-char
// (extended) locators for the extra precision, so they must validate too.
export function isValidGrid(grid: string): boolean {
return GRID_RE.test(grid.trim().toUpperCase());
}

// Convert a Maidenhead locator to the latitude/longitude of the *center* of the
// square (4-char) or subsquare (6-char). Returns null for anything that isn't a
// valid locator. Centering matches @/components/ContactLocationMap so the map
// pin and the distance readout agree.
// square (4-char), subsquare (6-char), or extended square (8-char). Returns
// null for anything that isn't a valid locator. Centering matches
// @/components/ContactLocationMap so the map pin and the distance readout agree.
export function gridToLatLon(grid: string): LatLon | null {
const g = grid.trim().toUpperCase();
if (!GRID_RE.test(g)) return null;
Expand All @@ -34,11 +39,24 @@ export function gridToLatLon(grid: string): LatLon | null {
let lon = -180 + lonField * 20 + lonSquare * 2;
let lat = -90 + latField * 10 + latSquare * 1;

if (g.length === 6) {
const lonSub = g.charCodeAt(4) - 65; // A–X → 0–23, 5' wide
const latSub = g.charCodeAt(5) - 65; // A–X → 0–23, 2.5' tall
lon += lonSub * (2 / 24) + 1 / 24; // + half a subsquare to center
lat += latSub * (1 / 24) + 1 / 48;
if (g.length >= 6) {
const lonSub = g.charCodeAt(4) - 65; // A–X → 0–23, 5' wide (2°/24)
const latSub = g.charCodeAt(5) - 65; // A–X → 0–23, 2.5' tall (1°/24)
lon += lonSub * (2 / 24);
lat += latSub * (1 / 24);
}

if (g.length === 8) {
// Extended square: 2 digits (0–9) dividing the subsquare into a 10×10 grid,
// so each cell is 30" lon × 15" lat. Offset to the digit, then half a cell
// more to land on the center.
const lonExt = Number(g[6]); // 0–9, 2°/240 wide
const latExt = Number(g[7]); // 0–9, 1°/240 tall
lon += lonExt * (2 / 240) + 1 / 240; // + half an extended square to center
lat += latExt * (1 / 240) + 1 / 480;
} else if (g.length === 6) {
lon += 1 / 24; // + half a subsquare to center
lat += 1 / 48;
} else {
lon += 1; // + half a square (2°) to center
lat += 0.5; // + half a square (1°) to center
Expand Down
34 changes: 33 additions & 1 deletion tests/grid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import {
// equatorial arc) with tolerances that allow for the great-circle model.

test.describe('isValidGrid', () => {
test('accepts 4- and 6-character locators, case-insensitively', () => {
test('accepts 4-, 6-, and 8-character locators, case-insensitively', () => {
expect(isValidGrid('FN31')).toBe(true);
expect(isValidGrid('FN31pr')).toBe(true);
expect(isValidGrid('FN31pr55')).toBe(true); // 8-char extended locator
expect(isValidGrid('io91')).toBe(true);
expect(isValidGrid(' JN58 ')).toBe(true);
expect(isValidGrid('jn58td99')).toBe(true);
});

test('rejects malformed locators', () => {
Expand All @@ -29,6 +31,9 @@ test.describe('isValidGrid', () => {
expect(isValidGrid('FN31YZ')).toBe(false); // subsquare past X
expect(isValidGrid('FNXY')).toBe(false); // square must be digits
expect(isValidGrid('FN31p')).toBe(false); // odd length
expect(isValidGrid('FN31pr5')).toBe(false); // odd length (extended pair)
expect(isValidGrid('FN31prAB')).toBe(false); // extended square must be digits
expect(isValidGrid('FN3155')).toBe(false); // can't skip the subsquare level
});
});

Expand Down Expand Up @@ -60,6 +65,23 @@ test.describe('gridToLatLon', () => {
expect(sub!.lon).toBeLessThan(-72);
});

test('8-character locator refines within its parent subsquare', () => {
// The FN31pr subsquare spans lon [-72.75, -72.6667), lat [41.7083, 41.75).
// An extended locator must resolve to a point inside that box, close to the
// 6-char center — the extra precision VHF/microwave operators log.
const ext = gridToLatLon('FN31pr55');
const sub = gridToLatLon('FN31pr');
expect(ext).not.toBeNull();
expect(sub).not.toBeNull();
expect(ext!.lon).toBeGreaterThanOrEqual(-72.75);
expect(ext!.lon).toBeLessThan(-72.6667);
expect(ext!.lat).toBeGreaterThanOrEqual(41.7083);
expect(ext!.lat).toBeLessThan(41.75);
// Center-ish extended square lands near the subsquare center.
expect(ext!.lon).toBeCloseTo(sub!.lon, 1);
expect(ext!.lat).toBeCloseTo(sub!.lat, 1);
});

test('returns null for invalid input', () => {
expect(gridToLatLon('nope')).toBeNull();
expect(gridToLatLon('')).toBeNull();
Expand Down Expand Up @@ -123,6 +145,16 @@ test.describe('gridPath', () => {
expect(gridPath('FN31', 'nope')).toBeNull();
expect(gridPath('', 'IO91')).toBeNull();
});

test('accepts an 8-character locator on either end', () => {
// Same transatlantic hop, but with an extended locator for the far end —
// the distance/bearing readout should resolve rather than showing nothing.
const path = gridPath('FN31pr55', 'IO91wm55');
expect(path).not.toBeNull();
expect(path!.distanceKm).toBeGreaterThan(5000);
expect(path!.distanceKm).toBeLessThan(6000);
expect(path!.compass).toBe('NE');
});
});

test.describe('kmToMiles', () => {
Expand Down
Loading