108 lines
3.9 KiB
JavaScript
108 lines
3.9 KiB
JavaScript
/**
|
|
* 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 };
|
|
}
|