267 lines
11 KiB
JavaScript
267 lines
11 KiB
JavaScript
/**
|
|
* Storm events hook.
|
|
*
|
|
* USE_MOCK = true → returns MOCK_STORM_EVENTS (instant, no network)
|
|
* USE_MOCK = false → calls IEM SBW archive directly from the browser.
|
|
* IEM is a free public API (Iowa State University) with
|
|
* CORS open to all origins — no proxy needed, works on
|
|
* localhost and production identically.
|
|
* Falls back to MOCK_STORM_EVENTS if IEM is unreachable.
|
|
*/
|
|
|
|
import { useState, useEffect, useMemo } from 'react';
|
|
import { MOCK_STORM_EVENTS } from '../data/mockStore';
|
|
|
|
const USE_MOCK = false;
|
|
|
|
// Iowa Environmental Mesonet — storm-based warnings archive
|
|
const IEM_SBW = 'https://mesonet.agron.iastate.edu/geojson/sbw.geojson';
|
|
const WFO = 'FWD'; // Fort Worth NWS office — covers Collin County / Plano TX
|
|
|
|
// Plano / Collin County bounding box
|
|
const BBOX = { minLat: 32.85, maxLat: 33.45, minLon: -97.15, maxLon: -96.35 };
|
|
|
|
// Storm-relevant NWS phenomena codes
|
|
const KEEP_PH = new Set(['SV', 'TO', 'FF', 'WS', 'BZ', 'WW', 'IS']);
|
|
|
|
function extractRing(geom) {
|
|
if (!geom) return null;
|
|
if (geom.type === 'Polygon' && geom.coordinates?.[0]?.length >= 3)
|
|
return geom.coordinates[0];
|
|
if (geom.type === 'MultiPolygon' && geom.coordinates?.[0]?.[0]?.length >= 3)
|
|
return geom.coordinates[0][0];
|
|
return null;
|
|
}
|
|
|
|
function inBBox(rawCoords) {
|
|
return rawCoords.some(([lon, lat]) =>
|
|
lat >= BBOX.minLat && lat <= BBOX.maxLat &&
|
|
lon >= BBOX.minLon && lon <= BBOX.maxLon
|
|
);
|
|
}
|
|
|
|
function derivedAreaName(rawCoords, ph) {
|
|
const n = rawCoords.length;
|
|
const centerLat = rawCoords.reduce((s, [, lat]) => s + lat, 0) / n;
|
|
const centerLon = rawCoords.reduce((s, [lon]) => s + lon, 0) / n;
|
|
|
|
let city;
|
|
if (centerLat > 33.22) city = 'McKinney';
|
|
else if (centerLat > 33.16 && centerLon < -96.79) city = 'Frisco';
|
|
else if (centerLat > 33.16) city = 'N. Plano / Allen';
|
|
else if (centerLat > 33.10) city = 'Allen';
|
|
else if (centerLon < -96.84) city = 'Legacy Corridor';
|
|
else if (centerLat > 33.04) city = 'Central Plano';
|
|
else city = 'S. Plano';
|
|
|
|
const label = ph === 'TO' ? 'Tornado Warning'
|
|
: ph === 'FF' ? 'Flash Flood Warning'
|
|
: 'Severe Thunderstorm';
|
|
return `${city} — ${label}`;
|
|
}
|
|
|
|
// Shoelace formula — polygon is Leaflet [lat,lon] pairs
|
|
function polygonAreaSqMi(polygon) {
|
|
const n = polygon.length;
|
|
if (n < 3) return 0;
|
|
const avgLat = polygon.reduce((s, p) => s + p[0], 0) / n;
|
|
const mLat = 69.0;
|
|
const mLon = 69.0 * Math.cos(avgLat * Math.PI / 180);
|
|
let area = 0;
|
|
for (let i = 0; i < n; i++) {
|
|
const [lat1, lon1] = polygon[i];
|
|
const [lat2, lon2] = polygon[(i + 1) % n];
|
|
area += (lon1 * mLon) * (lat2 * mLat) - (lon2 * mLon) * (lat1 * mLat);
|
|
}
|
|
return Math.abs(area) / 2;
|
|
}
|
|
|
|
const PH_LABEL = {
|
|
SV: 'Severe Thunderstorm Warning',
|
|
TO: 'Tornado Warning',
|
|
FF: 'Flash Flood Warning',
|
|
WS: 'Winter Storm Warning',
|
|
BZ: 'Blizzard Warning',
|
|
WW: 'Winter Weather Advisory',
|
|
IS: 'Ice Storm Warning',
|
|
};
|
|
|
|
function featureToEvent(feature, idx) {
|
|
const p = feature.properties || {};
|
|
const ph = p.phenomena || '';
|
|
if (!KEEP_PH.has(ph)) return null;
|
|
|
|
const rawCoords = extractRing(feature.geometry);
|
|
if (!rawCoords || !inBBox(rawCoords)) return null;
|
|
|
|
const polygon = rawCoords.map(([lon, lat]) => [lat, lon]); // → Leaflet [lat,lon]
|
|
|
|
const hailIn = p.hailtag ? parseFloat(p.hailtag) : null;
|
|
const windMph = p.windtag ? parseInt(p.windtag, 10) : null;
|
|
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;
|
|
|
|
const areaSqMi = Math.round(polygonAreaSqMi(polygon));
|
|
// Suburban DFW density ~1 800 homes/sq mi — conservative (polygons often include open land)
|
|
const estHomes = Math.round(areaSqMi * 1800);
|
|
|
|
const type = ph === 'TO' ? 'tornado'
|
|
: ph === 'FF' ? 'flood'
|
|
: ph === 'WS' || ph === 'BZ' ? 'snow'
|
|
: ph === 'WW' || ph === 'IS' ? 'ice'
|
|
: hailIn !== null ? 'hail'
|
|
: 'wind';
|
|
|
|
const severity = ph === 'TO' ? 'severe'
|
|
: hailIn !== null
|
|
? (hailIn >= 2.0 ? 'severe' : hailIn >= 1.5 ? 'significant' : hailIn >= 1.0 ? 'moderate' : 'trace')
|
|
: ph === 'FF' ? 'moderate'
|
|
: 'significant';
|
|
|
|
return {
|
|
id: `IEM-${WFO}-${ph}-${p.year ?? ''}-${p.eventid ?? idx}`,
|
|
date: issueTime || new Date().toISOString(),
|
|
expireTime,
|
|
warningDurationMins,
|
|
type,
|
|
severity,
|
|
phenomenaCode: ph,
|
|
phenomenaLabel: PH_LABEL[ph] ?? ph,
|
|
maxHailSize: hailIn,
|
|
windSpeed: windMph,
|
|
areaName: derivedAreaName(rawCoords, ph),
|
|
county: 'Collin',
|
|
areaSqMi,
|
|
estimatedHomes: estHomes > 0 ? estHomes : null,
|
|
description: `${PH_LABEL[ph] ?? ph} — NWS ${WFO}`,
|
|
source: 'IEM/NWS',
|
|
wfo: WFO,
|
|
polygon,
|
|
tornadoTag,
|
|
damageTag,
|
|
isEmergency,
|
|
status,
|
|
significance,
|
|
};
|
|
}
|
|
|
|
async function fetchIEMEvents() {
|
|
const now = new Date();
|
|
const cutoff = new Date(now.getTime() - 36 * 30.44 * 86400000);
|
|
const url = `${IEM_SBW}?sts=${encodeURIComponent(cutoff.toISOString())}&ets=${encodeURIComponent(now.toISOString())}&wfo=${WFO}`;
|
|
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`IEM ${res.status}`);
|
|
|
|
const geojson = await res.json();
|
|
const events = (geojson.features || [])
|
|
.map((f, i) => featureToEvent(f, i))
|
|
.filter(Boolean)
|
|
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
|
.slice(0, 150);
|
|
|
|
if (events.length === 0) throw new Error('no local events');
|
|
return events;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const DATE_RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '12m': 365, '18m': 548, '24m': 730, '36m': 1095 };
|
|
const SEVERITY_ORDER = { severe: 4, significant: 3, moderate: 2, trace: 1 };
|
|
|
|
export function useStormEvents({
|
|
dateRange = '12m',
|
|
typeFilter = 'all',
|
|
severityFilter = 'all',
|
|
sortBy = 'date',
|
|
customStart = '',
|
|
customEnd = '',
|
|
} = {}) {
|
|
const [allEvents, setAllEvents] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const load = async () => {
|
|
try {
|
|
let data;
|
|
if (USE_MOCK) {
|
|
await new Promise(r => setTimeout(r, 450));
|
|
data = MOCK_STORM_EVENTS;
|
|
} else {
|
|
try {
|
|
data = await fetchIEMEvents();
|
|
} catch {
|
|
// IEM unreachable or no local events — fall back to mock
|
|
data = MOCK_STORM_EVENTS;
|
|
}
|
|
}
|
|
if (!cancelled) setAllEvents(Array.isArray(data) ? data : []);
|
|
} catch (e) {
|
|
if (!cancelled) setError(e.message);
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
};
|
|
|
|
load();
|
|
return () => { cancelled = true; };
|
|
// Fetch once — full 36mo dataset loaded; all filtering is client-side below
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const events = useMemo(() => {
|
|
let filtered = allEvents.filter(e => {
|
|
const d = new Date(e.date);
|
|
|
|
if (dateRange === 'custom') {
|
|
const start = customStart ? new Date(customStart) : new Date(0);
|
|
const end = customEnd ? new Date(customEnd + 'T23:59:59') : new Date();
|
|
if (d < start || d > end) return false;
|
|
} else {
|
|
const days = DATE_RANGE_DAYS[dateRange] ?? 365;
|
|
if (d.getTime() < Date.now() - days * 86400000) return false;
|
|
}
|
|
|
|
if (typeFilter !== 'all' && e.type !== typeFilter) return false;
|
|
if (severityFilter !== 'all' && e.severity !== severityFilter) return false;
|
|
return true;
|
|
});
|
|
|
|
if (sortBy === 'severity') {
|
|
filtered = filtered.sort((a, b) => (SEVERITY_ORDER[b.severity] ?? 0) - (SEVERITY_ORDER[a.severity] ?? 0));
|
|
} else {
|
|
filtered = filtered.sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
}
|
|
|
|
return filtered;
|
|
}, [allEvents, dateRange, typeFilter, severityFilter, sortBy, customStart, customEnd]);
|
|
|
|
const totalHomes = useMemo(() => events.reduce((s, e) => s + (e.estimatedHomes ?? 0), 0), [events]);
|
|
|
|
return { events, loading, error, totalHomes };
|
|
}
|
|
|
|
// Returns a label + color class for how "hot" a storm zone is (conversion likelihood)
|
|
export function conversionWindow(dateStr) {
|
|
const days = Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000);
|
|
if (days < 7) return { label: 'Too Fresh', color: 'text-sky-500', bg: 'bg-sky-50 dark:bg-sky-500/10', border: 'border-sky-200 dark:border-sky-500/20' };
|
|
if (days <= 60) return { label: 'Hot Zone', color: 'text-red-500', bg: 'bg-red-50 dark:bg-red-500/10', border: 'border-red-200 dark:border-red-500/20' };
|
|
if (days <= 120) return { label: 'Warm', color: 'text-orange-500', bg: 'bg-orange-50 dark:bg-orange-500/10', border: 'border-orange-200 dark:border-orange-500/20' };
|
|
return { label: 'Cold', color: 'text-zinc-400', bg: 'bg-zinc-100 dark:bg-zinc-800', border: 'border-zinc-200 dark:border-white/10' };
|
|
}
|