diff --git a/api/storm-polygons.js b/api/storm-polygons.js index d03708a..0144da8 100644 --- a/api/storm-polygons.js +++ b/api/storm-polygons.js @@ -1,94 +1,108 @@ /** * Fetches historical NWS storm-based warnings (SVR + TOR) from the Iowa - * Environmental Mesonet (IEM) archive for WFO FWD (Fort Worth), which covers + * Environmental Mesonet (IEM) for WFO FWD (Fort Worth), which covers * Collin County and the Plano TX metro area. * - * IEM SBW archive — free, no auth, GeoJSON polygons derived from NEXRAD. - * https://mesonet.agron.iastate.edu/api/1/nws/sbw.geojson + * Correct endpoint: /geojson/sbw.geojson with sts/ets timestamp params. + * Geometry is MultiPolygon; date field is "issue"; phenomena filtered server-side. * - * Response: array of storm events matching MOCK_STORM_EVENTS schema. - * Cached 12 hours on Vercel edge — historical data rarely changes. + * 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/api/1/nws/sbw.geojson'; -const WFO = 'FWD'; // Fort Worth NWS office — covers Collin County / Plano TX +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 covering Plano, Allen, McKinney, Frisco, Richardson -const BBOX = { minLat: 32.85, maxLat: 33.40, minLon: -97.10, maxLon: -96.35 }; +// 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', }; -function polygonIntersectsBBox(rawCoords) { +// 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 derivedAreaName(rawCoords, phenomena) { - const n = rawCoords.length; +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; + 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'; + 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 evtLabel = phenomena === 'TO' ? 'Tornado Warning' - : phenomena === 'FF' ? 'Flash Flood Warning' - : 'Severe Thunderstorm'; - return `${city} — ${evtLabel}`; + 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 geom = feature.geometry; - if (!geom?.coordinates?.[0]) return null; + const p = feature.properties || {}; + const ph = p.phenomena || ''; - const rawCoords = geom.coordinates[0]; // GeoJSON: [lon, lat] - if (rawCoords.length < 3 || !polygonIntersectsBBox(rawCoords)) return null; + if (!KEEP_PHENOMENA.has(ph)) return null; - // Swap to Leaflet [lat, lon] order + 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 phenomena = p.phenomena || 'SV'; - const type = phenomena === 'TO' ? 'tornado' - : phenomena === 'FF' ? 'flood' - : 'hail'; + const type = ph === 'TO' ? 'tornado' : ph === 'FF' ? 'flood' : 'hail'; - // area2163 = area in km² (EPSG:2163 equal-area projection). Use as severity proxy. - const areaSqKm = parseFloat(p.area2163) || 0; - const severity = phenomena === 'TO' ? 'severe' - : areaSqKm > 900 ? 'severe' - : areaSqKm > 400 ? 'significant' - : areaSqKm > 120 ? 'moderate' - : 'trace'; + // hailtag stores hail size in inches (e.g. "1.75") — present on SVR warnings + const hailIn = p.hailtag ? parseFloat(p.hailtag) : null; - // Rough homes estimate: ~1200 homes/sq mile * 0.386 sq miles/km² ≈ 463 homes/km² - const estimatedHomes = areaSqKm > 0 - ? Math.min(Math.round((areaSqKm * 463) / 50) * 50, 50000) - : 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 issued = p.issued || p.expire || new Date().toISOString(); + const date = p.issue || p.polygon_begin || new Date().toISOString(); return { - id: `IEM-${WFO}-${phenomena}-${p.eventid ?? idx}`, - date: issued, + id: `IEM-${WFO}-${ph}-${p.year ?? ''}-${p.eventid ?? idx}`, + date, type, severity, - maxHailSize: null, - areaName: derivedAreaName(rawCoords, phenomena), + maxHailSize: hailIn, + areaName: derivedAreaName(rawCoords, ph), county: 'Collin', - estimatedHomes, - description: `${type === 'tornado' ? 'Tornado Warning' : type === 'flood' ? 'Flash Flood Warning' : 'Severe Thunderstorm Warning'} issued by NWS ${p.wfo || WFO}`, + estimatedHomes: null, + description: `${type === 'tornado' ? 'Tornado Warning' : type === 'flood' ? 'Flash Flood Warning' : 'Severe Thunderstorm Warning'} — NWS ${WFO}`, source: 'IEM/NWS', polygon, }; @@ -101,45 +115,30 @@ export default async function handler(req, res) { 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 months = Math.min(parseInt(req.query.months ?? '36', 10), 36); + const now = new Date(); + const cutoff = new Date(now.getTime() - months * 30.44 * 86400000); - // Collect every calendar year between cutoff and now - const years = new Set(); - for (let y = cutoff.getFullYear(); y <= now.getFullYear(); y++) years.add(y); - - // Fetch SV (Severe Thunderstorm) + TO (Tornado) per year in parallel - const phenomena = ['SV', 'TO']; - const fetches = []; - for (const year of years) { - for (const ph of phenomena) { - const url = `${IEM_BASE}?wfo=${WFO}&year=${year}&phenomena=${ph}&significance=W`; - fetches.push( - fetch(url, { headers: IEM_HEADERS }) - .then(r => r.ok ? r.json() : { features: [] }) - .catch(() => ({ features: [] })) - ); - } - } + const sts = cutoff.toISOString(); + const ets = now.toISOString(); + const url = `${IEM_BASE}?sts=${encodeURIComponent(sts)}&ets=${encodeURIComponent(ets)}&wfo=${WFO}`; try { - const results = await Promise.all(fetches); - const features = results.flatMap(r => Array.isArray(r.features) ? r.features : []); + 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) - .filter(e => { - const d = new Date(e.date); - return d >= cutoff && d <= now; - }) .sort((a, b) => new Date(b.date) - new Date(a.date)) - .slice(0, 150); // cap at 150 events + .slice(0, 150); return res.status(200).json(events); } catch (err) { - console.error('[storm-polygons] fetch error:', err.message); + console.error('[storm-polygons] error:', err.message); return res.status(200).json([]); } }