feat(storm-intel): Tier 1/2/3 data enrichment — SBW tags, LSR spotter reports, Hail Recon pin history

This commit is contained in:
Satyam-Rastogi
2026-05-19 02:27:47 +05:30
parent dc994be329
commit 4c32266f3d
3 changed files with 356 additions and 1 deletions
+11
View File
@@ -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,
};
}
+107
View File
@@ -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 };
}
+238 -1
View File
@@ -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 (
<CircleMarker
key={r.id}
center={[r.lat, r.lon]}
radius={4}
pathOptions={{ color, fillColor: color, fillOpacity: 0.85, weight: 1.5 }}
>
<Tooltip direction="top" opacity={1} className="storm-tooltip" sticky={false}>
<div style={{ fontFamily: 'system-ui,sans-serif', minWidth: 140, maxWidth: 200 }}>
<p style={{ fontSize: 11, fontWeight: 800, color, marginBottom: 2 }}>
{r.typetext}
{r.magnitude != null ? ` · ${r.magnitude}${unitLabel}` : ''}
</p>
{r.city && <p style={{ fontSize: 10, color: '#71717a', marginBottom: 1 }}>{r.city}</p>}
{tooltipRemark && <p style={{ fontSize: 10, color: '#a1a1aa', lineHeight: 1.4 }}>{tooltipRemark}</p>}
{r.source && <p style={{ fontSize: 9, color: '#d4d4d8', marginTop: 2 }}>{r.source}</p>}
</div>
</Tooltip>
</CircleMarker>
);
});
};
const StormMap = memo(({
events, selectedId, pinnedIds, onSelect, theme, flyCount, selectedStorm,
stormZones, isDrawing, draftPolygon, onAddPoint, onFinishPolygon,
pinDropMode, pins, onDropPin, pinRadius = 5,
reports, showSpotterReports,
}) => {
return (
<div className="relative w-full h-full storm-intel-map">
@@ -387,6 +426,9 @@ const StormMap = memo(({
</React.Fragment>
))}
{/* Spotter reports layer */}
{showSpotterReports && <SpotterReportsLayer reports={reports} />}
{/* Pin drop capture layer */}
{pinDropMode && <PinDropLayer onDrop={onDropPin} />}
@@ -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
</div>
</div>
{/* Warning Classification (Tier 1) */}
{(storm.damageTag || storm.tornadoTag || storm.isEmergency || storm.significance === 'A' || storm.significance === 'Y') && (
<div>
<p className="text-[10px] font-black uppercase tracking-wider text-zinc-400 mb-2">Warning Classification</p>
<div className="flex flex-wrap gap-1.5">
{storm.damageTag && (
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 text-red-700 dark:text-red-400">
{storm.damageTag}
</span>
)}
{storm.tornadoTag && (
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-purple-50 dark:bg-purple-500/10 border border-purple-200 dark:border-purple-500/20 text-purple-700 dark:text-purple-300">
{storm.tornadoTag}
</span>
)}
{storm.isEmergency && (
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-red-600 border border-red-700 text-white">
EMERGENCY
</span>
)}
{(storm.significance === 'A' || storm.significance === 'Y') && (
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300">
Advisory only
</span>
)}
</div>
</div>
)}
{/* Weather metrics */}
<div>
<p className="text-[10px] font-black uppercase tracking-wider text-zinc-400 mb-2">Weather Metrics</p>
@@ -847,6 +918,50 @@ const StormDetailDrawer = ({ storm, open, onClose, onFlyTo, onAssignZone, canMan
</p>
</div>
{/* 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 (
<div>
<p className="text-[10px] font-black uppercase tracking-wider text-zinc-400 mb-2">Ground Reports (Spotter Verified)</p>
<div className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-zinc-50 dark:bg-zinc-800/40 overflow-hidden">
<div className="px-3 py-2 border-b border-zinc-100 dark:border-white/[0.05]">
<p className="text-[11px] font-semibold text-zinc-600 dark:text-zinc-300">
{eventReports.length} report{eventReports.length !== 1 ? 's' : ''}
{summaryParts.length > 0 ? ` · ${summaryParts.join(' · ')}` : ''}
</p>
</div>
<div className="divide-y divide-zinc-100 dark:divide-white/[0.05]">
{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 (
<div key={r.id} className="flex items-start gap-2 px-3 py-2">
<div className="w-2 h-2 rounded-full shrink-0 mt-1" style={{ backgroundColor: color }} />
<div className="min-w-0">
<p className="text-[11px] font-bold text-zinc-700 dark:text-zinc-200">
{r.typetext}
{r.magnitude != null ? ` ${r.magnitude}${unit}` : ''}
{r.city ? ` · ${r.city}` : ''}
</p>
{remark && <p className="text-[10px] text-zinc-400 leading-snug">{remark}</p>}
</div>
</div>
);
})}
</div>
</div>
</div>
);
})()}
<button
onClick={onFlyTo}
className="w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl border border-zinc-200 dark:border-white/[0.07] hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors group"
@@ -1085,6 +1200,30 @@ const FilterChip = ({ active, onClick, children }) => (
</button>
);
// ─── 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}
/>
)}
</div>
@@ -1496,6 +1674,18 @@ const StormIntelPage = () => {
}
</p>
<div className="flex items-center gap-1.5 shrink-0">
{/* Spotter reports toggle */}
<button
onClick={() => setShowSpotterReports(v => !v)}
title={showSpotterReports ? 'Hide spotter reports' : 'Show spotter reports'}
className={`p-1.5 rounded-lg border transition-colors ${
showSpotterReports
? 'bg-teal-600 border-teal-700 text-white'
: 'border-zinc-200 dark:border-white/10 text-zinc-400 hover:text-teal-500 hover:border-teal-300 dark:hover:border-teal-500/40'
}`}
>
<Navigation size={14} />
</button>
{/* Pin drop toggle */}
<button
onClick={() => setPinDropMode(m => !m)}
@@ -1606,6 +1796,52 @@ const StormIntelPage = () => {
<span className="text-[9px] text-violet-400 italic">press Go</span>
)}
</div>
{/* Per-pin hail history — Tier 3 */}
{pins.length > 0 && (
<div className="pt-0.5 space-y-0.5">
<p className="text-[9px] font-black uppercase tracking-wider text-violet-400 mb-0.5">Hail History (5yr)</p>
{pins.map((_, idx) => {
const entry = pinHailData[idx];
let line;
if (!entry || entry.loading) {
line = (
<span className="flex items-center gap-1 text-[10px] text-violet-400">
<Loader2 size={10} className="animate-spin" />
Loading...
</span>
);
} else if (entry.error) {
line = <span className="text-[10px] text-red-400">Error: {entry.error}</span>;
} else {
const parsed = parseHailReconData(entry.data);
if (!parsed) {
line = <span className="text-[10px] text-violet-300">No data</span>;
} else if (parsed.unknown) {
line = <span className="text-[10px] text-violet-300">Unknown response format</span>;
} else if (parsed.count === 0) {
line = <span className="text-[10px] text-violet-300">No hail events found</span>;
} else {
const lastStr = parsed.lastDate
? parsed.lastDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: null;
line = (
<span className="text-[10px] text-violet-200 font-semibold">
{parsed.count} event{parsed.count !== 1 ? 's' : ''}
{parsed.maxSize ? ` · max ${parsed.maxSize}"` : ''}
{lastStr ? ` · last ${lastStr}` : ''}
</span>
);
}
}
return (
<div key={idx} className="flex items-center gap-1.5">
<span className="text-[10px] font-bold text-violet-400 shrink-0">Pin {idx + 1}:</span>
{line}
</div>
);
})}
</div>
)}
</div>
)}
@@ -1690,6 +1926,7 @@ const StormIntelPage = () => {
onFlyTo={handleFlyTo}
onAssignZone={handleStartDrawing}
canManageZones={canManageZones}
eventReports={eventReports}
/>
{/* Zone assignment modal */}