// Distances and positions on the earth, for matching aircraft to airports. const KM_PER_DEG = 111.195; export function distanceKm(lat1, lon1, lat2, lon2) { const toRad = Math.PI / 180; const dLat = (lat2 - lat1) * toRad; let dLon = lon2 - lon1; if (dLon > 180) dLon -= 360; if (dLon < -180) dLon += 360; dLon *= toRad; const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * toRad) * Math.cos(lat2 * toRad) * Math.sin(dLon / 2) ** 2; return 2 * 6371.0088 * Math.asin(Math.min(1, Math.sqrt(h))); } // The point `km` away on `bearing` (degrees true); flat-earth is plenty for the few kilometres it is used for. export function move(lat, lon, bearing, km) { const b = (bearing * Math.PI) / 180; const dLat = (km * Math.cos(b)) / KM_PER_DEG; const dLon = (km * Math.sin(b)) / (KM_PER_DEG * Math.max(0.01, Math.cos((lat * Math.PI) / 180))); let lon2 = lon + dLon; if (lon2 > 180) lon2 -= 360; if (lon2 < -180) lon2 += 360; return [lat + dLat, lon2]; } // A grid of airports: a lookup touches a handful of cells instead of 20,000 airports. export class AirportIndex { constructor(airports, cell = 0.25) { this.cell = cell; this.cols = Math.round(360 / cell); this.cells = new Map(); this.byIcao = new Map(); for (const a of airports) { this.byIcao.set(a.icao, a); const key = this.#key(Math.floor(a.lat / cell), Math.floor(a.lon / cell)); let list = this.cells.get(key); if (!list) this.cells.set(key, (list = [])); list.push(a); } } #key(row, col) { return row * this.cols + (((col % this.cols) + this.cols) % this.cols); } // Airports within `km` of a point, nearest first, each with its distance. near(lat, lon, km) { const rows = Math.ceil(km / (KM_PER_DEG * this.cell)); const cols = Math.min(this.cols, Math.ceil(km / (KM_PER_DEG * this.cell * Math.max(0.05, Math.cos((lat * Math.PI) / 180))))); const r0 = Math.floor(lat / this.cell); const c0 = Math.floor(lon / this.cell); const found = []; for (let r = r0 - rows; r <= r0 + rows; r++) { for (let c = c0 - cols; c <= c0 + cols; c++) { const list = this.cells.get(this.#key(r, c)); if (!list) continue; for (const a of list) { const d = distanceKm(lat, lon, a.lat, a.lon); if (d <= km) found.push({ airport: a, km: d }); } } } return found.sort((x, y) => x.km - y.km); } get(icao) { return this.byIcao.get(icao) || null; } get size() { return this.byIcao.size; } } // Initial bearing from the first point to the second, degrees true. export function bearing(lat1, lon1, lat2, lon2) { const toRad = Math.PI / 180; const y = Math.sin((lon2 - lon1) * toRad) * Math.cos(lat2 * toRad); const x = Math.cos(lat1 * toRad) * Math.sin(lat2 * toRad) - Math.sin(lat1 * toRad) * Math.cos(lat2 * toRad) * Math.cos((lon2 - lon1) * toRad); return ((Math.atan2(y, x) / toRad) + 360) % 360; } export function angleBetween(a, b) { const d = Math.abs(a - b) % 360; return d > 180 ? 360 - d : d; }