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 = {