5cd0d456f4
Use /geojson/sbw.geojson?sts=...&ets=... instead of year param (which was being ignored). Handle MultiPolygon geometry type from IEM response. Use correct 'issue' date field. Filter phenomena server-side (SV/TO/FF only). Use hailtag field for accurate severity on SVR warnings.
145 lines
5.4 KiB
JavaScript
145 lines
5.4 KiB
JavaScript
/**
|
|
* Fetches historical NWS storm-based warnings (SVR + TOR) from the Iowa
|
|
* Environmental Mesonet (IEM) for WFO FWD (Fort Worth), which covers
|
|
* Collin County and the Plano TX metro area.
|
|
*
|
|
* Correct endpoint: /geojson/sbw.geojson with sts/ets timestamp params.
|
|
* Geometry is MultiPolygon; date field is "issue"; phenomena filtered server-side.
|
|
*
|
|
* No API key required — IEM is a free public service (Iowa State University).
|
|
* Cached 12 hours on Vercel edge.
|
|
*/
|
|
|
|
const IEM_BASE = 'https://mesonet.agron.iastate.edu/geojson/sbw.geojson';
|
|
const WFO = 'FWD'; // Fort Worth NWS office — covers Collin County / Plano TX
|
|
|
|
// Generous bounding box: Plano, Allen, McKinney, Frisco, Richardson
|
|
const BBOX = { minLat: 32.85, maxLat: 33.45, minLon: -97.15, maxLon: -96.35 };
|
|
|
|
const IEM_HEADERS = {
|
|
'User-Agent': 'LynkedUpPro-CRM/1.0 (admin@lynkedup.com)',
|
|
Accept: 'application/geo+json',
|
|
};
|
|
|
|
// Only keep storm-relevant phenomena
|
|
const KEEP_PHENOMENA = new Set(['SV', 'TO', 'FF']);
|
|
|
|
function polygonInBBox(rawCoords) {
|
|
return rawCoords.some(([lon, lat]) =>
|
|
lat >= BBOX.minLat && lat <= BBOX.maxLat &&
|
|
lon >= BBOX.minLon && lon <= BBOX.maxLon
|
|
);
|
|
}
|
|
|
|
function extractRing(geom) {
|
|
if (!geom) return null;
|
|
if (geom.type === 'Polygon' && geom.coordinates?.[0]?.length >= 3)
|
|
return geom.coordinates[0];
|
|
if (geom.type === 'MultiPolygon' && geom.coordinates?.[0]?.[0]?.length >= 3)
|
|
return geom.coordinates[0][0]; // first ring of first polygon
|
|
return null;
|
|
}
|
|
|
|
function derivedAreaName(rawCoords, ph) {
|
|
const n = rawCoords.length;
|
|
const centerLat = rawCoords.reduce((s, [, lat]) => s + lat, 0) / n;
|
|
const centerLon = rawCoords.reduce((s, [lon]) => s + lon, 0) / n;
|
|
|
|
let city;
|
|
if (centerLat > 33.22) city = 'McKinney';
|
|
else if (centerLat > 33.16 && centerLon < -96.79) city = 'Frisco';
|
|
else if (centerLat > 33.16) city = 'N. Plano / Allen';
|
|
else if (centerLat > 33.10) city = 'Allen';
|
|
else if (centerLon < -96.84) city = 'Legacy Corridor';
|
|
else if (centerLat > 33.04) city = 'Central Plano';
|
|
else city = 'S. Plano';
|
|
|
|
const label = ph === 'TO' ? 'Tornado Warning'
|
|
: ph === 'FF' ? 'Flash Flood Warning'
|
|
: 'Severe Thunderstorm';
|
|
return `${city} — ${label}`;
|
|
}
|
|
|
|
function toStormEvent(feature, idx) {
|
|
const p = feature.properties || {};
|
|
const ph = p.phenomena || '';
|
|
|
|
if (!KEEP_PHENOMENA.has(ph)) return null;
|
|
|
|
const rawCoords = extractRing(feature.geometry);
|
|
if (!rawCoords || !polygonInBBox(rawCoords)) return null;
|
|
|
|
// GeoJSON coords are [lon, lat]; Leaflet needs [lat, lon]
|
|
const polygon = rawCoords.map(([lon, lat]) => [lat, lon]);
|
|
|
|
const type = ph === 'TO' ? 'tornado' : ph === 'FF' ? 'flood' : 'hail';
|
|
|
|
// hailtag stores hail size in inches (e.g. "1.75") — present on SVR warnings
|
|
const hailIn = p.hailtag ? parseFloat(p.hailtag) : null;
|
|
|
|
let severity;
|
|
if (ph === 'TO') {
|
|
severity = 'severe';
|
|
} else if (hailIn !== null) {
|
|
severity = hailIn >= 2.0 ? 'severe'
|
|
: hailIn >= 1.5 ? 'significant'
|
|
: hailIn >= 1.0 ? 'moderate'
|
|
: 'trace';
|
|
} else if (ph === 'FF') {
|
|
severity = 'moderate';
|
|
} else {
|
|
severity = 'significant'; // SVR without hail tag
|
|
}
|
|
|
|
const date = p.issue || p.polygon_begin || new Date().toISOString();
|
|
|
|
return {
|
|
id: `IEM-${WFO}-${ph}-${p.year ?? ''}-${p.eventid ?? idx}`,
|
|
date,
|
|
type,
|
|
severity,
|
|
maxHailSize: hailIn,
|
|
areaName: derivedAreaName(rawCoords, ph),
|
|
county: 'Collin',
|
|
estimatedHomes: null,
|
|
description: `${type === 'tornado' ? 'Tornado Warning' : type === 'flood' ? 'Flash Flood Warning' : 'Severe Thunderstorm Warning'} — NWS ${WFO}`,
|
|
source: 'IEM/NWS',
|
|
polygon,
|
|
};
|
|
}
|
|
|
|
export default async function handler(req, res) {
|
|
res.setHeader('Cache-Control', 's-maxage=43200, stale-while-revalidate=86400');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
|
|
if (req.method === 'OPTIONS') return res.status(200).end();
|
|
if (req.method !== 'GET') return res.status(405).json({ error: 'Method not allowed' });
|
|
|
|
const months = Math.min(parseInt(req.query.months ?? '36', 10), 36);
|
|
const now = new Date();
|
|
const cutoff = new Date(now.getTime() - months * 30.44 * 86400000);
|
|
|
|
const sts = cutoff.toISOString();
|
|
const ets = now.toISOString();
|
|
const url = `${IEM_BASE}?sts=${encodeURIComponent(sts)}&ets=${encodeURIComponent(ets)}&wfo=${WFO}`;
|
|
|
|
try {
|
|
const response = await fetch(url, { headers: IEM_HEADERS });
|
|
if (!response.ok) throw new Error(`IEM ${response.status}`);
|
|
|
|
const geojson = await response.json();
|
|
const features = Array.isArray(geojson.features) ? geojson.features : [];
|
|
|
|
const events = features
|
|
.map((f, i) => toStormEvent(f, i))
|
|
.filter(Boolean)
|
|
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
|
.slice(0, 150);
|
|
|
|
return res.status(200).json(events);
|
|
} catch (err) {
|
|
console.error('[storm-polygons] error:', err.message);
|
|
return res.status(200).json([]);
|
|
}
|
|
}
|