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