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
|
// Plano / Collin County bounding box
|
||||||
const BBOX = { minLat: 32.85, maxLat: 33.45, minLon: -97.15, maxLon: -96.35 };
|
const BBOX = { minLat: 32.85, maxLat: 33.45, minLon: -97.15, maxLon: -96.35 };
|
||||||
|
|
||||||
// Only keep storm-relevant phenomena
|
// Storm-relevant NWS phenomena codes
|
||||||
const KEEP_PH = new Set(['SV', 'TO', 'FF']);
|
const KEEP_PH = new Set(['SV', 'TO', 'FF', 'WS', 'BZ', 'WW', 'IS']);
|
||||||
|
|
||||||
function extractRing(geom) {
|
function extractRing(geom) {
|
||||||
if (!geom) return null;
|
if (!geom) return null;
|
||||||
@@ -70,7 +70,12 @@ function featureToEvent(feature, idx) {
|
|||||||
|
|
||||||
const polygon = rawCoords.map(([lon, lat]) => [lat, lon]); // → Leaflet [lat,lon]
|
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 hailIn = p.hailtag ? parseFloat(p.hailtag) : null;
|
||||||
const severity = ph === 'TO' ? 'severe'
|
const severity = ph === 'TO' ? 'severe'
|
||||||
|
|||||||
+260
-33
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
MapContainer, TileLayer, Polygon, Polyline, CircleMarker,
|
MapContainer, TileLayer, Polygon, Polyline, CircleMarker,
|
||||||
Tooltip, useMap, useMapEvents,
|
Circle, Tooltip, useMap, useMapEvents,
|
||||||
} from 'react-leaflet';
|
} from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
Home, AlertTriangle, TrendingUp, Users, Calendar, MapPin,
|
Home, AlertTriangle, TrendingUp, Users, Calendar, MapPin,
|
||||||
Flame, Snowflake, Clock, ArrowRight, Eye, UserPlus, Loader2,
|
Flame, Snowflake, Clock, ArrowRight, Eye, UserPlus, Loader2,
|
||||||
DollarSign, Target, BarChart2, Map, Pencil, Sun, Cloud,
|
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';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
import { useTheme } from '../context/ThemeContext';
|
||||||
@@ -40,10 +41,19 @@ const SEVERITY_STYLES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_ICONS = {
|
const TYPE_ICONS = {
|
||||||
hail: CloudLightning,
|
hail: CloudHail,
|
||||||
wind: Wind,
|
wind: Wind,
|
||||||
tornado: Zap,
|
tornado: Zap,
|
||||||
flood: Droplets,
|
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 = {
|
const FORECAST_ICONS = {
|
||||||
@@ -63,6 +73,23 @@ function scoreColor(score) {
|
|||||||
return '#10B981';
|
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 = `
|
const MAP_STYLES = `
|
||||||
.storm-intel-map .leaflet-container { background: #e8e0d8; font-family: system-ui, sans-serif; }
|
.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; }
|
.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(({
|
const StormMap = memo(({
|
||||||
events, selectedId, pinnedIds, onSelect, theme, flyCount, selectedStorm,
|
events, selectedId, pinnedIds, onSelect, theme, flyCount, selectedStorm,
|
||||||
stormZones, isDrawing, draftPolygon, onAddPoint, onFinishPolygon,
|
stormZones, isDrawing, draftPolygon, onAddPoint, onFinishPolygon,
|
||||||
|
pinDropMode, pins, onDropPin,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full storm-intel-map">
|
<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',
|
dashArray: isSelected ? undefined : isPinned ? '3 3' : '6 4',
|
||||||
}}
|
}}
|
||||||
eventHandlers={{
|
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 }); },
|
mouseover: (e) => { e.target.setStyle({ fillOpacity: Math.min(sty.fillOpacity + 0.1, 0.4), weight: sty.weight + 1 }); },
|
||||||
mouseout: (e) => {
|
mouseout: (e) => {
|
||||||
if (storm.id === selectedId) return;
|
if (storm.id === selectedId) return;
|
||||||
@@ -294,6 +329,29 @@ const StormMap = memo(({
|
|||||||
{/* Canvasser zones layer */}
|
{/* Canvasser zones layer */}
|
||||||
<ZoneLayer zones={stormZones} />
|
<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) */}
|
{/* Drawing layer (active only when isDrawing) */}
|
||||||
{isDrawing && (
|
{isDrawing && (
|
||||||
<ZoneDrawingLayer
|
<ZoneDrawingLayer
|
||||||
@@ -339,6 +397,15 @@ const StormMap = memo(({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -447,7 +514,7 @@ const ForecastStrip = ({ forecast, loading, alertDay, hasStormAlert }) => {
|
|||||||
|
|
||||||
// ─── Storm card ───────────────────────────────────────────────────────────────
|
// ─── 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 sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace;
|
||||||
const conv = conversionWindow(storm.date);
|
const conv = conversionWindow(storm.date);
|
||||||
const TypeIcon = TYPE_ICONS[storm.type] ?? CloudLightning;
|
const TypeIcon = TYPE_ICONS[storm.type] ?? CloudLightning;
|
||||||
@@ -478,6 +545,11 @@ const StormCard = ({ storm, isSelected, isPinned, onSelect, onTogglePin }) => {
|
|||||||
{storm.areaName}
|
{storm.areaName}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<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">
|
<span className="text-[10px] text-zinc-400 dark:text-zinc-500">
|
||||||
{days === 0 ? 'Today' : `${days}d ago`}
|
{days === 0 ? 'Today' : `${days}d ago`}
|
||||||
</span>
|
</span>
|
||||||
@@ -912,11 +984,16 @@ const StormIntelPage = () => {
|
|||||||
const [typeFilter, setTypeFilter] = useState('all');
|
const [typeFilter, setTypeFilter] = useState('all');
|
||||||
const [severityFilter, setSeverityFilter] = useState('all');
|
const [severityFilter, setSeverityFilter] = useState('all');
|
||||||
const [sortBy, setSortBy] = useState('date');
|
const [sortBy, setSortBy] = useState('date');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedStorm, setSelectedStorm] = useState(null);
|
const [selectedStorm, setSelectedStorm] = useState(null);
|
||||||
const [pinnedIds, setPinnedIds] = useState(new Set());
|
const [pinnedIds, setPinnedIds] = useState(new Set());
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [flyCount, setFlyCount] = useState(0);
|
const [flyCount, setFlyCount] = useState(0);
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
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
|
// Zone drawing state
|
||||||
const [isDrawing, setIsDrawing] = useState(false);
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
@@ -928,14 +1005,61 @@ const StormIntelPage = () => {
|
|||||||
const { events: allEvents } = useStormEvents({ dateRange: '36m' });
|
const { events: allEvents } = useStormEvents({ dateRange: '36m' });
|
||||||
const { forecast, loading: forecastLoading, alertDay, hasStormAlert } = useStormForecast();
|
const { forecast, loading: forecastLoading, alertDay, hasStormAlert } = useStormForecast();
|
||||||
|
|
||||||
// Auto-select the most recent event on first load (no drawer, no fly)
|
// Text search filter
|
||||||
const hasAutoSelected = useRef(false);
|
const displayEvents = useMemo(() => {
|
||||||
useEffect(() => {
|
if (!searchQuery.trim()) return events;
|
||||||
if (!hasAutoSelected.current && events.length > 0) {
|
const q = searchQuery.toLowerCase();
|
||||||
hasAutoSelected.current = true;
|
return events.filter(e =>
|
||||||
setSelectedStorm(events[0]);
|
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) => {
|
const handleTogglePin = useCallback((id) => {
|
||||||
setPinnedIds(prev => {
|
setPinnedIds(prev => {
|
||||||
@@ -1158,6 +1282,13 @@ const StormIntelPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" />
|
<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">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Severity</span>
|
<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]) => (
|
{[['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-[13px] text-zinc-500 font-medium">Failed to load storm data</p>
|
||||||
<p className="text-[11px] text-zinc-400">{error}</p>
|
<p className="text-[11px] text-zinc-400">{error}</p>
|
||||||
</div>
|
</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">
|
<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" />
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<StormMap
|
<StormMap
|
||||||
@@ -1228,39 +1378,100 @@ const StormIntelPage = () => {
|
|||||||
draftPolygon={draftPolygon}
|
draftPolygon={draftPolygon}
|
||||||
onAddPoint={handleAddPoint}
|
onAddPoint={handleAddPoint}
|
||||||
onFinishPolygon={handleFinishPolygon}
|
onFinishPolygon={handleFinishPolygon}
|
||||||
|
pinDropMode={pinDropMode}
|
||||||
|
pins={pins}
|
||||||
|
onDropPin={handleDropPin}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Storm list */}
|
{/* 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="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">
|
{/* Sidebar header */}
|
||||||
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
<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">
|
||||||
{events.length} Event{events.length !== 1 ? 's' : ''}
|
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-500 dark:text-zinc-400 min-w-0 truncate">
|
||||||
{pinnedIds.size > 0 && (
|
{pins.length > 0
|
||||||
<span className="ml-1.5 text-amber-500 font-bold">· {pinnedIds.size} pinned</span>
|
? <>{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>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
{pinnedIds.size > 0 && (
|
{/* Pin drop toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setPinnedIds(new Set())}
|
onClick={() => setPinDropMode(m => !m)}
|
||||||
className="text-[11px] text-amber-500 hover:text-amber-600 dark:hover:text-amber-400 flex items-center gap-1 transition-colors"
|
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'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<MapPin size={11} /> Clear pins
|
<Crosshair size={14} />
|
||||||
|
</button>
|
||||||
|
{pins.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleClearPins}
|
||||||
|
className="text-[11px] text-violet-500 hover:text-violet-600 flex items-center gap-0.5 font-bold transition-colors"
|
||||||
|
>
|
||||||
|
<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>
|
</button>
|
||||||
)}
|
)}
|
||||||
{selectedStorm && (
|
{selectedStorm && (
|
||||||
<button
|
<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">
|
||||||
onClick={() => { setSelectedStorm(null); setDrawerOpen(false); }}
|
<X size={12} />
|
||||||
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Zone summary strip */}
|
||||||
{stormZones.length > 0 && (
|
{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]">
|
<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) => (
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div key={i} className="h-24 rounded-xl bg-zinc-100 dark:bg-zinc-800/40 animate-pulse" />
|
<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">
|
<div className="flex flex-col items-center justify-center h-32 text-center">
|
||||||
<CloudLightning size={22} className="text-zinc-300 mb-2" />
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
events.map(storm => (
|
sidebarEvents.map(storm => (
|
||||||
<StormCard
|
<StormCard
|
||||||
key={storm.id}
|
key={storm.id}
|
||||||
storm={storm}
|
storm={storm}
|
||||||
@@ -1311,6 +1537,7 @@ const StormIntelPage = () => {
|
|||||||
isPinned={pinnedIds.has(storm.id)}
|
isPinned={pinnedIds.has(storm.id)}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onTogglePin={handleTogglePin}
|
onTogglePin={handleTogglePin}
|
||||||
|
distanceMiles={storm._distanceMiles}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user