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