@adonix.org/nws-report
Version:
Generate weather reports from the National Weather Service
536 lines (523 loc) • 14.7 kB
JavaScript
// 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