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
+67 -12
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 { 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}
/>
))
)}