import React, { useState, useEffect, useRef, useCallback, useMemo, forwardRef } from 'react';
import Map, { Marker, Popup, Source, Layer } from 'react-map-gl';
import { AnimatePresence } from 'framer-motion';
import {
MAPBOX_TOKEN,
INITIAL_VIEW_STATE,
MAP_SETTINGS,
ZOOM_LEVELS
} from '../config/MapboxConfig';
import {
initializeCluster,
getClustersInBounds,
getClusterExpansionZoom,
getClusterSentiment,
getClusterNegativeRatio
} from '../utils/clusteringUtils';
import { sentimentToColor, clusterSentimentColor } from '../utils/sentimentColors';
import { getCountrySentiments, createCountryFillExpression } from '../utils/countryHighlight';
import ClusterCircle from './ClusterCircle';
import DataPoint from './DataPoint';
import 'mapbox-gl/dist/mapbox-gl.css';
import '../styles/mapbox-theme.css';
import '../styles/popup-glass.css';
const MapboxView = forwardRef(({ events, sentimentFilter, selectedEvent, onEventClick, onClosePopup, loading }, ref) => {
const internalMapRef = useRef(null);
const activeMapRef = ref || internalMapRef;
const [viewState, setViewState] = useState(INITIAL_VIEW_STATE);
const [clusters, setClusters] = useState([]);
const [supercluster, setSupercluster] = useState(null);
const [countryFillExpression, setCountryFillExpression] = useState(['match', ['get', 'name'], 'rgba(0, 0, 0, 0)']);
const [clusterPopup, setClusterPopup] = useState(null);
const [previousClusterPopup, setPreviousClusterPopup] = useState(null);
// Filter events based on sentiment
const filteredEvents = useMemo(() => {
if (!sentimentFilter) return events;
return events.filter(event => event.sentiment_label === sentimentFilter);
}, [events, sentimentFilter]);
// Initialize clustering and country highlights when events change
useEffect(() => {
if (filteredEvents && filteredEvents.length > 0) {
const cluster = initializeCluster(filteredEvents);
setSupercluster(cluster);
// Create country highlight expression
const countrySentiments = getCountrySentiments(filteredEvents);
const fillExpression = createCountryFillExpression(countrySentiments);
// Only set if we have a valid expression (not just the default color)
if (typeof fillExpression !== 'string') {
setCountryFillExpression(fillExpression);
}
} else {
// No events, clear everything
setSupercluster(null);
setCountryFillExpression('rgba(0, 0, 0, 0)');
}
}, [filteredEvents]);
useEffect(() => {
if (!supercluster || !activeMapRef.current) return;
const map = activeMapRef.current.getMap();
const bounds = map.getBounds();
const boundsArray = {
west: bounds.getWest(),
south: bounds.getSouth(),
east: bounds.getEast(),
north: bounds.getNorth()
};
const newClusters = getClustersInBounds(
supercluster,
boundsArray,
viewState.zoom
);
setClusters(newClusters);
}, [supercluster, viewState]);
// Handle cluster click - show articles or zoom in
const handleClusterClick = useCallback((clusterId, longitude, latitude) => {
if (!supercluster) return;
// Get all points in this cluster
const clusterPoints = supercluster.getLeaves(clusterId, Infinity);
// If cluster has 20 or fewer points, show them in a popup
if (clusterPoints.length <= 20) {
setClusterPopup({
longitude,
latitude,
articles: clusterPoints.map(p => ({
...p.properties,
location: {
longitude: p.geometry.coordinates[0],
latitude: p.geometry.coordinates[1],
entity_name: p.properties.entity_name
}
}))
});
} else {
// If too many articles, zoom in instead
const expansionZoom = getClusterExpansionZoom(supercluster, clusterId);
setViewState({
...viewState,
longitude,
latitude,
zoom: expansionZoom,
transitionDuration: 500
});
}
}, [supercluster, viewState]);
// Handle individual point click
const handlePointClick = useCallback((cluster) => {
const { coordinates } = cluster.geometry;
const properties = cluster.properties;
// Reconstruct event with location object
const event = {
...properties,
location: {
longitude: coordinates[0],
latitude: coordinates[1],
entity_name: properties.entity_name || properties.title
}
};
console.log('Point clicked, reconstructed event:', event);
if (onEventClick) {
onEventClick(event);
}
}, [onEventClick]);
// Determine current zoom level category
const getZoomCategory = (zoom) => {
if (!zoom) return 'world'; // Default to world if zoom is undefined
if (zoom <= ZOOM_LEVELS.WORLD.max) return 'world';
if (zoom <= ZOOM_LEVELS.COUNTRY.max) return 'country';
return 'city';
};
const currentZoomCategory = getZoomCategory(viewState?.zoom);
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
const getSentimentLabel = (score) => {
if (score === null || score === undefined) return 'neutral';
if (score < -0.3) return 'negative';
if (score > 0.3) return 'positive';
return 'neutral';
};
return (
<div className="map-wrapper">
{loading && (
<div className="map-loading-overlay">
<div className="spinner">⟳</div>
<span style={{ marginLeft: '12px' }}>Loading news data...</span>
</div>
)}
<Map
ref={activeMapRef}
{...viewState}
onMove={evt => setViewState(evt.viewState)}
mapboxAccessToken={MAPBOX_TOKEN}
mapStyle="mapbox://styles/mapbox/dark-v11"
{...MAP_SETTINGS}
style={{ width: '100%', height: '100%' }}
>
{/* Country Highlight Layer */}
<Source
id="country-boundaries"
type="vector"
url="mapbox://mapbox.country-boundaries-v1"
>
<Layer
id="country-fills"
type="fill"
source-layer="country_boundaries"
paint={{
'fill-color': countryFillExpression,
'fill-opacity': 0.3
}}
/>
</Source>
{/* Render clusters and points */}
<AnimatePresence>
{clusters.map((cluster) => {
const [longitude, latitude] = cluster.geometry.coordinates;
const { cluster: isCluster, point_count: pointCount } = cluster.properties;
if (isCluster) {
// Render cluster
const sentiment = getClusterSentiment(cluster.properties);
const negativeRatio = getClusterNegativeRatio(cluster.properties);
const color = clusterSentimentColor(negativeRatio);
return (
<Marker
key={`cluster-${cluster.id}`}
longitude={longitude}
latitude={latitude}
anchor="center"
>
<ClusterCircle
count={pointCount}
sentiment={sentiment}
negativeRatio={negativeRatio}
color={color}
size={currentZoomCategory === 'world' ? 'large' : 'medium'}
onClick={() => handleClusterClick(cluster.id, longitude, latitude)}
/>
</Marker>
);
} else {
// Render individual point
const sentimentScore = cluster.properties.sentiment_score || 0;
const color = sentimentToColor(sentimentScore);
const priority = cluster.properties.priority || 'medium';
return (
<Marker
key={`point-${cluster.properties.id}`}
longitude={longitude}
latitude={latitude}
anchor="center"
>
<DataPoint
color={color}
sentiment={sentimentScore}
priority={priority}
onClick={() => handlePointClick(cluster)}
/>
</Marker>
);
}
})}
</AnimatePresence>
{/* Cluster Popup - shows list of articles in cluster */}
{clusterPopup && (
<Popup
longitude={clusterPopup.longitude}
latitude={clusterPopup.latitude}
anchor="center"
offset={[0, 0]}
onClose={() => setClusterPopup(null)}
closeButton={true}
closeOnClick={false}
maxWidth="450px"
className="mapbox-popup-glass"
>
<div className="glass-popup-content">
<h3 className="glass-popup-title" style={{ marginBottom: '16px' }}>
{clusterPopup.articles.length} News Articles
</h3>
<div style={{
maxHeight: '400px',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '12px'
}}>
{clusterPopup.articles.map((article, index) => (
<div
key={article.id || index}
onClick={() => {
setPreviousClusterPopup(clusterPopup);
setClusterPopup(null);
onEventClick(article);
}}
style={{
padding: '12px',
background: 'rgba(255, 255, 255, 0.05)',
borderRadius: '8px',
cursor: 'pointer',
border: '1px solid rgba(255, 255, 255, 0.1)',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
e.currentTarget.style.borderColor = sentimentToColor(article.sentiment_score);
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)';
e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.1)';
}}
>
<div style={{
fontSize: '14px',
fontWeight: 600,
marginBottom: '6px',
color: '#fff'
}}>
{article.title.length > 80
? `${article.title.substring(0, 80)}...`
: article.title}
</div>
<div style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
fontSize: '11px',
opacity: 0.7
}}>
<span className={`sentiment-badge sentiment-${getSentimentLabel(article.sentiment_score)}`}
style={{ fontSize: '10px', padding: '2px 6px' }}>
{getSentimentLabel(article.sentiment_score)}
</span>
<span>{article.source_name}</span>
</div>
</div>
))}
</div>
</div>
</Popup>
)}
{/* Native Mapbox Popup */}
{(() => {
console.log('Popup render check:', {
hasSelectedEvent: !!selectedEvent,
selectedEvent: selectedEvent,
hasLocation: selectedEvent?.location,
willRender: !!(selectedEvent && selectedEvent.location)
});
return selectedEvent && selectedEvent.location && (
<Popup
longitude={selectedEvent.location.longitude}
latitude={selectedEvent.location.latitude}
anchor="center"
offset={[0, 0]}
onClose={onClosePopup}
closeButton={true}
closeOnClick={false}
maxWidth="400px"
className="mapbox-popup-glass"
>
<div className="glass-popup-content">
{/* Back button to return to cluster list */}
{previousClusterPopup && (
<button
onClick={() => {
onClosePopup();
setClusterPopup(previousClusterPopup);
setPreviousClusterPopup(null);
}}
style={{
background: 'rgba(255, 255, 255, 0.1)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '8px',
padding: '8px 16px',
color: '#00d9ff',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 600,
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '6px',
transition: 'all 0.2s ease',
width: '100%',
justifyContent: 'center'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(0, 217, 255, 0.2)';
e.currentTarget.style.borderColor = '#00d9ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.2)';
}}
>
← Back to {previousClusterPopup.articles.length} Articles
</button>
)}
<h3 className="glass-popup-title">{selectedEvent.title}</h3>
{selectedEvent.summary && (
<p className="glass-popup-description">
{selectedEvent.summary.length > 150
? `${selectedEvent.summary.substring(0, 150)}...`
: selectedEvent.summary}
</p>
)}
<div className="glass-popup-meta">
<span className={`sentiment-badge sentiment-${getSentimentLabel(selectedEvent.sentiment_score)}`}>
{getSentimentLabel(selectedEvent.sentiment_score) === 'negative' && '🔴'}
{getSentimentLabel(selectedEvent.sentiment_score) === 'positive' && '🟢'}
{getSentimentLabel(selectedEvent.sentiment_score) === 'neutral' && '🔵'}
{getSentimentLabel(selectedEvent.sentiment_score)}
</span>
<span className="glass-popup-date">
{formatDate(selectedEvent.published_date)}
</span>
</div>
<div className="glass-popup-source">
<small>Source: {selectedEvent.source_name}</small>
</div>
{selectedEvent.link && (
<a
href={selectedEvent.link}
target="_blank"
rel="noopener noreferrer"
className="glass-popup-button"
style={{
pointerEvents: 'auto',
zIndex: 9999,
position: 'relative'
}}
onClick={(e) => {
console.log('Button clicked! Opening:', selectedEvent.link);
// Let the default behavior happen
}}
>
Read Full Article →
</a>
)}
</div>
</Popup>
);
})()}
</Map>
</div>
);
});
export default MapboxView;