import React, { useEffect, useRef, useMemo, useCallback } from 'react'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { useTheme } from '@/components/ThemeProvider'; /* ── Types ─────────────────────────────────────────────────────────── */ export interface MapLocation { lat: number; lng: number; city: string; country: string; visitors: number; hits: number; } interface AnalyticsMapProps { locations: MapLocation[]; className?: string; } /* ── Map tile styles ──────────────────────────────────────────────── */ const MAP_STYLES = { light: 'https://api.maptiler.com/maps/basic-v2/style.json?key=aQ5CJxgn3brYPrfV3ws9', dark: 'https://api.maptiler.com/maps/dataviz-dark/style.json?key=aQ5CJxgn3brYPrfV3ws9', }; /* ── Marker sizing by visitor count ──────────────────────────────── */ function markerSize(visitors: number): number { if (visitors <= 1) return 14; if (visitors <= 5) return 20; if (visitors <= 20) return 28; if (visitors <= 50) return 36; return 44; } function markerEl(loc: MapLocation, isNew = false): HTMLElement { const size = markerSize(loc.visitors); // Outer wrapper with invisible padding to stabilise hover hit-area const wrapper = document.createElement('div'); wrapper.className = 'analytics-marker-wrap'; wrapper.style.cssText = `padding: 6px; cursor: pointer;`; const el = document.createElement('div'); el.className = 'analytics-marker'; el.style.cssText = ` width: ${size}px; height: ${size}px; border-radius: 50%; background: ${isNew ? 'rgba(34, 197, 94, 0.85)' : 'rgba(99, 102, 241, 0.75)'}; border: 2px solid ${isNew ? '#16a34a' : '#4f46e5'}; display: flex; align-items: center; justify-content: center; color: #fff; font-size: ${Math.max(9, size * 0.35)}px; font-weight: 700; box-shadow: 0 2px 8px rgba(0,0,0,0.25); transition: transform 0.2s, box-shadow 0.2s; pointer-events: none; `; el.textContent = String(loc.visitors); wrapper.appendChild(el); wrapper.title = `${loc.city || loc.country || 'Unknown'} — ${loc.visitors} visitor${loc.visitors !== 1 ? 's' : ''}, ${loc.hits} hit${loc.hits !== 1 ? 's' : ''}`; // Hover on the stable wrapper → scale the inner circle wrapper.addEventListener('mouseenter', () => { el.style.transform = 'scale(1.2)'; el.style.boxShadow = '0 4px 16px rgba(0,0,0,0.35)'; }); wrapper.addEventListener('mouseleave', () => { el.style.transform = ''; el.style.boxShadow = '0 2px 8px rgba(0,0,0,0.25)'; }); // Pulse animation for new SSE markers if (isNew) { el.style.animation = 'analytics-marker-pulse 1.5s ease-out'; } return wrapper; } /* ── Component ─────────────────────────────────────────────────────── */ const AnalyticsMap: React.FC = ({ locations, className }) => { const containerRef = useRef(null); const mapRef = useRef(null); const markersRef = useRef([]); const { theme } = useTheme(); const getResolvedTheme = useCallback(() => { if (theme === 'system' && typeof window !== 'undefined') { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } return theme === 'dark' ? 'dark' : 'light'; }, [theme]); const [resolvedTheme, setResolvedTheme] = React.useState<'dark' | 'light'>(getResolvedTheme()); useEffect(() => { const updateTheme = () => setResolvedTheme(getResolvedTheme()); updateTheme(); if (theme === 'system') { const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); mediaQuery.addEventListener("change", updateTheme); return () => mediaQuery.removeEventListener("change", updateTheme); } }, [theme, getResolvedTheme]); const style = useMemo(() => resolvedTheme === 'dark' ? MAP_STYLES.dark : MAP_STYLES.light, [resolvedTheme]); // Init map useEffect(() => { if (mapRef.current || !containerRef.current) return; const m = new maplibregl.Map({ container: containerRef.current, style, center: [10, 30], zoom: 1.8, pitch: 0, attributionControl: false, }); m.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right'); mapRef.current = m; return () => { m.remove(); mapRef.current = null; }; }, []); // intentionally stable // Style sync useEffect(() => { mapRef.current?.setStyle(style); }, [style]); // Place / update markers const prevLocKeyRef = useRef(''); useEffect(() => { const m = mapRef.current; if (!m) return; // Serialise to detect change const key = locations.map(l => `${l.lat},${l.lng},${l.visitors}`).join('|'); const isInitial = prevLocKeyRef.current === ''; prevLocKeyRef.current = key; // Clear old markers markersRef.current.forEach(mk => mk.remove()); markersRef.current = []; if (locations.length === 0) return; // Add new markers locations.forEach(loc => { const el = markerEl(loc); const popup = new maplibregl.Popup({ offset: 14, closeButton: false, maxWidth: '220px' }) .setHTML(`
${loc.city || 'Unknown city'}
${loc.country || ''}
${loc.visitors} visitor${loc.visitors !== 1 ? 's' : ''} · ${loc.hits} hit${loc.hits !== 1 ? 's' : ''}
`); const marker = new maplibregl.Marker({ element: el }) .setLngLat([loc.lng, loc.lat]) .setPopup(popup) .addTo(m); markersRef.current.push(marker); }); // Fit bounds on initial load if (isInitial && locations.length > 1) { const bounds = new maplibregl.LngLatBounds(); locations.forEach(l => bounds.extend([l.lng, l.lat])); m.fitBounds(bounds, { padding: 50, maxZoom: 8 }); } }, [locations]); return (
); }; export default AnalyticsMap;