locate-the-news / frontend / src / components / MapboxView.jsx
MapboxView.jsx
Raw
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;