feat(storm-intel): replace Hail Recon per-pin history with keyless IEM LSR + NOAA SWDI storm history (multi-type, no API key)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1206,28 +1206,21 @@ const FilterChip = ({ active, onClick, children }) => (
|
||||
</button>
|
||||
);
|
||||
|
||||
// ─── 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 && (
|
||||
<div className="pt-0.5 space-y-0.5">
|
||||
<p className="text-[9px] font-black uppercase tracking-wider text-violet-400 mb-0.5">Hail History (5yr)</p>
|
||||
<p className="text-[9px] font-black uppercase tracking-wider text-violet-400 mb-0.5">Storm History (5yr)</p>
|
||||
{pins.map((_, idx) => {
|
||||
const entry = pinHailData[idx];
|
||||
let line;
|
||||
@@ -1843,22 +1836,29 @@ const StormIntelPage = () => {
|
||||
? <span className="text-[10px] text-violet-300/70 italic">Not available in dev mode</span>
|
||||
: <span className="text-[10px] text-red-400">{entry.error}</span>;
|
||||
} else {
|
||||
const parsed = parseHailReconData(entry.data);
|
||||
if (!parsed) {
|
||||
const sum = summarizeStormHistory(entry.data);
|
||||
if (!sum) {
|
||||
line = <span className="text-[10px] text-violet-300">No data</span>;
|
||||
} else if (parsed.unknown) {
|
||||
} else if (sum.unknown) {
|
||||
line = <span className="text-[10px] text-violet-300">Unknown response format</span>;
|
||||
} else if (parsed.count === 0) {
|
||||
line = <span className="text-[10px] text-violet-300">No hail events found</span>;
|
||||
} else if (sum.count === 0 && !sum.radarHailDays) {
|
||||
line = <span className="text-[10px] text-violet-300">No storm events found</span>;
|
||||
} 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 = (
|
||||
<span className="text-[10px] text-violet-200 font-semibold">
|
||||
{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` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user