UNPKG

@adonix.org/nws-report

Version:

Generate weather reports from the National Weather Service

536 lines (523 loc) 14.7 kB
// src/error.ts var NWSError = class extends Error { constructor(url, message, cause) { super(message, { cause }); this.url = url; this.name = new.target.name; } }; var NWSFetchError = class extends NWSError { constructor(url, cause) { super(url, `${url}`, cause); } }; var NWSResponseError = class extends Error { constructor(url, status, details) { super(`${status} ${details.title}: ${url}`); this.url = url; this.status = status; this.details = details; this.name = new.target.name; } }; function isNWSProblemDetails(obj) { if (!obj || typeof obj !== "object") return false; const o = obj; return typeof o["type"] === "string" && typeof o["title"] === "string" && typeof o["status"] === "number" && typeof o["detail"] === "string" && typeof o["instance"] === "string" && typeof o["correlationId"] === "string"; } var NWSJsonError = class extends Error { constructor(url, status, json, cause) { super(`${status} Invalid NWS Error: ${JSON.stringify(json)}`, { cause }); this.url = url; this.status = status; this.json = json; this.name = new.target.name; } }; var HTTPError = class extends Error { constructor(url, status, statusText, cause) { super(`${status} ${statusText}`, { cause }); this.url = url; this.status = status; this.statusText = statusText; this.name = new.target.name; } }; // src/nws.ts var NationalWeatherService = class _NationalWeatherService { static _origin = "https://api.weather.gov"; static headers = new Headers({ Accept: "application/geo+json" }); headers = new Headers(); params = new URLSearchParams(); static get origin() { return this._origin; } static set origin(origin) { this._origin = origin; } async get() { const url = new URL(`${_NationalWeatherService.origin}${this.resource}`); for (const [key, value] of this.params) { url.searchParams.set(key, value); } const headers = new Headers(_NationalWeatherService.headers); new Headers(this.headers).forEach((v, k) => headers.set(k, v)); let response; try { response = await fetch(url, { method: "GET", headers }); } catch (cause) { throw new NWSFetchError(url, cause); } const text = await response.text(); if (response.ok) { try { return JSON.parse(text); } catch (cause) { throw new NWSJsonError(url, response.status, text, cause); } } if (text.trim() === "") { throw new HTTPError(url, response.status, "(empty response text)"); } const contentType = response.headers.get("Content-Type")?.toLowerCase() ?? ""; const isJsonContent = contentType.includes("application/json") || contentType.includes("+json"); if (!isJsonContent) { throw new HTTPError(url, response.status, text, text); } let json; try { json = JSON.parse(text); } catch (cause) { throw new HTTPError(url, response.status, text, cause); } if (isNWSProblemDetails(json)) { throw new NWSResponseError(url, response.status, json); } throw new NWSJsonError(url, response.status, json); } }; // src/products.ts var Products = class extends NationalWeatherService { constructor(type, wfo) { super(); this.type = type; this.wfo = wfo; } /** * If the product is not found, the NWS API still returns a JSON * object that is mostly empty. If not found, return undefined. */ async get() { const product = await super.get(); return product && "productCode" in product ? product : void 0; } get resource() { return `/products/types/${this.type}/locations/${this.wfo}/latest`; } }; // src/admin.ts var AlertAdminMessage = class extends Products { constructor() { super("ADA", "SDM"); } }; // src/zones.ts var ZoneUtil = class _ZoneUtil { static getForecastZone = (point) => _ZoneUtil.getZone(point.properties.forecastZone); static getFireZone = (point) => _ZoneUtil.getZone(point.properties.fireWeatherZone); static getCounty = (point) => _ZoneUtil.getZone(point.properties.county); static getZone = (text) => { const match = /[A-Z]{2}[ZC]\d{3}/.exec(text); if (match) return match[0]; throw new Error(`Unable to find a zone in: ${text}`); }; }; // src/segment.ts var ZONE_REGEX = /([A-Z]{2}Z\d{3}(?:[->\n\dA-Z]*)?-\n?\d{6}-)/; var SegmentedProducts = class { constructor(type, wfo, zone, filter) { this.type = type; this.wfo = wfo; this.zone = zone; this.filter = filter; } async get() { const product = await new Products(this.type, this.wfo).get(); if (!product) { return void 0; } let segmented = new SegmentParser(product).get(); if (!segmented?.segments.length) { return void 0; } if (this.filter) { segmented = this.doFilter(segmented); if (!segmented.segments.length) { return void 0; } } return segmented; } doFilter(product) { const filtered = product.segments.filter( (segment) => segment.zones.includes(this.zone) ); return { ...product, segments: filtered }; } }; var SegmentParser = class { constructor(product) { this.product = product; } get() { if (!this.isSegmented()) { return void 0; } const [header, headline] = this.getHeaders(); const segmentStrings = this.getSegmentStrings(); const segments = this.getSegments(segmentStrings); return { header, headline, product: this.product, segments }; } isSegmented() { return ZONE_REGEX.test(this.product.productText); } getHeaders() { const zoneMatch = ZONE_REGEX.exec(this.product.productText); if (zoneMatch) { return this.product.productText.slice(0, zoneMatch.index).split(/\n\n+/).map((s) => s.trim()).filter(Boolean); } throw new Error( `Unable to find a zoned segment in ${this.product.productText}` ); } getSegmentStrings() { const indexArray = []; let match; const regex = new RegExp(ZONE_REGEX, "g"); while ((match = regex.exec(this.product.productText)) !== null) { indexArray.push(match.index); } const segmentStrings = []; for (let i = 0; i < indexArray.length; i++) { segmentStrings.push( this.product.productText.slice(indexArray[i], indexArray[i + 1]) ); } return segmentStrings; } getSegments(segmentStrings) { const segments = []; for (const body of segmentStrings) { const match = body.match(/^(.*?)-(\d{6})-/s); if (match && match[1] && match[2]) { const zoneText = match[1]; const timestamp = match[2]; segments.push({ body, timestamp, zoneText, zones: ProductZone.getZones(zoneText) }); } } return segments; } }; var ProductZone = class _ProductZone { static getZones(zoneText) { const states = zoneText.replaceAll("\n", "").split(/(?<=\d)-(?=[A-Z]{2}Z\d{3})/); const zones = []; states.forEach((state) => { zones.push(..._ProductZone.getStateZones(state)); }); return zones; } static getStateZones(stateZone) { const prefix = stateZone.slice(0, 3); const parts = stateZone.slice(3).split("-"); const zones = []; for (const part of parts) { const [start, end] = part.split(">").map((s) => { const n = parseInt(s.trim(), 10); if (Number.isNaN(n) || n > 999) return void 0; return n; }); if (start !== void 0 && end !== void 0) { for (let i = start; i <= end; i++) { zones.push(`${prefix}${i.toString().padStart(3, "0")}`); } } else if (start !== void 0) { zones.push(`${prefix}${start.toString().padStart(3, "0")}`); } } return zones; } }; // src/alerts.ts var LatestAlerts = class extends NationalWeatherService { constructor(point, status = "actual") { super(); this.point = point; this.params.set("zone", `${ZoneUtil.getForecastZone(this.point)}`); this.params.set("status", status); } async get() { const alerts = await super.get(); alerts.features = this.filter(alerts.features); return alerts; } get resource() { return `/alerts/active`; } filter(features) { const ids = /* @__PURE__ */ new Set(); return features.filter((feature) => { if (ids.has(feature.properties.id)) { return false; } ids.add(feature.properties.id); return true; }); } }; var LatestAlertsProducts = class _LatestAlertsProducts extends LatestAlerts { async get() { const alerts = await super.get(); const products = await this.fetchProducts(alerts); for (const feature of alerts.features) { const awips = _LatestAlertsProducts.getAwipsId(feature); feature.product = products.get(awips); } return alerts; } async fetchProducts(alerts) { const products = /* @__PURE__ */ new Map(); const types = /* @__PURE__ */ new Set(); for (const feature of alerts.features) { types.add(_LatestAlertsProducts.getAwipsId(feature)); } await Promise.all( [...types].map(async (awips) => { const product = await new SegmentedProducts( awips.slice(0, 3), awips.slice(3), ZoneUtil.getForecastZone(this.point), true ).get(); if (product) { products.set(awips, product); } }) ); return products; } static getAwipsId(feature) { const awipsId = feature.properties.parameters.AWIPSidentifier?.[0]; if (!awipsId) { throw new Error("Missing AWIPS ID"); } if (!/^[A-Z]{5,6}$/.test(awipsId)) { throw new Error(`Invalid AWIPS ID: ${awipsId}`); } return awipsId; } }; // src/forecast.ts var BaseGridpointForecast = class extends NationalWeatherService { constructor(point) { super(); this.point = point; this.headers.append( "Feature-Flags", "forecast_temperature_qv, forecast_wind_speed_qv" ); } get resource() { const { gridId, gridX, gridY } = this.point.properties; return `/gridpoints/${gridId}/${gridX},${gridY}/${this.endpoint}`; } }; var DailyForecast = class extends BaseGridpointForecast { get endpoint() { return "forecast"; } }; var HourlyForecast = class extends BaseGridpointForecast { get endpoint() { return "forecast/hourly"; } }; // src/observation.ts var LatestObservation = class extends NationalWeatherService { constructor(station) { super(); this.station = station; this.params.set("require_qc", String(true)); } get resource() { return `/stations/${this.station}/observations/latest`; } }; var Observations = class extends NationalWeatherService { constructor(station, limit = 1) { super(); this.station = station; this.params.set("limit", String(limit)); } get resource() { return `/stations/${this.station}/observations`; } }; // src/points.ts var Points = class extends NationalWeatherService { constructor(latitude, longitude) { super(); this.latitude = latitude; this.longitude = longitude; } get resource() { return `/points/${this.latitude},${this.longitude}`; } }; // src/hwo.ts var HazardousWeatherOutlook = class _HazardousWeatherOutlook extends SegmentedProducts { static PRODUCT_TYPE = "HWO"; constructor(point, filter = true) { super( _HazardousWeatherOutlook.PRODUCT_TYPE, point.properties.cwa, ZoneUtil.getForecastZone(point), filter ); } }; // src/stations.ts var Stations = class extends NationalWeatherService { constructor(point, limit = 1) { super(); this.point = point; this.params.set("limit", String(limit)); this.headers.append("Feature-Flags", "obs_station_provider"); } get resource() { const { gridId, gridX, gridY } = this.point.properties; return `/gridpoints/${gridId}/${gridX},${gridY}/stations`; } }; // src/report.ts var WeatherReport = class _WeatherReport { constructor(latitude, longitude, forecastType) { this.latitude = latitude; this.longitude = longitude; this.forecastType = forecastType; } _point; _station; _current; _forecast; _products = []; _hwo; _alerts; static async create(latitude, longitude, forecast = "daily") { const instance = new _WeatherReport(latitude, longitude, forecast); await instance.refresh(); return instance; } get point() { return this._point; } get station() { return this._station; } get current() { return this._current; } get forecast() { return this._forecast; } get alerts() { return this._alerts; } get hwo() { return this._hwo; } get products() { return this._products; } async refresh() { this._products.length = 0; this._point = await new Points(this.latitude, this.longitude).get(); const alertsPromise = new LatestAlertsProducts(this._point).get(); const hwoPromise = new HazardousWeatherOutlook(this._point).get(); const forecastPromise = this.forecastType === "daily" ? new DailyForecast(this._point).get() : new HourlyForecast(this._point).get(); const stations = await new Stations(this._point).get(); const [station] = stations.features; if (station) { this._station = station; this._current = await new LatestObservation( station.properties.stationIdentifier ).get(); } this._alerts = await alertsPromise; this._hwo = await hwoPromise; this._forecast = await forecastPromise; if (this._hwo) { this._products.push(this._hwo); } } }; // src/units.ts var Units = class { static to_number(quant) { return quant?.value ?? void 0; } static c_to_f(celsius) { return celsius * 9 / 5 + 32; } static meters_to_miles(meters) { return meters / 1609.344; } static degrees_to_cardinal(degrees) { const directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; const index = Math.round(degrees % 360 / 45) % 8; return directions[index] ?? "?"; } static kmh_to_mph(kmh) { return kmh * 0.621371; } static pascals_to_inches(pascals) { return pascals * 2953e-7; } static pascals_to_mb(pascals) { return pascals / 100; } }; export { AlertAdminMessage, DailyForecast, HTTPError, HourlyForecast, LatestAlerts, LatestAlertsProducts, LatestObservation, NWSFetchError, NWSJsonError, NWSResponseError, NationalWeatherService, Observations, Points, Products, SegmentedProducts, Stations, Units, WeatherReport, isNWSProblemDetails }; //# sourceMappingURL=index.js.map