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 && (
+ setSearchQuery('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600">
+
+
+ )}
+
+
+
+ {/* Active pins strip */}
+ {pins.length > 0 && (
+
+ {pins.map((pin, i) => (
+
+
+
Pin {i+1}
+
handleRemovePin(i)} className="text-violet-400 hover:text-violet-600 ml-0.5">
+
+
+
+ ))}
+ {pins.length < MAX_PINS && (
+
setPinDropMode(true)}
+ className="flex items-center gap-1 px-2 py-0.5 rounded-full border border-dashed border-violet-300 dark:border-violet-500/40 text-[10px] font-bold text-violet-500 hover:bg-violet-100 dark:hover:bg-violet-500/15 transition-colors"
+ >
+ + Add pin
+
+ )}
+
+ )}
+
{/* 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.
+
setPinDropMode(true)}
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-violet-600 text-white text-[12px] font-bold hover:bg-violet-700 transition-colors"
+ >
+ Drop a Pin
+
+
+ ) : 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}
/>
))
)}