visualize dx spots

pull/27/head
accius 5 days ago
parent e7788ef6be
commit 77b1fac79b

@ -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: `<div style="width: 6px; height: 6px; background: ${color}; border-radius: 50%; border: 1px solid white; box-shadow: 0 0 3px ${color};"></div>`,
iconSize: [6, 6],
iconAnchor: [3, 3]
});
const marker = L.marker([path.dxLat, path.dxLon], { icon: dxIcon })
.bindPopup(`
<div style="font-family: JetBrains Mono, monospace; font-size: 12px;">
<b style="color: ${color}">${path.dxCall}</b><br>
<span style="color: #888">spotted by</span> <b>${path.spotter}</b><br>
<b>${path.freq} MHz</b><br>
${path.comment || ''}<br>
<span style="color: #666">${path.time}</span>
</div>
`)
.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'}
</button>
{/* DX Paths toggle */}
<button
onClick={() => setShowDXPaths(!showDXPaths)}
style={{
position: 'absolute',
top: '10px',
left: '155px',
background: showDXPaths ? 'rgba(68, 136, 255, 0.2)' : 'rgba(0, 0, 0, 0.7)',
border: `1px solid ${showDXPaths ? '#4488ff' : '#666'}`,
color: showDXPaths ? '#4488ff' : '#888',
padding: '6px 10px',
borderRadius: '4px',
fontSize: '11px',
fontFamily: 'JetBrains Mono',
cursor: 'pointer',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
>
📡 {showDXPaths ? 'DX ON' : 'DX OFF'}
</button>
</div>
);
};
@ -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}
/>
</div>
@ -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 */}
<div style={{ position: 'relative', borderRadius: '6px', overflow: 'hidden' }}>
<WorldMap deLocation={config.location} dxLocation={dxLocation} onDXChange={handleDXChange} potaSpots={potaSpots.data} mySpots={mySpots.data} satellites={satellites.positions} />
<WorldMap deLocation={config.location} dxLocation={dxLocation} onDXChange={handleDXChange} potaSpots={potaSpots.data} mySpots={mySpots.data} dxPaths={dxPaths.data} satellites={satellites.positions} />
<div style={{ position: 'absolute', bottom: '8px', left: '50%', transform: 'translateX(-50%)', fontSize: '13px', color: 'var(--text-muted)', background: 'rgba(0,0,0,0.7)', padding: '2px 8px', borderRadius: '4px' }}>
Click map to set DX • 73 de {config.callsign}
</div>

@ -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)
// ============================================

Loading…
Cancel
Save

Powered by TurnKey Linux.