feat(storm-intel): select/pin system for map event visibility
Map now shows only the currently selected event + any pinned events. If nothing is selected or pinned, all events are shown (default state). - Pin button (MapPin icon) on each card — click to persist that event on the map independently of selection changes - Card click = select (amber border, opens drawer, flies to zone) - Pinned cards get a subtle amber tint + filled pin icon - Pinned events on map: tighter dash + slightly elevated opacity vs unselected - List header shows "X pinned" count + "Clear pins" button - Selection clear button still works independently of pins
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useRef, useEffect, memo } from 'react';
|
||||
import React, { useState, useCallback, useRef, useEffect, memo, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
@@ -16,7 +16,7 @@ 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,
|
||||
CloudRain, CloudDrizzle, CheckCircle, XCircle, CalendarRange, Pin,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
@@ -211,7 +211,7 @@ const ZoneDrawingLayer = ({ draftPolygon, onAddPoint, onFinish }) => {
|
||||
};
|
||||
|
||||
const StormMap = memo(({
|
||||
events, selectedId, onSelect, theme, flyCount, selectedStorm,
|
||||
events, selectedId, pinnedIds, onSelect, theme, flyCount, selectedStorm,
|
||||
stormZones, isDrawing, draftPolygon, onAddPoint, onFinishPolygon,
|
||||
}) => {
|
||||
return (
|
||||
@@ -233,10 +233,11 @@ const StormMap = memo(({
|
||||
|
||||
<MapFlyTo storm={selectedStorm} flyCount={flyCount} />
|
||||
|
||||
{/* Existing storm polygons */}
|
||||
{/* Storm polygons — only selected + pinned (or all if none active) */}
|
||||
{events.map(storm => {
|
||||
const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace;
|
||||
const isSelected = storm.id === selectedId;
|
||||
const isPinned = pinnedIds?.has(storm.id);
|
||||
|
||||
return (
|
||||
<Polygon
|
||||
@@ -245,17 +246,24 @@ const StormMap = memo(({
|
||||
pathOptions={{
|
||||
color: isSelected ? sty.stroke : sty.fill,
|
||||
fillColor: sty.fill,
|
||||
fillOpacity: isSelected ? Math.min(sty.fillOpacity + 0.12, 0.42) : sty.fillOpacity,
|
||||
weight: isSelected ? sty.weight + 1.5 : sty.weight,
|
||||
opacity: isSelected ? 1 : 0.8,
|
||||
dashArray: isSelected ? undefined : '6 4',
|
||||
fillOpacity: isSelected
|
||||
? Math.min(sty.fillOpacity + 0.14, 0.44)
|
||||
: isPinned
|
||||
? Math.min(sty.fillOpacity + 0.06, 0.36)
|
||||
: sty.fillOpacity,
|
||||
weight: isSelected ? sty.weight + 1.5 : isPinned ? sty.weight + 0.5 : sty.weight,
|
||||
opacity: isSelected ? 1 : isPinned ? 0.95 : 0.75,
|
||||
dashArray: isSelected ? undefined : isPinned ? '3 3' : '6 4',
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: () => !isDrawing && 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;
|
||||
e.target.setStyle({ fillOpacity: sty.fillOpacity, weight: sty.weight });
|
||||
e.target.setStyle({
|
||||
fillOpacity: isPinned ? Math.min(sty.fillOpacity + 0.06, 0.36) : sty.fillOpacity,
|
||||
weight: isPinned ? sty.weight + 0.5 : sty.weight,
|
||||
});
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -439,7 +447,7 @@ const ForecastStrip = ({ forecast, loading, alertDay, hasStormAlert }) => {
|
||||
|
||||
// ─── Storm card ───────────────────────────────────────────────────────────────
|
||||
|
||||
const StormCard = ({ storm, isSelected, onSelect }) => {
|
||||
const StormCard = ({ storm, isSelected, isPinned, onSelect, onTogglePin }) => {
|
||||
const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace;
|
||||
const conv = conversionWindow(storm.date);
|
||||
const TypeIcon = TYPE_ICONS[storm.type] ?? CloudLightning;
|
||||
@@ -452,6 +460,8 @@ const StormCard = ({ storm, isSelected, onSelect }) => {
|
||||
className={`w-full text-left rounded-xl border transition-all duration-150 overflow-hidden ${
|
||||
isSelected
|
||||
? 'border-amber-400 dark:border-amber-500 bg-amber-50/60 dark:bg-amber-500/5 shadow-md'
|
||||
: isPinned
|
||||
? 'border-amber-300/60 dark:border-amber-500/30 bg-amber-50/30 dark:bg-amber-500/[0.03]'
|
||||
: 'border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 hover:border-zinc-300 dark:hover:border-white/15 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
@@ -467,9 +477,23 @@ const StormCard = ({ storm, isSelected, onSelect }) => {
|
||||
<p className="text-[13px] font-bold text-zinc-900 dark:text-white leading-snug truncate">
|
||||
{storm.areaName}
|
||||
</p>
|
||||
<span className="text-[10px] text-zinc-400 dark:text-zinc-500 shrink-0 mt-0.5">
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-[10px] text-zinc-400 dark:text-zinc-500">
|
||||
{days === 0 ? 'Today' : `${days}d ago`}
|
||||
</span>
|
||||
{/* Pin button */}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onTogglePin(storm.id); }}
|
||||
title={isPinned ? 'Unpin from map' : 'Pin to map'}
|
||||
className={`ml-0.5 p-1 rounded-md transition-colors ${
|
||||
isPinned
|
||||
? 'text-amber-500 bg-amber-50 dark:bg-amber-500/10'
|
||||
: 'text-zinc-300 dark:text-zinc-600 hover:text-amber-400 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10'
|
||||
}`}
|
||||
>
|
||||
<MapPin size={11} fill={isPinned ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mb-2">{dateLabel}</p>
|
||||
@@ -889,6 +913,7 @@ const StormIntelPage = () => {
|
||||
const [severityFilter, setSeverityFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('date');
|
||||
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);
|
||||
@@ -903,6 +928,20 @@ const StormIntelPage = () => {
|
||||
const { events: allEvents } = useStormEvents({ dateRange: '36m' });
|
||||
const { forecast, loading: forecastLoading, alertDay, hasStormAlert } = useStormForecast();
|
||||
|
||||
const handleTogglePin = useCallback((id) => {
|
||||
setPinnedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Map shows only selected + pinned events; if nothing active, show all
|
||||
const mapEvents = useMemo(() => {
|
||||
if (!selectedStorm && pinnedIds.size === 0) return events;
|
||||
return events.filter(e => pinnedIds.has(e.id) || e.id === selectedStorm?.id);
|
||||
}, [events, selectedStorm, pinnedIds]);
|
||||
|
||||
const handleSelect = useCallback((storm) => {
|
||||
setSelectedStorm(storm);
|
||||
setDrawerOpen(true);
|
||||
@@ -1168,8 +1207,9 @@ const StormIntelPage = () => {
|
||||
</div>
|
||||
) : (
|
||||
<StormMap
|
||||
events={events}
|
||||
events={mapEvents}
|
||||
selectedId={selectedStorm?.id}
|
||||
pinnedIds={pinnedIds}
|
||||
selectedStorm={selectedStorm}
|
||||
onSelect={handleSelect}
|
||||
theme={theme}
|
||||
@@ -1188,7 +1228,19 @@ const StormIntelPage = () => {
|
||||
<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>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{pinnedIds.size > 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"
|
||||
>
|
||||
<MapPin size={11} /> Clear pins
|
||||
</button>
|
||||
)}
|
||||
{selectedStorm && (
|
||||
<button
|
||||
onClick={() => { setSelectedStorm(null); setDrawerOpen(false); }}
|
||||
@@ -1198,6 +1250,7 @@ const StormIntelPage = () => {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zone summary strip */}
|
||||
{stormZones.length > 0 && (
|
||||
@@ -1246,7 +1299,9 @@ const StormIntelPage = () => {
|
||||
key={storm.id}
|
||||
storm={storm}
|
||||
isSelected={selectedStorm?.id === storm.id}
|
||||
isPinned={pinnedIds.has(storm.id)}
|
||||
onSelect={handleSelect}
|
||||
onTogglePin={handleTogglePin}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user