feat: add click-to-dismiss × button on global incidents popups

Each alert bubble now has an × button in the top-right corner.
Clicking it hides the alert for the session and clears its selection
if it was active.

- Dismissal keyed by stable content hash (title+coords) so dismissed
  state survives data.news array replacement on every 60s polling cycle
- Button stopPropagation prevents accidental entity selection on dismiss
- Single useState<Set<string>> — avoids naming collision with the
  react-map-gl `Map` import that caused the previous black-screen crash

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: ce2dec52a9a40a581995323354414b278abdf443
This commit is contained in:
csysp
2026-03-12 18:26:43 -06:00
parent 7c6049020d
commit 9aed9d3eea
+47 -12
View File
@@ -330,6 +330,11 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
const [shipClusters, setShipClusters] = useState<any[]>([]);
const [eqClusters, setEqClusters] = useState<any[]>([]);
// Global Incidents popup: dismiss state
// Keys use stable content hash (title+coords) to survive data.news array replacement on refresh
// NOTE: Using Set (not Map) to avoid collision with the `Map` react-map-gl import
const [dismissedAlerts, setDismissedAlerts] = useState<Set<string>>(new Set());
// --- Smooth interpolation: tick counter triggers GeoJSON recalc every second ---
const [interpTick, setInterpTick] = useState(0);
const dataTimestamp = useRef<number>(Date.now());
@@ -1911,17 +1916,20 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
{/* Maplibre HTML Custom Markers for high-importance Threat Overlays (highest z-index) */}
{activeLayers.global_incidents && spreadAlerts.map((n: any) => {
const idx = n.originalIdx;
// Stable key: survives data.news array reorder/replacement across polling cycles
const alertKey = `${n.title || ''}_${(n.coords || []).join(',')}`;
if (dismissedAlerts.has(alertKey)) return null;
const count = n.cluster_count || 1;
const score = n.risk_score || 0;
let riskColor = '#22c55e'; // Green (1-3)
if (score >= 9) riskColor = '#ef4444'; // Red (9-10)
else if (score >= 7) riskColor = '#f97316'; // Orange (7-8)
else if (score >= 4) riskColor = '#eab308'; // Yellow (4-6)
else if (score >= 1) riskColor = '#3b82f6'; // Blue (1-3)
let riskColor = '#22c55e'; // Green (0)
if (score >= 9) riskColor = '#ef4444'; // Red
else if (score >= 7) riskColor = '#f97316'; // Orange
else if (score >= 4) riskColor = '#eab308'; // Yellow
else if (score >= 1) riskColor = '#3b82f6'; // Blue
// Hide alerts when any entity is selected (focus mode)
// For news: only show the selected alert. For all others: hide all alerts.
let isVisible = viewState.zoom >= 1;
if (selectedEntity) {
if (selectedEntity.type === 'news') {
@@ -1933,7 +1941,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
return (
<Marker
key={`threat-${idx}`}
key={`threat-${alertKey}`}
longitude={n.coords[1]}
latitude={n.coords[0]}
anchor="center"
@@ -1945,7 +1953,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
}}
>
<div className="relative group/alert">
{/* Connector Line for scattered markers (Speech Bubble Line) */}
{/* Connector Line */}
{n.showLine && isVisible && (
<svg className="absolute pointer-events-none" style={{ left: '50%', top: '50%', width: 1, height: 1, overflow: 'visible', zIndex: -1 }}>
<line x1={0} y1={0} x2={-n.offsetX} y2={-n.offsetY} stroke={riskColor} strokeWidth="1.5" strokeDasharray="3,3" className="opacity-80" />
@@ -1961,7 +1969,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
backgroundColor: 'rgba(5, 5, 5, 0.95)',
border: `1.5px solid ${riskColor}`,
borderRadius: '4px',
padding: '5px 8px',
padding: '5px 24px 5px 8px',
color: riskColor,
fontFamily: 'monospace',
fontSize: '9px',
@@ -1970,10 +1978,11 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
boxShadow: `0 0 12px ${riskColor}60`,
zIndex: 10,
lineHeight: '1.2',
minWidth: '120px'
minWidth: '120px',
position: 'relative',
}}
>
{/* Bubble Tail / Triangle */}
{/* Bubble Tail */}
{n.showLine && isVisible && (
<div
className="absolute"
@@ -1982,7 +1991,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
height: 0,
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
// If above origin, point down. If below, point up.
borderTop: n.offsetY < 0 ? `6px solid ${riskColor}` : 'none',
borderBottom: n.offsetY > 0 ? `6px solid ${riskColor}` : 'none',
left: '50%',
@@ -1992,6 +2000,33 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
/>
)}
{/* Dismiss button */}
<button
onClick={(e) => {
e.stopPropagation();
setDismissedAlerts(prev => new Set(prev).add(alertKey));
if (selectedEntity?.type === 'news' && selectedEntity.id === idx) {
onEntityClick?.(null);
}
}}
aria-label="Dismiss alert"
style={{
position: 'absolute',
top: '3px',
right: '4px',
background: 'none',
border: 'none',
color: riskColor,
cursor: 'pointer',
fontSize: '11px',
lineHeight: 1,
padding: '0 2px',
opacity: 0.8,
}}
>
×
</button>
<div className="absolute inset-0 border border-current rounded opacity-50 animate-pulse" style={{ color: riskColor, zIndex: -1 }}></div>
<div style={{ fontSize: '10px', letterSpacing: '0.5px' }}>!! ALERT LVL {score} !!</div>
<div style={{ color: '#fff', fontSize: '9px', marginTop: '2px', maxWidth: '160px', overflow: 'hidden', textOverflow: 'ellipsis' }}>