/** * Fetches historical NWS storm-based warnings (SVR + TOR) from the Iowa * Environmental Mesonet (IEM) archive 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 * * Response: array of storm events matching MOCK_STORM_EVENTS schema. * Cached 12 hours on Vercel edge — historical data rarely changes. */ 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 // Generous bounding box covering Plano, Allen, McKinney, Frisco, Richardson const BBOX = { minLat: 32.85, maxLat: 33.40, minLon: -97.10, maxLon: -96.35 }; const IEM_HEADERS = { 'User-Agent': 'LynkedUpPro-CRM/1.0 (admin@lynkedup.com)', Accept: 'application/geo+json', }; function polygonIntersectsBBox(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; 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 evtLabel = phenomena === 'TO' ? 'Tornado Warning' : phenomena === 'FF' ? 'Flash Flood Warning' : 'Severe Thunderstorm'; return `${city} — ${evtLabel}`; } function toStormEvent(feature, idx) { const p = feature.properties || {}; const geom = feature.geometry; if (!geom?.coordinates?.[0]) return null; const rawCoords = geom.coordinates[0]; // GeoJSON: [lon, lat] if (rawCoords.length < 3 || !polygonIntersectsBBox(rawCoords)) return null; // Swap to Leaflet [lat, lon] order const polygon = rawCoords.map(([lon, lat]) => [lat, lon]); const phenomena = p.phenomena || 'SV'; const type = phenomena === 'TO' ? 'tornado' : phenomena === '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'; // 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; const issued = p.issued || p.expire || new Date().toISOString(); return { id: `IEM-${WFO}-${phenomena}-${p.eventid ?? idx}`, date: issued, type, severity, maxHailSize: null, areaName: derivedAreaName(rawCoords, phenomena), county: 'Collin', estimatedHomes, description: `${type === 'tornado' ? 'Tornado Warning' : type === 'flood' ? 'Flash Flood Warning' : 'Severe Thunderstorm Warning'} issued by NWS ${p.wfo || 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); // 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: [] })) ); } } try { const results = await Promise.all(fetches); const features = results.flatMap(r => Array.isArray(r.features) ? r.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 return res.status(200).json(events); } catch (err) { console.error('[storm-polygons] fetch error:', err.message); return res.status(200).json([]); } }