import { useEffect, useMemo } from 'react'; import { MapContainer, TileLayer, useMap, ZoomControl, CircleMarker, Popup } from 'react-leaflet'; import { HeatmapLayer } from 'react-leaflet-heatmap-layer-v3'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import type { LocationStats } from '@tracearr/shared'; import { cn } from '@/lib/utils'; import { useTheme } from '@/components/theme-provider'; export type MapViewMode = 'heatmap' | 'circles'; // Custom styles for dark theme, zoom control, and z-index fixes const mapStyles = ` /* Ensure map container doesn't overlap sidebars/modals */ .leaflet-container { z-index: 0 !important; } .leaflet-pane { z-index: 1 !important; } .leaflet-tile-pane { z-index: 1 !important; } .leaflet-overlay-pane { z-index: 2 !important; } .leaflet-marker-pane { z-index: 3 !important; } .leaflet-tooltip-pane { z-index: 4 !important; } .leaflet-popup-pane { z-index: 5 !important; } .leaflet-control { z-index: 10 !important; } .leaflet-control-zoom { border: 1px solid hsl(var(--border)) !important; border-radius: 0.5rem !important; overflow: hidden; } .leaflet-control-zoom a { background: hsl(var(--card)) !important; color: hsl(var(--foreground)) !important; border-bottom: 1px solid hsl(var(--border)) !important; } .leaflet-control-zoom a:hover { background: hsl(var(--muted)) !important; } .leaflet-control-zoom a:last-child { border-bottom: none !important; } `; interface StreamMapProps { locations: LocationStats[]; className?: string; isLoading?: boolean; viewMode?: MapViewMode; } // Heatmap configuration optimized for streaming location data const HEATMAP_CONFIG = { // Gradient: dark cyan base → bright cyan → white hotspots // Designed for dark map tiles with good contrast gradient: { 0.0: 'rgba(14, 116, 144, 0)', // cyan-700 transparent (fade from nothing) 0.2: 'rgba(14, 116, 144, 0.8)', // cyan-700 0.4: '#0891b2', // cyan-600 0.6: '#06b6d4', // cyan-500 0.8: '#22d3ee', // cyan-400 0.95: '#67e8f9', // cyan-300 1.0: '#ffffff', // white for hotspots }, // Radius: larger for world view, heatmap auto-adjusts with zoom radius: 30, // Blur: soft edges for smooth transitions blur: 20, // minOpacity: ensure even low-activity areas are visible minOpacity: 0.4, // maxZoom: heatmap intensity calculation stops scaling at this zoom maxZoom: 12, }; // Circle markers layer component function CircleMarkersLayer({ locations }: { locations: LocationStats[] }) { const maxCount = useMemo(() => Math.max(...locations.map((l) => l.count), 1), [locations]); // Calculate radius based on count (scaled logarithmically) const getRadius = (count: number) => { const minRadius = 6; const maxRadius = 25; const scale = Math.log(count + 1) / Math.log(maxCount + 1); return minRadius + scale * (maxRadius - minRadius); }; // Get opacity based on count const getOpacity = (count: number) => { const minOpacity = 0.4; const maxOpacity = 0.8; const scale = count / maxCount; return minOpacity + scale * (maxOpacity - minOpacity); }; return ( <> {locations .filter((l) => l.lat && l.lon) .map((location, index) => (
{location.city ? `${location.city}, ` : ''} {location.country || 'Unknown'}
{location.count.toLocaleString()} stream{location.count !== 1 ? 's' : ''}
))} ); } // Component to fit bounds when data changes function MapBoundsUpdater({ locations, isLoading }: { locations: LocationStats[]; isLoading?: boolean }) { const map = useMap(); useEffect(() => { // Don't update bounds while loading - prevents zoom reset during filter changes if (isLoading) return; const points: [number, number][] = locations .filter((l) => l.lat && l.lon) .map((l) => [l.lat, l.lon]); if (points.length > 0) { const bounds = L.latLngBounds(points); map.fitBounds(bounds, { padding: [50, 50], maxZoom: 8 }); } // Note: Don't zoom out when no data - preserve current view during filter transitions }, [locations, map, isLoading]); return null; } // Map tile URLs for different themes const TILE_URLS = { dark: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', light: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', }; export function StreamMap({ locations, className, isLoading, viewMode = 'heatmap' }: StreamMapProps) { const hasData = locations.length > 0; const { theme } = useTheme(); const resolvedTheme = theme === 'system' ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : theme; const tileUrl = TILE_URLS[resolvedTheme]; return (
{/* Visualization layer - heatmap or circles */} {hasData && viewMode === 'heatmap' && ( l.lat && l.lon)} latitudeExtractor={(l: LocationStats) => l.lat} longitudeExtractor={(l: LocationStats) => l.lon} // Logarithmic intensity: prevents high-count locations from dominating intensityExtractor={(l: LocationStats) => Math.log10(l.count + 1)} gradient={HEATMAP_CONFIG.gradient} radius={HEATMAP_CONFIG.radius} blur={HEATMAP_CONFIG.blur} minOpacity={HEATMAP_CONFIG.minOpacity} maxZoom={HEATMAP_CONFIG.maxZoom} // Dynamic max based on log scale max={Math.log10(Math.max(...locations.map((l) => l.count), 1) + 1)} /> )} {hasData && viewMode === 'circles' && } {/* Loading overlay */} {isLoading && (
Loading map data...
)} {/* No data message */} {!isLoading && !hasData && (

No location data for current filters

)}
); }