diff --git a/public/index.html b/public/index.html
index 2c9a329..d01302e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -285,6 +285,35 @@
margin-bottom: 8px;
letter-spacing: 0.5px;
}
+
+ /* DX Cluster map tooltips */
+ .dx-tooltip {
+ background: rgba(20, 20, 30, 0.95) !important;
+ border: 1px solid rgba(0, 170, 255, 0.5) !important;
+ border-radius: 4px !important;
+ padding: 4px 8px !important;
+ font-family: 'JetBrains Mono', monospace !important;
+ font-size: 11px !important;
+ color: #00aaff !important;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5) !important;
+ }
+ .dx-tooltip::before {
+ border-top-color: rgba(0, 170, 255, 0.5) !important;
+ }
+
+ /* Leaflet popup styling for DX spots */
+ .leaflet-popup-content-wrapper {
+ background: rgba(20, 20, 30, 0.95) !important;
+ border: 1px solid rgba(100, 100, 100, 0.5) !important;
+ border-radius: 8px !important;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5) !important;
+ }
+ .leaflet-popup-content {
+ margin: 10px 12px !important;
+ }
+ .leaflet-popup-tip {
+ background: rgba(20, 20, 30, 0.95) !important;
+ }
@@ -2326,26 +2355,66 @@
}
});
- // Add small markers at DX station end only (to reduce clutter)
- const dxIcon = L.divIcon({
- className: '',
- html: ``,
- iconSize: [6, 6],
- iconAnchor: [3, 3]
- });
+ // Create popup content for spots
+ const dxPopupContent = `
+
+
${path.dxCall}
+ ${path.dxGrid ? `
📍 ${path.dxGrid}
` : ''}
+
${path.freq} MHz
+
spotted by ${path.spotter}
+ ${path.comment ? `
${path.comment}
` : ''}
+
${path.time}
+
+ `;
- const marker = L.marker([path.dxLat, path.dxLon], { icon: dxIcon })
- .bindPopup(`
-
- ${path.dxCall}
- spotted by ${path.spotter}
- ${path.freq} MHz
- ${path.comment || ''}
- ${path.time}
-
- `)
+ const spotterPopupContent = `
+
+
${path.spotter}
+
Spotter
+
spotted ${path.dxCall}
+
on ${path.freq} MHz
+
${path.time}
+
+ `;
+
+ // Add hoverable circle at DX station end
+ const dxCircle = L.circleMarker([path.dxLat, path.dxLon], {
+ radius: 6,
+ fillColor: color,
+ color: '#fff',
+ weight: 1.5,
+ opacity: 1,
+ fillOpacity: 0.9
+ })
+ .bindPopup(dxPopupContent)
+ .bindTooltip(path.dxCall, {
+ permanent: false,
+ direction: 'top',
+ offset: [0, -8],
+ className: 'dx-tooltip'
+ })
+ .addTo(map);
+ dxPathsMarkersRef.current.push(dxCircle);
+
+ // Add hoverable circle at spotter end (smaller, different style)
+ const spotterCircle = L.circleMarker([path.spotterLat, path.spotterLon], {
+ radius: 4,
+ fillColor: '#00aaff',
+ color: '#fff',
+ weight: 1,
+ opacity: 0.8,
+ fillOpacity: 0.7
+ })
+ .bindPopup(spotterPopupContent)
+ .bindTooltip(path.spotter, {
+ permanent: false,
+ direction: 'top',
+ offset: [0, -6],
+ className: 'dx-tooltip'
+ })
.addTo(map);
- dxPathsMarkersRef.current.push(marker);
+ dxPathsMarkersRef.current.push(spotterCircle);
+
} catch (err) {
console.error('[DX Paths] Error rendering path:', err, path);
}
diff --git a/server.js b/server.js
index 1ed282e..1d53f38 100644
--- a/server.js
+++ b/server.js
@@ -801,33 +801,54 @@ app.get('/api/dxcluster/paths', async (req, res) => {
allCalls.add(s.dxCall);
});
- // Look up locations for all callsigns
- const locations = {};
- const callsToLookup = [...allCalls].slice(0, 40);
+ // Look up locations for all callsigns (fallback)
+ const prefixLocations = {};
+ const callsToLookup = [...allCalls].slice(0, 60);
for (const call of callsToLookup) {
const loc = estimateLocationFromPrefix(call);
if (loc) {
- locations[call] = { lat: loc.lat, lon: loc.lon, country: loc.country };
+ prefixLocations[call] = { lat: loc.lat, lon: loc.lon, country: loc.country, source: 'prefix' };
}
}
- // Build new paths with locations
+ // Build new paths with locations - try grid from comment first
const newPaths = newSpots
.map(spot => {
- const spotterLoc = locations[spot.spotter];
- const dxLoc = locations[spot.dxCall];
+ // Try to extract grid from comment for DX station location
+ const dxGrid = extractGridFromComment(spot.comment);
+ let dxLoc = null;
+ let dxGridSquare = null;
+
+ if (dxGrid) {
+ const gridLoc = maidenheadToLatLon(dxGrid);
+ if (gridLoc) {
+ dxLoc = { lat: gridLoc.lat, lon: gridLoc.lon, country: '', source: 'grid' };
+ dxGridSquare = dxGrid;
+ }
+ }
+
+ // Fall back to prefix location if no grid
+ if (!dxLoc) {
+ dxLoc = prefixLocations[spot.dxCall];
+ }
+
+ // Spotter location from prefix (usually no grid available)
+ const spotterLoc = prefixLocations[spot.spotter];
if (spotterLoc && dxLoc) {
return {
spotter: spot.spotter,
spotterLat: spotterLoc.lat,
spotterLon: spotterLoc.lon,
- spotterCountry: spotterLoc.country,
+ spotterCountry: spotterLoc.country || '',
+ spotterLocSource: spotterLoc.source,
dxCall: spot.dxCall,
dxLat: dxLoc.lat,
dxLon: dxLoc.lon,
- dxCountry: dxLoc.country,
+ dxCountry: dxLoc.country || '',
+ dxGrid: dxGridSquare,
+ dxLocSource: dxLoc.source,
freq: spot.freq,
comment: spot.comment,
time: spot.time,
@@ -927,6 +948,76 @@ app.get('/api/callsign/:call', async (req, res) => {
}
});
+// Convert Maidenhead grid locator to lat/lon (center of grid square)
+function maidenheadToLatLon(grid) {
+ if (!grid || typeof grid !== 'string') return null;
+
+ grid = grid.toUpperCase().trim();
+
+ // Validate grid format (2, 4, 6, or 8 characters)
+ if (!/^[A-R]{2}([0-9]{2}([A-X]{2}([0-9]{2})?)?)?$/.test(grid)) return null;
+
+ let lon = -180;
+ let lat = -90;
+
+ // Field (2 chars): 20° lon x 10° lat
+ lon += (grid.charCodeAt(0) - 65) * 20;
+ lat += (grid.charCodeAt(1) - 65) * 10;
+
+ if (grid.length >= 4) {
+ // Square (2 digits): 2° lon x 1° lat
+ lon += parseInt(grid[2]) * 2;
+ lat += parseInt(grid[3]) * 1;
+ }
+
+ if (grid.length >= 6) {
+ // Subsquare (2 chars): 5' lon x 2.5' lat
+ lon += (grid.charCodeAt(4) - 65) * (5 / 60);
+ lat += (grid.charCodeAt(5) - 65) * (2.5 / 60);
+ }
+
+ if (grid.length >= 8) {
+ // Extended square (2 digits): 0.5' lon x 0.25' lat
+ lon += parseInt(grid[6]) * (0.5 / 60);
+ lat += parseInt(grid[7]) * (0.25 / 60);
+ }
+
+ // Add offset to center of the grid square
+ if (grid.length === 2) {
+ lon += 10; lat += 5;
+ } else if (grid.length === 4) {
+ lon += 1; lat += 0.5;
+ } else if (grid.length === 6) {
+ lon += 2.5 / 60; lat += 1.25 / 60;
+ } else if (grid.length === 8) {
+ lon += 0.25 / 60; lat += 0.125 / 60;
+ }
+
+ return { lat, lon, grid };
+}
+
+// Try to extract a grid locator from a comment string
+function extractGridFromComment(comment) {
+ if (!comment || typeof comment !== 'string') return null;
+
+ // Look for 4 or 6 character grid squares (most common)
+ // Pattern: 2 letters + 2 digits + optional 2 letters
+ const match = comment.match(/\b([A-Ra-r]{2}[0-9]{2}(?:[A-Xa-x]{2})?)\b/);
+
+ if (match) {
+ const grid = match[1].toUpperCase();
+ // Validate it's a reasonable grid (not something like "CQ00" or "DE12")
+ const firstChar = grid.charCodeAt(0);
+ const secondChar = grid.charCodeAt(1);
+ // First char should be A-R, second char should be A-R
+ if (firstChar >= 65 && firstChar <= 82 && secondChar >= 65 && secondChar <= 82) {
+ return grid;
+ }
+ }
+
+ return null;
+}
+
// Estimate location from callsign prefix (fallback)
function estimateLocationFromPrefix(callsign) {
const prefixLocations = {