From 1f0e74a7b05de3258c2f4d442c98be2bdbd160c2 Mon Sep 17 00:00:00 2001 From: Satyam-Rastogi Date: Tue, 19 May 2026 00:22:05 +0530 Subject: [PATCH] =?UTF-8?q?fix(storm-intel):=20call=20IEM=20API=20directly?= =?UTF-8?q?=20from=20browser=20=E2=80=94=20no=20proxy=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/hooks/useStormEvents.js | 137 ++++++++++++++++++++++++++++++------ vercel.json | 7 +- 2 files changed, 122 insertions(+), 22 deletions(-) diff --git a/src/hooks/useStormEvents.js b/src/hooks/useStormEvents.js index bc827fa..90f953a 100644 --- a/src/hooks/useStormEvents.js +++ b/src/hooks/useStormEvents.js @@ -1,27 +1,130 @@ /** * Storm events hook. * - * USE_MOCK = true → returns MOCK_STORM_EVENTS from mockStore (local dev) - * USE_MOCK = false → calls /api/storm-events which proxies NWS/NOAA + * USE_MOCK = true → returns MOCK_STORM_EVENTS (instant, no network) + * 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 { 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 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 -const SEVERITY_ORDER = { severe: 4, significant: 3, moderate: 2, trace: 1 }; +// 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 }; export function useStormEvents({ - dateRange = '12m', - typeFilter = 'all', + dateRange = '12m', + typeFilter = 'all', severityFilter = 'all', - sortBy = 'date', - customStart = '', - customEnd = '', + sortBy = 'date', + customStart = '', + customEnd = '', } = {}) { const [allEvents, setAllEvents] = useState([]); const [loading, setLoading] = useState(true); @@ -40,14 +143,9 @@ export function useStormEvents({ data = MOCK_STORM_EVENTS; } else { 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; + data = await fetchIEMEvents(); } catch { + // IEM unreachable or no local events — fall back to mock data = MOCK_STORM_EVENTS; } } @@ -61,7 +159,7 @@ export function useStormEvents({ load(); 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 }, []); @@ -69,7 +167,6 @@ export function useStormEvents({ let filtered = allEvents.filter(e => { const d = new Date(e.date); - // Custom date range if (dateRange === 'custom') { const start = customStart ? new Date(customStart) : new Date(0); const end = customEnd ? new Date(customEnd + 'T23:59:59') : new Date(); @@ -79,7 +176,7 @@ export function useStormEvents({ if (d.getTime() < Date.now() - days * 86400000) return false; } - if (typeFilter !== 'all' && e.type !== typeFilter) return false; + if (typeFilter !== 'all' && e.type !== typeFilter) return false; if (severityFilter !== 'all' && e.severity !== severityFilter) return false; return true; }); diff --git a/vercel.json b/vercel.json index 591e056..ff5c944 100644 --- a/vercel.json +++ b/vercel.json @@ -1,6 +1,9 @@ { + "buildCommand": "pnpm build", + "devCommand": "pnpm dev", + "outputDirectory": "dist", "rewrites": [ - { "source": "/api/(.*)", "destination": "/api/$1" }, - { "source": "/(.*)", "destination": "/index.html" } + { "source": "/((?!api/).*)", "destination": "/index.html" } ] } +