152 lines
5.8 KiB
TypeScript
152 lines
5.8 KiB
TypeScript
// 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 (0–360). 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; }
|