diff --git a/public/index.html b/public/index.html index 54f2b69..b7c8586 100644 --- a/public/index.html +++ b/public/index.html @@ -696,71 +696,162 @@ return { data, loading }; }; - const useDXCluster = (source = 'auto') => { - const [data, setData] = useState([]); + const useDXCluster = (source = 'auto', filters = {}) => { + const [allSpots, setAllSpots] = useState([]); // All accumulated spots + const [data, setData] = useState([]); // Filtered spots for display const [loading, setLoading] = useState(true); const [activeSource, setActiveSource] = useState(''); + const spotRetentionMs = 30 * 60 * 1000; // 30 minutes + const pollInterval = 5000; // 5 seconds + + // Apply filters to spots + const applyFilters = useCallback((spots, filters) => { + if (!filters || Object.keys(filters).length === 0) return spots; + + return spots.filter(spot => { + // Zone filter + if (filters.zones && filters.zones.length > 0) { + // Extract zone from callsign prefix (simplified) + const zone = spot.zone || null; + if (zone && !filters.zones.includes(zone)) return false; + } + + // Band filter + if (filters.bands && filters.bands.length > 0) { + const freq = parseFloat(spot.freq); + const band = getBandFromFreq(freq); + if (!filters.bands.includes(band)) return false; + } + + // Mode filter + if (filters.modes && filters.modes.length > 0) { + const mode = spot.mode || detectMode(spot.comment); + if (mode && !filters.modes.includes(mode)) return false; + } + + // Callsign filter (search in DX call or spotter) + if (filters.callsign && filters.callsign.trim()) { + const search = filters.callsign.toUpperCase().trim(); + const matchesDX = spot.call && spot.call.toUpperCase().includes(search); + const matchesSpotter = spot.spotter && spot.spotter.toUpperCase().includes(search); + if (!matchesDX && !matchesSpotter) return false; + } + + // Exclude callsign patterns + if (filters.exclude && filters.exclude.trim()) { + const excludePatterns = filters.exclude.toUpperCase().split(',').map(p => p.trim()).filter(p => p); + for (const pattern of excludePatterns) { + if (spot.call && spot.call.toUpperCase().includes(pattern)) return false; + } + } + + return true; + }); + }, []); + + // Helper to get band from frequency + const getBandFromFreq = (freq) => { + if (freq >= 1800 && freq <= 2000) return '160m'; + if (freq >= 3500 && freq <= 4000) return '80m'; + if (freq >= 5330 && freq <= 5405) return '60m'; + if (freq >= 7000 && freq <= 7300) return '40m'; + if (freq >= 10100 && freq <= 10150) return '30m'; + if (freq >= 14000 && freq <= 14350) return '20m'; + if (freq >= 18068 && freq <= 18168) return '17m'; + if (freq >= 21000 && freq <= 21450) return '15m'; + if (freq >= 24890 && freq <= 24990) return '12m'; + if (freq >= 28000 && freq <= 29700) return '10m'; + if (freq >= 50000 && freq <= 54000) return '6m'; + if (freq >= 144000 && freq <= 148000) return '2m'; + return 'other'; + }; + + // Helper to detect mode from comment + const detectMode = (comment) => { + if (!comment) return null; + const upper = comment.toUpperCase(); + if (upper.includes('FT8') || upper.includes('FT4')) return 'DIGI'; + if (upper.includes('CW')) return 'CW'; + if (upper.includes('SSB') || upper.includes('LSB') || upper.includes('USB')) return 'SSB'; + if (upper.includes('RTTY') || upper.includes('PSK')) return 'DIGI'; + return null; + }; useEffect(() => { const fetchDX = async () => { try { - // Use our proxy endpoint with source parameter const response = await fetch(`/api/dxcluster/spots?source=${source}`); if (response.ok) { - const spots = await response.json(); - if (spots && spots.length > 0) { - // Track the active source from the first spot - if (spots[0].source) { - setActiveSource(spots[0].source); + const newSpots = await response.json(); + const now = Date.now(); + + if (newSpots && newSpots.length > 0) { + if (newSpots[0].source) { + setActiveSource(newSpots[0].source); } - setData(spots.slice(0, 15).map(s => ({ + + // Process new spots with timestamps + const processedSpots = newSpots.map(s => ({ freq: s.freq || (s.frequency ? (parseFloat(s.frequency) / 1000).toFixed(3) : '0.000'), call: s.call || s.dx_call || 'UNKNOWN', comment: s.comment || s.info || '', time: s.time || new Date().toISOString().substr(11, 5) + 'z', - spotter: s.spotter || '' - }))); - } else { - setData([{ - freq: '---', - call: 'NO SPOTS', - comment: 'No DX spots available', - time: '--:--z', - spotter: '' - }]); - setActiveSource(''); + spotter: s.spotter || '', + zone: s.zone || null, + mode: s.mode || null, + timestamp: now, + id: `${s.call || s.dx_call}-${s.freq}-${s.spotter}-${now}` + })); + + setAllSpots(prev => { + // Remove expired spots (older than 30 minutes) + const validSpots = prev.filter(s => (now - s.timestamp) < spotRetentionMs); + + // Merge new spots, avoiding duplicates (same call+freq within 2 minutes) + const merged = [...validSpots]; + for (const newSpot of processedSpots) { + const isDuplicate = merged.some(existing => + existing.call === newSpot.call && + existing.freq === newSpot.freq && + (now - existing.timestamp) < 120000 // 2 minute dedup window + ); + if (!isDuplicate) { + merged.push(newSpot); + } + } + + // Sort by timestamp (newest first) and limit + return merged.sort((a, b) => b.timestamp - a.timestamp).slice(0, 200); + }); } - } else { - setData([{ - freq: '---', - call: 'OFFLINE', - comment: 'DX cluster unavailable', - time: '--:--z', - spotter: '' - }]); - setActiveSource(''); } } catch (err) { console.error('DX Cluster error:', err); - setData([{ - freq: '---', - call: 'ERROR', - comment: 'Failed to fetch', - time: '--:--z', - spotter: '' - }]); - setActiveSource(''); } finally { setLoading(false); } }; + fetchDX(); - const interval = setInterval(fetchDX, DEFAULT_CONFIG.refreshIntervals.dxCluster); + const interval = setInterval(fetchDX, pollInterval); return () => clearInterval(interval); }, [source]); - - return { data, loading, activeSource }; + + // Update filtered data when allSpots or filters change + useEffect(() => { + const filtered = applyFilters(allSpots, filters); + setData(filtered.slice(0, 50)); // Return up to 50 filtered spots + }, [allSpots, filters, applyFilters]); + + return { + data, + allSpots, // All accumulated spots (unfiltered) + loading, + activeSource, + spotCount: allSpots.length, + filteredCount: data.length + }; }; // ============================================ @@ -791,8 +882,8 @@ }; fetchPaths(); - // Refresh every 30 seconds - const interval = setInterval(fetchPaths, 30000); + // Refresh every 10 seconds to keep up with faster cluster polling + const interval = setInterval(fetchPaths, 10000); return () => clearInterval(interval); }, []); @@ -2504,47 +2595,208 @@ ); }; - const DXClusterPanel = ({ spots, loading, activeSource, showOnMap, onToggleMap }) => ( -
-
- 🌐 DX CLUSTER -
- {loading &&
} - {activeSource && {activeSource}} - - ● LIVE + const DXClusterPanel = ({ spots, loading, activeSource, showOnMap, onToggleMap, spotCount, filteredCount, filters, onFilterChange }) => { + const [showFilters, setShowFilters] = useState(false); + + const bandOptions = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m']; + const modeOptions = ['CW', 'SSB', 'DIGI']; + + const toggleBand = (band) => { + const current = filters?.bands || []; + const newBands = current.includes(band) + ? current.filter(b => b !== band) + : [...current, band]; + onFilterChange({ ...filters, bands: newBands.length > 0 ? newBands : undefined }); + }; + + const toggleMode = (mode) => { + const current = filters?.modes || []; + const newModes = current.includes(mode) + ? current.filter(m => m !== mode) + : [...current, mode]; + onFilterChange({ ...filters, modes: newModes.length > 0 ? newModes : undefined }); + }; + + const hasActiveFilters = (filters?.bands?.length > 0) || (filters?.modes?.length > 0) || filters?.callsign || filters?.exclude; + + return ( +
+
+ 🌐 DX CLUSTER +
+ {loading &&
} + + {filteredCount !== undefined && spotCount !== undefined ? `${filteredCount}/${spotCount}` : ''} + + + + ● LIVE +
-
-
- {spots.map((s, i) => ( -
- {s.freq} - {s.call} - {s.comment} - {s.time} + + {/* Filter controls */} + {showFilters && ( +
+ {/* Callsign search */} +
+ onFilterChange({ ...filters, callsign: e.target.value || undefined })} + style={{ + width: '100%', + padding: '4px 8px', + background: 'var(--bg-secondary)', + border: '1px solid var(--border-color)', + borderRadius: '4px', + color: 'var(--text-primary)', + fontSize: '11px', + fontFamily: 'JetBrains Mono' + }} + /> +
+ + {/* Band filters */} +
+
Bands:
+
+ {bandOptions.map(band => ( + + ))} +
+
+ + {/* Mode filters */} +
+
Modes:
+
+ {modeOptions.map(mode => ( + + ))} +
+
+ + {/* Exclude patterns */} +
+ onFilterChange({ ...filters, exclude: e.target.value || undefined })} + style={{ + width: '100%', + padding: '4px 8px', + background: 'var(--bg-secondary)', + border: '1px solid var(--border-color)', + borderRadius: '4px', + color: 'var(--text-primary)', + fontSize: '11px', + fontFamily: 'JetBrains Mono' + }} + /> +
+ + {/* Clear filters */} + {hasActiveFilters && ( + + )}
- ))} + )} + + {activeSource &&
Source: {activeSource}
} + +
+ {spots.length > 0 ? spots.map((s, i) => ( +
+ {s.freq} + {s.call} + {s.comment} + {s.time} +
+ )) : ( +
+ {hasActiveFilters ? 'No spots match filters' : 'No spots available'} +
+ )} +
-
- ); + ); + }; const POTAPanel = ({ activities, loading, showOnMap, onToggleMap }) => (
@@ -3038,7 +3290,7 @@ {/* DX Cluster */}
- 🌐 DX CLUSTER + 🌐 DX CLUSTER {dxCluster.filteredCount}/{dxCluster.spotCount}
+ {/* Filter bar */} +
+ setDxFilters(prev => ({ ...prev, callsign: e.target.value || undefined }))} + style={{ + flex: 1, + padding: '3px 6px', + background: 'var(--bg-secondary)', + border: '1px solid var(--border-color)', + borderRadius: '3px', + color: 'var(--text-primary)', + fontSize: '10px', + fontFamily: 'JetBrains Mono' + }} + /> + {(dxFilters?.callsign || dxFilters?.bands?.length || dxFilters?.modes?.length) && ( + + )} +
{dxCluster.data.slice(0, 8).map((s, i) => (
@@ -3069,6 +3356,7 @@
{s.comment}
))} + {dxCluster.data.length === 0 &&
No spots match filter
}
@@ -3698,7 +3986,23 @@ const bandConditions = useBandConditions(spaceWeather.data); const solarIndices = useSolarIndices(); const potaSpots = usePOTASpots(); - const dxCluster = useDXCluster(config.dxClusterSource || 'auto'); + + // DX Cluster filters with localStorage persistence + const [dxFilters, setDxFilters] = useState(() => { + try { + const stored = localStorage.getItem('openhamclock_dxFilters'); + return stored ? JSON.parse(stored) : {}; + } catch (e) { return {}; } + }); + + // Save DX filters when changed + useEffect(() => { + try { + localStorage.setItem('openhamclock_dxFilters', JSON.stringify(dxFilters)); + } catch (e) {} + }, [dxFilters]); + + const dxCluster = useDXCluster(config.dxClusterSource || 'auto', dxFilters); const dxPaths = useDXPaths(); const dxpeditions = useDXpeditions(); const contests = useContests(); @@ -3972,11 +4276,12 @@ {/* RIGHT SIDEBAR */}
- {/* DX Cluster - Compact */} + {/* DX Cluster - Compact with filters */}
🌐 DX CLUSTER ● LIVE
+ {dxCluster.filteredCount}/{dxCluster.spotCount}
-
+ {/* Filter bar */} +
+ setDxFilters(prev => ({ ...prev, callsign: e.target.value || undefined }))} + style={{ + flex: 1, + padding: '3px 6px', + background: 'var(--bg-secondary)', + border: '1px solid var(--border-color)', + borderRadius: '3px', + color: 'var(--text-primary)', + fontSize: '10px', + fontFamily: 'JetBrains Mono' + }} + /> + {(dxFilters?.callsign || dxFilters?.bands?.length || dxFilters?.modes?.length) && ( + + )} +
+
{dxCluster.data.slice(0, 8).map((s, i) => (
diff --git a/server.js b/server.js index 2fab155..e5536d9 100644 --- a/server.js +++ b/server.js @@ -657,8 +657,9 @@ app.get('/api/dxcluster/sources', (req, res) => { // ============================================ // Cache for DX spot paths to avoid excessive lookups -let dxSpotPathsCache = { paths: [], timestamp: 0 }; -const DXPATHS_CACHE_TTL = 30000; // 30 seconds cache +let dxSpotPathsCache = { paths: [], allPaths: [], timestamp: 0 }; +const DXPATHS_CACHE_TTL = 5000; // 5 seconds cache between fetches +const DXPATHS_RETENTION = 30 * 60 * 1000; // 30 minute spot retention app.get('/api/dxcluster/paths', async (req, res) => { // Check cache first @@ -673,22 +674,24 @@ app.get('/api/dxcluster/paths', async (req, res) => { 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' }, + headers: { 'User-Agent': 'OpenHamClock/3.7' }, signal: controller.signal }); clearTimeout(timeout); + const now = Date.now(); + if (!response.ok) { - return res.json([]); + // Return existing paths if fetch failed + const validPaths = dxSpotPathsCache.allPaths.filter(p => (now - p.timestamp) < DXPATHS_RETENTION); + return res.json(validPaths.slice(0, 50)); } 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 = []; + // Parse new spots + const newSpots = []; for (const line of lines) { const parts = line.split('^'); @@ -702,37 +705,24 @@ app.get('/api/dxcluster/paths', async (req, res) => { 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' : '' - }); - } + newSpots.push({ + spotter, + dxCall, + freq: (freqKhz / 1000).toFixed(3), + comment, + time: timeDate.length >= 4 ? timeDate.substring(0, 2) + ':' + timeDate.substring(2, 4) + 'z' : '', + id: `${dxCall}-${freqKhz}-${spotter}` + }); } // Get unique callsigns to look up const allCalls = new Set(); - spots.forEach(s => { + newSpots.forEach(s => { allCalls.add(s.spotter); allCalls.add(s.dxCall); }); - // Look up locations for all callsigns (limit to 40 to avoid timeouts) + // Look up locations for all callsigns const locations = {}; const callsToLookup = [...allCalls].slice(0, 40); @@ -743,8 +733,8 @@ app.get('/api/dxcluster/paths', async (req, res) => { } } - // Build paths with both locations - const paths = spots + // Build new paths with locations + const newPaths = newSpots .map(spot => { const spotterLoc = locations[spot.spotter]; const dxLoc = locations[spot.dxCall]; @@ -761,23 +751,50 @@ app.get('/api/dxcluster/paths', async (req, res) => { dxCountry: dxLoc.country, freq: spot.freq, comment: spot.comment, - time: spot.time + time: spot.time, + id: spot.id, + timestamp: now }; } return null; }) - .filter(p => p !== null) - .slice(0, 25); // Limit to 25 paths to avoid cluttering the map + .filter(p => p !== null); - console.log('[DX Paths]', paths.length, 'paths with locations from', spots.length, 'spots'); + // Merge with existing paths, removing expired and duplicates + const existingValidPaths = dxSpotPathsCache.allPaths.filter(p => + (now - p.timestamp) < DXPATHS_RETENTION + ); + + // Add new paths, avoiding duplicates (same dxCall+freq within 2 minutes) + const mergedPaths = [...existingValidPaths]; + for (const newPath of newPaths) { + const isDuplicate = mergedPaths.some(existing => + existing.dxCall === newPath.dxCall && + existing.freq === newPath.freq && + (now - existing.timestamp) < 120000 // 2 minute dedup window + ); + if (!isDuplicate) { + mergedPaths.push(newPath); + } + } + + // Sort by timestamp (newest first) and limit + const sortedPaths = mergedPaths.sort((a, b) => b.timestamp - a.timestamp).slice(0, 100); + + console.log('[DX Paths]', sortedPaths.length, 'total paths (', newPaths.length, 'new from', newSpots.length, 'spots)'); // Update cache - dxSpotPathsCache = { paths, timestamp: Date.now() }; + dxSpotPathsCache = { + paths: sortedPaths.slice(0, 50), // Return 50 for display + allPaths: sortedPaths, // Keep all for accumulation + timestamp: now + }; - res.json(paths); + res.json(dxSpotPathsCache.paths); } catch (error) { console.error('[DX Paths] Error:', error.message); - res.json([]); + // Return cached data on error + res.json(dxSpotPathsCache.paths || []); } });