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} {tooltipRemark} {r.source}
Warning Classification
+Weather Metrics
@@ -847,6 +918,50 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo, onAssignZone, canManGround Reports (Spotter Verified)
++ {eventReports.length} report{eventReports.length !== 1 ? 's' : ''} + {summaryParts.length > 0 ? ` · ${summaryParts.join(' · ')}` : ''} +
++ {r.typetext} + {r.magnitude != null ? ` ${r.magnitude}${unit}` : ''} + {r.city ? ` · ${r.city}` : ''} +
+ {remark &&{remark}
} +Hail History (5yr)
+ {pins.map((_, idx) => { + const entry = pinHailData[idx]; + let line; + if (!entry || entry.loading) { + line = ( + +