/** * 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, }, }); }