Files
OpsLog/frontend/src/lib/maidenhead.ts
T
2026-06-07 12:50:04 +02:00

152 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Maidenhead grid locator ⇄ lat/lon, plus great-circle distance + bearing.
//
// Used to drive the "AZ SP/LP / Dist SP/LP" readouts in the entry form so
// the operator knows where to point an antenna without having to fire up an
// external tool.
const EARTH_KM = 6371.0088;
const EARTH_CIRCUMFERENCE_KM = 2 * Math.PI * EARTH_KM; // ≈ 40 030
// gridToLatLon parses a Maidenhead locator (4, 6, or 8 chars) and returns
// the center of the indicated square. Returns null on bad input.
export function gridToLatLon(grid: string): { lat: number; lon: number } | null {
if (!grid) return null;
const g = grid.trim().toUpperCase();
if (g.length < 4 || g.length % 2 !== 0) return null;
const A = 'A'.charCodeAt(0);
const Z = 'Z'.charCodeAt(0);
const isLetter = (c: number) => c >= A && c <= Z;
const isDigit = (c: string) => c >= '0' && c <= '9';
if (!isLetter(g.charCodeAt(0)) || !isLetter(g.charCodeAt(1))) return null;
if (!isDigit(g[2]) || !isDigit(g[3])) return null;
let lon = (g.charCodeAt(0) - A) * 20 - 180;
let lat = (g.charCodeAt(1) - A) * 10 - 90;
lon += parseInt(g[2], 10) * 2;
lat += parseInt(g[3], 10);
if (g.length >= 6) {
if (!isLetter(g.charCodeAt(4)) || !isLetter(g.charCodeAt(5))) return null;
lon += (g.charCodeAt(4) - A) * (2 / 24);
lat += (g.charCodeAt(5) - A) * (1 / 24);
// center of the sub-square
lon += 1 / 24;
lat += 0.5 / 24;
} else {
// center of the 2°×1° square
lon += 1;
lat += 0.5;
}
if (g.length >= 8) {
if (!isDigit(g[6]) || !isDigit(g[7])) return null;
// Extended grid (rare) — refine; using simple 10x subdivision.
lon += parseInt(g[6], 10) * (2 / 24 / 10) - 1 / 24;
lat += parseInt(g[7], 10) * (1 / 24 / 10) - 0.5 / 24;
}
return { lat, lon };
}
// gridSquareBounds returns the SW/NE corners of a Maidenhead square so a map
// can draw its outline. Half-extents shrink with locator precision.
export function gridSquareBounds(grid: string):
{ south: number; west: number; north: number; east: number } | null {
const c = gridToLatLon(grid);
if (!c) return null;
const g = grid.trim();
let dLon = 1, dLat = 0.5; // 4-char square: 2°×1°
if (g.length >= 6) { dLon = 1 / 24; dLat = 0.5 / 24; }
if (g.length >= 8) { dLon = 1 / 24 / 10; dLat = 0.5 / 24 / 10; }
return { south: c.lat - dLat, north: c.lat + dLat, west: c.lon - dLon, east: c.lon + dLon };
}
// PathInfo describes both short and long great-circle path between two
// points. Bearing in degrees from true north (0360). Distance in km.
export interface PathInfo {
bearingShort: number;
bearingLong: number;
distanceShort: number;
distanceLong: number;
}
// pathBetween computes great-circle bearing+distance between two
// Maidenhead grids. Returns null if either is unparseable.
export function pathBetween(fromGrid: string, toGrid: string): PathInfo | null {
const a = gridToLatLon(fromGrid);
const b = gridToLatLon(toGrid);
if (!a || !b) return null;
return pathBetweenLatLon(a, b);
}
// pathBetweenLatLon computes the great-circle path between two lat/lon points.
// Used as a fallback when a station has a known location (e.g. cty.dat entity
// coordinates for Svalbard) but no Maidenhead grid.
export function pathBetweenLatLon(
a: { lat: number; lon: number },
b: { lat: number; lon: number },
): PathInfo {
const φ1 = toRad(a.lat);
const φ2 = toRad(b.lat);
const Δλ = toRad(b.lon - a.lon);
// Spherical law of cosines is simpler than haversine and accurate enough
// for ham bearings (>1 km errors don't matter at the antenna-rotor level).
let cos = Math.sin(φ1) * Math.sin(φ2) + Math.cos(φ1) * Math.cos(φ2) * Math.cos(Δλ);
cos = Math.max(-1, Math.min(1, cos));
const distShort = EARTH_KM * Math.acos(cos);
// Forward azimuth.
const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
let bearing = toDeg(Math.atan2(y, x));
bearing = (bearing + 360) % 360;
return {
bearingShort: bearing,
bearingLong: (bearing + 180) % 360,
distanceShort: distShort,
distanceLong: EARTH_CIRCUMFERENCE_KM - distShort,
};
}
// greatCirclePoints returns n+1 [lat, lon] points along the short great-circle
// path between two lat/lon points (spherical slerp). Longitudes are unwrapped
// to stay continuous (no ±180 jump) so a map polyline draws as one smooth arc.
export function greatCirclePoints(
lat1: number, lon1: number, lat2: number, lon2: number, n = 96,
): [number, number][] {
const φ1 = toRad(lat1), λ1 = toRad(lon1);
const φ2 = toRad(lat2), λ2 = toRad(lon2);
// Angular distance between the two points.
const sinΔφ = Math.sin((φ2 - φ1) / 2);
const sinΔλ = Math.sin((λ2 - λ1) / 2);
const h = sinΔφ * sinΔφ + Math.cos(φ1) * Math.cos(φ2) * sinΔλ * sinΔλ;
const d = 2 * Math.asin(Math.min(1, Math.sqrt(h)));
const out: [number, number][] = [];
if (d === 0) return [[lat1, lon1]];
let prevLon = NaN;
for (let i = 0; i <= n; i++) {
const f = i / n;
const A = Math.sin((1 - f) * d) / Math.sin(d);
const B = Math.sin(f * d) / Math.sin(d);
const x = A * Math.cos(φ1) * Math.cos(λ1) + B * Math.cos(φ2) * Math.cos(λ2);
const y = A * Math.cos(φ1) * Math.sin(λ1) + B * Math.cos(φ2) * Math.sin(λ2);
const z = A * Math.sin(φ1) + B * Math.sin(φ2);
const lat = toDeg(Math.atan2(z, Math.sqrt(x * x + y * y)));
let lon = toDeg(Math.atan2(y, x));
// Unwrap longitude so the polyline never snaps across the whole map.
if (!Number.isNaN(prevLon)) {
while (lon - prevLon > 180) lon -= 360;
while (lon - prevLon < -180) lon += 360;
}
prevLon = lon;
out.push([lat, lon]);
}
return out;
}
function toRad(d: number): number { return (d * Math.PI) / 180; }
function toDeg(r: number): number { return (r * 180) / Math.PI; }