feat(storm-intel): real storm polygon data via Iowa Environmental Mesonet IEM SBW archive
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).
This commit is contained in:
@@ -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([]);
|
||||
}
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user