diff --git a/public/index.html b/public/index.html
index 9a8f14b..822ea84 100644
--- a/public/index.html
+++ b/public/index.html
@@ -705,6 +705,66 @@
}
} catch (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([{
freq: '---',
call: 'ERROR',
@@ -1182,7 +1242,7 @@
// ============================================
// LEAFLET MAP COMPONENT
// ============================================
- const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots }) => {
+ const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots, mySpots }) => {
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
const tileLayerRef = useRef(null);
@@ -1192,6 +1252,8 @@
const dxMarkerRef = useRef(null);
const sunMarkerRef = useRef(null);
const potaMarkersRef = useRef([]);
+ const mySpotsMarkersRef = useRef([]);
+ const mySpotsLinesRef = useRef([]);
const [mapStyle, setMapStyle] = useState('dark');
// Initialize map
@@ -1350,6 +1412,62 @@
}, [deLocation, dxLocation]);
// 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: `
${spot.targetCall}
`,
+ iconAnchor: [25, 12]
+ });
+
+ const marker = L.marker([spot.lat, spot.lon], { icon })
+ .bindPopup(`
+ ${spot.targetCall}
+ ${spot.isMySpot ? 'You spotted' : 'Spotted you'}
+ ${spot.freq} MHz
+ ${spot.comment || ''}
+ ${spot.time}
+ `)
+ .addTo(map);
+ mySpotsMarkersRef.current.push(marker);
+ }
+ });
+ }
+ }, [mySpots, deLocation]);
+
useEffect(() => {
if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current;
@@ -1689,7 +1807,7 @@
const LegacyLayout = ({
config, currentTime, utcTime, utcDate, localTime, localDate,
deGrid, dxGrid, deSunTimes, dxSunTimes, dxLocation, onDXChange,
- spaceWeather, bandConditions, potaSpots, dxCluster, contests, propagation,
+ spaceWeather, bandConditions, potaSpots, dxCluster, contests, propagation, mySpots,
onSettingsClick
}) => {
const bearing = calculateBearing(config.location.lat, config.location.lon, dxLocation.lat, dxLocation.lon);
@@ -1848,7 +1966,8 @@
deLocation={config.location}
dxLocation={dxLocation}
onDXChange={onDXChange}
- potaSpots={potaSpots.data}
+ potaSpots={potaSpots.data}
+ mySpots={mySpots.data}
/>
@@ -2298,6 +2417,7 @@
const dxCluster = useDXCluster();
const contests = useContests();
const propagation = usePropagation(config.location, dxLocation);
+ const mySpots = useMySpots(config.callsign);
const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]);
const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]);
@@ -2348,6 +2468,7 @@
dxCluster={dxCluster}
contests={contests}
propagation={propagation}
+ mySpots={mySpots}
onSettingsClick={() => setShowSettings(true)}
/>
-
+
Click map to set DX • 73 de {config.callsign}
diff --git a/server.js b/server.js
index f490a66..bd19e28 100644
--- a/server.js
+++ b/server.js
@@ -282,6 +282,209 @@ app.get('/api/dxcluster/spots', async (req, res) => {
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>/);
+ const lonMatch = text.match(/([^<]+)<\/lng>/);
+ const countryMatch = text.match(/([^<]+)<\/name>/);
+ const cqMatch = text.match(/([^<]+)<\/cq>/);
+ const ituMatch = text.match(/([^<]+)<\/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
// ============================================