fix(storm-intel): call IEM API directly from browser — no proxy needed
IEM geojson/sbw.geojson has CORS open to all origins so the browser can fetch it directly. Moves all transformation logic into useStormEvents.js, eliminating the need for vercel dev or any server-side proxy. Works with plain pnpm run dev on localhost. Falls back to MOCK_STORM_EVENTS if IEM is unreachable. api/storm-polygons.js kept as optional edge cache layer.
This commit is contained in:
+110
-13
@@ -1,18 +1,121 @@
|
|||||||
/**
|
/**
|
||||||
* Storm events hook.
|
* Storm events hook.
|
||||||
*
|
*
|
||||||
* USE_MOCK = true → returns MOCK_STORM_EVENTS from mockStore (local dev)
|
* USE_MOCK = true → returns MOCK_STORM_EVENTS (instant, no network)
|
||||||
* USE_MOCK = false → calls /api/storm-events which proxies NWS/NOAA
|
* USE_MOCK = false → calls IEM SBW archive directly from the browser.
|
||||||
|
* IEM is a free public API (Iowa State University) with
|
||||||
|
* CORS open to all origins — no proxy needed, works on
|
||||||
|
* localhost and production identically.
|
||||||
|
* Falls back to MOCK_STORM_EVENTS if IEM is unreachable.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { MOCK_STORM_EVENTS } from '../data/mockStore';
|
import { MOCK_STORM_EVENTS } from '../data/mockStore';
|
||||||
|
|
||||||
// false = use real IEM/NWS data via /api/storm-polygons (falls back to mock if unreachable)
|
|
||||||
const USE_MOCK = false;
|
const USE_MOCK = false;
|
||||||
|
|
||||||
const DATE_RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '12m': 365, '18m': 548, '24m': 730, '36m': 1095 };
|
// Iowa Environmental Mesonet — storm-based warnings archive
|
||||||
|
const IEM_SBW = 'https://mesonet.agron.iastate.edu/geojson/sbw.geojson';
|
||||||
|
const WFO = 'FWD'; // Fort Worth NWS office — covers Collin County / Plano TX
|
||||||
|
|
||||||
|
// Plano / Collin County bounding box
|
||||||
|
const BBOX = { minLat: 32.85, maxLat: 33.45, minLon: -97.15, maxLon: -96.35 };
|
||||||
|
|
||||||
|
// Only keep storm-relevant phenomena
|
||||||
|
const KEEP_PH = new Set(['SV', 'TO', 'FF']);
|
||||||
|
|
||||||
|
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];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inBBox(rawCoords) {
|
||||||
|
return rawCoords.some(([lon, lat]) =>
|
||||||
|
lat >= BBOX.minLat && lat <= BBOX.maxLat &&
|
||||||
|
lon >= BBOX.minLon && lon <= BBOX.maxLon
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 featureToEvent(feature, idx) {
|
||||||
|
const p = feature.properties || {};
|
||||||
|
const ph = p.phenomena || '';
|
||||||
|
if (!KEEP_PH.has(ph)) return null;
|
||||||
|
|
||||||
|
const rawCoords = extractRing(feature.geometry);
|
||||||
|
if (!rawCoords || !inBBox(rawCoords)) return null;
|
||||||
|
|
||||||
|
const polygon = rawCoords.map(([lon, lat]) => [lat, lon]); // → Leaflet [lat,lon]
|
||||||
|
|
||||||
|
const type = ph === 'TO' ? 'tornado' : ph === 'FF' ? 'flood' : 'hail';
|
||||||
|
|
||||||
|
const hailIn = p.hailtag ? parseFloat(p.hailtag) : null;
|
||||||
|
const severity = ph === 'TO' ? 'severe'
|
||||||
|
: hailIn !== null
|
||||||
|
? (hailIn >= 2.0 ? 'severe' : hailIn >= 1.5 ? 'significant' : hailIn >= 1.0 ? 'moderate' : 'trace')
|
||||||
|
: ph === 'FF' ? 'moderate'
|
||||||
|
: 'significant';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `IEM-${WFO}-${ph}-${p.year ?? ''}-${p.eventid ?? idx}`,
|
||||||
|
date: p.issue || p.polygon_begin || new Date().toISOString(),
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchIEMEvents() {
|
||||||
|
const now = new Date();
|
||||||
|
const cutoff = new Date(now.getTime() - 36 * 30.44 * 86400000);
|
||||||
|
const url = `${IEM_SBW}?sts=${encodeURIComponent(cutoff.toISOString())}&ets=${encodeURIComponent(now.toISOString())}&wfo=${WFO}`;
|
||||||
|
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`IEM ${res.status}`);
|
||||||
|
|
||||||
|
const geojson = await res.json();
|
||||||
|
const events = (geojson.features || [])
|
||||||
|
.map((f, i) => featureToEvent(f, i))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||||
|
.slice(0, 150);
|
||||||
|
|
||||||
|
if (events.length === 0) throw new Error('no local events');
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DATE_RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '12m': 365, '18m': 548, '24m': 730, '36m': 1095 };
|
||||||
const SEVERITY_ORDER = { severe: 4, significant: 3, moderate: 2, trace: 1 };
|
const SEVERITY_ORDER = { severe: 4, significant: 3, moderate: 2, trace: 1 };
|
||||||
|
|
||||||
export function useStormEvents({
|
export function useStormEvents({
|
||||||
@@ -40,14 +143,9 @@ export function useStormEvents({
|
|||||||
data = MOCK_STORM_EVENTS;
|
data = MOCK_STORM_EVENTS;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
// Always fetch full 36-month window so range changes are instant client-side.
|
data = await fetchIEMEvents();
|
||||||
// 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 {
|
} catch {
|
||||||
|
// IEM unreachable or no local events — fall back to mock
|
||||||
data = MOCK_STORM_EVENTS;
|
data = MOCK_STORM_EVENTS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +159,7 @@ export function useStormEvents({
|
|||||||
|
|
||||||
load();
|
load();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
// Run once — 36mo dataset is fetched in full; all filtering is client-side in useMemo below
|
// Fetch once — full 36mo dataset loaded; all filtering is client-side below
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -69,7 +167,6 @@ export function useStormEvents({
|
|||||||
let filtered = allEvents.filter(e => {
|
let filtered = allEvents.filter(e => {
|
||||||
const d = new Date(e.date);
|
const d = new Date(e.date);
|
||||||
|
|
||||||
// Custom date range
|
|
||||||
if (dateRange === 'custom') {
|
if (dateRange === 'custom') {
|
||||||
const start = customStart ? new Date(customStart) : new Date(0);
|
const start = customStart ? new Date(customStart) : new Date(0);
|
||||||
const end = customEnd ? new Date(customEnd + 'T23:59:59') : new Date();
|
const end = customEnd ? new Date(customEnd + 'T23:59:59') : new Date();
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
|
"buildCommand": "pnpm build",
|
||||||
|
"devCommand": "pnpm dev",
|
||||||
|
"outputDirectory": "dist",
|
||||||
"rewrites": [
|
"rewrites": [
|
||||||
{ "source": "/api/(.*)", "destination": "/api/$1" },
|
{ "source": "/((?!api/).*)", "destination": "/index.html" }
|
||||||
{ "source": "/(.*)", "destination": "/index.html" }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user