From 034ed1d18024fc9dbeed728a488839c23822397a Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Sat, 30 May 2026 06:12:48 +0530 Subject: [PATCH] feat(storm-intel): replace Hail Recon per-pin history with keyless IEM LSR + NOAA SWDI storm history (multi-type, no API key) --- api/storm-history.js | 165 +++++++++++++++++++++++++++++++++++ src/pages/StormIntelPage.jsx | 64 +++++++------- vite.config.js | 1 + 3 files changed, 198 insertions(+), 32 deletions(-) create mode 100644 api/storm-history.js diff --git a/api/storm-history.js b/api/storm-history.js new file mode 100644 index 0000000..5a91755 --- /dev/null +++ b/api/storm-history.js @@ -0,0 +1,165 @@ +/** + * Per-location storm history — KEYLESS, combines two free public sources: + * 1) IEM Local Storm Reports (LSR) — multi-year, multi-type ground reports + * (hail / thunderstorm wind / tornado / snow / flood) near the point. + * 2) NOAA SWDI nx3hail — recent (~30 day) NEXRAD radar-derived hail near the point. + * (SWDI caps requests at 744h/31 days, so it covers recent radar hail; LSR carries + * the multi-year history.) + * + * No API key required (Iowa State IEM + NOAA NCEI are free public services), so this + * works identically in local dev and on Vercel with zero env configuration. + * + * GET /api/storm-history?lat=33.055&lng=-96.752&months=60&radiusMi=20 + * Response: { reports[], radarHailRecent[], summary, errors } + */ + +const WFO = 'FWD'; // Fort Worth NWS office — covers Plano / Collin County + +function haversineMi(lat1, lon1, lat2, lon2) { + const R = 3958.8; + const toRad = (d) => (d * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +// Normalize an LSR typetext to a category the UI groups by. +function categorize(typetext = '') { + const t = typetext.toUpperCase(); + if (t.includes('HAIL')) return 'hail'; + if (t.includes('TORNADO') || t.includes('FUNNEL')) return 'tornado'; + if (t.includes('TSTM') || t.includes('WND') || t.includes('WIND') || t.includes('GUST')) return 'wind'; + if (t.includes('SNOW') || t.includes('SLEET') || t.includes('ICE') || t.includes('FREEZ') || t.includes('BLIZZARD')) return 'snow'; + if (t.includes('FLOOD')) return 'flood'; + return 'other'; +} + +async function fetchLSR(lat, lng, months, radiusMi) { + const now = new Date(); + const sts = new Date(now.getTime() - months * 30.44 * 86400000).toISOString(); + const ets = now.toISOString(); + const url = `https://mesonet.agron.iastate.edu/geojson/lsr.geojson?sts=${encodeURIComponent(sts)}&ets=${encodeURIComponent(ets)}&wfos=${WFO}`; + const res = await fetch(url, { headers: { 'User-Agent': 'LynkedUpPro-CRM/1.0 (admin@lynkedup.com)', Accept: 'application/geo+json' } }); + if (!res.ok) throw new Error(`IEM LSR ${res.status}`); + const geo = await res.json(); + const feats = Array.isArray(geo.features) ? geo.features : []; + + return feats + .map((f) => { + const p = f.properties || {}; + const lon = p.lon ?? f.geometry?.coordinates?.[0]; + const la = p.lat ?? f.geometry?.coordinates?.[1]; + if (la == null || lon == null) return null; + const dist = haversineMi(lat, lng, la, lon); + if (dist > radiusMi) return null; + return { + date: p.valid, + type: categorize(p.typetext), + label: p.typetext || 'Storm Report', + magnitude: typeof p.magf === 'number' ? p.magf : null, + unit: p.unit || '', + city: p.city || '', + county: p.county || '', + distanceMi: Math.round(dist * 10) / 10, + source: 'IEM-LSR', + }; + }) + .filter(Boolean) + .sort((a, b) => new Date(b.date) - new Date(a.date)); +} + +function parsePointWKT(shape = '') { + const m = shape.match(/POINT\s*\(([-\d.]+)\s+([-\d.]+)\)/i); + return m ? { lon: parseFloat(m[1]), lat: parseFloat(m[2]) } : null; +} + +function ymd(d) { + return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`; +} + +async function fetchSWDIRecentHail(lat, lng, radiusMi) { + // SWDI caps at 744h (31 days) per request — query the most recent 30 days. + const now = new Date(); + const start = new Date(now.getTime() - 30 * 86400000); + // Small bbox around the pin. ~69 mi per deg lat; ~58 mi per deg lon at ~33°N. + const dLat = radiusMi / 69; + const dLon = radiusMi / 58; + const bbox = [ + (lng - dLon).toFixed(3), (lat - dLat).toFixed(3), + (lng + dLon).toFixed(3), (lat + dLat).toFixed(3), + ].join(','); + const url = `https://www.ncei.noaa.gov/swdiws/json/nx3hail/${ymd(start)}:${ymd(now)}?bbox=${bbox}`; + const res = await fetch(url, { headers: { Accept: 'application/json' } }); + if (!res.ok) throw new Error(`SWDI ${res.status}`); + const j = await res.json(); + const rows = Array.isArray(j.result) ? j.result : []; + + // Each radar volume scan emits a cell; collapse to a per-day max hail size near the pin. + const byDay = {}; + for (const r of rows) { + const pt = parsePointWKT(r.SHAPE); + if (!pt) continue; + if (haversineMi(lat, lng, pt.lat, pt.lon) > radiusMi) continue; + const day = (r.ZTIME || '').slice(0, 10); + if (!day) continue; + const size = parseFloat(r.MAXSIZE) || 0; + if (!byDay[day] || size > byDay[day]) byDay[day] = size; + } + return Object.entries(byDay) + .map(([day, maxSize]) => ({ date: day, maxSize: Math.round(maxSize * 100) / 100, source: 'NOAA-SWDI' })) + .sort((a, b) => (a.date < b.date ? 1 : -1)); +} + +export default async function handler(req, res) { + res.setHeader('Cache-Control', 's-maxage=21600, 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 lat = parseFloat(req.query.lat); + const lng = parseFloat(req.query.lng); + const months = Math.min(parseInt(req.query.months ?? '60', 10) || 60, 60); + const radiusMi = Math.min(parseFloat(req.query.radiusMi) || 20, 50); + + if (Number.isNaN(lat) || Number.isNaN(lng)) { + return res.status(400).json({ error: 'lat and lng query params are required' }); + } + + const [lsrR, swdiR] = await Promise.allSettled([ + fetchLSR(lat, lng, months, radiusMi), + fetchSWDIRecentHail(lat, lng, radiusMi), + ]); + + const reports = lsrR.status === 'fulfilled' ? lsrR.value : []; + const radarHailRecent = swdiR.status === 'fulfilled' ? swdiR.value : []; + + const byType = {}; + let maxHail = 0; + for (const r of reports) { + byType[r.type] = (byType[r.type] || 0) + 1; + if (r.type === 'hail' && typeof r.magnitude === 'number') maxHail = Math.max(maxHail, r.magnitude); + } + for (const h of radarHailRecent) maxHail = Math.max(maxHail, h.maxSize || 0); + const lastEvent = reports[0] || null; + + return res.status(200).json({ + reports, + radarHailRecent, + summary: { + total: reports.length, + byType, + maxHail: maxHail || null, + lastEvent: lastEvent + ? { date: lastEvent.date, type: lastEvent.type, label: lastEvent.label } + : null, + radarHailDays: radarHailRecent.length, + }, + errors: { + lsr: lsrR.status === 'rejected' ? String(lsrR.reason?.message || lsrR.reason) : null, + swdi: swdiR.status === 'rejected' ? String(swdiR.reason?.message || swdiR.reason) : null, + }, + }); +} diff --git a/src/pages/StormIntelPage.jsx b/src/pages/StormIntelPage.jsx index bcf9608..b99a6a7 100644 --- a/src/pages/StormIntelPage.jsx +++ b/src/pages/StormIntelPage.jsx @@ -1206,28 +1206,21 @@ const FilterChip = ({ active, onClick, children }) => ( ); -// ─── Hail Recon response parser ────────────────────────────────────────────── +// ─── Storm-history response summarizer (IEM LSR + NOAA SWDI) ───────────────── -function parseHailReconData(data) { +function summarizeStormHistory(data) { if (data == null) return null; - let events = null; - if (Array.isArray(data)) { - events = data; - } else if (Array.isArray(data?.ImpactDates)) { - events = data.ImpactDates; - } else if (Array.isArray(data?.Events)) { - events = data.Events; - } - if (!events) return { count: null, maxSize: null, lastDate: null, unknown: true }; - if (events.length === 0) return { count: 0, maxSize: null, lastDate: null }; - const maxSize = events.length - ? Math.max(...events.map(d => parseFloat(d.MaxSize ?? d.size ?? d.magnitude ?? 0) || 0)) - : 0; - const dates = events.map(d => d.Date || d.date || d.ImpactDate || d.impact_date || null).filter(Boolean); - const lastDate = dates.length - ? new Date(Math.max(...dates.map(s => new Date(s).getTime()))) - : null; - return { count: events.length, maxSize: maxSize > 0 ? maxSize : null, lastDate }; + const s = data.summary; + if (!s) return { count: null, unknown: true }; + const lastDate = s.lastEvent?.date ? new Date(s.lastEvent.date) : null; + return { + count: s.total ?? 0, + byType: s.byType || {}, + maxHail: s.maxHail ?? null, + lastEvent: s.lastEvent || null, + lastDate, + radarHailDays: s.radarHailDays ?? 0, + }; } // ─── Main page ──────────────────────────────────────────────────────────────── @@ -1348,14 +1341,14 @@ const StormIntelPage = () => { }); }, [reports, selectedStorm]); - // Tier 3: fetch Hail Recon data for each dropped pin + // Tier 3: fetch multi-source storm history (IEM LSR + NOAA SWDI) for each dropped pin useEffect(() => { if (!pins.length) { setPinHailData({}); return; } pins.forEach((pin, idx) => { if (pinHailData[idx] !== undefined) return; const [lat, lng] = pin; setPinHailData(prev => ({ ...prev, [idx]: { loading: true, data: null, error: null } })); - fetch(`/api/hail-proxy?endpoint=ImpactDatesForLatLong&Lat=${lat}&Long=${lng}&Months=60`) + fetch(`/api/storm-history?lat=${lat}&lng=${lng}&months=60&radiusMi=20`) .then(async r => { const ct = r.headers.get('content-type') || ''; if (!ct.includes('json')) throw new Error('proxy_unavailable'); @@ -1827,7 +1820,7 @@ const StormIntelPage = () => { {/* Per-pin hail history — Tier 3 */} {pins.length > 0 && (
-

Hail History (5yr)

+

Storm History (5yr)

{pins.map((_, idx) => { const entry = pinHailData[idx]; let line; @@ -1843,22 +1836,29 @@ const StormIntelPage = () => { ? Not available in dev mode : {entry.error}; } else { - const parsed = parseHailReconData(entry.data); - if (!parsed) { + const sum = summarizeStormHistory(entry.data); + if (!sum) { line = No data; - } else if (parsed.unknown) { + } else if (sum.unknown) { line = Unknown response format; - } else if (parsed.count === 0) { - line = No hail events found; + } else if (sum.count === 0 && !sum.radarHailDays) { + line = No storm events found; } else { - const lastStr = parsed.lastDate - ? parsed.lastDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + const typeStr = Object.entries(sum.byType) + .sort((a, b) => b[1] - a[1]) + .slice(0, 4) + .map(([t, n]) => `${n} ${t}`) + .join(' · '); + const lastStr = sum.lastDate + ? sum.lastDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : null; line = ( - {parsed.count} event{parsed.count !== 1 ? 's' : ''} - {parsed.maxSize ? ` · max ${parsed.maxSize}"` : ''} + {sum.count} report{sum.count !== 1 ? 's' : ''} + {typeStr ? ` · ${typeStr}` : ''} + {sum.maxHail ? ` · max ${sum.maxHail}"` : ''} {lastStr ? ` · last ${lastStr}` : ''} + {sum.radarHailDays ? ` · radar ${sum.radarHailDays}d/30` : ''} ); } diff --git a/vite.config.js b/vite.config.js index 7e6c703..3b60404 100644 --- a/vite.config.js +++ b/vite.config.js @@ -18,6 +18,7 @@ import react from '@vitejs/plugin-react' // Only KV-free handlers are safe to import into the dev pipeline. const DEV_API_HANDLERS = { 'storm-forecast': () => import('./api/storm-forecast.js'), + 'storm-history': () => import('./api/storm-history.js'), 'hail-proxy': () => import('./api/hail-proxy.js'), 'storm-events': () => import('./api/storm-events.js'), 'storm-polygons': () => import('./api/storm-polygons.js'),