/**
* WorldMap Component
* Leaflet map with DE/DX markers, terminator, DX paths, POTA, satellites, PSKReporter
*/
import React, { useRef, useEffect, useState } from 'react';
import { MAP_STYLES } from '../utils/config.js';
import {
calculateGridSquare,
getSunPosition,
getMoonPosition,
getGreatCirclePoints
} from '../utils/geo.js';
import { filterDXPaths, getBandColor } from '../utils/callsign.js';
import { getAllLayers } from '../plugins/layerRegistry.js';
import { IconSatellite, IconTag, IconSun, IconMoon } from './Icons.jsx';
import PluginLayer from './PluginLayer.jsx';
import { DXNewsTicker } from './DXNewsTicker.jsx';
export const WorldMap = ({
deLocation,
dxLocation,
onDXChange,
potaSpots,
mySpots,
dxPaths,
dxFilters,
satellites,
pskReporterSpots,
wsjtxSpots,
showDXPaths,
showDXLabels,
onToggleDXLabels,
showPOTA,
showSatellites,
showPSKReporter,
showWSJTX,
onToggleSatellites,
hoveredSpot
}) => {
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
const tileLayerRef = useRef(null);
const terminatorRef = useRef(null);
const deMarkerRef = useRef(null);
const dxMarkerRef = useRef(null);
const sunMarkerRef = useRef(null);
const moonMarkerRef = useRef(null);
const potaMarkersRef = useRef([]);
const mySpotsMarkersRef = useRef([]);
const mySpotsLinesRef = useRef([]);
const dxPathsLinesRef = useRef([]);
const dxPathsMarkersRef = useRef([]);
const satMarkersRef = useRef([]);
const satTracksRef = useRef([]);
const pskMarkersRef = useRef([]);
const wsjtxMarkersRef = useRef([]);
const countriesLayerRef = useRef(null);
// Plugin system refs and state
const pluginLayersRef = useRef({});
const [pluginLayerStates, setPluginLayerStates] = useState({});
// Load map style from localStorage
const getStoredMapSettings = () => {
try {
const stored = localStorage.getItem('openhamclock_mapSettings');
return stored ? JSON.parse(stored) : {};
} catch (e) { return {}; }
};
const storedSettings = getStoredMapSettings();
const [mapStyle, setMapStyle] = useState(storedSettings.mapStyle || 'dark');
const [mapView, setMapView] = useState({
center: storedSettings.center || [20, 0],
zoom: storedSettings.zoom || 2.5
});
// Save map settings to localStorage when changed (merge, don't overwrite)
useEffect(() => {
try {
const existing = getStoredMapSettings();
localStorage.setItem('openhamclock_mapSettings', JSON.stringify({
...existing,
mapStyle,
center: mapView.center,
zoom: mapView.zoom
}));
} catch (e) { console.error('Failed to save map settings:', e); }
}, [mapStyle, mapView]);
// Initialize map
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
// Make sure Leaflet is available
if (typeof L === 'undefined') {
console.error('Leaflet not loaded');
return;
}
const map = L.map(mapRef.current, {
center: mapView.center,
zoom: mapView.zoom,
minZoom: 1,
maxZoom: 18,
worldCopyJump: true,
zoomControl: true,
zoomSnap: 0.1,
zoomDelta: 0.25,
wheelPxPerZoomLevel: 200,
maxBounds: [[-90, -Infinity], [90, Infinity]],
maxBoundsViscosity: 0.8
});
// Initial tile layer
tileLayerRef.current = L.tileLayer(MAP_STYLES[mapStyle].url, {
attribution: MAP_STYLES[mapStyle].attribution,
noWrap: false,
crossOrigin: 'anonymous',
bounds: [[-85, -180], [85, 180]]
}).addTo(map);
// Day/night terminator
terminatorRef.current = L.terminator({
resolution: 2,
fillOpacity: 0.35,
fillColor: '#000020',
color: '#ffaa00',
weight: 2,
dashArray: '5, 5'
}).addTo(map);
// Refresh terminator
setTimeout(() => {
if (terminatorRef.current) {
terminatorRef.current.setTime();
}
}, 100);
// Update terminator every minute
const terminatorInterval = setInterval(() => {
if (terminatorRef.current) {
terminatorRef.current.setTime();
}
}, 60000);
// Click handler for setting DX
map.on('click', (e) => {
if (onDXChange) {
onDXChange({ lat: e.latlng.lat, lon: e.latlng.lng });
}
});
// Save map view when user pans or zooms
map.on('moveend', () => {
const center = map.getCenter();
const zoom = map.getZoom();
setMapView({ center: [center.lat, center.lng], zoom });
});
mapInstanceRef.current = map;
return () => {
clearInterval(terminatorInterval);
map.remove();
mapInstanceRef.current = null;
};
}, []);
// Update tile layer when style changes
useEffect(() => {
if (!mapInstanceRef.current || !tileLayerRef.current) return;
mapInstanceRef.current.removeLayer(tileLayerRef.current);
tileLayerRef.current = L.tileLayer(MAP_STYLES[mapStyle].url, {
attribution: MAP_STYLES[mapStyle].attribution,
noWrap: false,
crossOrigin: 'anonymous',
bounds: [[-85, -180], [85, 180]]
}).addTo(mapInstanceRef.current);
// Ensure terminator is on top
if (terminatorRef.current) {
terminatorRef.current.bringToFront();
}
}, [mapStyle]);
// Countries overlay for "Countries" map style
useEffect(() => {
if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current;
// Remove existing countries layer
if (countriesLayerRef.current) {
map.removeLayer(countriesLayerRef.current);
countriesLayerRef.current = null;
}
// Only add overlay for countries style
if (!MAP_STYLES[mapStyle]?.countriesOverlay) return;
// Bright distinct colors for countries (designed for maximum contrast between neighbors)
const COLORS = [
'#e6194b', '#3cb44b', '#4363d8', '#f58231', '#911eb4',
'#42d4f4', '#f032e6', '#bfef45', '#fabed4', '#469990',
'#dcbeff', '#9A6324', '#800000', '#aaffc3', '#808000',
'#000075', '#e6beff', '#ff6961', '#77dd77', '#fdfd96',
'#84b6f4', '#fdcae1', '#c1e1c1', '#b39eb5', '#ffb347'
];
// Simple string hash for consistent color assignment
const hashColor = (str) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return COLORS[Math.abs(hash) % COLORS.length];
};
// Fetch world countries GeoJSON (Natural Earth 110m simplified, ~240KB)
fetch('https://cdn.jsdelivr.net/gh/johan/world.geo.json@master/countries.geo.json')
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(geojson => {
if (!mapInstanceRef.current) return;
countriesLayerRef.current = L.geoJSON(geojson, {
style: (feature) => {
const name = feature.properties?.name || feature.id || 'Unknown';
return {
fillColor: hashColor(name),
fillOpacity: 0.65,
color: '#fff',
weight: 1,
opacity: 0.8
};
},
onEachFeature: (feature, layer) => {
const name = feature.properties?.name || 'Unknown';
layer.bindTooltip(name, {
sticky: true,
className: 'country-tooltip',
direction: 'top',
offset: [0, -5]
});
}
}).addTo(map);
// Ensure countries layer is below markers but above tiles
countriesLayerRef.current.bringToBack();
// Put tile layer behind countries
if (tileLayerRef.current) tileLayerRef.current.bringToBack();
// Terminator on top
if (terminatorRef.current) terminatorRef.current.bringToFront();
})
.catch(err => {
console.warn('Could not load countries GeoJSON:', err);
});
}, [mapStyle]);
// Update DE/DX markers and celestial bodies
useEffect(() => {
if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current;
// Remove old markers
if (deMarkerRef.current) map.removeLayer(deMarkerRef.current);
if (dxMarkerRef.current) map.removeLayer(dxMarkerRef.current);
if (sunMarkerRef.current) map.removeLayer(sunMarkerRef.current);
if (moonMarkerRef.current) map.removeLayer(moonMarkerRef.current);
// DE Marker
const deIcon = L.divIcon({
className: 'custom-marker de-marker',
html: 'DE',
iconSize: [32, 32],
iconAnchor: [16, 16]
});
deMarkerRef.current = L.marker([deLocation.lat, deLocation.lon], { icon: deIcon })
.bindPopup(`DE - Your Location
${calculateGridSquare(deLocation.lat, deLocation.lon)}
${deLocation.lat.toFixed(4)}°, ${deLocation.lon.toFixed(4)}°`)
.addTo(map);
// DX Marker
const dxIcon = L.divIcon({
className: 'custom-marker dx-marker',
html: 'DX',
iconSize: [32, 32],
iconAnchor: [16, 16]
});
dxMarkerRef.current = L.marker([dxLocation.lat, dxLocation.lon], { icon: dxIcon })
.bindPopup(`DX - Target
${calculateGridSquare(dxLocation.lat, dxLocation.lon)}
${dxLocation.lat.toFixed(4)}°, ${dxLocation.lon.toFixed(4)}°`)
.addTo(map);
// Sun marker
const sunPos = getSunPosition(new Date());
const sunIcon = L.divIcon({
className: 'custom-marker sun-marker',
html: '☼',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
sunMarkerRef.current = L.marker([sunPos.lat, sunPos.lon], { icon: sunIcon })
.bindPopup(`☼ Subsolar Point
${sunPos.lat.toFixed(2)}°, ${sunPos.lon.toFixed(2)}°`)
.addTo(map);
// Moon marker
const moonPos = getMoonPosition(new Date());
const moonIcon = L.divIcon({
className: 'custom-marker moon-marker',
html: '☽',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
moonMarkerRef.current = L.marker([moonPos.lat, moonPos.lon], { icon: moonIcon })
.bindPopup(`☽ Sublunar Point
${moonPos.lat.toFixed(2)}°, ${moonPos.lon.toFixed(2)}°`)
.addTo(map);
}, [deLocation, dxLocation]);
// Update DX paths
useEffect(() => {
if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current;
// Remove old DX paths
dxPathsLinesRef.current.forEach(l => map.removeLayer(l));
dxPathsLinesRef.current = [];
dxPathsMarkersRef.current.forEach(m => map.removeLayer(m));
dxPathsMarkersRef.current = [];
// Add new DX paths if enabled
if (showDXPaths && dxPaths && dxPaths.length > 0) {
const filteredPaths = filterDXPaths(dxPaths, dxFilters);
filteredPaths.forEach((path) => {
try {
if (!path.spotterLat || !path.spotterLon || !path.dxLat || !path.dxLon) return;
if (isNaN(path.spotterLat) || isNaN(path.spotterLon) || isNaN(path.dxLat) || isNaN(path.dxLon)) return;
const pathPoints = getGreatCirclePoints(
path.spotterLat, path.spotterLon,
path.dxLat, path.dxLon
);
if (!pathPoints || !Array.isArray(pathPoints) || pathPoints.length === 0) return;
const freq = parseFloat(path.freq);
const color = getBandColor(freq);
const isHovered = hoveredSpot &&
hoveredSpot.call?.toUpperCase() === path.dxCall?.toUpperCase();
// Handle path rendering (single continuous array, unwrapped across antimeridian)
if (pathPoints && Array.isArray(pathPoints) && pathPoints.length > 1) {
const line = L.polyline(pathPoints, {
color: isHovered ? '#ffffff' : color,
weight: isHovered ? 4 : 1.5,
opacity: isHovered ? 1 : 0.5
}).addTo(map);
if (isHovered) line.bringToFront();
dxPathsLinesRef.current.push(line);
}
// Use unwrapped endpoint so marker sits where the line ends
const endPoint = pathPoints[pathPoints.length - 1];
const dxLatDisplay = endPoint[0];
const dxLonDisplay = endPoint[1];
// Add DX marker
const dxCircle = L.circleMarker([dxLatDisplay, dxLonDisplay], {
radius: isHovered ? 12 : 6,
fillColor: isHovered ? '#ffffff' : color,
color: isHovered ? color : '#fff',
weight: isHovered ? 3 : 1.5,
opacity: 1,
fillOpacity: isHovered ? 1 : 0.9
})
.bindPopup(`${path.dxCall}
${path.freq} MHz
by ${path.spotter}`)
.addTo(map);
if (isHovered) dxCircle.bringToFront();
dxPathsMarkersRef.current.push(dxCircle);
// Add label if enabled
if (showDXLabels || isHovered) {
const labelIcon = L.divIcon({
className: '',
html: `${path.dxCall}`,
iconSize: null,
iconAnchor: [0, 0]
});
const label = L.marker([dxLatDisplay, dxLonDisplay], {
icon: labelIcon,
interactive: false,
zIndexOffset: isHovered ? 10000 : 0
}).addTo(map);
dxPathsMarkersRef.current.push(label);
}
} catch (err) {
console.error('Error rendering DX path:', err);
}
});
}
}, [dxPaths, dxFilters, showDXPaths, showDXLabels, hoveredSpot]);
// Update POTA markers
useEffect(() => {
if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current;
potaMarkersRef.current.forEach(m => map.removeLayer(m));
potaMarkersRef.current = [];
if (showPOTA && potaSpots) {
potaSpots.forEach(spot => {
if (spot.lat && spot.lon) {
// Green triangle marker for POTA activators
const triangleIcon = L.divIcon({
className: '',
html: ``,
iconSize: [14, 14],
iconAnchor: [7, 14]
});
const marker = L.marker([spot.lat, spot.lon], { icon: triangleIcon })
.bindPopup(`${spot.call}
${spot.ref}
${spot.freq} ${spot.mode}`)
.addTo(map);
potaMarkersRef.current.push(marker);
// Only show callsign label when labels are enabled
if (showDXLabels) {
const labelIcon = L.divIcon({
className: '',
html: `${spot.call}`,
iconSize: null,
iconAnchor: [0, -2]
});
const label = L.marker([spot.lat, spot.lon], { icon: labelIcon, interactive: false }).addTo(map);
potaMarkersRef.current.push(label);
}
}
});
}
}, [potaSpots, showPOTA, showDXLabels]);
// Update satellite markers with orbit tracks
useEffect(() => {
if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current;
satMarkersRef.current.forEach(m => map.removeLayer(m));
satMarkersRef.current = [];
satTracksRef.current.forEach(t => map.removeLayer(t));
satTracksRef.current = [];
if (showSatellites && satellites && satellites.length > 0) {
satellites.forEach(sat => {
const satColor = sat.color || '#00ffff';
const satColorDark = sat.visible ? satColor : '#446666';
// Draw orbit track if available
if (sat.track && sat.track.length > 1) {
// Unwrap longitudes for continuous rendering across antimeridian
const unwrapped = sat.track.map(p => [...p]);
for (let i = 1; i < unwrapped.length; i++) {
while (unwrapped[i][1] - unwrapped[i-1][1] > 180) unwrapped[i][1] -= 360;
while (unwrapped[i][1] - unwrapped[i-1][1] < -180) unwrapped[i][1] += 360;
}
const trackLine = L.polyline(unwrapped, {
color: sat.visible ? satColor : satColorDark,
weight: 2,
opacity: sat.visible ? 0.8 : 0.4,
dashArray: sat.visible ? null : '5, 5'
}).addTo(map);
satTracksRef.current.push(trackLine);
}
// Draw footprint circle if available and satellite is visible
if (sat.footprintRadius && sat.lat && sat.lon && sat.visible) {
const footprint = L.circle([sat.lat, sat.lon], {
radius: sat.footprintRadius * 1000, // Convert km to meters
color: satColor,
weight: 1,
opacity: 0.5,
fillColor: satColor,
fillOpacity: 0.1
}).addTo(map);
satTracksRef.current.push(footprint);
}
// Add satellite marker icon
const icon = L.divIcon({
className: '',
html: `⛊ ${sat.name}`,
iconSize: null,
iconAnchor: [0, 0]
});
const marker = L.marker([sat.lat, sat.lon], { icon })
.bindPopup(`
⛊ ${sat.name}
| Mode: | ${sat.mode || 'Unknown'} |
| Alt: | ${sat.alt} km |
| Az: | ${sat.azimuth}° |
| El: | ${sat.elevation}° |
| Range: | ${sat.range} km |
| Status: | ${sat.visible ? '✓ Visible' : 'Below horizon'} |