Skip to content

Commit a6db4d4

Browse files
patrickrbOptio Agentclaude
authored
feat(grid): support 8-character (extended) Maidenhead locators (#246)
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: Optio Agent <optio-agent@noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cf160cd commit a6db4d4

2 files changed

Lines changed: 62 additions & 12 deletions

File tree

src/lib/grid.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,22 @@ export interface LatLon {
1111
lon: number;
1212
}
1313

14-
const GRID_RE = /^[A-R]{2}[0-9]{2}([A-X]{2})?$/;
15-
16-
// True for a well-formed 4- or 6-character Maidenhead locator (case-insensitive).
14+
// 4-char (field+square), 6-char (+subsquare), or 8-char (+extended square)
15+
// Maidenhead locator. The extended-square pair can only follow a subsquare —
16+
// you can't skip a level (e.g. `FN3155` is invalid).
17+
const GRID_RE = /^[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?$/;
18+
19+
// True for a well-formed 4-, 6-, or 8-character Maidenhead locator
20+
// (case-insensitive). VHF/UHF/microwave and satellite operators log 8-char
21+
// (extended) locators for the extra precision, so they must validate too.
1722
export function isValidGrid(grid: string): boolean {
1823
return GRID_RE.test(grid.trim().toUpperCase());
1924
}
2025

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

37-
if (g.length === 6) {
38-
const lonSub = g.charCodeAt(4) - 65; // A–X → 0–23, 5' wide
39-
const latSub = g.charCodeAt(5) - 65; // A–X → 0–23, 2.5' tall
40-
lon += lonSub * (2 / 24) + 1 / 24; // + half a subsquare to center
41-
lat += latSub * (1 / 24) + 1 / 48;
42+
if (g.length >= 6) {
43+
const lonSub = g.charCodeAt(4) - 65; // A–X → 0–23, 5' wide (2°/24)
44+
const latSub = g.charCodeAt(5) - 65; // A–X → 0–23, 2.5' tall (1°/24)
45+
lon += lonSub * (2 / 24);
46+
lat += latSub * (1 / 24);
47+
}
48+
49+
if (g.length === 8) {
50+
// Extended square: 2 digits (0–9) dividing the subsquare into a 10×10 grid,
51+
// so each cell is 30" lon × 15" lat. Offset to the digit, then half a cell
52+
// more to land on the center.
53+
const lonExt = Number(g[6]); // 0–9, 2°/240 wide
54+
const latExt = Number(g[7]); // 0–9, 1°/240 tall
55+
lon += lonExt * (2 / 240) + 1 / 240; // + half an extended square to center
56+
lat += latExt * (1 / 240) + 1 / 480;
57+
} else if (g.length === 6) {
58+
lon += 1 / 24; // + half a subsquare to center
59+
lat += 1 / 48;
4260
} else {
4361
lon += 1; // + half a square (2°) to center
4462
lat += 0.5; // + half a square (1°) to center

tests/grid.spec.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ import {
1515
// equatorial arc) with tolerances that allow for the great-circle model.
1616

1717
test.describe('isValidGrid', () => {
18-
test('accepts 4- and 6-character locators, case-insensitively', () => {
18+
test('accepts 4-, 6-, and 8-character locators, case-insensitively', () => {
1919
expect(isValidGrid('FN31')).toBe(true);
2020
expect(isValidGrid('FN31pr')).toBe(true);
21+
expect(isValidGrid('FN31pr55')).toBe(true); // 8-char extended locator
2122
expect(isValidGrid('io91')).toBe(true);
2223
expect(isValidGrid(' JN58 ')).toBe(true);
24+
expect(isValidGrid('jn58td99')).toBe(true);
2325
});
2426

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

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

68+
test('8-character locator refines within its parent subsquare', () => {
69+
// The FN31pr subsquare spans lon [-72.75, -72.6667), lat [41.7083, 41.75).
70+
// An extended locator must resolve to a point inside that box, close to the
71+
// 6-char center — the extra precision VHF/microwave operators log.
72+
const ext = gridToLatLon('FN31pr55');
73+
const sub = gridToLatLon('FN31pr');
74+
expect(ext).not.toBeNull();
75+
expect(sub).not.toBeNull();
76+
expect(ext!.lon).toBeGreaterThanOrEqual(-72.75);
77+
expect(ext!.lon).toBeLessThan(-72.6667);
78+
expect(ext!.lat).toBeGreaterThanOrEqual(41.7083);
79+
expect(ext!.lat).toBeLessThan(41.75);
80+
// Center-ish extended square lands near the subsquare center.
81+
expect(ext!.lon).toBeCloseTo(sub!.lon, 1);
82+
expect(ext!.lat).toBeCloseTo(sub!.lat, 1);
83+
});
84+
6385
test('returns null for invalid input', () => {
6486
expect(gridToLatLon('nope')).toBeNull();
6587
expect(gridToLatLon('')).toBeNull();
@@ -123,6 +145,16 @@ test.describe('gridPath', () => {
123145
expect(gridPath('FN31', 'nope')).toBeNull();
124146
expect(gridPath('', 'IO91')).toBeNull();
125147
});
148+
149+
test('accepts an 8-character locator on either end', () => {
150+
// Same transatlantic hop, but with an extended locator for the far end —
151+
// the distance/bearing readout should resolve rather than showing nothing.
152+
const path = gridPath('FN31pr55', 'IO91wm55');
153+
expect(path).not.toBeNull();
154+
expect(path!.distanceKm).toBeGreaterThan(5000);
155+
expect(path!.distanceKm).toBeLessThan(6000);
156+
expect(path!.compass).toBe('NE');
157+
});
126158
});
127159

128160
test.describe('kmToMiles', () => {

0 commit comments

Comments
 (0)