You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
897 lines
33 KiB
897 lines
33 KiB
/**
|
|
* 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,
|
|
hideOverlays
|
|
}) => {
|
|
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(`<b>DE - Your Location</b><br>${calculateGridSquare(deLocation.lat, deLocation.lon)}<br>${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(`<b>DX - Target</b><br>${calculateGridSquare(dxLocation.lat, dxLocation.lon)}<br>${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(`<b>☼ Subsolar Point</b><br>${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(`<b>☽ Sublunar Point</b><br>${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(`<b style="color: ${color}">${path.dxCall}</b><br>${path.freq} MHz<br>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: `<span style="display:inline-block;background:${isHovered ? '#fff' : color};color:${isHovered ? color : '#000'};padding:${isHovered ? '5px 10px' : '4px 8px'};border-radius:4px;font-family:'JetBrains Mono',monospace;font-size:${isHovered ? '14px' : '12px'};font-weight:700;white-space:nowrap;border:2px solid ${isHovered ? color : 'rgba(0,0,0,0.5)'};box-shadow:0 2px ${isHovered ? '8px' : '4px'} rgba(0,0,0,${isHovered ? '0.6' : '0.4'});">${path.dxCall}</span>`,
|
|
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: `<span style="display:inline-block;width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #44cc44;filter:drop-shadow(0 1px 2px rgba(0,0,0,0.6));"></span>`,
|
|
iconSize: [14, 14],
|
|
iconAnchor: [7, 14]
|
|
});
|
|
const marker = L.marker([spot.lat, spot.lon], { icon: triangleIcon })
|
|
.bindPopup(`<b style="color:#44cc44">${spot.call}</b><br><span style="color:#888">${spot.ref}</span> ${spot.locationDesc || ''}<br>${spot.name ? `<i>${spot.name}</i><br>` : ''}${spot.freq} ${spot.mode || ''} <span style="color:#888">${spot.time || ''}</span>`)
|
|
.addTo(map);
|
|
potaMarkersRef.current.push(marker);
|
|
|
|
// Only show callsign label when labels are enabled
|
|
if (showDXLabels) {
|
|
const labelIcon = L.divIcon({
|
|
className: '',
|
|
html: `<span style="display:inline-block;background:#44cc44;color:#000;padding:4px 8px;border-radius:4px;font-size:12px;font-family:'JetBrains Mono',monospace;font-weight:700;white-space:nowrap;border:2px solid rgba(0,0,0,0.5);box-shadow:0 2px 4px rgba(0,0,0,0.4);">${spot.call}</span>`,
|
|
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: `<span style="display:inline-block;background:${sat.visible ? satColor : satColorDark};color:${sat.visible ? '#000' : '#fff'};padding:4px 8px;border-radius:4px;font-size:11px;font-family:'JetBrains Mono',monospace;white-space:nowrap;border:2px solid ${sat.visible ? '#fff' : '#666'};font-weight:bold;box-shadow:0 2px 4px rgba(0,0,0,0.4);">⛊ ${sat.name}</span>`,
|
|
iconSize: null,
|
|
iconAnchor: [0, 0]
|
|
});
|
|
|
|
const marker = L.marker([sat.lat, sat.lon], { icon })
|
|
.bindPopup(`
|
|
<b>⛊ ${sat.name}</b><br>
|
|
<table style="font-size: 11px;">
|
|
<tr><td>Mode:</td><td><b>${sat.mode || 'Unknown'}</b></td></tr>
|
|
<tr><td>Alt:</td><td>${sat.alt} km</td></tr>
|
|
<tr><td>Az:</td><td>${sat.azimuth}°</td></tr>
|
|
<tr><td>El:</td><td>${sat.elevation}°</td></tr>
|
|
<tr><td>Range:</td><td>${sat.range} km</td></tr>
|
|
<tr><td>Status:</td><td>${sat.visible ? '<span style="color:green">✓ Visible</span>' : '<span style="color:gray">Below horizon</span>'}</td></tr>
|
|
</table>
|
|
`)
|
|
.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(`
|
|
<b>${displayCall}</b><br>
|
|
${spot.mode} @ ${freqMHz} MHz<br>
|
|
${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]);
|
|
|
|
// Update WSJT-X markers (CQ callers with grid locators)
|
|
useEffect(() => {
|
|
if (!mapInstanceRef.current) return;
|
|
const map = mapInstanceRef.current;
|
|
|
|
wsjtxMarkersRef.current.forEach(m => map.removeLayer(m));
|
|
wsjtxMarkersRef.current = [];
|
|
|
|
const hasValidDE = deLocation &&
|
|
typeof deLocation.lat === 'number' && !isNaN(deLocation.lat) &&
|
|
typeof deLocation.lon === 'number' && !isNaN(deLocation.lon);
|
|
|
|
if (showWSJTX && wsjtxSpots && wsjtxSpots.length > 0 && hasValidDE) {
|
|
// Deduplicate by callsign - keep most recent
|
|
const seen = new Map();
|
|
wsjtxSpots.forEach(spot => {
|
|
const call = spot.caller || spot.dxCall || '';
|
|
if (call && (!seen.has(call) || spot.timestamp > seen.get(call).timestamp)) {
|
|
seen.set(call, spot);
|
|
}
|
|
});
|
|
|
|
seen.forEach((spot, call) => {
|
|
let spotLat = parseFloat(spot.lat);
|
|
let spotLon = parseFloat(spot.lon);
|
|
|
|
if (!isNaN(spotLat) && !isNaN(spotLon)) {
|
|
const freqMHz = spot.dialFrequency ? (spot.dialFrequency / 1000000) : 0;
|
|
const bandColor = freqMHz ? getBandColor(freqMHz) : '#a78bfa';
|
|
|
|
try {
|
|
// Draw line from DE to CQ caller
|
|
const points = getGreatCirclePoints(
|
|
deLocation.lat, deLocation.lon,
|
|
spotLat, spotLon,
|
|
50
|
|
);
|
|
|
|
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: '#a78bfa',
|
|
weight: 1.5,
|
|
opacity: 0.4,
|
|
dashArray: '2, 6'
|
|
}).addTo(map);
|
|
wsjtxMarkersRef.current.push(line);
|
|
|
|
const endPoint = points[points.length - 1];
|
|
spotLat = endPoint[0];
|
|
spotLon = endPoint[1];
|
|
}
|
|
|
|
// Diamond-shaped marker to distinguish from PSK circles
|
|
const diamond = L.marker([spotLat, spotLon], {
|
|
icon: L.divIcon({
|
|
className: '',
|
|
html: `<div style="
|
|
width: 8px; height: 8px;
|
|
background: ${bandColor};
|
|
border: 1px solid #fff;
|
|
transform: rotate(45deg);
|
|
opacity: 0.9;
|
|
"></div>`,
|
|
iconSize: [8, 8],
|
|
iconAnchor: [4, 4]
|
|
})
|
|
}).bindPopup(`
|
|
<b>${call}</b> CQ<br>
|
|
${spot.grid || ''} ${spot.band || ''}<br>
|
|
${spot.mode || ''} SNR: ${spot.snr != null ? (spot.snr >= 0 ? '+' : '') + spot.snr : '?'} dB
|
|
`).addTo(map);
|
|
wsjtxMarkersRef.current.push(diamond);
|
|
} catch (err) {
|
|
// skip bad spots
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}, [wsjtxSpots, showWSJTX, deLocation]);
|
|
|
|
return (
|
|
<div style={{ position: 'relative', height: '100%', minHeight: '200px' }}>
|
|
<div ref={mapRef} style={{ height: '100%', width: '100%', borderRadius: '8px', background: mapStyle === 'countries' ? '#4a90d9' : undefined }} />
|
|
|
|
{/* Render all plugin layers */}
|
|
{mapInstanceRef.current && getAllLayers().map(layerDef => (
|
|
<PluginLayer
|
|
key={layerDef.id}
|
|
plugin={layerDef}
|
|
enabled={pluginLayerStates[layerDef.id]?.enabled || false}
|
|
opacity={pluginLayerStates[layerDef.id]?.opacity || layerDef.defaultOpacity}
|
|
map={mapInstanceRef.current}
|
|
/>
|
|
))}
|
|
|
|
{/* Map style dropdown */}
|
|
<select
|
|
value={mapStyle}
|
|
onChange={(e) => setMapStyle(e.target.value)}
|
|
style={{
|
|
position: 'absolute',
|
|
top: '10px',
|
|
right: '10px',
|
|
background: 'rgba(0, 0, 0, 0.8)',
|
|
border: '1px solid #444',
|
|
color: '#00ffcc',
|
|
padding: '6px 10px',
|
|
borderRadius: '4px',
|
|
fontSize: '11px',
|
|
fontFamily: 'JetBrains Mono',
|
|
cursor: 'pointer',
|
|
zIndex: 1000,
|
|
outline: 'none'
|
|
}}
|
|
>
|
|
{Object.entries(MAP_STYLES).map(([key, style]) => (
|
|
<option key={key} value={key}>{style.name}</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Satellite toggle */}
|
|
{onToggleSatellites && (
|
|
<button
|
|
onClick={onToggleSatellites}
|
|
title={showSatellites ? 'Hide satellite tracks' : 'Show satellite tracks'}
|
|
style={{
|
|
position: 'absolute',
|
|
top: '10px',
|
|
left: '50px',
|
|
background: showSatellites ? 'rgba(0, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.8)',
|
|
border: `1px solid ${showSatellites ? '#00ffff' : '#666'}`,
|
|
color: showSatellites ? '#00ffff' : '#888',
|
|
padding: '6px 10px',
|
|
borderRadius: '4px',
|
|
fontSize: '11px',
|
|
fontFamily: 'JetBrains Mono',
|
|
cursor: 'pointer',
|
|
zIndex: 1000
|
|
}}
|
|
>
|
|
⛊ SAT {showSatellites ? 'ON' : 'OFF'}
|
|
</button>
|
|
)}
|
|
|
|
{/* Labels toggle */}
|
|
{onToggleDXLabels && showDXPaths && (
|
|
<button
|
|
onClick={onToggleDXLabels}
|
|
title={showDXLabels ? 'Hide callsign labels on map' : 'Show callsign labels on map'}
|
|
style={{
|
|
position: 'absolute',
|
|
top: '10px',
|
|
left: '145px',
|
|
background: showDXLabels ? 'rgba(255, 170, 0, 0.2)' : 'rgba(0, 0, 0, 0.8)',
|
|
border: `1px solid ${showDXLabels ? '#ffaa00' : '#666'}`,
|
|
color: showDXLabels ? '#ffaa00' : '#888',
|
|
padding: '6px 10px',
|
|
borderRadius: '4px',
|
|
fontSize: '11px',
|
|
fontFamily: 'JetBrains Mono',
|
|
cursor: 'pointer',
|
|
zIndex: 1000
|
|
}}
|
|
>
|
|
⊞ CALLS {showDXLabels ? 'ON' : 'OFF'}
|
|
</button>
|
|
)}
|
|
|
|
{/* DX News Ticker - left side of bottom bar */}
|
|
{!hideOverlays && <DXNewsTicker />}
|
|
|
|
{/* Legend - right side */}
|
|
{!hideOverlays && (
|
|
<div style={{
|
|
position: 'absolute',
|
|
bottom: '8px',
|
|
right: '8px',
|
|
background: 'rgba(0, 0, 0, 0.85)',
|
|
border: '1px solid #444',
|
|
borderRadius: '6px',
|
|
padding: '6px 10px',
|
|
zIndex: 1000,
|
|
display: 'flex',
|
|
gap: '8px',
|
|
alignItems: 'center',
|
|
fontSize: '11px',
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
flexWrap: 'nowrap',
|
|
maxWidth: '50%'
|
|
}}>
|
|
{showDXPaths && (
|
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
|
<span style={{ color: '#888' }}>DX:</span>
|
|
<span style={{ background: '#ff6666', color: '#000', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}>160m</span>
|
|
<span style={{ background: '#ff9966', color: '#000', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}>80m</span>
|
|
<span style={{ background: '#ffcc66', color: '#000', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}>40m</span>
|
|
<span style={{ background: '#ccff66', color: '#000', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}>30m</span>
|
|
<span style={{ background: '#66ff99', color: '#000', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}>20m</span>
|
|
<span style={{ background: '#66ffcc', color: '#000', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}>17m</span>
|
|
<span style={{ background: '#66ccff', color: '#000', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}>15m</span>
|
|
<span style={{ background: '#6699ff', color: '#000', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}>12m</span>
|
|
<span style={{ background: '#9966ff', color: '#fff', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}>10m</span>
|
|
<span style={{ background: '#cc66ff', color: '#fff', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}>6m</span>
|
|
</div>
|
|
)}
|
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
|
<span style={{ background: 'var(--accent-amber)', color: '#000', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}>● DE</span>
|
|
<span style={{ background: '#00aaff', color: '#000', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}>● DX</span>
|
|
</div>
|
|
{showPOTA && (
|
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
|
<span style={{ background: '#44cc44', color: '#000', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}>▲ POTA</span>
|
|
</div>
|
|
)}
|
|
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
|
<span style={{ color: '#ffcc00' }}>☼ Sun</span>
|
|
<span style={{ color: '#aaaaaa' }}>☽ Moon</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
|
|
export default WorldMap;
|