Files
OpsLog/frontend/src/lib/maidenhead.ts
T
2026-06-07 21:44:49 +02:00

192 lines
7.7 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 };
}
// latLonToGrid encodes a lat/lon to a Maidenhead locator (default 6 chars).
// Inverse of gridToLatLon. Used to derive a default grid from a cty.dat entity
// centroid when no provider grid is available (e.g. a portable/rare DX call) —
// a centre-of-entity locator is more useful than an empty field.
export function latLonToGrid(lat: number, lon: number, precision = 6): string {
let adjLon = Math.min(359.9999, Math.max(0, lon + 180));
let adjLat = Math.min(179.9999, Math.max(0, lat + 90));
const A = 'A'.charCodeAt(0);
const a = 'a'.charCodeAt(0);
const fLon = Math.floor(adjLon / 20);
const fLat = Math.floor(adjLat / 10);
adjLon -= fLon * 20;
adjLat -= fLat * 10;
const sLon = Math.floor(adjLon / 2);
const sLat = Math.floor(adjLat);
let grid = String.fromCharCode(A + fLon) + String.fromCharCode(A + fLat) + sLon + sLat;
if (precision >= 6) {
adjLon -= sLon * 2;
adjLat -= sLat;
grid += String.fromCharCode(a + Math.min(23, Math.floor(adjLon * 12)))
+ String.fromCharCode(a + Math.min(23, Math.floor(adjLat * 24)));
}
return grid;
}
// 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; }
// destinationPoint returns the lat/lon reached from (lat,lon) by travelling
// distanceKm along the great circle at the given initial bearing. Used to draw
// the antenna beam lobe (a sector of bearings) on the world map.
export function destinationPoint(lat: number, lon: number, bearingDeg: number, distanceKm: number): { lat: number; lon: number } {
const delta = distanceKm / EARTH_KM;
const theta = toRad(bearingDeg);
const phi1 = toRad(lat), lam1 = toRad(lon);
const sinPhi2 = Math.sin(phi1) * Math.cos(delta) + Math.cos(phi1) * Math.sin(delta) * Math.cos(theta);
const phi2 = Math.asin(Math.max(-1, Math.min(1, sinPhi2)));
const y = Math.sin(theta) * Math.sin(delta) * Math.cos(phi1);
const x = Math.cos(delta) - Math.sin(phi1) * sinPhi2;
const lam2 = lam1 + Math.atan2(y, x);
return { lat: toDeg(phi2), lon: ((toDeg(lam2) + 540) % 360) - 180 };
}