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:
Satyam-Rastogi
2026-05-19 00:30:37 +05:30
parent 1f0e74a7b0
commit 9c0e5c2302
+78 -23
View File
@@ -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 { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
@@ -16,7 +16,7 @@ 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, CloudRain, CloudDrizzle, CheckCircle, XCircle, CalendarRange, Pin,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTheme } from '../context/ThemeContext'; import { useTheme } from '../context/ThemeContext';
@@ -211,7 +211,7 @@ const ZoneDrawingLayer = ({ draftPolygon, onAddPoint, onFinish }) => {
}; };
const StormMap = memo(({ const StormMap = memo(({
events, selectedId, onSelect, theme, flyCount, selectedStorm, events, selectedId, pinnedIds, onSelect, theme, flyCount, selectedStorm,
stormZones, isDrawing, draftPolygon, onAddPoint, onFinishPolygon, stormZones, isDrawing, draftPolygon, onAddPoint, onFinishPolygon,
}) => { }) => {
return ( return (
@@ -233,10 +233,11 @@ const StormMap = memo(({
<MapFlyTo storm={selectedStorm} flyCount={flyCount} /> <MapFlyTo storm={selectedStorm} flyCount={flyCount} />
{/* Existing storm polygons */} {/* Storm polygons — only selected + pinned (or all if none active) */}
{events.map(storm => { {events.map(storm => {
const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace; const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace;
const isSelected = storm.id === selectedId; const isSelected = storm.id === selectedId;
const isPinned = pinnedIds?.has(storm.id);
return ( return (
<Polygon <Polygon
@@ -245,17 +246,24 @@ const StormMap = memo(({
pathOptions={{ pathOptions={{
color: isSelected ? sty.stroke : sty.fill, color: isSelected ? sty.stroke : sty.fill,
fillColor: sty.fill, fillColor: sty.fill,
fillOpacity: isSelected ? Math.min(sty.fillOpacity + 0.12, 0.42) : sty.fillOpacity, fillOpacity: isSelected
weight: isSelected ? sty.weight + 1.5 : sty.weight, ? Math.min(sty.fillOpacity + 0.14, 0.44)
opacity: isSelected ? 1 : 0.8, : isPinned
dashArray: isSelected ? undefined : '6 4', ? 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={{ eventHandlers={{
click: () => !isDrawing && onSelect(storm), click: () => !isDrawing && 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;
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 ─────────────────────────────────────────────────────────────── // ─── Storm card ───────────────────────────────────────────────────────────────
const StormCard = ({ storm, isSelected, onSelect }) => { const StormCard = ({ storm, isSelected, isPinned, onSelect, onTogglePin }) => {
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;
@@ -452,6 +460,8 @@ const StormCard = ({ storm, isSelected, onSelect }) => {
className={`w-full text-left rounded-xl border transition-all duration-150 overflow-hidden ${ className={`w-full text-left rounded-xl border transition-all duration-150 overflow-hidden ${
isSelected isSelected
? 'border-amber-400 dark:border-amber-500 bg-amber-50/60 dark:bg-amber-500/5 shadow-md' ? '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' : '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"> <p className="text-[13px] font-bold text-zinc-900 dark:text-white leading-snug truncate">
{storm.areaName} {storm.areaName}
</p> </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">
{days === 0 ? 'Today' : `${days}d ago`} <span className="text-[10px] text-zinc-400 dark:text-zinc-500">
</span> {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> </div>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mb-2">{dateLabel}</p> <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 [severityFilter, setSeverityFilter] = useState('all');
const [sortBy, setSortBy] = useState('date'); const [sortBy, setSortBy] = useState('date');
const [selectedStorm, setSelectedStorm] = useState(null); const [selectedStorm, setSelectedStorm] = useState(null);
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);
@@ -903,6 +928,20 @@ 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();
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) => { const handleSelect = useCallback((storm) => {
setSelectedStorm(storm); setSelectedStorm(storm);
setDrawerOpen(true); setDrawerOpen(true);
@@ -1168,8 +1207,9 @@ const StormIntelPage = () => {
</div> </div>
) : ( ) : (
<StormMap <StormMap
events={events} events={mapEvents}
selectedId={selectedStorm?.id} selectedId={selectedStorm?.id}
pinnedIds={pinnedIds}
selectedStorm={selectedStorm} selectedStorm={selectedStorm}
onSelect={handleSelect} onSelect={handleSelect}
theme={theme} theme={theme}
@@ -1188,15 +1228,28 @@ 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"> <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"> <p className="text-[12px] font-black uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
{events.length} Event{events.length !== 1 ? 's' : ''} {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> </p>
{selectedStorm && ( <div className="flex items-center gap-2">
<button {pinnedIds.size > 0 && (
onClick={() => { setSelectedStorm(null); setDrawerOpen(false); }} <button
className="text-[11px] text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 flex items-center gap-1 transition-colors" 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"
<X size={12} /> Clear >
</button> <MapPin size={11} /> Clear pins
)} </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>
)}
</div>
</div> </div>
{/* Zone summary strip */} {/* Zone summary strip */}
@@ -1246,7 +1299,9 @@ const StormIntelPage = () => {
key={storm.id} key={storm.id}
storm={storm} storm={storm}
isSelected={selectedStorm?.id === storm.id} isSelected={selectedStorm?.id === storm.id}
isPinned={pinnedIds.has(storm.id)}
onSelect={handleSelect} onSelect={handleSelect}
onTogglePin={handleTogglePin}
/> />
)) ))
)} )}