feat(storm-intel): Tier 1/2/3 data enrichment — SBW tags, LSR spotter reports, Hail Recon pin history

This commit is contained in:
Satyam-Rastogi
2026-05-19 02:27:47 +05:30
parent dc994be329
commit 4c32266f3d
3 changed files with 356 additions and 1 deletions
+11
View File
@@ -101,6 +101,12 @@ function featureToEvent(feature, idx) {
const issueTime = p.issue || p.polygon_begin || null;
const expireTime = p.expire || p.polygon_end || null;
const tornadoTag = p.tornadotag || null; // "POSSIBLE", "OBSERVED", "PDS", "TORNADO EMERGENCY"
const damageTag = p.damagetag || null; // "CONSIDERABLE", "DESTRUCTIVE", "CATASTROPHIC"
const isEmergency = !!p.is_emergency;
const status = p.status || 'EXP'; // "NEW", "CON", "EXP", "CAN"
const significance = p.significance || 'W'; // "W"=Warning, "A"=Advisory, "Y"=Advisory
const warningDurationMins = (issueTime && expireTime)
? Math.round((new Date(expireTime) - new Date(issueTime)) / 60000)
: null;
@@ -141,6 +147,11 @@ function featureToEvent(feature, idx) {
source: 'IEM/NWS',
wfo: WFO,
polygon,
tornadoTag,
damageTag,
isEmergency,
status,
significance,
};
}
+107
View File
@@ -0,0 +1,107 @@
/**
* IEM Local Storm Reports (LSR) hook.
* Fetches spotter-verified ground truth reports from the same provider as SBW.
* CORS-open, no auth needed.
*/
import { useState, useEffect } from 'react';
const LSR_URL = 'https://mesonet.agron.iastate.edu/geojson/lsr.geojson';
const WFO = 'FWD';
const KEEP_TYPES = new Set(['H', 'G', 'W', 'T', 'F', 'D', '4']);
const TYPE_TEXT = {
H: 'HAIL',
'4': 'HAIL',
G: 'WIND GUST',
W: 'HIGH WIND',
T: 'TORNADO',
F: 'FLASH FLOOD',
D: 'STRUCTURAL DAMAGE',
};
// Plano / Collin County bounding box
const BBOX = { minLat: 32.85, maxLat: 33.45, minLon: -97.15, maxLon: -96.35 };
function inBBox(lat, lon) {
return lat >= BBOX.minLat && lat <= BBOX.maxLat && lon >= BBOX.minLon && lon <= BBOX.maxLon;
}
export function useStormReports() {
const [reports, setReports] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
try {
const now = new Date();
const cutoff = new Date(now.getTime() - 36 * 30.44 * 86400000);
const sts = encodeURIComponent(cutoff.toISOString());
const ets = encodeURIComponent(now.toISOString());
const url = `${LSR_URL}?sts=${sts}&ets=${ets}&wfo=${WFO}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`LSR ${res.status}`);
const geojson = await res.json();
const parsed = (geojson.features || [])
.map(feature => {
const p = feature.properties || {};
const type = p.typetext?.charAt(0)?.toUpperCase() || p.type || '';
// Some feeds use numeric string type code instead
const typeKey = KEEP_TYPES.has(type) ? type
: KEEP_TYPES.has(p.type) ? p.type
: null;
if (!typeKey) return null;
const coords = feature.geometry?.coordinates;
if (!coords || coords.length < 2) return null;
const [lon, lat] = coords;
if (!inBBox(lat, lon)) return null;
const valid = p.valid || p.utc_valid || null;
const magnitude = (p.magnitude != null && p.magnitude !== '') ? parseFloat(p.magnitude) : null;
const city = p.city || p.st || '';
const county = p.county || '';
const source = p.source || p.remark_source || '';
const remark = p.remark || '';
return {
id: `LSR-${typeKey}-${valid}-${Math.round(lat * 1000)}-${Math.round(lon * 1000)}`,
lat,
lon,
type: typeKey,
typetext: TYPE_TEXT[typeKey] ?? typeKey,
magnitude: isNaN(magnitude) ? null : magnitude,
city,
county,
source,
remark,
valid,
};
})
.filter(Boolean)
.sort((a, b) => new Date(b.valid) - new Date(a.valid))
.slice(0, 500);
if (!cancelled) setReports(parsed);
} catch {
// LSR unreachable — silently show empty
if (!cancelled) setReports([]);
} finally {
if (!cancelled) setLoading(false);
}
};
load();
return () => { cancelled = true; };
}, []);
return { reports, loading };
}