feat(storm-intel): Tier 1/2/3 data enrichment — SBW tags, LSR spotter reports, Hail Recon pin history
This commit is contained in:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user