Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
234 lines
7.7 KiB
TypeScript
234 lines
7.7 KiB
TypeScript
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) => (
|
|
<CircleMarker
|
|
key={`${location.lat}-${location.lon}-${index}`}
|
|
center={[location.lat, location.lon]}
|
|
radius={getRadius(location.count)}
|
|
pathOptions={{
|
|
color: '#06b6d4', // cyan-500
|
|
fillColor: '#22d3ee', // cyan-400
|
|
fillOpacity: getOpacity(location.count),
|
|
weight: 1,
|
|
}}
|
|
>
|
|
<Popup>
|
|
<div className="text-sm">
|
|
<div className="font-semibold">
|
|
{location.city ? `${location.city}, ` : ''}
|
|
{location.country || 'Unknown'}
|
|
</div>
|
|
<div className="text-muted-foreground">
|
|
{location.count.toLocaleString()} stream{location.count !== 1 ? 's' : ''}
|
|
</div>
|
|
</div>
|
|
</Popup>
|
|
</CircleMarker>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div className={cn('relative h-full w-full', className)}>
|
|
<style>{mapStyles}</style>
|
|
<MapContainer
|
|
center={[20, 0]}
|
|
zoom={2}
|
|
className="h-full w-full"
|
|
scrollWheelZoom={true}
|
|
zoomControl={false}
|
|
>
|
|
<TileLayer
|
|
key={resolvedTheme}
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
url={tileUrl}
|
|
/>
|
|
<ZoomControl position="bottomright" />
|
|
|
|
<MapBoundsUpdater locations={locations} isLoading={isLoading} />
|
|
|
|
{/* Visualization layer - heatmap or circles */}
|
|
{hasData && viewMode === 'heatmap' && (
|
|
<HeatmapLayer
|
|
points={locations.filter((l) => 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' && <CircleMarkersLayer locations={locations} />}
|
|
</MapContainer>
|
|
|
|
{/* Loading overlay */}
|
|
{isLoading && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
Loading map data...
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* No data message */}
|
|
{!isLoading && !hasData && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
|
<p className="text-sm text-muted-foreground">No location data for current filters</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|