From 4c32266f3d7e0eaf9e3de1a7f999ca3582f06e57 Mon Sep 17 00:00:00 2001 From: Satyam-Rastogi Date: Tue, 19 May 2026 02:27:47 +0530 Subject: [PATCH] =?UTF-8?q?feat(storm-intel):=20Tier=201/2/3=20data=20enri?= =?UTF-8?q?chment=20=E2=80=94=20SBW=20tags,=20LSR=20spotter=20reports,=20H?= =?UTF-8?q?ail=20Recon=20pin=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useStormEvents.js | 11 ++ src/hooks/useStormReports.js | 107 ++++++++++++++++ src/pages/StormIntelPage.jsx | 239 ++++++++++++++++++++++++++++++++++- 3 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useStormReports.js diff --git a/src/hooks/useStormEvents.js b/src/hooks/useStormEvents.js index 9abc097..42ce19d 100644 --- a/src/hooks/useStormEvents.js +++ b/src/hooks/useStormEvents.js @@ -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, }; } diff --git a/src/hooks/useStormReports.js b/src/hooks/useStormReports.js new file mode 100644 index 0000000..7d23750 --- /dev/null +++ b/src/hooks/useStormReports.js @@ -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 }; +} diff --git a/src/pages/StormIntelPage.jsx b/src/pages/StormIntelPage.jsx index 3293220..d1a1b6f 100644 --- a/src/pages/StormIntelPage.jsx +++ b/src/pages/StormIntelPage.jsx @@ -23,6 +23,7 @@ import { toast } from 'sonner'; import { useTheme } from '../context/ThemeContext'; import { useMockStore } from '../data/mockStore'; import { useStormEvents, conversionWindow } from '../hooks/useStormEvents'; +import { useStormReports } from '../hooks/useStormReports'; import { useStormAttribution } from '../hooks/useStormAttribution'; import { useStormForecast } from '../hooks/useStormForecast'; import ZoneAssignmentModal from '../components/storm/ZoneAssignmentModal'; @@ -97,6 +98,14 @@ const MAP_STYLES = ` .draw-cursor .leaflet-container { cursor: crosshair !important; } `; +const LSR_COLORS = { + H: '#F97316', '4': '#F97316', // hail - orange + G: '#06B6D4', W: '#06B6D4', // wind - cyan + T: '#DC2626', // tornado - red + F: '#3B82F6', // flood - blue + D: '#8B5CF6', // damage - purple +}; + // ─── Map sub-components ─────────────────────────────────────────────────────── const MapFlyTo = ({ storm, flyCount }) => { @@ -271,10 +280,40 @@ const FitToPins = ({ pins, pinRadius }) => { return null; }; +const SpotterReportsLayer = ({ reports }) => { + if (!reports?.length) return null; + return reports.map(r => { + const color = LSR_COLORS[r.type] ?? '#9CA3AF'; + const unitLabel = (r.type === 'H' || r.type === '4') ? '"' : ' mph'; + const tooltipRemark = r.remark ? r.remark.slice(0, 80) + (r.remark.length > 80 ? '…' : '') : ''; + return ( + + +
+

+ {r.typetext} + {r.magnitude != null ? ` · ${r.magnitude}${unitLabel}` : ''} +

+ {r.city &&

{r.city}

} + {tooltipRemark &&

{tooltipRemark}

} + {r.source &&

{r.source}

} +
+
+
+ ); + }); +}; + const StormMap = memo(({ events, selectedId, pinnedIds, onSelect, theme, flyCount, selectedStorm, stormZones, isDrawing, draftPolygon, onAddPoint, onFinishPolygon, pinDropMode, pins, onDropPin, pinRadius = 5, + reports, showSpotterReports, }) => { return (
@@ -387,6 +426,9 @@ const StormMap = memo(({ ))} + {/* Spotter reports layer */} + {showSpotterReports && } + {/* Pin drop capture layer */} {pinDropMode && } @@ -639,7 +681,7 @@ const StormCard = ({ storm, isSelected, isPinned, onSelect, onTogglePin, distanc // ─── Detail drawer ──────────────────────────────────────────────────────────── -const StormDetailDrawer = ({ storm, open, onClose, onFlyTo, onAssignZone, canManageZones }) => { +const StormDetailDrawer = ({ storm, open, onClose, onFlyTo, onAssignZone, canManageZones, eventReports = [] }) => { const navigate = useNavigate(); const { theme } = useTheme(); @@ -737,6 +779,35 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo, onAssignZone, canMan
+ {/* Warning Classification (Tier 1) */} + {(storm.damageTag || storm.tornadoTag || storm.isEmergency || storm.significance === 'A' || storm.significance === 'Y') && ( +
+

Warning Classification

+
+ {storm.damageTag && ( + + {storm.damageTag} + + )} + {storm.tornadoTag && ( + + {storm.tornadoTag} + + )} + {storm.isEmergency && ( + + EMERGENCY + + )} + {(storm.significance === 'A' || storm.significance === 'Y') && ( + + Advisory only + + )} +
+
+ )} + {/* Weather metrics */}

Weather Metrics

@@ -847,6 +918,50 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo, onAssignZone, canMan

+ {/* Ground Reports — Tier 2 */} + {eventReports.length > 0 && (() => { + const hailReports = eventReports.filter(r => r.type === 'H' || r.type === '4'); + const windReports = eventReports.filter(r => r.type === 'G' || r.type === 'W'); + const maxHail = hailReports.length ? Math.max(...hailReports.map(r => r.magnitude ?? 0)) : null; + const maxWind = windReports.length ? Math.max(...windReports.map(r => r.magnitude ?? 0)) : null; + const summaryParts = []; + if (maxHail && maxHail > 0) summaryParts.push(`max hail ${maxHail}"`); + if (maxWind && maxWind > 0) summaryParts.push(`max wind ${maxWind} mph`); + return ( +
+

Ground Reports (Spotter Verified)

+
+
+

+ {eventReports.length} report{eventReports.length !== 1 ? 's' : ''} + {summaryParts.length > 0 ? ` · ${summaryParts.join(' · ')}` : ''} +

+
+
+ {eventReports.slice(0, 4).map(r => { + const color = LSR_COLORS[r.type] ?? '#9CA3AF'; + const unit = (r.type === 'H' || r.type === '4') ? '"' : ' mph'; + const remark = r.remark ? r.remark.slice(0, 60) + (r.remark.length > 60 ? '…' : '') : ''; + return ( +
+
+
+

+ {r.typetext} + {r.magnitude != null ? ` ${r.magnitude}${unit}` : ''} + {r.city ? ` · ${r.city}` : ''} +

+ {remark &&

{remark}

} +
+
+ ); + })} +
+
+
+ ); + })()} + ); +// ─── Hail Recon response parser ────────────────────────────────────────────── + +function parseHailReconData(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 }; +} + // ─── Main page ──────────────────────────────────────────────────────────────── const StormIntelPage = () => { @@ -1126,9 +1265,13 @@ const StormIntelPage = () => { const [drawingLinkedStormId, setDrawingLinkedStormId] = useState(null); const [showZoneModal, setShowZoneModal] = useState(false); + const [showSpotterReports, setShowSpotterReports] = useState(false); + const [pinHailData, setPinHailData] = useState({}); // { idx: { loading, data, error } } + const { events, loading, error, totalHomes } = useStormEvents({ dateRange, typeFilter, severityFilter, sortBy, customStart, customEnd }); const { events: allEvents } = useStormEvents({ dateRange: '36m' }); const { forecast, loading: forecastLoading, alertDay, hasStormAlert } = useStormForecast(); + const { reports } = useStormReports(); // Text search filter const displayEvents = useMemo(() => { @@ -1172,6 +1315,39 @@ const StormIntelPage = () => { return mapPool.filter(e => pinnedIds.has(e.id) || e.id === selectedStorm?.id); }, [mapPool, showAllOnMap, selectedStorm, pinnedIds]); + // LSR reports near the selected storm (±6 hours + 50 miles of centroid) + const eventReports = useMemo(() => { + if (!selectedStorm || !reports.length) return []; + const centroid = selectedStorm.polygon?.length + ? polygonCentroid(selectedStorm.polygon) + : null; + if (!centroid) return []; + const stormTime = new Date(selectedStorm.date).getTime(); + const SIX_HOURS = 6 * 3600 * 1000; + return reports.filter(r => { + if (!r.valid) return false; + const rTime = new Date(r.valid).getTime(); + if (Math.abs(rTime - stormTime) > SIX_HOURS) return false; + const dist = haversine(centroid[0], centroid[1], r.lat, r.lon); + return dist <= 50; + }); + }, [reports, selectedStorm]); + + // Tier 3: fetch Hail Recon data 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`) + .then(r => r.json()) + .then(data => setPinHailData(prev => ({ ...prev, [idx]: { loading: false, data, error: null } }))) + .catch(err => setPinHailData(prev => ({ ...prev, [idx]: { loading: false, data: null, error: err.message } }))); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pins]); + const handleDropPin = useCallback((latlng) => { setPins(prev => { if (prev.length >= MAX_PINS) return prev; // cap at 5 @@ -1481,6 +1657,8 @@ const StormIntelPage = () => { pins={pins} onDropPin={handleDropPin} pinRadius={pinRadius} + reports={reports} + showSpotterReports={showSpotterReports} /> )}
@@ -1496,6 +1674,18 @@ const StormIntelPage = () => { }

+ {/* Spotter reports toggle */} + {/* Pin drop toggle */}
+ {/* Per-pin hail history — Tier 3 */} + {pins.length > 0 && ( +
+

Hail History (5yr)

+ {pins.map((_, idx) => { + const entry = pinHailData[idx]; + let line; + if (!entry || entry.loading) { + line = ( + + + Loading... + + ); + } else if (entry.error) { + line = Error: {entry.error}; + } else { + const parsed = parseHailReconData(entry.data); + if (!parsed) { + line = No data; + } else if (parsed.unknown) { + line = Unknown response format; + } else if (parsed.count === 0) { + line = No hail events found; + } else { + const lastStr = parsed.lastDate + ? parsed.lastDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + : null; + line = ( + + {parsed.count} event{parsed.count !== 1 ? 's' : ''} + {parsed.maxSize ? ` · max ${parsed.maxSize}"` : ''} + {lastStr ? ` · last ${lastStr}` : ''} + + ); + } + } + return ( +
+ Pin {idx + 1}: + {line} +
+ ); + })} +
+ )} )} @@ -1690,6 +1926,7 @@ const StormIntelPage = () => { onFlyTo={handleFlyTo} onAssignZone={handleStartDrawing} canManageZones={canManageZones} + eventReports={eventReports} /> {/* Zone assignment modal */}