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
This commit is contained in:
Satyam-Rastogi
2026-05-19 00:40:17 +05:30
parent 723a0fb3a8
commit 023c76471b
2 changed files with 271 additions and 39 deletions
+8 -3
View File
@@ -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'
+263 -36
View File
@@ -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 (
<div className="relative w-full h-full storm-intel-map">
@@ -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 */}
<ZoneLayer zones={stormZones} />
{/* Pin drop circles */}
{pins?.map((pin, i) => (
<React.Fragment key={i}>
<Circle
center={pin}
radius={PIN_RADIUS_M}
pathOptions={{ color: '#8B5CF6', fillColor: '#8B5CF6', fillOpacity: 0.06, weight: 1.5, dashArray: '5 5' }}
/>
<CircleMarker
center={pin}
radius={7}
pathOptions={{ color: '#fff', fillColor: '#8B5CF6', fillOpacity: 1, weight: 2 }}
>
<Tooltip direction="top" opacity={1} className="storm-tooltip" permanent={false}>
<p style={{ fontSize: 11, fontWeight: 700, color: '#18181b' }}>Pin {i + 1} · 5 mi radius</p>
</Tooltip>
</CircleMarker>
</React.Fragment>
))}
{/* Pin drop capture layer */}
{pinDropMode && <PinDropLayer onDrop={onDropPin} />}
{/* Drawing layer (active only when isDrawing) */}
{isDrawing && (
<ZoneDrawingLayer
@@ -339,6 +397,15 @@ const StormMap = memo(({
</div>
</div>
)}
{/* Pin drop instruction */}
{pinDropMode && (
<div className="absolute top-3 right-3 z-[1000]">
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-violet-600 text-white shadow-lg text-[11px] font-bold">
<Crosshair size={12} />
Click map to drop a pin (5 mi radius)
</div>
</div>
)}
</div>
);
});
@@ -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}
</p>
<div className="flex items-center gap-1 shrink-0">
{distanceMiles != null && (
<span className="text-[10px] font-bold text-violet-500 dark:text-violet-400">
{distanceMiles.toFixed(1)} mi
</span>
)}
<span className="text-[10px] text-zinc-400 dark:text-zinc-500">
{days === 0 ? 'Today' : `${days}d ago`}
</span>
@@ -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 = () => {
</div>
)}
<div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" />
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Type</span>
{[['all','All'],['hail','Hail'],['tornado','Tornado'],['flood','Flood'],['wind','Wind'],['snow','Snow'],['rain','Rain']].map(([val, label]) => (
<FilterChip key={val} active={typeFilter === val} onClick={() => setTypeFilter(val)}>{label}</FilterChip>
))}
</div>
<div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" />
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Severity</span>
{[['all', 'All'], ['severe', 'Severe'], ['significant', 'Significant'], ['moderate', 'Moderate'], ['trace', 'Trace']].map(([val, label]) => (
@@ -1209,10 +1340,29 @@ const StormIntelPage = () => {
<p className="text-[13px] text-zinc-500 font-medium">Failed to load storm data</p>
<p className="text-[11px] text-zinc-400">{error}</p>
</div>
) : events.length === 0 ? (
) : pins.length === 0 ? (
/* Show base map with pin prompt overlay */
<StormMap
events={[]}
selectedId={null}
pinnedIds={pinnedIds}
selectedStorm={null}
onSelect={handleSelect}
theme={theme}
flyCount={flyCount}
stormZones={stormZones}
isDrawing={false}
draftPolygon={[]}
onAddPoint={handleAddPoint}
onFinishPolygon={handleFinishPolygon}
pinDropMode={pinDropMode}
pins={pins}
onDropPin={handleDropPin}
/>
) : mapEvents.length === 0 ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-100 dark:bg-zinc-900 gap-2">
<CloudLightning size={28} className="text-zinc-300" />
<p className="text-[13px] text-zinc-400 font-medium">No storms match your filters</p>
<p className="text-[13px] text-zinc-400 font-medium">No storms within 5 miles of your pins</p>
</div>
) : (
<StormMap
@@ -1228,39 +1378,100 @@ const StormIntelPage = () => {
draftPolygon={draftPolygon}
onAddPoint={handleAddPoint}
onFinishPolygon={handleFinishPolygon}
pinDropMode={pinDropMode}
pins={pins}
onDropPin={handleDropPin}
/>
)}
</div>
{/* Storm list */}
<div className="lg:w-[23rem] flex flex-col border-t lg:border-t-0 lg:border-l border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/40 min-h-0">
<div className="px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06] shrink-0 flex items-center justify-between">
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
{events.length} Event{events.length !== 1 ? 's' : ''}
{pinnedIds.size > 0 && (
<span className="ml-1.5 text-amber-500 font-bold">· {pinnedIds.size} pinned</span>
)}
{/* Sidebar header */}
<div className="px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06] shrink-0 flex items-center justify-between gap-2">
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-500 dark:text-zinc-400 min-w-0 truncate">
{pins.length > 0
? <>{nearbyEvents.length} Nearby{pinnedIds.size > 0 && <span className="ml-1 text-amber-500">· {pinnedIds.size} pinned</span>}</>
: <>{displayEvents.length} Event{displayEvents.length !== 1 ? 's' : ''}{pinnedIds.size > 0 && <span className="ml-1 text-amber-500">· {pinnedIds.size} pinned</span>}</>
}
</p>
<div className="flex items-center gap-2">
{pinnedIds.size > 0 && (
<div className="flex items-center gap-1.5 shrink-0">
{/* Pin drop toggle */}
<button
onClick={() => setPinDropMode(m => !m)}
disabled={pins.length >= MAX_PINS}
title={pins.length >= MAX_PINS ? 'Max 5 pins' : pinDropMode ? 'Cancel pin drop' : 'Drop a pin (5 mi radius)'}
className={`p-1.5 rounded-lg border transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
pinDropMode
? 'bg-violet-600 border-violet-700 text-white'
: 'border-zinc-200 dark:border-white/10 text-zinc-400 hover:text-violet-500 hover:border-violet-300 dark:hover:border-violet-500/40'
}`}
>
<Crosshair size={14} />
</button>
{pins.length > 0 && (
<button
onClick={() => setPinnedIds(new Set())}
className="text-[11px] text-amber-500 hover:text-amber-600 dark:hover:text-amber-400 flex items-center gap-1 transition-colors"
onClick={handleClearPins}
className="text-[11px] text-violet-500 hover:text-violet-600 flex items-center gap-0.5 font-bold transition-colors"
>
<MapPin size={11} /> Clear pins
<X size={11} /> Pins
</button>
)}
{pinnedIds.size > 0 && (
<button onClick={() => setPinnedIds(new Set())} className="text-[11px] text-amber-500 hover:text-amber-600 flex items-center gap-0.5 font-bold transition-colors">
<MapPin size={11} /> Clear
</button>
)}
{selectedStorm && (
<button
onClick={() => { setSelectedStorm(null); setDrawerOpen(false); }}
className="text-[11px] text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 flex items-center gap-1 transition-colors"
>
<X size={12} /> Clear
<button onClick={() => { setSelectedStorm(null); setDrawerOpen(false); }} className="text-[11px] text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 flex items-center gap-0.5 transition-colors">
<X size={12} />
</button>
)}
</div>
</div>
{/* Search */}
<div className="px-3 pt-2 pb-1.5 border-b border-zinc-100 dark:border-white/[0.06] shrink-0">
<div className="relative">
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={e => 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 && (
<button onClick={() => setSearchQuery('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600">
<X size={12} />
</button>
)}
</div>
</div>
{/* Active pins strip */}
{pins.length > 0 && (
<div className="px-3 py-2 border-b border-violet-100 dark:border-violet-500/15 bg-violet-50/60 dark:bg-violet-500/5 shrink-0 flex flex-wrap gap-1.5">
{pins.map((pin, i) => (
<div key={i} className="flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-full bg-violet-100 dark:bg-violet-500/15 border border-violet-200 dark:border-violet-500/25">
<div className="w-2 h-2 rounded-full bg-violet-500 shrink-0" />
<span className="text-[10px] font-bold text-violet-700 dark:text-violet-300">Pin {i+1}</span>
<button onClick={() => handleRemovePin(i)} className="text-violet-400 hover:text-violet-600 ml-0.5">
<X size={10} />
</button>
</div>
))}
{pins.length < MAX_PINS && (
<button
onClick={() => 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
</button>
)}
</div>
)}
{/* Zone summary strip */}
{stormZones.length > 0 && (
<div className="px-4 py-2 border-b border-zinc-100 dark:border-white/[0.06] shrink-0 bg-zinc-50/50 dark:bg-white/[0.02]">
@@ -1297,13 +1508,28 @@ const StormIntelPage = () => {
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 rounded-xl bg-zinc-100 dark:bg-zinc-800/40 animate-pulse" />
))
) : events.length === 0 ? (
) : pins.length === 0 ? (
/* No pins yet — prompt user to drop one */
<div className="flex flex-col items-center justify-center h-40 text-center px-4">
<div className="w-12 h-12 rounded-2xl bg-violet-50 dark:bg-violet-500/10 border border-violet-200 dark:border-violet-500/20 flex items-center justify-center mb-3">
<Crosshair size={22} className="text-violet-500" />
</div>
<p className="text-[13px] font-bold text-zinc-700 dark:text-zinc-200 mb-1">Drop a pin to explore</p>
<p className="text-[11px] text-zinc-400 mb-3 leading-relaxed">Click the crosshair button and place a pin on the map to find storm events within 5 miles.</p>
<button
onClick={() => 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"
>
<Crosshair size={12} /> Drop a Pin
</button>
</div>
) : sidebarEvents.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-center">
<CloudLightning size={22} className="text-zinc-300 mb-2" />
<p className="text-[12px] text-zinc-400">No storms in this range</p>
<p className="text-[12px] text-zinc-400">No events within 5 miles of your pins</p>
</div>
) : (
events.map(storm => (
sidebarEvents.map(storm => (
<StormCard
key={storm.id}
storm={storm}
@@ -1311,6 +1537,7 @@ const StormIntelPage = () => {
isPinned={pinnedIds.has(storm.id)}
onSelect={handleSelect}
onTogglePin={handleTogglePin}
distanceMiles={storm._distanceMiles}
/>
))
)}