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:
@@ -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
@@ -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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user