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.

894 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
}) => {
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>${spot.ref}<br>${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: `<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 */}
<DXNewsTicker />
{/* Legend - right side */}
<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;

Powered by TurnKey Linux.