From 31ec2ae4a3a66db470771cf433908f0ac2325ed0 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Fri, 24 Jul 2026 11:08:10 +0000 Subject: [PATCH] feat(grid): support 8-character (extended) Maidenhead locators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Maidenhead helpers powering the live distance/bearing readout on the logging form only recognised 4- and 6-character locators. An 8-character (extended-square) grid — routinely logged by VHF/UHF/microwave and satellite operators for the extra precision — was rejected: isValidGrid returned false and gridToLatLon returned null, so the 'how far, which way' readout showed nothing for a perfectly valid grid. Extend GRID_RE to accept an optional extended-square digit pair (only after a subsquare, so a level can't be skipped) and refine gridToLatLon to add the extended-square offset (a 10x10 subdivision of the subsquare, 30" lon x 15" lat per cell) centred on the cell. 4/6-char behaviour is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/grid.ts | 40 +++++++++++++++++++++++++++++----------- tests/grid.spec.ts | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/src/lib/grid.ts b/src/lib/grid.ts index 9b8ccbf..4b2aee3 100644 --- a/src/lib/grid.ts +++ b/src/lib/grid.ts @@ -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; @@ -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 diff --git a/tests/grid.spec.ts b/tests/grid.spec.ts index e1c9e76..85c69c7 100644 --- a/tests/grid.spec.ts +++ b/tests/grid.spec.ts @@ -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', () => { @@ -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 }); }); @@ -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(); @@ -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', () => {