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 }) => ( -