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 { 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">
|
||||||
|
<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>
|
||||||
|
{/* 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,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">
|
<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>
|
||||||
|
<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 && (
|
{selectedStorm && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSelectedStorm(null); setDrawerOpen(false); }}
|
onClick={() => { setSelectedStorm(null); setDrawerOpen(false); }}
|
||||||
@@ -1198,6 +1250,7 @@ const StormIntelPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Zone summary strip */}
|
{/* Zone summary strip */}
|
||||||
{stormZones.length > 0 && (
|
{stormZones.length > 0 && (
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user