Add My Spots - show user's connections on map with lines and markers

pull/1/head
accius 5 days ago
parent c62b3d7bd5
commit 5c27d39a79

@ -705,6 +705,66 @@
} }
} catch (err) { } catch (err) {
console.error('DX Cluster error:', err); console.error('DX Cluster error:', err);
setData([{
freq: '---',
call: 'ERROR',
comment: 'Failed to fetch',
time: '--:--z',
spotter: ''
}]);
} finally {
setLoading(false);
}
};
fetchDX();
const interval = setInterval(fetchDX, DEFAULT_CONFIG.refreshIntervals.dxCluster);
return () => clearInterval(interval);
}, []);
return { data, loading };
};
// ============================================
// MY SPOTS HOOK - Spots involving the user's callsign
// ============================================
const useMySpots = (callsign) => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!callsign || callsign === 'N0CALL') {
setData([]);
setLoading(false);
return;
}
const fetchMySpots = async () => {
try {
const response = await fetch(`/api/myspots/${encodeURIComponent(callsign)}`);
if (response.ok) {
const spots = await response.json();
setData(spots.slice(0, 20)); // Limit to 20 spots
console.log('[My Spots] Loaded', spots.length, 'spots for', callsign);
} else {
setData([]);
}
} catch (err) {
console.error('My Spots error:', err);
setData([]);
} finally {
setLoading(false);
}
};
fetchMySpots();
// Refresh every 2 minutes
const interval = setInterval(fetchMySpots, 120000);
return () => clearInterval(interval);
}, [callsign]);
return { data, loading };
};
setData([{ setData([{
freq: '---', freq: '---',
call: 'ERROR', call: 'ERROR',
@ -1182,7 +1242,7 @@
// ============================================ // ============================================
// LEAFLET MAP COMPONENT // LEAFLET MAP COMPONENT
// ============================================ // ============================================
const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots }) => { const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots, mySpots }) => {
const mapRef = useRef(null); const mapRef = useRef(null);
const mapInstanceRef = useRef(null); const mapInstanceRef = useRef(null);
const tileLayerRef = useRef(null); const tileLayerRef = useRef(null);
@ -1192,6 +1252,8 @@
const dxMarkerRef = useRef(null); const dxMarkerRef = useRef(null);
const sunMarkerRef = useRef(null); const sunMarkerRef = useRef(null);
const potaMarkersRef = useRef([]); const potaMarkersRef = useRef([]);
const mySpotsMarkersRef = useRef([]);
const mySpotsLinesRef = useRef([]);
const [mapStyle, setMapStyle] = useState('dark'); const [mapStyle, setMapStyle] = useState('dark');
// Initialize map // Initialize map
@ -1350,6 +1412,62 @@
}, [deLocation, dxLocation]); }, [deLocation, dxLocation]);
// Update POTA markers // Update POTA markers
// Update my spots markers and connection lines
useEffect(() => {
if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current;
// Remove old my spots markers and lines
mySpotsMarkersRef.current.forEach(m => map.removeLayer(m));
mySpotsMarkersRef.current = [];
mySpotsLinesRef.current.forEach(l => map.removeLayer(l));
mySpotsLinesRef.current = [];
// Add new my spots markers and lines
if (mySpots && mySpots.length > 0) {
mySpots.forEach(spot => {
if (spot.lat && spot.lon) {
// Draw great circle line from DE to spot location
const pathPoints = getGreatCirclePoints(
deLocation.lat, deLocation.lon,
spot.lat, spot.lon
);
// Handle antimeridian crossing - pathPoints may be array of segments
const segments = Array.isArray(pathPoints[0]) ? pathPoints : [pathPoints];
segments.forEach(segment => {
const line = L.polyline(segment, {
color: spot.isMySpot ? '#00ffaa' : '#ffaa00', // Green if I spotted, amber if spotted me
weight: 2,
opacity: 0.7,
dashArray: '5, 10'
}).addTo(map);
mySpotsLinesRef.current.push(line);
});
// Create marker for the spot
const markerColor = spot.isMySpot ? '#00ffaa' : '#ffaa00';
const icon = L.divIcon({
className: '',
html: `<div style="background: ${markerColor}; color: #000; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-family: JetBrains Mono; white-space: nowrap; border: 2px solid white; font-weight: bold;">${spot.targetCall}</div>`,
iconAnchor: [25, 12]
});
const marker = L.marker([spot.lat, spot.lon], { icon })
.bindPopup(`
<b style="color: ${markerColor}">${spot.targetCall}</b><br>
<span style="color: #888">${spot.isMySpot ? 'You spotted' : 'Spotted you'}</span><br>
<b>${spot.freq} MHz</b><br>
${spot.comment || ''}<br>
<span style="color: #666">${spot.time}</span>
`)
.addTo(map);
mySpotsMarkersRef.current.push(marker);
}
});
}
}, [mySpots, deLocation]);
useEffect(() => { useEffect(() => {
if (!mapInstanceRef.current) return; if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current; const map = mapInstanceRef.current;
@ -1689,7 +1807,7 @@
const LegacyLayout = ({ const LegacyLayout = ({
config, currentTime, utcTime, utcDate, localTime, localDate, config, currentTime, utcTime, utcDate, localTime, localDate,
deGrid, dxGrid, deSunTimes, dxSunTimes, dxLocation, onDXChange, deGrid, dxGrid, deSunTimes, dxSunTimes, dxLocation, onDXChange,
spaceWeather, bandConditions, potaSpots, dxCluster, contests, propagation, spaceWeather, bandConditions, potaSpots, dxCluster, contests, propagation, mySpots,
onSettingsClick onSettingsClick
}) => { }) => {
const bearing = calculateBearing(config.location.lat, config.location.lon, dxLocation.lat, dxLocation.lon); const bearing = calculateBearing(config.location.lat, config.location.lon, dxLocation.lat, dxLocation.lon);
@ -1848,7 +1966,8 @@
deLocation={config.location} deLocation={config.location}
dxLocation={dxLocation} dxLocation={dxLocation}
onDXChange={onDXChange} onDXChange={onDXChange}
potaSpots={potaSpots.data} potaSpots={potaSpots.data}
mySpots={mySpots.data}
/> />
</div> </div>
@ -2298,6 +2417,7 @@
const dxCluster = useDXCluster(); const dxCluster = useDXCluster();
const contests = useContests(); const contests = useContests();
const propagation = usePropagation(config.location, dxLocation); const propagation = usePropagation(config.location, dxLocation);
const mySpots = useMySpots(config.callsign);
const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]); const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]);
const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]); const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]);
@ -2348,6 +2468,7 @@
dxCluster={dxCluster} dxCluster={dxCluster}
contests={contests} contests={contests}
propagation={propagation} propagation={propagation}
mySpots={mySpots}
onSettingsClick={() => setShowSettings(true)} onSettingsClick={() => setShowSettings(true)}
/> />
<SettingsPanel <SettingsPanel
@ -2493,7 +2614,7 @@
{/* CENTER - MAP */} {/* CENTER - MAP */}
<div style={{ position: 'relative', borderRadius: '6px', overflow: 'hidden' }}> <div style={{ position: 'relative', borderRadius: '6px', overflow: 'hidden' }}>
<WorldMap deLocation={config.location} dxLocation={dxLocation} onDXChange={handleDXChange} potaSpots={potaSpots.data} /> <WorldMap deLocation={config.location} dxLocation={dxLocation} onDXChange={handleDXChange} potaSpots={potaSpots.data} mySpots={mySpots.data} />
<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' }}> <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} Click map to set DX • 73 de {config.callsign}
</div> </div>

@ -282,6 +282,209 @@ app.get('/api/dxcluster/spots', async (req, res) => {
res.json([]); res.json([]);
}); });
// ============================================
// CALLSIGN LOOKUP API (for getting location from callsign)
// ============================================
// Simple callsign to grid/location lookup using HamQTH
app.get('/api/callsign/:call', async (req, res) => {
const callsign = req.params.call.toUpperCase();
console.log('[Callsign Lookup] Looking up:', callsign);
try {
// Try HamQTH XML API (no auth needed for basic lookup)
const response = await fetch(`https://www.hamqth.com/dxcc.php?callsign=${callsign}`);
if (response.ok) {
const text = await response.text();
// Parse basic info from response
const latMatch = text.match(/<lat>([^<]+)<\/lat>/);
const lonMatch = text.match(/<lng>([^<]+)<\/lng>/);
const countryMatch = text.match(/<name>([^<]+)<\/name>/);
const cqMatch = text.match(/<cq>([^<]+)<\/cq>/);
const ituMatch = text.match(/<itu>([^<]+)<\/itu>/);
if (latMatch && lonMatch) {
const result = {
callsign,
lat: parseFloat(latMatch[1]),
lon: parseFloat(lonMatch[1]),
country: countryMatch ? countryMatch[1] : 'Unknown',
cqZone: cqMatch ? cqMatch[1] : '',
ituZone: ituMatch ? ituMatch[1] : ''
};
console.log('[Callsign Lookup] Found:', result);
return res.json(result);
}
}
// Fallback: estimate location from callsign prefix
const estimated = estimateLocationFromPrefix(callsign);
if (estimated) {
console.log('[Callsign Lookup] Estimated from prefix:', estimated);
return res.json(estimated);
}
res.status(404).json({ error: 'Callsign not found' });
} catch (error) {
console.error('[Callsign Lookup] Error:', error.message);
res.status(500).json({ error: 'Lookup failed' });
}
});
// Estimate location from callsign prefix (fallback)
function estimateLocationFromPrefix(callsign) {
const prefixLocations = {
'K': { lat: 39.8, lon: -98.5, country: 'USA' },
'W': { lat: 39.8, lon: -98.5, country: 'USA' },
'N': { lat: 39.8, lon: -98.5, country: 'USA' },
'AA': { lat: 39.8, lon: -98.5, country: 'USA' },
'AB': { lat: 39.8, lon: -98.5, country: 'USA' },
'VE': { lat: 56.1, lon: -106.3, country: 'Canada' },
'VA': { lat: 56.1, lon: -106.3, country: 'Canada' },
'G': { lat: 52.4, lon: -1.5, country: 'England' },
'M': { lat: 52.4, lon: -1.5, country: 'England' },
'F': { lat: 46.2, lon: 2.2, country: 'France' },
'DL': { lat: 51.2, lon: 10.4, country: 'Germany' },
'DJ': { lat: 51.2, lon: 10.4, country: 'Germany' },
'DK': { lat: 51.2, lon: 10.4, country: 'Germany' },
'I': { lat: 41.9, lon: 12.6, country: 'Italy' },
'JA': { lat: 36.2, lon: 138.3, country: 'Japan' },
'JH': { lat: 36.2, lon: 138.3, country: 'Japan' },
'JR': { lat: 36.2, lon: 138.3, country: 'Japan' },
'VK': { lat: -25.3, lon: 133.8, country: 'Australia' },
'ZL': { lat: -40.9, lon: 174.9, country: 'New Zealand' },
'ZS': { lat: -30.6, lon: 22.9, country: 'South Africa' },
'LU': { lat: -38.4, lon: -63.6, country: 'Argentina' },
'PY': { lat: -14.2, lon: -51.9, country: 'Brazil' },
'EA': { lat: 40.5, lon: -3.7, country: 'Spain' },
'CT': { lat: 39.4, lon: -8.2, country: 'Portugal' },
'PA': { lat: 52.1, lon: 5.3, country: 'Netherlands' },
'ON': { lat: 50.5, lon: 4.5, country: 'Belgium' },
'OZ': { lat: 56.3, lon: 9.5, country: 'Denmark' },
'SM': { lat: 60.1, lon: 18.6, country: 'Sweden' },
'LA': { lat: 60.5, lon: 8.5, country: 'Norway' },
'OH': { lat: 61.9, lon: 25.7, country: 'Finland' },
'UA': { lat: 61.5, lon: 105.3, country: 'Russia' },
'RU': { lat: 61.5, lon: 105.3, country: 'Russia' },
'RA': { lat: 61.5, lon: 105.3, country: 'Russia' },
'BY': { lat: 35.9, lon: 104.2, country: 'China' },
'BV': { lat: 23.7, lon: 121.0, country: 'Taiwan' },
'HL': { lat: 35.9, lon: 127.8, country: 'South Korea' },
'VU': { lat: 20.6, lon: 79.0, country: 'India' },
'HS': { lat: 15.9, lon: 100.9, country: 'Thailand' },
'DU': { lat: 12.9, lon: 121.8, country: 'Philippines' },
'YB': { lat: -0.8, lon: 113.9, country: 'Indonesia' },
'9V': { lat: 1.4, lon: 103.8, country: 'Singapore' },
'9M': { lat: 4.2, lon: 101.9, country: 'Malaysia' }
};
// Try 2-char prefix first, then 1-char
const prefix2 = callsign.substring(0, 2);
const prefix1 = callsign.substring(0, 1);
if (prefixLocations[prefix2]) {
return { callsign, ...prefixLocations[prefix2], estimated: true };
}
if (prefixLocations[prefix1]) {
return { callsign, ...prefixLocations[prefix1], estimated: true };
}
return null;
}
// ============================================
// MY SPOTS API - Get spots involving a specific callsign
// ============================================
app.get('/api/myspots/:callsign', async (req, res) => {
const callsign = req.params.callsign.toUpperCase();
console.log('[My Spots] Searching for callsign:', callsign);
const mySpots = [];
try {
// Try HamQTH for spots involving this callsign
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const response = await fetch(
`https://www.hamqth.com/dxc_csv.php?limit=100`,
{
headers: { 'User-Agent': 'OpenHamClock/3.3' },
signal: controller.signal
}
);
clearTimeout(timeout);
if (response.ok) {
const text = await response.text();
const lines = text.trim().split('\n');
for (const line of lines) {
if (!line.trim()) continue;
const parts = line.split('^');
if (parts.length < 3) continue;
const spotter = parts[0]?.trim().toUpperCase();
const dxCall = parts[2]?.trim().toUpperCase();
const freq = parts[1]?.trim();
const comment = parts[3]?.trim() || '';
const timeStr = parts[4]?.trim() || '';
// Check if our callsign is involved (as spotter or spotted)
if (spotter === callsign || dxCall === callsign ||
spotter.includes(callsign) || dxCall.includes(callsign)) {
mySpots.push({
spotter,
dxCall,
freq: freq ? (parseFloat(freq) / 1000).toFixed(3) : '0.000',
comment,
time: timeStr ? timeStr.substring(0, 5) + 'z' : '',
isMySpot: spotter.includes(callsign),
isSpottedMe: dxCall.includes(callsign)
});
}
}
}
console.log('[My Spots] Found', mySpots.length, 'spots involving', callsign);
// Now try to get locations for each unique callsign
const uniqueCalls = [...new Set(mySpots.map(s => s.isMySpot ? s.dxCall : s.spotter))];
const locations = {};
for (const call of uniqueCalls.slice(0, 10)) { // Limit to 10 lookups
try {
const loc = estimateLocationFromPrefix(call);
if (loc) {
locations[call] = { lat: loc.lat, lon: loc.lon, country: loc.country };
}
} catch (e) {
// Ignore lookup errors
}
}
// Add locations to spots
const spotsWithLocations = mySpots.map(spot => {
const targetCall = spot.isMySpot ? spot.dxCall : spot.spotter;
const loc = locations[targetCall];
return {
...spot,
targetCall,
lat: loc?.lat,
lon: loc?.lon,
country: loc?.country
};
}).filter(s => s.lat && s.lon); // Only return spots with valid locations
res.json(spotsWithLocations);
} catch (error) {
console.error('[My Spots] Error:', error.message);
res.json([]);
}
});
// ============================================ // ============================================
// VOACAP / HF PROPAGATION PREDICTION API // VOACAP / HF PROPAGATION PREDICTION API
// ============================================ // ============================================

Loading…
Cancel
Save

Powered by TurnKey Linux.