From 023c76471baafeaea39a003ade7d127b14bfcdbe Mon Sep 17 00:00:00 2001 From: Satyam-Rastogi Date: Tue, 19 May 2026 00:40:17 +0530 Subject: [PATCH] feat(storm-intel): search, pin-drop radius, more event types Search: text filter across areaName/type/severity in sidebar Pin drop (up to 5 pins): - Crosshair button activates drop mode; next map click places a pin - 5-mile radius circle drawn on map for each pin - Sidebar shows only events within 5mi of any pin, sorted by distance - Distance label (e.g. "1.2 mi") shown on each card - Individual pin removal + clear all; + Add pin shortcut in pin strip - Map shows blank + drop-prompt until at least one pin is placed More event types: - Hook now fetches WS/BZ/WW/IS (winter storm, blizzard, ice) from IEM - SV without hailtag maps to wind; SV with hailtag maps to hail - New icons: CloudHail, CloudSnow, CloudRain per type - Type filter row added to filter bar: All/Hail/Tornado/Flood/Wind/Snow/Rain --- src/hooks/useStormEvents.js | 11 +- src/pages/StormIntelPage.jsx | 299 ++++++++++++++++++++++++++++++----- 2 files changed, 271 insertions(+), 39 deletions(-) diff --git a/src/hooks/useStormEvents.js b/src/hooks/useStormEvents.js index 90f953a..ec59afb 100644 --- a/src/hooks/useStormEvents.js +++ b/src/hooks/useStormEvents.js @@ -21,8 +21,8 @@ const WFO = 'FWD'; // Fort Worth NWS office — covers Collin County / Plan // Plano / Collin County bounding box const BBOX = { minLat: 32.85, maxLat: 33.45, minLon: -97.15, maxLon: -96.35 }; -// Only keep storm-relevant phenomena -const KEEP_PH = new Set(['SV', 'TO', 'FF']); +// Storm-relevant NWS phenomena codes +const KEEP_PH = new Set(['SV', 'TO', 'FF', 'WS', 'BZ', 'WW', 'IS']); function extractRing(geom) { if (!geom) return null; @@ -70,7 +70,12 @@ function featureToEvent(feature, idx) { const polygon = rawCoords.map(([lon, lat]) => [lat, lon]); // → Leaflet [lat,lon] - const type = ph === 'TO' ? 'tornado' : ph === 'FF' ? 'flood' : 'hail'; + const type = ph === 'TO' ? 'tornado' + : ph === 'FF' ? 'flood' + : ph === 'WS' || ph === 'BZ' ? 'snow' + : ph === 'WW' || ph === 'IS' ? 'ice' + : hailIn !== null ? 'hail' + : 'wind'; const hailIn = p.hailtag ? parseFloat(p.hailtag) : null; const severity = ph === 'TO' ? 'severe' diff --git a/src/pages/StormIntelPage.jsx b/src/pages/StormIntelPage.jsx index 5e3c63d..01e9e51 100644 --- a/src/pages/StormIntelPage.jsx +++ b/src/pages/StormIntelPage.jsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { MapContainer, TileLayer, Polygon, Polyline, CircleMarker, - Tooltip, useMap, useMapEvents, + Circle, Tooltip, useMap, useMapEvents, } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; @@ -16,7 +16,8 @@ import { Home, AlertTriangle, TrendingUp, Users, Calendar, MapPin, Flame, Snowflake, Clock, ArrowRight, Eye, UserPlus, Loader2, DollarSign, Target, BarChart2, Map, Pencil, Sun, Cloud, - CloudRain, CloudDrizzle, CheckCircle, XCircle, CalendarRange, Pin, + CloudRain, CloudDrizzle, CheckCircle, XCircle, CalendarRange, + Search, Crosshair, CloudSnow, CloudHail, Navigation, } from 'lucide-react'; import { toast } from 'sonner'; import { useTheme } from '../context/ThemeContext'; @@ -40,10 +41,19 @@ const SEVERITY_STYLES = { }; const TYPE_ICONS = { - hail: CloudLightning, - wind: Wind, - tornado: Zap, - flood: Droplets, + hail: CloudHail, + wind: Wind, + tornado: Zap, + flood: Droplets, + rain: CloudRain, + snow: CloudSnow, + ice: CloudSnow, + thunderstorm: CloudLightning, +}; + +const TYPE_LABELS = { + hail: 'Hail', wind: 'Wind', tornado: 'Tornado', flood: 'Flood', + rain: 'Rain', snow: 'Snow', ice: 'Ice Storm', thunderstorm: 'Thunderstorm', }; const FORECAST_ICONS = { @@ -63,6 +73,23 @@ function scoreColor(score) { return '#10B981'; } +function haversine(lat1, lon1, lat2, lon2) { + const R = 3958.8; + const toRad = d => d * Math.PI / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +function polygonCentroid(polygon) { + const n = polygon.length; + return [ + polygon.reduce((s, p) => s + p[0], 0) / n, + polygon.reduce((s, p) => s + p[1], 0) / n, + ]; +} + const MAP_STYLES = ` .storm-intel-map .leaflet-container { background: #e8e0d8; font-family: system-ui, sans-serif; } .storm-tooltip { background: white !important; border: 1px solid rgba(0,0,0,0.08) !important; border-radius: 10px !important; box-shadow: 0 4px 14px rgba(0,0,0,0.12) !important; padding: 8px 10px !important; pointer-events: none !important; } @@ -210,9 +237,17 @@ const ZoneDrawingLayer = ({ draftPolygon, onAddPoint, onFinish }) => { ); }; +const PinDropLayer = ({ onDrop }) => { + useMapEvents({ click: e => onDrop([e.latlng.lat, e.latlng.lng]) }); + return null; +}; + +const PIN_RADIUS_M = 8047; // 5 miles in metres + const StormMap = memo(({ events, selectedId, pinnedIds, onSelect, theme, flyCount, selectedStorm, stormZones, isDrawing, draftPolygon, onAddPoint, onFinishPolygon, + pinDropMode, pins, onDropPin, }) => { return (
@@ -256,7 +291,7 @@ const StormMap = memo(({ dashArray: isSelected ? undefined : isPinned ? '3 3' : '6 4', }} eventHandlers={{ - click: () => !isDrawing && onSelect(storm), + click: () => !isDrawing && !pinDropMode && onSelect(storm), mouseover: (e) => { e.target.setStyle({ fillOpacity: Math.min(sty.fillOpacity + 0.1, 0.4), weight: sty.weight + 1 }); }, mouseout: (e) => { if (storm.id === selectedId) return; @@ -294,6 +329,29 @@ const StormMap = memo(({ {/* Canvasser zones layer */} + {/* Pin drop circles */} + {pins?.map((pin, i) => ( + + + + +

Pin {i + 1} · 5 mi radius

+
+
+
+ ))} + + {/* Pin drop capture layer */} + {pinDropMode && } + {/* Drawing layer (active only when isDrawing) */} {isDrawing && (
)} + {/* Pin drop instruction */} + {pinDropMode && ( +
+
+ + Click map to drop a pin (5 mi radius) +
+
+ )} ); }); @@ -447,7 +514,7 @@ const ForecastStrip = ({ forecast, loading, alertDay, hasStormAlert }) => { // ─── Storm card ─────────────────────────────────────────────────────────────── -const StormCard = ({ storm, isSelected, isPinned, onSelect, onTogglePin }) => { +const StormCard = ({ storm, isSelected, isPinned, onSelect, onTogglePin, distanceMiles }) => { const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace; const conv = conversionWindow(storm.date); const TypeIcon = TYPE_ICONS[storm.type] ?? CloudLightning; @@ -478,6 +545,11 @@ const StormCard = ({ storm, isSelected, isPinned, onSelect, onTogglePin }) => { {storm.areaName}

+ {distanceMiles != null && ( + + {distanceMiles.toFixed(1)} mi + + )} {days === 0 ? 'Today' : `${days}d ago`} @@ -912,11 +984,16 @@ const StormIntelPage = () => { const [typeFilter, setTypeFilter] = useState('all'); const [severityFilter, setSeverityFilter] = useState('all'); const [sortBy, setSortBy] = useState('date'); + const [searchQuery, setSearchQuery] = useState(''); const [selectedStorm, setSelectedStorm] = useState(null); const [pinnedIds, setPinnedIds] = useState(new Set()); const [drawerOpen, setDrawerOpen] = useState(false); const [flyCount, setFlyCount] = useState(0); const [showFilters, setShowFilters] = useState(false); + // Pin drop — up to 5 pins + const [pinDropMode, setPinDropMode] = useState(false); + const [pins, setPins] = useState([]); // array of [lat,lng] + const MAX_PINS = 5; // Zone drawing state const [isDrawing, setIsDrawing] = useState(false); @@ -928,14 +1005,61 @@ const StormIntelPage = () => { const { events: allEvents } = useStormEvents({ dateRange: '36m' }); const { forecast, loading: forecastLoading, alertDay, hasStormAlert } = useStormForecast(); - // Auto-select the most recent event on first load (no drawer, no fly) - const hasAutoSelected = useRef(false); - useEffect(() => { - if (!hasAutoSelected.current && events.length > 0) { - hasAutoSelected.current = true; - setSelectedStorm(events[0]); + // Text search filter + const displayEvents = useMemo(() => { + if (!searchQuery.trim()) return events; + const q = searchQuery.toLowerCase(); + return events.filter(e => + e.areaName?.toLowerCase().includes(q) || + e.type?.toLowerCase().includes(q) || + e.severity?.toLowerCase().includes(q) || + e.description?.toLowerCase().includes(q) + ); + }, [events, searchQuery]); + + // Events within 5 miles of ANY dropped pin + const nearbyEvents = useMemo(() => { + if (!pins.length) return []; + const seen = new Set(); + const results = []; + for (const event of displayEvents) { + if (!event.polygon?.length) continue; + const [cLat, cLon] = polygonCentroid(event.polygon); + const minDist = Math.min(...pins.map(p => haversine(p[0], p[1], cLat, cLon))); + if (minDist <= 5 && !seen.has(event.id)) { + seen.add(event.id); + results.push({ ...event, _distanceMiles: minDist }); + } } - }, [events]); + return results.sort((a, b) => a._distanceMiles - b._distanceMiles); + }, [displayEvents, pins]); + + // Sidebar shows nearby events when pins exist, otherwise full filtered list + const sidebarEvents = pins.length > 0 ? nearbyEvents : displayEvents; + + // Map shows: when pins exist → all nearby; else → selected+pinned only (or none until pins placed) + const mapEvents = useMemo(() => { + if (pins.length > 0) return nearbyEvents; + if (!selectedStorm && pinnedIds.size === 0) return []; // nothing until pin placed + return displayEvents.filter(e => pinnedIds.has(e.id) || e.id === selectedStorm?.id); + }, [displayEvents, nearbyEvents, pins, selectedStorm, pinnedIds]); + + const handleDropPin = useCallback((latlng) => { + setPins(prev => { + if (prev.length >= MAX_PINS) return prev; // cap at 5 + return [...prev, latlng]; + }); + setPinDropMode(false); + }, []); + + const handleRemovePin = useCallback((idx) => { + setPins(prev => prev.filter((_, i) => i !== idx)); + }, []); + + const handleClearPins = useCallback(() => { + setPins([]); + setPinDropMode(false); + }, []); const handleTogglePin = useCallback((id) => { setPinnedIds(prev => { @@ -1158,6 +1282,13 @@ const StormIntelPage = () => {
)}
+
+ Type + {[['all','All'],['hail','Hail'],['tornado','Tornado'],['flood','Flood'],['wind','Wind'],['snow','Snow'],['rain','Rain']].map(([val, label]) => ( + setTypeFilter(val)}>{label} + ))} +
+
Severity {[['all', 'All'], ['severe', 'Severe'], ['significant', 'Significant'], ['moderate', 'Moderate'], ['trace', 'Trace']].map(([val, label]) => ( @@ -1209,10 +1340,29 @@ const StormIntelPage = () => {

Failed to load storm data

{error}

- ) : events.length === 0 ? ( + ) : pins.length === 0 ? ( + /* Show base map with pin prompt overlay */ + + ) : mapEvents.length === 0 ? (
-

No storms match your filters

+

No storms within 5 miles of your pins

) : ( { draftPolygon={draftPolygon} onAddPoint={handleAddPoint} onFinishPolygon={handleFinishPolygon} + pinDropMode={pinDropMode} + pins={pins} + onDropPin={handleDropPin} /> )}
{/* Storm list */}
-
-

- {events.length} Event{events.length !== 1 ? 's' : ''} - {pinnedIds.size > 0 && ( - · {pinnedIds.size} pinned - )} + {/* Sidebar header */} +

+

+ {pins.length > 0 + ? <>{nearbyEvents.length} Nearby{pinnedIds.size > 0 && · {pinnedIds.size} pinned} + : <>{displayEvents.length} Event{displayEvents.length !== 1 ? 's' : ''}{pinnedIds.size > 0 && · {pinnedIds.size} pinned} + }

-
- {pinnedIds.size > 0 && ( +
+ {/* Pin drop toggle */} + + {pins.length > 0 && ( + )} + {pinnedIds.size > 0 && ( + )} {selectedStorm && ( - )}
+ {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search events..." + className="w-full pl-7 pr-7 py-1.5 text-[12px] rounded-lg border border-zinc-200 dark:border-white/[0.07] bg-zinc-50 dark:bg-zinc-800/60 text-zinc-800 dark:text-zinc-200 placeholder-zinc-400 outline-none focus:ring-2 focus:ring-amber-500/30 focus:border-amber-400 transition-all" + /> + {searchQuery && ( + + )} +
+
+ + {/* Active pins strip */} + {pins.length > 0 && ( +
+ {pins.map((pin, i) => ( +
+
+ Pin {i+1} + +
+ ))} + {pins.length < MAX_PINS && ( + + )} +
+ )} + {/* Zone summary strip */} {stormZones.length > 0 && (
@@ -1297,13 +1508,28 @@ const StormIntelPage = () => { Array.from({ length: 4 }).map((_, i) => (
)) - ) : events.length === 0 ? ( + ) : pins.length === 0 ? ( + /* No pins yet — prompt user to drop one */ +
+
+ +
+

Drop a pin to explore

+

Click the crosshair button and place a pin on the map to find storm events within 5 miles.

+ +
+ ) : sidebarEvents.length === 0 ? (
-

No storms in this range

+

No events within 5 miles of your pins

) : ( - events.map(storm => ( + sidebarEvents.map(storm => ( { isPinned={pinnedIds.has(storm.id)} onSelect={handleSelect} onTogglePin={handleTogglePin} + distanceMiles={storm._distanceMiles} /> )) )}