diff --git a/public/index.html b/public/index.html
index d3532e3..47bc726 100644
--- a/public/index.html
+++ b/public/index.html
@@ -735,6 +735,42 @@
return { data, loading, activeSource };
};
+ // ============================================
+ // DX PATHS HOOK - DX spots with locations for map visualization
+ // ============================================
+ const useDXPaths = () => {
+ const [data, setData] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchPaths = async () => {
+ try {
+ const response = await fetch('/api/dxcluster/paths');
+
+ if (response.ok) {
+ const paths = await response.json();
+ setData(paths);
+ console.log('[DX Paths] Loaded', paths.length, 'paths');
+ } else {
+ setData([]);
+ }
+ } catch (err) {
+ console.error('DX Paths error:', err);
+ setData([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchPaths();
+ // Refresh every 30 seconds
+ const interval = setInterval(fetchPaths, 30000);
+ return () => clearInterval(interval);
+ }, []);
+
+ return { data, loading };
+ };
+
// ============================================
// MY SPOTS HOOK - Spots involving the user's callsign
// ============================================
@@ -1346,7 +1382,7 @@
// ============================================
// LEAFLET MAP COMPONENT
// ============================================
- const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots, mySpots, satellites }) => {
+ const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots, mySpots, dxPaths, satellites }) => {
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
const tileLayerRef = useRef(null);
@@ -1358,10 +1394,13 @@
const potaMarkersRef = useRef([]);
const mySpotsMarkersRef = useRef([]);
const mySpotsLinesRef = useRef([]);
+ const dxPathsLinesRef = useRef([]);
+ const dxPathsMarkersRef = useRef([]);
const satMarkersRef = useRef([]);
const satTracksRef = useRef([]);
const [mapStyle, setMapStyle] = useState('dark');
const [showSatellites, setShowSatellites] = useState(true);
+ const [showDXPaths, setShowDXPaths] = useState(true);
// Initialize map
useEffect(() => {
@@ -1577,6 +1616,75 @@
}
}, [mySpots, deLocation]);
+ // Update DX paths - lines showing who spotted whom
+ 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) {
+ dxPaths.forEach((path, index) => {
+ // Draw great circle line from spotter to DX station
+ const pathPoints = getGreatCirclePoints(
+ path.spotterLat, path.spotterLon,
+ path.dxLat, path.dxLon
+ );
+
+ // Use different colors based on band (derived from frequency)
+ const freq = parseFloat(path.freq);
+ let color = '#4488ff'; // Default blue
+ if (freq >= 1.8 && freq < 2) color = '#ff6666'; // 160m - red
+ else if (freq >= 3.5 && freq < 4) color = '#ff9966'; // 80m - orange
+ else if (freq >= 7 && freq < 7.5) color = '#ffcc66'; // 40m - yellow
+ else if (freq >= 10 && freq < 10.5) color = '#99ff66'; // 30m - lime
+ else if (freq >= 14 && freq < 14.5) color = '#66ff99'; // 20m - green
+ else if (freq >= 18 && freq < 18.5) color = '#66ffcc'; // 17m - teal
+ else if (freq >= 21 && freq < 21.5) color = '#66ccff'; // 15m - cyan
+ else if (freq >= 24 && freq < 25) color = '#6699ff'; // 12m - blue
+ else if (freq >= 28 && freq < 30) color = '#9966ff'; // 10m - purple
+ else if (freq >= 50 && freq < 54) color = '#ff66ff'; // 6m - magenta
+
+ // Handle antimeridian crossing
+ const segments = Array.isArray(pathPoints[0]) ? pathPoints : [pathPoints];
+ segments.forEach(segment => {
+ const line = L.polyline(segment, {
+ color: color,
+ weight: 1.5,
+ opacity: 0.5
+ }).addTo(map);
+ dxPathsLinesRef.current.push(line);
+ });
+
+ // Add small markers at DX station end only (to reduce clutter)
+ const dxIcon = L.divIcon({
+ className: '',
+ html: `
`,
+ iconSize: [6, 6],
+ iconAnchor: [3, 3]
+ });
+
+ const marker = L.marker([path.dxLat, path.dxLon], { icon: dxIcon })
+ .bindPopup(`
+
+ ${path.dxCall}
+ spotted by ${path.spotter}
+ ${path.freq} MHz
+ ${path.comment || ''}
+ ${path.time}
+
+ `)
+ .addTo(map);
+ dxPathsMarkersRef.current.push(marker);
+ });
+ }
+ }, [dxPaths, showDXPaths]);
+
useEffect(() => {
if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current;
@@ -1728,6 +1836,30 @@
>
🛰 {showSatellites ? 'SATS ON' : 'SATS OFF'}
+
+ {/* DX Paths toggle */}
+
);
};
@@ -2033,7 +2165,7 @@
const LegacyLayout = ({
config, currentTime, utcTime, utcDate, localTime, localDate,
deGrid, dxGrid, deSunTimes, dxSunTimes, dxLocation, onDXChange,
- spaceWeather, bandConditions, potaSpots, dxCluster, contests, propagation, mySpots, satellites,
+ spaceWeather, bandConditions, potaSpots, dxCluster, dxPaths, contests, propagation, mySpots, satellites,
onSettingsClick
}) => {
const bearing = calculateBearing(config.location.lat, config.location.lon, dxLocation.lat, dxLocation.lon);
@@ -2194,6 +2326,7 @@
onDXChange={onDXChange}
potaSpots={potaSpots.data}
mySpots={mySpots.data}
+ dxPaths={dxPaths.data}
satellites={satellites.positions}
/>
@@ -2676,6 +2809,7 @@
const bandConditions = useBandConditions(spaceWeather.data);
const potaSpots = usePOTASpots();
const dxCluster = useDXCluster(config.dxClusterSource || 'auto');
+ const dxPaths = useDXPaths();
const contests = useContests();
const propagation = usePropagation(config.location, dxLocation);
const mySpots = useMySpots(config.callsign);
@@ -2728,6 +2862,7 @@
bandConditions={bandConditions}
potaSpots={potaSpots}
dxCluster={dxCluster}
+ dxPaths={dxPaths}
contests={contests}
propagation={propagation}
mySpots={mySpots}
@@ -2912,7 +3047,7 @@
{/* CENTER - MAP */}
-
+
Click map to set DX • 73 de {config.callsign}
diff --git a/server.js b/server.js
index fe82ced..1589e65 100644
--- a/server.js
+++ b/server.js
@@ -334,6 +334,136 @@ app.get('/api/dxcluster/sources', (req, res) => {
]);
});
+// ============================================
+// DX SPOT PATHS API - Get spots with locations for map visualization
+// Returns spots from the last 5 minutes with spotter and DX locations
+// ============================================
+
+// Cache for DX spot paths to avoid excessive lookups
+let dxSpotPathsCache = { paths: [], timestamp: 0 };
+const DXPATHS_CACHE_TTL = 30000; // 30 seconds cache
+
+app.get('/api/dxcluster/paths', async (req, res) => {
+ // Check cache first
+ if (Date.now() - dxSpotPathsCache.timestamp < DXPATHS_CACHE_TTL && dxSpotPathsCache.paths.length > 0) {
+ console.log('[DX Paths] Returning', dxSpotPathsCache.paths.length, 'cached paths');
+ return res.json(dxSpotPathsCache.paths);
+ }
+
+ try {
+ // Get recent DX spots from HamQTH
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 10000);
+
+ const response = await fetch('https://www.hamqth.com/dxc_csv.php?limit=50', {
+ headers: { 'User-Agent': 'OpenHamClock/3.5' },
+ signal: controller.signal
+ });
+ clearTimeout(timeout);
+
+ if (!response.ok) {
+ return res.json([]);
+ }
+
+ const text = await response.text();
+ const lines = text.trim().split('\n').filter(line => line.includes('^'));
+
+ // Parse spots and filter to last 5 minutes
+ const now = new Date();
+ const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
+ const spots = [];
+
+ for (const line of lines) {
+ const parts = line.split('^');
+ if (parts.length < 5) continue;
+
+ const spotter = parts[0]?.trim().toUpperCase();
+ const freqKhz = parseFloat(parts[1]) || 0;
+ const dxCall = parts[2]?.trim().toUpperCase();
+ const comment = parts[3]?.trim() || '';
+ const timeDate = parts[4]?.trim() || '';
+
+ if (!spotter || !dxCall || freqKhz <= 0) continue;
+
+ // Parse time: "2149 2025-05-27" -> check if within last 5 minutes
+ // Note: HamQTH shows UTC time, format is "HHMM YYYY-MM-DD"
+ let spotTime = null;
+ if (timeDate.length >= 15) {
+ const timeStr = timeDate.substring(0, 4); // HHMM
+ const dateStr = timeDate.substring(5); // YYYY-MM-DD
+ const hours = parseInt(timeStr.substring(0, 2));
+ const minutes = parseInt(timeStr.substring(2, 4));
+ spotTime = new Date(`${dateStr}T${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:00Z`);
+ }
+
+ // Include spot if we couldn't parse time or if it's within 5 minutes
+ if (!spotTime || spotTime >= fiveMinutesAgo) {
+ spots.push({
+ spotter,
+ dxCall,
+ freq: (freqKhz / 1000).toFixed(3),
+ comment,
+ time: timeDate.length >= 4 ? timeDate.substring(0, 2) + ':' + timeDate.substring(2, 4) + 'z' : ''
+ });
+ }
+ }
+
+ // Get unique callsigns to look up
+ const allCalls = new Set();
+ spots.forEach(s => {
+ allCalls.add(s.spotter);
+ allCalls.add(s.dxCall);
+ });
+
+ // Look up locations for all callsigns (limit to 40 to avoid timeouts)
+ const locations = {};
+ const callsToLookup = [...allCalls].slice(0, 40);
+
+ for (const call of callsToLookup) {
+ const loc = estimateLocationFromPrefix(call);
+ if (loc) {
+ locations[call] = { lat: loc.lat, lon: loc.lon, country: loc.country };
+ }
+ }
+
+ // Build paths with both locations
+ const paths = spots
+ .map(spot => {
+ const spotterLoc = locations[spot.spotter];
+ const dxLoc = locations[spot.dxCall];
+
+ if (spotterLoc && dxLoc) {
+ return {
+ spotter: spot.spotter,
+ spotterLat: spotterLoc.lat,
+ spotterLon: spotterLoc.lon,
+ spotterCountry: spotterLoc.country,
+ dxCall: spot.dxCall,
+ dxLat: dxLoc.lat,
+ dxLon: dxLoc.lon,
+ dxCountry: dxLoc.country,
+ freq: spot.freq,
+ comment: spot.comment,
+ time: spot.time
+ };
+ }
+ return null;
+ })
+ .filter(p => p !== null)
+ .slice(0, 25); // Limit to 25 paths to avoid cluttering the map
+
+ console.log('[DX Paths]', paths.length, 'paths with locations from', spots.length, 'spots');
+
+ // Update cache
+ dxSpotPathsCache = { paths, timestamp: Date.now() };
+
+ res.json(paths);
+ } catch (error) {
+ console.error('[DX Paths] Error:', error.message);
+ res.json([]);
+ }
+});
+
// ============================================
// CALLSIGN LOOKUP API (for getting location from callsign)
// ============================================