import { Vector, Point } from "@fboes/geojson"; /** * @see https://aviationweather.gov/data/api/#/Data/dataMetar */ export type AviationWeatherApiCloud = { cover: "CAVOK" | "CLR" | "SKC" | "FEW" | "SCT" | "BKN" | "OVC"; base: number | null; }; export type AviationWeatherNormalizedCloud = { cover: "CLR" | "FEW" | "SCT" | "BKN" | "OVC"; /** * 0..8 */ coverOctas: number; /** * in feet AGL */ base: number | null; }; /** * @see https://aviationweather.gov/data/api/#/Data/dataMetar */ export type AviationWeatherApiMetar = { icaoId: string; reportTime: string; temp: number; dewp: number; wdir: "VRB" | number; wspd: number; wgst: number | null; visib: string | number; altim: number; lat: number; lon: number; /** * meters MSL */ elev: number; clouds: AviationWeatherApiCloud[]; }; export type AviationWeatherNormalizedMetar = { icaoId: string; reportTime: Date; /** * in °C */ temp: number; /** * in °C */ dewp: number; /** * in °, null on VRB */ wdir: number | null; /** * in kts */ wspd: number; wgst: number | null; /** * in SM, 10 on any distance being open-ended */ visib: number; altim: number; lat: number; lon: number; /** * meters MSL */ elev: number; clouds: AviationWeatherNormalizedCloud[]; }; export type AviationWeatherApiTaf = { icaoId: string; issueTime: string; lat: number; lon: number; elev: number; fcsts: AviationWeatherApiTafForecast[]; }; export type AviationWeatherApiTafForecast = { /** * Timestamp */ timeFrom: number; /** * Timestamp */ timeTo: number; wdir: "VRB" | number | null; wspd: number | null; wgst: number | null; visib: string | number | null; altim: number | null; clouds: AviationWeatherApiCloud[]; temp: number[]; }; export type AviationWeatherApiNormalizedTaf = { icaoId: string; issueTime: string; lat: number; lon: number; elev: number; fcsts: AviationWeatherApiNormalizedTafForecast[]; }; export type AviationWeatherApiNormalizedTafForecast = { timeFrom: Date; timeTo: Date; wdir: number | null; wspd: number | null; wgst: number | null; visib: number | null; altim: number | null; clouds: AviationWeatherNormalizedCloud[]; temp: number[]; }; type AviationWeatherApiRunwaySurface = "A" | "C" | "G" | "W" | "T" | "H"; /** * @see https://aviationweather.gov/data/api/#/Data/dataAirport */ export type AviationWeatherApiRunway = { id: string; dimension: string; surface: AviationWeatherApiRunwaySurface; alignment: string; }; type AviationWeatherNormalizedRunway = { id: [string, string]; /** * length, width in ft */ dimension: [number, number]; surface: AviationWeatherApiRunwaySurface; alignment: number | null; }; /** * @see https://aviationweather.gov/data/api/#/Data/dataAirport */ export type AviationWeatherApiFrequency = { type: string; freq?: number; }; /** * @see https://aviationweather.gov/data/api/#/Data/dataAirport */ export type AviationWeatherApiAirport = { icaoId: string; name: string; type: "ARP" | "HEL"; lat: number; lon: number; /** * meters MSL */ elev: number; magdec: string; rwyNum: string; services: "S" | "-" | null; tower: "T" | "-" | null; beacon: "B" | "-" | null; passengers: string; runways: AviationWeatherApiRunway[]; freqs: AviationWeatherApiFrequency[] | string; }; export type AviationWeatherNormalizedAirport = { icaoId: string; name: string; type: "ARP" | "HEL"; lat: number; lon: number; /** * meters MSL */ elev: number; magdec: number; rwyNum: number; services: boolean; tower: boolean; beacon: boolean; passengers: number; runways: AviationWeatherNormalizedRunway[]; freqs: AviationWeatherApiFrequency[]; }; export type AviationWeatherApiNavaidType = "VORTAC" | "VOR/DME" | "TACAN" | "NDB" | "VOR"; /** * @see https://aviationweather.gov/data/api/#/Data/dataNavaid */ export type AviationWeatherApiNavaidRaw = { id: string; type: AviationWeatherApiNavaidType; name: string; lat: number | string; lon: number | string; elev: number | string; freq: number | string; mag_dec: string; }; export type AviationWeatherApiNavaid = { id: string; type: AviationWeatherApiNavaidType; name: string; lat: number; lon: number; /** * meters MSL */ elev: number; /** * in kHz for NDB, MHz for VOR/TACAN * @see https://aviationweather.gov/data/api/#/Data/dataNavaid */ freq: number; freq_unit: "kHz" | "MHz"; /** * with "+" to the east and "-" to the west. Substracted from a true heading this will give the magnetic heading. */ mag_dec: number; }; export type AviationWeatherApiFix = { id: string; type: "I" | "L" | "H" | "S" | "D" | "-"; lat: number; lon: number; }; export class AviationWeatherApi { async fetchMetar(ids: string[], date: Date | null = null): Promise { return this.doRequest( "/api/data/metar", new URLSearchParams({ ids: ids.join(","), format: "json", // taf, // hours, // bbox: this.buildBbox(longitude, latitude, distance).join(","), date: date ? date.toISOString().replace(/\.\d+(Z)/, "$1") : "", }), ); } async fetchTaf(ids: string[], date: Date | null = null): Promise { return this.doRequest( "/api/data/taf", new URLSearchParams({ ids: ids.join(","), format: "json", // metar, // time, // bbox: this.buildBbox(longitude, latitude, distance).join(","), date: date ? date.toISOString().replace(/\.\d+(Z)/, "$1") : "", }), ); } /** * @param {number} longitude center of search area * @param {number} latitude center of search area * @param distance in meters, default 1000 * @param date if given, only metars for this date will be returned, otherwise the latest metars * @see https://aviationweather.gov/data/api/#/Data/dataMetar * @returns {Promise} */ async fetchMetarByPosition( longitude: number, latitude: number, distance: number = 1000, date: Date | null = null, ): Promise { return this.doRequest( "/api/data/metar", new URLSearchParams({ // ids: ids.join(","), format: "json", // taf, // hours, bbox: this.buildBbox(longitude, latitude, distance).join(","), date: date ? date.toISOString().replace(/\.\d+(Z)/, "$1") : "", }), ); } async fetchAirports(ids: string[]): Promise { return this.doRequest( "/api/data/airport", new URLSearchParams({ ids: ids.join(","), // bbox: this.buildBbox(longitude, latitude, distance).join(","), format: "json", }), ).then((data) => data.map( (airport: AviationWeatherApiAirport): AviationWeatherNormalizedAirport => this.normalizeAirport(airport), ), ); } async fetchNavaids(ids: string[]): Promise { return this.doRequest( "/api/data/navaid", new URLSearchParams({ ids: ids.join(","), format: "json", // bbox: this.buildBbox(longitude, latitude, distance).join(","), }), ).then((data) => data.map((navaid: AviationWeatherApiNavaidRaw): AviationWeatherApiNavaid => this.normalizeNavAid(navaid)), ); } async fetchFix(ids: string[]): Promise { return this.doRequest( "/api/data/fix", new URLSearchParams({ ids: ids.join(","), format: "json", // bbox: this.buildBbox(longitude, latitude, distance).join(","), }), ); } /** * @param {number} longitude center of search area * @param {number} latitude center of search area * @param distance in meters, default 1000 * @see https://aviationweather.gov/data/api/#/Data/dataNavaid * @returns {Promise} */ async fetchNavaidsByPosition( longitude: number, latitude: number, distance: number = 1000, ): Promise { return this.doRequest( "/api/data/navaid", new URLSearchParams({ // ids: ids.join(","), format: "json", bbox: this.buildBbox(longitude, latitude, distance).join(","), }), ).then((data) => data.map((navaid: AviationWeatherApiNavaidRaw): AviationWeatherApiNavaid => this.normalizeNavAid(navaid)), ); } async fetchFixByPosition( longitude: number, latitude: number, distance: number = 1000, ): Promise { return this.doRequest( "/api/data/fix", new URLSearchParams({ // ids: ids.join(","), format: "json", bbox: this.buildBbox(longitude, latitude, distance).join(","), }), ); } private normalizeNavAid(navaid: AviationWeatherApiNavaidRaw): AviationWeatherApiNavaid { return { ...navaid, lat: Number(navaid.lat), lon: Number(navaid.lon), elev: Number(navaid.elev), freq: Number(navaid.freq), freq_unit: navaid.type === "NDB" ? "kHz" : "MHz", mag_dec: this.magDecConverter(navaid.mag_dec), }; } async doRequest(route: string, query: URLSearchParams, timeoutMs = 5000): Promise { const url = new URL(route + "?" + query.toString(), "https://aviationweather.gov"); const response = await fetch(url, { headers: { Accept: "application/json", }, signal: AbortSignal.timeout(timeoutMs), }); if (!response.body) { throw new Error("No results returned"); } return (await response.json()) as T; } /** * * @param {number} longitude center of search area * @param {number} latitude center of search area * @param {number} [distance] in meters * @returns {[number,number,number,number]} southEast.latitude, southEast.longitude, northWest.latitude, northWest.longitude */ buildBbox(longitude: number, latitude: number, distance: number = 1000): [number, number, number, number] { const position = new Point(longitude, latitude); const southEast = position.getPointBy(new Vector(distance * 1.41, 225)); const northWest = position.getPointBy(new Vector(distance * 1.41, 45)); return [southEast.latitude, southEast.longitude, northWest.latitude, northWest.longitude]; } normalizeAirport(airport: AviationWeatherApiAirport): AviationWeatherNormalizedAirport { return { ...airport, name: airport.name .replace(/_/g, " ") .trim() .replace(/\bINTL\b/g, "INTERNATIONAL") .replace(/\bRGNL\b/g, "REGIONAL") .replace(/\bFLD\b/g, "FIELD") .replace(/(\/)/g, " $1 ") .toLowerCase() .replace(/(^|\s)[a-z]/g, (char) => { return char.toUpperCase(); }), magdec: this.magDecConverter(airport.magdec), rwyNum: Number(airport.rwyNum), services: airport.services === "S", tower: airport.tower === "T", beacon: airport.beacon === "B", passengers: Number(airport.passengers), runways: airport.runways.map((r): AviationWeatherNormalizedRunway => { const idSplit = r.id.split("/"); const dimensionSplit = r.dimension.split("x"); return { ...r, id: [idSplit[0] ?? "", idSplit[1] ?? ""], dimension: [Number(dimensionSplit[0] ?? "0"), Number(dimensionSplit[1] ?? "0")], alignment: r.alignment !== "-" ? Number(r.alignment) : null, }; }), freqs: typeof airport.freqs !== "string" ? airport.freqs : airport.freqs.split(";").map((f: string): AviationWeatherApiFrequency => { const [type, freq] = f.split(","); return { type: type ?? "", freq: freq ? Number(freq) : undefined, }; }), }; } normalizeWeather(weather: AviationWeatherApiMetar): AviationWeatherNormalizedMetar { return { ...weather, reportTime: new Date(weather.reportTime), wdir: weather.wdir !== "VRB" ? weather.wdir : null, visib: typeof weather.visib === "string" ? 10 : weather.visib, clouds: weather.clouds.map((c) => { return { ...c, cover: c.cover === "CAVOK" || c.cover === "SKC" ? "CLR" : c.cover, coverOctas: { CLR: 0, CAVOK: 0, SKC: 0, FEW: 1, SCT: 2, BKN: 4, OVC: 8, }[c.cover], }; }), }; } normalizeTaf(taf: AviationWeatherApiTaf): AviationWeatherApiNormalizedTaf { return { ...taf, fcsts: taf.fcsts.map((weather) => { return { ...weather, timeFrom: new Date(weather.timeFrom * 1000), timeTo: new Date(weather.timeTo * 1000), wdir: weather.wdir !== "VRB" ? weather.wdir : null, visib: typeof weather.visib === "string" ? 10 : weather.visib, clouds: weather.clouds.map((c) => { return { ...c, cover: c.cover === "CAVOK" || c.cover === "SKC" ? "CLR" : c.cover, coverOctas: { CLR: 0, CAVOK: 0, SKC: 0, FEW: 1, SCT: 2, BKN: 4, OVC: 8, }[c.cover], }; }), }; }), }; } /** * @returns {number} with "+" to the east and "-" to the west. Substracted from a true heading this will give the magnetic heading. */ magDecConverter(magdec: string): number { let magDec = 0; const magdecMatch = magdec.match(/^(\d+)(E|W)$/); if (magdecMatch) { magDec = Number(magdecMatch[1]); if (magdecMatch[2] === "W") { magDec *= -1; } } return magDec; } }