From fd353ab7322acc324f455433ab1bc861bb67707d Mon Sep 17 00:00:00 2001 From: Satyam-Rastogi Date: Mon, 18 May 2026 23:45:50 +0530 Subject: [PATCH] feat(storm-intel): real storm polygon data via Iowa Environmental Mesonet IEM SBW archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api/storm-polygons.js — Vercel proxy that fetches historical NWS storm-based warnings (severe thunderstorm + tornado) from IEM for WFO FWD (Fort Worth), covering Collin County / Plano TX. Filters to local bounding box, derives severity from polygon area (area2163 km²), estimates impacted homes from density, derives area name from centroid. Cached 12h on Vercel edge. useStormEvents.js — switched to real IEM data (USE_MOCK=false). Fetches full 36-month dataset once on mount; all range/type/severity filtering stays client-side in useMemo for instant UI response. Falls back to MOCK_STORM_EVENTS when /api routes are unavailable (local vite dev). --- api/storm-polygons.js | 145 ++++++++++++++++++++++++++++++++++++ src/hooks/useStormEvents.js | 22 ++++-- 2 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 api/storm-polygons.js diff --git a/api/storm-polygons.js b/api/storm-polygons.js new file mode 100644 index 0000000..d03708a --- /dev/null +++ b/api/storm-polygons.js @@ -0,0 +1,145 @@ +/** + * 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([]); + } +} diff --git a/src/hooks/useStormEvents.js b/src/hooks/useStormEvents.js index f1ba6ba..bc827fa 100644 --- a/src/hooks/useStormEvents.js +++ b/src/hooks/useStormEvents.js @@ -8,7 +8,8 @@ import { useState, useEffect, useMemo } from 'react'; import { MOCK_STORM_EVENTS } from '../data/mockStore'; -const USE_MOCK = true; +// false = use real IEM/NWS data via /api/storm-polygons (falls back to mock if unreachable) +const USE_MOCK = false; const DATE_RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '12m': 365, '18m': 548, '24m': 730, '36m': 1095 }; @@ -38,10 +39,17 @@ export function useStormEvents({ await new Promise(r => setTimeout(r, 450)); data = MOCK_STORM_EVENTS; } else { - const days = DATE_RANGE_DAYS[dateRange] ?? 365; - const res = await fetch(`/api/storm-events?days=${days}&county=Collin`); - if (!res.ok) throw new Error(`Storm events error: ${res.status}`); - data = await res.json(); + try { + // Always fetch full 36-month window so range changes are instant client-side. + // Falls back to MOCK_STORM_EVENTS when /api routes aren't available (local vite dev). + const res = await fetch('/api/storm-polygons?months=36'); + if (!res.ok) throw new Error(`${res.status}`); + const json = await res.json(); + if (!Array.isArray(json) || json.length === 0) throw new Error('empty'); + data = json; + } catch { + data = MOCK_STORM_EVENTS; + } } if (!cancelled) setAllEvents(Array.isArray(data) ? data : []); } catch (e) { @@ -53,7 +61,9 @@ export function useStormEvents({ load(); return () => { cancelled = true; }; - }, [dateRange]); + // Run once — 36mo dataset is fetched in full; all filtering is client-side in useMemo below + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const events = useMemo(() => { let filtered = allEvents.filter(e => {