/** * 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 PluginLayer from './PluginLayer.jsx'; export const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots, mySpots, dxPaths, dxFilters, satellites, pskReporterSpots, showDXPaths, showDXLabels, onToggleDXLabels, showPOTA, showSatellites, showPSKReporter, 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 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 useEffect(() => { try { localStorage.setItem('openhamclock_mapSettings', JSON.stringify({ 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) { const icon = L.divIcon({ className: '', html: `${spot.call}`, iconSize: null, iconAnchor: [0, 0] }); const marker = L.marker([spot.lat, spot.lon], { icon }) .bindPopup(`${spot.call}
${spot.ref}
${spot.freq} ${spot.mode}`) .addTo(map); potaMarkersRef.current.push(marker); } }); } }, [potaSpots, showPOTA]); // 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'}
`) .addTo(map); satMarkersRef.current.push(marker); }); } }, [satellites, showSatellites]); // Plugin layer system - properly load saved states useEffect(() => { if (!mapInstanceRef.current) return; try { const availableLayers = getAllLayers(); const settings = getStoredMapSettings(); const savedLayers = settings.layers || {}; // Build initial states from localStorage const initialStates = {}; availableLayers.forEach(layerDef => { // Use saved state if it exists, otherwise use defaults if (savedLayers[layerDef.id]) { initialStates[layerDef.id] = savedLayers[layerDef.id]; } else { initialStates[layerDef.id] = { enabled: layerDef.defaultEnabled, opacity: layerDef.defaultOpacity }; } }); // Initialize state ONLY on first mount (when empty) if (Object.keys(pluginLayerStates).length === 0) { console.log('Loading saved layer states:', initialStates); setPluginLayerStates(initialStates); } // Expose controls for SettingsPanel window.hamclockLayerControls = { layers: availableLayers.map(l => ({ ...l, enabled: pluginLayerStates[l.id]?.enabled ?? initialStates[l.id]?.enabled ?? l.defaultEnabled, opacity: pluginLayerStates[l.id]?.opacity ?? initialStates[l.id]?.opacity ?? l.defaultOpacity })), toggleLayer: (id, enabled) => { console.log(`Toggle layer ${id}:`, enabled); const settings = getStoredMapSettings(); const layers = settings.layers || {}; layers[id] = { enabled: enabled, opacity: layers[id]?.opacity ?? 0.6 }; localStorage.setItem('openhamclock_mapSettings', JSON.stringify({ ...settings, layers })); console.log('Saved to localStorage:', layers); setPluginLayerStates(prev => ({ ...prev, [id]: { ...prev[id], enabled: enabled } })); }, setOpacity: (id, opacity) => { console.log(`Set opacity ${id}:`, opacity); const settings = getStoredMapSettings(); const layers = settings.layers || {}; layers[id] = { enabled: layers[id]?.enabled ?? false, opacity: opacity }; localStorage.setItem('openhamclock_mapSettings', JSON.stringify({ ...settings, layers })); console.log('Saved to localStorage:', layers); setPluginLayerStates(prev => ({ ...prev, [id]: { ...prev[id], opacity: opacity } })); } }; } catch (err) { console.error('Plugin system error:', err); } }, [pluginLayerStates]); // Update PSKReporter markers useEffect(() => { if (!mapInstanceRef.current) return; const map = mapInstanceRef.current; pskMarkersRef.current.forEach(m => map.removeLayer(m)); pskMarkersRef.current = []; // Validate deLocation exists and has valid coordinates const hasValidDE = deLocation && typeof deLocation.lat === 'number' && !isNaN(deLocation.lat) && typeof deLocation.lon === 'number' && !isNaN(deLocation.lon); if (showPSKReporter && pskReporterSpots && pskReporterSpots.length > 0 && hasValidDE) { pskReporterSpots.forEach(spot => { // Validate spot coordinates are valid numbers let spotLat = parseFloat(spot.lat); let spotLon = parseFloat(spot.lon); if (!isNaN(spotLat) && !isNaN(spotLon)) { const displayCall = spot.receiver || spot.sender; const freqMHz = spot.freqMHz || (spot.freq ? (spot.freq / 1000000).toFixed(3) : '?'); const bandColor = getBandColor(parseFloat(freqMHz)); try { // Draw line from DE to spot location const points = getGreatCirclePoints( deLocation.lat, deLocation.lon, spotLat, spotLon, 50 ); // Validate points before creating polyline (single continuous array, unwrapped across antimeridian) if (points && Array.isArray(points) && points.length > 1 && points.every(p => Array.isArray(p) && !isNaN(p[0]) && !isNaN(p[1]))) { const line = L.polyline(points, { color: bandColor, weight: 1.5, opacity: 0.5, dashArray: '4, 4' }).addTo(map); pskMarkersRef.current.push(line); // Use unwrapped endpoint so dot sits where the line ends const endPoint = points[points.length - 1]; spotLat = endPoint[0]; spotLon = endPoint[1]; } // Add small dot marker at spot location const circle = L.circleMarker([spotLat, spotLon], { radius: 4, fillColor: bandColor, color: '#fff', weight: 1, opacity: 0.9, fillOpacity: 0.8 }).bindPopup(` ${displayCall}
${spot.mode} @ ${freqMHz} MHz
${spot.snr !== null ? `SNR: ${spot.snr > 0 ? '+' : ''}${spot.snr} dB` : ''} `).addTo(map); pskMarkersRef.current.push(circle); } catch (err) { console.warn('Error rendering PSKReporter spot:', err); } } }); } }, [pskReporterSpots, showPSKReporter, deLocation]); return (
{/* Render all plugin layers */} {mapInstanceRef.current && getAllLayers().map(layerDef => ( ))} {/* Map style dropdown */} {/* Satellite toggle */} {onToggleSatellites && ( )} {/* Labels toggle */} {onToggleDXLabels && showDXPaths && ( )} {/* Legend */}
{showDXPaths && (
DX: 160m 80m 40m 30m 20m 17m 15m 12m 10m 6m
)}
● DE ● DX
{showPOTA && (
● POTA
)} {showSatellites && (
🛰 SAT
)}
☀ Sun 🌙 Moon
); }; export default WorldMap;