diff --git a/dxspider-proxy/server.js b/dxspider-proxy/server.js index bfd9e65..eef237f 100644 --- a/dxspider-proxy/server.js +++ b/dxspider-proxy/server.js @@ -369,6 +369,7 @@ app.get('/api/stats', (req, res) => { else if (freq >= 18068 && freq <= 18168) band = '17m'; else if (freq >= 21000 && freq <= 21450) band = '15m'; else if (freq >= 24890 && freq <= 24990) band = '12m'; + else if (freq >= 26500 && freq <= 27500) band = '11m'; // CB band else if (freq >= 28000 && freq <= 29700) band = '10m'; else if (freq >= 50000 && freq <= 54000) band = '6m'; diff --git a/index.html b/index.html index 6b1f80c..a70dc8c 100644 --- a/index.html +++ b/index.html @@ -19,21 +19,27 @@ + + +
@@ -213,6 +347,8 @@ callsign: 'N0CALL', location: { lat: 40.0150, lon: -105.2705 }, // Boulder, CO (default) defaultDX: { lat: 35.6762, lon: 139.6503 }, // Tokyo + theme: 'dark', // 'dark', 'light', or 'legacy' + layout: 'modern', // 'modern' or 'legacy' refreshIntervals: { spaceWeather: 300000, bandConditions: 300000, @@ -245,19 +381,24 @@ } }; + // Apply theme to document + const applyTheme = (theme) => { + document.documentElement.setAttribute('data-theme', theme); + }; + // ============================================ // MAP TILE PROVIDERS // ============================================ const MAP_STYLES = { dark: { name: 'Dark', - url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', - attribution: '© OSM © CARTO' + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}', + attribution: '© Esri' }, satellite: { name: 'Satellite', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', - attribution: '© Esri' + attribution: '© Esri' }, terrain: { name: 'Terrain', @@ -272,22 +413,22 @@ topo: { name: 'Topo', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}', - attribution: '© Esri' + attribution: '© Esri' }, - ocean: { + watercolor: { name: 'Ocean', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}', - attribution: '© Esri' + attribution: '© Esri' }, - natgeo: { - name: 'NatGeo', - url: 'https://server.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}', - attribution: '© Esri © National Geographic' + hybrid: { + name: 'Hybrid', + url: 'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', + attribution: '© Google' }, gray: { name: 'Gray', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}', - attribution: '© Esri' + attribution: '© Esri' } }; @@ -333,6 +474,97 @@ return { lat: declination, lon: longitude }; }; + // Calculate moon position (sublunar point) + const getMoonPosition = (date) => { + // Julian date calculation + const JD = date.getTime() / 86400000 + 2440587.5; + const T = (JD - 2451545.0) / 36525; // Julian centuries from J2000 + + // Moon's mean longitude + const L0 = (218.316 + 481267.8813 * T) % 360; + + // Moon's mean anomaly + const M = (134.963 + 477198.8676 * T) % 360; + const MRad = M * Math.PI / 180; + + // Moon's mean elongation + const D = (297.850 + 445267.1115 * T) % 360; + const DRad = D * Math.PI / 180; + + // Sun's mean anomaly + const Ms = (357.529 + 35999.0503 * T) % 360; + const MsRad = Ms * Math.PI / 180; + + // Moon's argument of latitude + const F = (93.272 + 483202.0175 * T) % 360; + const FRad = F * Math.PI / 180; + + // Longitude corrections (simplified) + const dL = 6.289 * Math.sin(MRad) + + 1.274 * Math.sin(2 * DRad - MRad) + + 0.658 * Math.sin(2 * DRad) + + 0.214 * Math.sin(2 * MRad) + - 0.186 * Math.sin(MsRad) + - 0.114 * Math.sin(2 * FRad); + + // Moon's ecliptic longitude + const moonLon = ((L0 + dL) % 360 + 360) % 360; + + // Moon's ecliptic latitude (simplified) + const moonLat = 5.128 * Math.sin(FRad) + + 0.281 * Math.sin(MRad + FRad) + + 0.278 * Math.sin(MRad - FRad); + + // Convert ecliptic to equatorial coordinates + const obliquity = 23.439 - 0.0000004 * (JD - 2451545.0); + const oblRad = obliquity * Math.PI / 180; + const moonLonRad = moonLon * Math.PI / 180; + const moonLatRad = moonLat * Math.PI / 180; + + // Right ascension + const RA = Math.atan2( + Math.sin(moonLonRad) * Math.cos(oblRad) - Math.tan(moonLatRad) * Math.sin(oblRad), + Math.cos(moonLonRad) + ) * 180 / Math.PI; + + // Declination + const dec = Math.asin( + Math.sin(moonLatRad) * Math.cos(oblRad) + + Math.cos(moonLatRad) * Math.sin(oblRad) * Math.sin(moonLonRad) + ) * 180 / Math.PI; + + // Greenwich Mean Sidereal Time + const GMST = (280.46061837 + 360.98564736629 * (JD - 2451545.0)) % 360; + + // Sublunar point longitude + const sublunarLon = ((RA - GMST) % 360 + 540) % 360 - 180; + + return { lat: dec, lon: sublunarLon }; + }; + + // Calculate moon phase (0-1, 0=new, 0.5=full) + const getMoonPhase = (date) => { + const JD = date.getTime() / 86400000 + 2440587.5; + const T = (JD - 2451545.0) / 36525; + const D = (297.850 + 445267.1115 * T) % 360; // Mean elongation + // Phase angle (simplified) + const phase = ((D + 180) % 360) / 360; + return phase; + }; + + // Get moon phase emoji + const getMoonPhaseEmoji = (phase) => { + if (phase < 0.0625) return '🌑'; // New moon + if (phase < 0.1875) return '🌒'; // Waxing crescent + if (phase < 0.3125) return '🌓'; // First quarter + if (phase < 0.4375) return '🌔'; // Waxing gibbous + if (phase < 0.5625) return '🌕'; // Full moon + if (phase < 0.6875) return '🌖'; // Waning gibbous + if (phase < 0.8125) return '🌗'; // Last quarter + if (phase < 0.9375) return '🌘'; // Waning crescent + return '🌑'; // New moon + }; + const calculateSunTimes = (lat, lon, date) => { const dayOfYear = Math.floor((date - new Date(date.getFullYear(), 0, 0)) / 86400000); const declination = -23.45 * Math.cos((360/365) * (dayOfYear + 10) * Math.PI / 180); @@ -355,7 +587,6 @@ // Great circle path for Leaflet const getGreatCirclePoints = (lat1, lon1, lat2, lon2, n = 100) => { - const points = []; const toRad = d => d * Math.PI / 180; const toDeg = r => r * 180 / Math.PI; @@ -366,6 +597,12 @@ Math.sin((φ1-φ2)/2)**2 + Math.cos(φ1)*Math.cos(φ2)*Math.sin((λ1-λ2)/2)**2 )); + // If distance is essentially zero, return just the two points + if (d < 0.0001) { + return [[lat1, lon1], [lat2, lon2]]; + } + + const rawPoints = []; for (let i = 0; i <= n; i++) { const f = i / n; const A = Math.sin((1-f)*d) / Math.sin(d); @@ -373,9 +610,29 @@ const x = A*Math.cos(φ1)*Math.cos(λ1) + B*Math.cos(φ2)*Math.cos(λ2); const y = A*Math.cos(φ1)*Math.sin(λ1) + B*Math.cos(φ2)*Math.sin(λ2); const z = A*Math.sin(φ1) + B*Math.sin(φ2); - points.push([toDeg(Math.atan2(z, Math.sqrt(x*x+y*y))), toDeg(Math.atan2(y, x))]); + rawPoints.push([toDeg(Math.atan2(z, Math.sqrt(x*x+y*y))), toDeg(Math.atan2(y, x))]); + } + + // Split path at antimeridian crossings for proper Leaflet rendering + const segments = []; + let currentSegment = [rawPoints[0]]; + + for (let i = 1; i < rawPoints.length; i++) { + const prevLon = rawPoints[i-1][1]; + const currLon = rawPoints[i][1]; + + // Check if we crossed the antimeridian (lon jumps more than 180°) + if (Math.abs(currLon - prevLon) > 180) { + // Finish current segment + segments.push(currentSegment); + // Start new segment + currentSegment = []; + } + currentSegment.push(rawPoints[i]); } - return points; + segments.push(currentSegment); + + return segments; }; // ============================================ @@ -433,16 +690,122 @@ return { data, loading }; }; - const useBandConditions = () => { - const [data, setData] = useState([ - { band: '160m', condition: 'FAIR' }, { band: '80m', condition: 'GOOD' }, - { band: '40m', condition: 'GOOD' }, { band: '30m', condition: 'GOOD' }, - { band: '20m', condition: 'GOOD' }, { band: '17m', condition: 'GOOD' }, - { band: '15m', condition: 'FAIR' }, { band: '12m', condition: 'FAIR' }, - { band: '10m', condition: 'POOR' }, { band: '6m', condition: 'POOR' }, - { band: '2m', condition: 'GOOD' }, { band: '70cm', condition: 'GOOD' } - ]); - const [loading, setLoading] = useState(false); + // Solar Indices with History and Kp Forecast Hook + const useSolarIndices = () => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchData = async () => { + try { + const response = await fetch('/api/solar-indices'); + if (response.ok) { + const result = await response.json(); + setData(result); + } + } catch (err) { + console.error('Solar indices error:', err); + } finally { + setLoading(false); + } + }; + fetchData(); + // Refresh every 15 minutes + const interval = setInterval(fetchData, 15 * 60 * 1000); + return () => clearInterval(interval); + }, []); + + return { data, loading }; + }; + + const useBandConditions = (spaceWeather) => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!spaceWeather?.solarFlux) { + setLoading(true); + return; + } + + const sfi = parseInt(spaceWeather.solarFlux) || 100; + const kIndex = parseInt(spaceWeather.kIndex) || 3; + const hour = new Date().getUTCHours(); + + // Determine if it's day or night (simplified - assumes mid-latitudes) + const isDaytime = hour >= 6 && hour <= 18; + const isGrayline = (hour >= 5 && hour <= 7) || (hour >= 17 && hour <= 19); + + // Calculate band conditions based on SFI, K-index, and time + const calculateCondition = (band) => { + let score = 50; // Base score + + // SFI impact (higher SFI = better high bands, less impact on low bands) + const sfiImpact = { + '160m': (sfi - 100) * 0.05, + '80m': (sfi - 100) * 0.1, + '60m': (sfi - 100) * 0.15, + '40m': (sfi - 100) * 0.2, + '30m': (sfi - 100) * 0.25, + '20m': (sfi - 100) * 0.35, + '17m': (sfi - 100) * 0.4, + '15m': (sfi - 100) * 0.45, + '12m': (sfi - 100) * 0.5, + '11m': (sfi - 100) * 0.52, // CB band - similar to 12m/10m + '10m': (sfi - 100) * 0.55, + '6m': (sfi - 100) * 0.6, + '2m': 0, // VHF not affected by HF propagation + '70cm': 0 + }; + score += sfiImpact[band] || 0; + + // K-index impact (geomagnetic storms hurt propagation) + // K=0-1: bonus, K=2-3: neutral, K=4+: penalty + if (kIndex <= 1) score += 15; + else if (kIndex <= 2) score += 5; + else if (kIndex >= 5) score -= 40; + else if (kIndex >= 4) score -= 25; + else if (kIndex >= 3) score -= 10; + + // Time of day impact + const timeImpact = { + '160m': isDaytime ? -30 : 25, // Night band + '80m': isDaytime ? -20 : 20, // Night band + '60m': isDaytime ? -10 : 15, + '40m': isGrayline ? 20 : (isDaytime ? 5 : 15), // Good day & night + '30m': isDaytime ? 15 : 10, + '20m': isDaytime ? 25 : -15, // Day band + '17m': isDaytime ? 25 : -20, + '15m': isDaytime ? 20 : -25, // Day band + '12m': isDaytime ? 15 : -30, + '11m': isDaytime ? 15 : -32, // CB band - day band, needs high SFI + '10m': isDaytime ? 15 : -35, // Day band, needs high SFI + '6m': isDaytime ? 10 : -40, // Sporadic E, mostly daytime + '2m': 10, // Local/tropo - always available + '70cm': 10 + }; + score += timeImpact[band] || 0; + + // High bands need minimum SFI to open + if (['10m', '11m', '12m', '6m'].includes(band) && sfi < 100) score -= 30; + if (['15m', '17m'].includes(band) && sfi < 80) score -= 15; + + // Convert score to condition + if (score >= 65) return 'GOOD'; + if (score >= 40) return 'FAIR'; + return 'POOR'; + }; + + const bands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '11m', '10m', '6m', '2m']; + const conditions = bands.map(band => ({ + band, + condition: calculateCondition(band) + })); + + setData(conditions); + setLoading(false); + }, [spaceWeather?.solarFlux, spaceWeather?.kIndex]); + return { data, loading }; }; @@ -473,124 +836,1561 @@ return { data, loading }; }; - const useDXCluster = () => { - const [data, setData] = useState([]); + // ============================================ + // SHARED FILTER HELPER FUNCTIONS + // Used by both DX Cluster hook and Map component + // ============================================ + + // Helper to get band from frequency (in kHz) + const getBandFromFreq = (freq) => { + const f = parseFloat(freq); + // Handle MHz input (convert to kHz) + const freqKhz = f < 1000 ? f * 1000 : f; + if (freqKhz >= 1800 && freqKhz <= 2000) return '160m'; + if (freqKhz >= 3500 && freqKhz <= 4000) return '80m'; + if (freqKhz >= 5330 && freqKhz <= 5405) return '60m'; + if (freqKhz >= 7000 && freqKhz <= 7300) return '40m'; + if (freqKhz >= 10100 && freqKhz <= 10150) return '30m'; + if (freqKhz >= 14000 && freqKhz <= 14350) return '20m'; + if (freqKhz >= 18068 && freqKhz <= 18168) return '17m'; + if (freqKhz >= 21000 && freqKhz <= 21450) return '15m'; + if (freqKhz >= 24890 && freqKhz <= 24990) return '12m'; + if (freqKhz >= 28000 && freqKhz <= 29700) return '10m'; + if (freqKhz >= 50000 && freqKhz <= 54000) return '6m'; + if (freqKhz >= 144000 && freqKhz <= 148000) return '2m'; + if (freqKhz >= 420000 && freqKhz <= 450000) return '70cm'; + return 'other'; + }; + + // Helper to detect mode from comment + const detectMode = (comment) => { + if (!comment) return null; + const upper = comment.toUpperCase(); + if (upper.includes('FT8')) return 'FT8'; + if (upper.includes('FT4')) return 'FT4'; + if (upper.includes('CW')) return 'CW'; + if (upper.includes('SSB') || upper.includes('LSB') || upper.includes('USB')) return 'SSB'; + if (upper.includes('RTTY')) return 'RTTY'; + if (upper.includes('PSK')) return 'PSK'; + if (upper.includes('AM')) return 'AM'; + if (upper.includes('FM')) return 'FM'; + return null; + }; + + // Callsign prefix to CQ/ITU zone and continent mapping + const getCallsignInfo = (call) => { + if (!call) return { cqZone: null, ituZone: null, continent: null }; + const upper = call.toUpperCase(); + + // Simplified prefix->zone mapping (common prefixes) + const prefixMap = { + // North America + 'W': { cq: 5, itu: 8, cont: 'NA' }, 'K': { cq: 5, itu: 8, cont: 'NA' }, 'N': { cq: 5, itu: 8, cont: 'NA' }, 'AA': { cq: 5, itu: 8, cont: 'NA' }, + 'VE': { cq: 5, itu: 4, cont: 'NA' }, 'VA': { cq: 5, itu: 4, cont: 'NA' }, 'VY': { cq: 2, itu: 4, cont: 'NA' }, + 'XE': { cq: 6, itu: 10, cont: 'NA' }, 'XF': { cq: 6, itu: 10, cont: 'NA' }, + // Europe + 'G': { cq: 14, itu: 27, cont: 'EU' }, 'M': { cq: 14, itu: 27, cont: 'EU' }, '2E': { cq: 14, itu: 27, cont: 'EU' }, + 'F': { cq: 14, itu: 27, cont: 'EU' }, 'DL': { cq: 14, itu: 28, cont: 'EU' }, 'D': { cq: 14, itu: 28, cont: 'EU' }, + 'I': { cq: 15, itu: 28, cont: 'EU' }, 'IK': { cq: 15, itu: 28, cont: 'EU' }, + 'EA': { cq: 14, itu: 37, cont: 'EU' }, 'EC': { cq: 14, itu: 37, cont: 'EU' }, + 'PA': { cq: 14, itu: 27, cont: 'EU' }, 'PD': { cq: 14, itu: 27, cont: 'EU' }, + 'ON': { cq: 14, itu: 27, cont: 'EU' }, 'OZ': { cq: 14, itu: 18, cont: 'EU' }, + 'SM': { cq: 14, itu: 18, cont: 'EU' }, 'LA': { cq: 14, itu: 18, cont: 'EU' }, + 'OH': { cq: 15, itu: 18, cont: 'EU' }, 'SP': { cq: 15, itu: 28, cont: 'EU' }, + 'OK': { cq: 15, itu: 28, cont: 'EU' }, 'OM': { cq: 15, itu: 28, cont: 'EU' }, + 'HA': { cq: 15, itu: 28, cont: 'EU' }, 'HB': { cq: 14, itu: 28, cont: 'EU' }, + 'OE': { cq: 15, itu: 28, cont: 'EU' }, 'LZ': { cq: 20, itu: 28, cont: 'EU' }, + 'YO': { cq: 20, itu: 28, cont: 'EU' }, 'YU': { cq: 15, itu: 28, cont: 'EU' }, + 'UA': { cq: 16, itu: 29, cont: 'EU' }, 'R': { cq: 16, itu: 29, cont: 'EU' }, + 'UR': { cq: 16, itu: 29, cont: 'EU' }, 'UT': { cq: 16, itu: 29, cont: 'EU' }, + 'LY': { cq: 15, itu: 29, cont: 'EU' }, 'ES': { cq: 15, itu: 29, cont: 'EU' }, + 'YL': { cq: 15, itu: 29, cont: 'EU' }, 'CT': { cq: 14, itu: 37, cont: 'EU' }, + 'EI': { cq: 14, itu: 27, cont: 'EU' }, 'GI': { cq: 14, itu: 27, cont: 'EU' }, + 'GM': { cq: 14, itu: 27, cont: 'EU' }, 'GW': { cq: 14, itu: 27, cont: 'EU' }, + 'SV': { cq: 20, itu: 28, cont: 'EU' }, '9A': { cq: 15, itu: 28, cont: 'EU' }, + 'S5': { cq: 15, itu: 28, cont: 'EU' }, + // Asia + 'JA': { cq: 25, itu: 45, cont: 'AS' }, 'JH': { cq: 25, itu: 45, cont: 'AS' }, 'JR': { cq: 25, itu: 45, cont: 'AS' }, + 'HL': { cq: 25, itu: 44, cont: 'AS' }, 'DS': { cq: 25, itu: 44, cont: 'AS' }, + 'BY': { cq: 24, itu: 44, cont: 'AS' }, 'BV': { cq: 24, itu: 44, cont: 'AS' }, + 'VU': { cq: 22, itu: 41, cont: 'AS' }, 'VK': { cq: 30, itu: 59, cont: 'OC' }, + 'DU': { cq: 27, itu: 50, cont: 'OC' }, '9M': { cq: 28, itu: 54, cont: 'AS' }, + 'HS': { cq: 26, itu: 49, cont: 'AS' }, 'XV': { cq: 26, itu: 49, cont: 'AS' }, + // South America + 'LU': { cq: 13, itu: 14, cont: 'SA' }, 'PY': { cq: 11, itu: 15, cont: 'SA' }, + 'CE': { cq: 12, itu: 14, cont: 'SA' }, 'CX': { cq: 13, itu: 14, cont: 'SA' }, + 'HK': { cq: 9, itu: 12, cont: 'SA' }, 'YV': { cq: 9, itu: 12, cont: 'SA' }, + 'HC': { cq: 10, itu: 12, cont: 'SA' }, 'OA': { cq: 10, itu: 12, cont: 'SA' }, + // Africa + 'ZS': { cq: 38, itu: 57, cont: 'AF' }, '5N': { cq: 35, itu: 46, cont: 'AF' }, + 'EA8': { cq: 33, itu: 36, cont: 'AF' }, 'CN': { cq: 33, itu: 37, cont: 'AF' }, + '7X': { cq: 33, itu: 37, cont: 'AF' }, 'SU': { cq: 34, itu: 38, cont: 'AF' }, + 'ST': { cq: 34, itu: 47, cont: 'AF' }, 'ET': { cq: 37, itu: 48, cont: 'AF' }, + '5Z': { cq: 37, itu: 48, cont: 'AF' }, '5H': { cq: 37, itu: 53, cont: 'AF' }, + // Oceania + 'ZL': { cq: 32, itu: 60, cont: 'OC' }, 'FK': { cq: 32, itu: 56, cont: 'OC' }, + 'VK9': { cq: 30, itu: 60, cont: 'OC' }, 'YB': { cq: 28, itu: 51, cont: 'OC' }, + 'KH6': { cq: 31, itu: 61, cont: 'OC' }, 'KH2': { cq: 27, itu: 64, cont: 'OC' }, + // Caribbean + 'VP5': { cq: 8, itu: 11, cont: 'NA' }, 'PJ': { cq: 9, itu: 11, cont: 'SA' }, + 'HI': { cq: 8, itu: 11, cont: 'NA' }, 'CO': { cq: 8, itu: 11, cont: 'NA' }, + 'KP4': { cq: 8, itu: 11, cont: 'NA' }, 'FG': { cq: 8, itu: 11, cont: 'NA' }, + // Antarctica + 'DP0': { cq: 38, itu: 67, cont: 'AN' }, 'VP8': { cq: 13, itu: 73, cont: 'AN' }, + 'KC4': { cq: 13, itu: 67, cont: 'AN' } + }; + + // Try to match prefix (longest match first) + for (let len = 4; len >= 1; len--) { + const prefix = upper.substring(0, len); + if (prefixMap[prefix]) { + return { + cqZone: prefixMap[prefix].cq, + ituZone: prefixMap[prefix].itu, + continent: prefixMap[prefix].cont + }; + } + } + + // Fallback based on first character + const firstChar = upper[0]; + const fallbackMap = { + 'A': { cq: 21, itu: 39, cont: 'AS' }, + 'B': { cq: 24, itu: 44, cont: 'AS' }, + 'C': { cq: 14, itu: 27, cont: 'EU' }, + 'D': { cq: 14, itu: 28, cont: 'EU' }, + 'E': { cq: 14, itu: 27, cont: 'EU' }, + 'F': { cq: 14, itu: 27, cont: 'EU' }, + 'G': { cq: 14, itu: 27, cont: 'EU' }, + 'H': { cq: 14, itu: 27, cont: 'EU' }, + 'I': { cq: 15, itu: 28, cont: 'EU' }, + 'J': { cq: 25, itu: 45, cont: 'AS' }, + 'K': { cq: 5, itu: 8, cont: 'NA' }, + 'L': { cq: 13, itu: 14, cont: 'SA' }, + 'M': { cq: 14, itu: 27, cont: 'EU' }, + 'N': { cq: 5, itu: 8, cont: 'NA' }, + 'O': { cq: 15, itu: 18, cont: 'EU' }, + 'P': { cq: 11, itu: 15, cont: 'SA' }, + 'R': { cq: 16, itu: 29, cont: 'EU' }, + 'S': { cq: 15, itu: 28, cont: 'EU' }, + 'T': { cq: 37, itu: 48, cont: 'AF' }, + 'U': { cq: 16, itu: 29, cont: 'EU' }, + 'V': { cq: 5, itu: 4, cont: 'NA' }, + 'W': { cq: 5, itu: 8, cont: 'NA' }, + 'X': { cq: 6, itu: 10, cont: 'NA' }, + 'Y': { cq: 15, itu: 28, cont: 'EU' }, + 'Z': { cq: 38, itu: 57, cont: 'AF' } + }; + + if (fallbackMap[firstChar]) { + return { + cqZone: fallbackMap[firstChar].cq, + ituZone: fallbackMap[firstChar].itu, + continent: fallbackMap[firstChar].cont + }; + } + + return { cqZone: null, ituZone: null, continent: null }; + }; + + // Filter DX paths based on filters (same logic as spot filtering) + const filterDXPaths = (paths, filters) => { + if (!paths || !filters) return paths; + if (Object.keys(filters).length === 0) return paths; + + return paths.filter(path => { + // Get info for DX call (the target station) + const dxCallInfo = getCallsignInfo(path.dxCall); + // Get info for spotter (origin) + const spotterInfo = getCallsignInfo(path.spotter); + + // Watchlist filter - show ONLY watchlist if enabled + if (filters.watchlistOnly && filters.watchlist?.length > 0) { + const inWatchlist = filters.watchlist.some(w => + path.dxCall?.toUpperCase().includes(w.toUpperCase()) || + path.spotter?.toUpperCase().includes(w.toUpperCase()) + ); + if (!inWatchlist) return false; + } + + // Exclude list - hide matching callsigns + if (filters.excludeList?.length > 0) { + const isExcluded = filters.excludeList.some(e => + path.dxCall?.toUpperCase().includes(e.toUpperCase()) || + path.spotter?.toUpperCase().includes(e.toUpperCase()) + ); + if (isExcluded) return false; + } + + // CQ Zone filter (applies to DX station) + if (filters.cqZones?.length > 0) { + if (!dxCallInfo.cqZone || !filters.cqZones.includes(dxCallInfo.cqZone)) { + return false; + } + } + + // ITU Zone filter (applies to DX station) + if (filters.ituZones?.length > 0) { + if (!dxCallInfo.ituZone || !filters.ituZones.includes(dxCallInfo.ituZone)) { + return false; + } + } + + // Continent filter (applies to DX station) + if (filters.continents?.length > 0) { + if (!dxCallInfo.continent || !filters.continents.includes(dxCallInfo.continent)) { + return false; + } + } + + // Band filter + if (filters.bands?.length > 0) { + const freqKhz = parseFloat(path.freq) * 1000; // Convert MHz to kHz + const band = getBandFromFreq(freqKhz); + if (!filters.bands.includes(band)) return false; + } + + // Mode filter + if (filters.modes?.length > 0) { + const mode = detectMode(path.comment); + if (!mode || !filters.modes.includes(mode)) return false; + } + + // Callsign search filter + if (filters.callsign && filters.callsign.trim()) { + const search = filters.callsign.trim().toUpperCase(); + const matchesDX = path.dxCall?.toUpperCase().includes(search); + const matchesSpotter = path.spotter?.toUpperCase().includes(search); + if (!matchesDX && !matchesSpotter) return false; + } + + return true; + }); + }; + + 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 => { + const callInfo = getCallsignInfo(spot.call); + + // Watchlist only mode - must match watchlist + if (filters.watchlistOnly && filters.watchlist?.length > 0) { + const matchesWatchlist = filters.watchlist.some(w => + spot.call?.toUpperCase().includes(w.toUpperCase()) || + spot.spotter?.toUpperCase().includes(w.toUpperCase()) + ); + if (!matchesWatchlist) return false; + } + + // Exclude list - hide matching calls + if (filters.excludeList?.length > 0) { + const isExcluded = filters.excludeList.some(exc => + spot.call?.toUpperCase().includes(exc.toUpperCase()) || + spot.spotter?.toUpperCase().includes(exc.toUpperCase()) + ); + if (isExcluded) return false; + } + + // CQ Zone filter + if (filters.cqZones?.length > 0) { + if (!callInfo.cqZone || !filters.cqZones.includes(callInfo.cqZone)) return false; + } + + // ITU Zone filter + if (filters.ituZones?.length > 0) { + if (!callInfo.ituZone || !filters.ituZones.includes(callInfo.ituZone)) return false; + } + + // Continent filter + if (filters.continents?.length > 0) { + if (!callInfo.continent || !filters.continents.includes(callInfo.continent)) return false; + } + + // Band filter + if (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?.length > 0) { + const mode = spot.mode || detectMode(spot.comment); + if (!mode || !filters.modes.includes(mode)) return false; + } + + // Simple callsign search filter + if (filters.callsign?.trim()) { + const search = filters.callsign.toUpperCase().trim(); + const matchesDX = spot.call?.toUpperCase().includes(search); + const matchesSpotter = spot.spotter?.toUpperCase().includes(search); + if (!matchesDX && !matchesSpotter) return false; + } + + return true; + }); + }, []); useEffect(() => { const fetchDX = async () => { try { - // Use our proxy endpoint (works when running via server.js) - const response = await fetch('/api/dxcluster/spots'); + const response = await fetch(`/api/dxcluster/spots?source=${source}`); if (response.ok) { - const spots = await response.json(); - if (spots && spots.length > 0) { - setData(spots.slice(0, 15).map(s => ({ + const newSpots = await response.json(); + const now = Date.now(); + + if (newSpots && newSpots.length > 0) { + if (newSpots[0].source) { + setActiveSource(newSpots[0].source); + } + + // 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: '' - }]); + 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: '' - }]); } } catch (err) { console.error('DX Cluster error:', err); - setData([{ - freq: '---', - call: 'ERROR', - comment: 'Could not connect to DX cluster', - time: '--:--z', - spotter: '' - }]); + } finally { + setLoading(false); } - finally { setLoading(false); } }; + fetchDX(); - const interval = setInterval(fetchDX, 30000); // 30 second refresh + const interval = setInterval(fetchDX, pollInterval); return () => clearInterval(interval); - }, []); + }, [source]); + + // 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, loading }; + return { + data, + allSpots, // All accumulated spots (unfiltered) + loading, + activeSource, + spotCount: allSpots.length, + filteredCount: data.length + }; }; // ============================================ - // LEAFLET MAP COMPONENT + // DX PATHS HOOK - DX spots with locations for map visualization // ============================================ - const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots }) => { - const mapRef = useRef(null); - const mapInstanceRef = useRef(null); - const tileLayerRef = useRef(null); - const terminatorRef = useRef(null); - const pathRef = useRef(null); - const deMarkerRef = useRef(null); - const dxMarkerRef = useRef(null); - const sunMarkerRef = useRef(null); - const potaMarkersRef = useRef([]); - const [mapStyle, setMapStyle] = useState('dark'); + const useDXPaths = () => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); - // Initialize map useEffect(() => { - if (!mapRef.current || mapInstanceRef.current) return; + const fetchPaths = async () => { + try { + const response = await fetch('/api/dxcluster/paths'); + + if (response.ok) { + const paths = await response.json(); + setData(paths); + console.log('[DX Paths] Loaded', paths.length, 'paths'); + } else { + setData([]); + } + } catch (err) { + console.error('DX Paths error:', err); + setData([]); + } finally { + setLoading(false); + } + }; + + fetchPaths(); + // Refresh every 10 seconds to keep up with faster cluster polling + const interval = setInterval(fetchPaths, 10000); + return () => clearInterval(interval); + }, []); - const map = L.map(mapRef.current, { - center: [20, 0], - zoom: 2, - minZoom: 1, - maxZoom: 18, - worldCopyJump: true, - zoomControl: true - }); + return { data, loading }; + }; - // Initial tile layer - tileLayerRef.current = L.tileLayer(MAP_STYLES[mapStyle].url, { - attribution: MAP_STYLES[mapStyle].attribution, - noWrap: false - }).addTo(map); + // ============================================ + // MY SPOTS HOOK - Spots involving the user's callsign + // ============================================ + const useMySpots = (callsign) => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); - // Day/night terminator - terminatorRef.current = L.terminator({ - fillOpacity: 0.4, - fillColor: '#000010', - color: '#ffaa00', - weight: 2, - dashArray: '5, 5' - }).addTo(map); + useEffect(() => { + if (!callsign || callsign === 'N0CALL') { + setData([]); + setLoading(false); + return; + } - // Update terminator every minute - setInterval(() => { - if (terminatorRef.current) { - terminatorRef.current.setTime(); + 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); } - }, 60000); + }; + + fetchMySpots(); + // Refresh every 2 minutes + const interval = setInterval(fetchMySpots, 120000); + return () => clearInterval(interval); + }, [callsign]); - // Click handler for setting DX - map.on('click', (e) => { - if (onDXChange) { - onDXChange({ lat: e.latlng.lat, lon: e.latlng.lng }); - } - }); + return { data, loading }; + }; - mapInstanceRef.current = map; + // ============================================ + // LOCAL WEATHER HOOK - Using Open-Meteo API (free, no API key) + // ============================================ + const useLocalWeather = (location) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!location || !location.lat || !location.lon) { + setLoading(false); + return; + } + + const fetchWeather = async () => { + try { + // Open-Meteo free API - no key required + const url = `https://api.open-meteo.com/v1/forecast?latitude=${location.lat}&longitude=${location.lon}¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=auto`; + + const response = await fetch(url); + if (response.ok) { + const result = await response.json(); + const current = result.current; + + // Weather code to description mapping + const weatherCodes = { + 0: { desc: 'Clear', icon: '☀️' }, + 1: { desc: 'Mostly Clear', icon: '🌤️' }, + 2: { desc: 'Partly Cloudy', icon: '⛅' }, + 3: { desc: 'Overcast', icon: '☁️' }, + 45: { desc: 'Foggy', icon: '🌫️' }, + 48: { desc: 'Rime Fog', icon: '🌫️' }, + 51: { desc: 'Light Drizzle', icon: '🌧️' }, + 53: { desc: 'Drizzle', icon: '🌧️' }, + 55: { desc: 'Heavy Drizzle', icon: '🌧️' }, + 61: { desc: 'Light Rain', icon: '🌧️' }, + 63: { desc: 'Rain', icon: '🌧️' }, + 65: { desc: 'Heavy Rain', icon: '🌧️' }, + 71: { desc: 'Light Snow', icon: '🌨️' }, + 73: { desc: 'Snow', icon: '🌨️' }, + 75: { desc: 'Heavy Snow', icon: '🌨️' }, + 77: { desc: 'Snow Grains', icon: '🌨️' }, + 80: { desc: 'Light Showers', icon: '🌦️' }, + 81: { desc: 'Showers', icon: '🌦️' }, + 82: { desc: 'Heavy Showers', icon: '🌦️' }, + 85: { desc: 'Snow Showers', icon: '🌨️' }, + 86: { desc: 'Heavy Snow Showers', icon: '🌨️' }, + 95: { desc: 'Thunderstorm', icon: '⛈️' }, + 96: { desc: 'Thunderstorm w/ Hail', icon: '⛈️' }, + 99: { desc: 'Severe Thunderstorm', icon: '⛈️' } + }; + + const weather = weatherCodes[current.weather_code] || { desc: 'Unknown', icon: '❓' }; + + setData({ + temp: Math.round(current.temperature_2m), + humidity: current.relative_humidity_2m, + windSpeed: Math.round(current.wind_speed_10m), + windDir: current.wind_direction_10m, + description: weather.desc, + icon: weather.icon + }); + console.log('[Weather] Loaded:', current.temperature_2m + '°F', weather.desc); + } + } catch (err) { + console.error('Weather fetch error:', err); + setData(null); + } finally { + setLoading(false); + } + }; + + fetchWeather(); + // Refresh every 15 minutes + const interval = setInterval(fetchWeather, 900000); + return () => clearInterval(interval); + }, [location.lat, location.lon]); + + return { data, loading }; + }; + + // ============================================ + // CONTEST CALENDAR HOOK + // ============================================ + const useContests = () => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchContests = async () => { + try { + // Try our proxy endpoint first + const response = await fetch('/api/contests'); + + if (response.ok) { + const contests = await response.json(); + if (contests && contests.length > 0) { + setData(contests.slice(0, 10)); + } else { + // No contests from API, use calculated upcoming contests + setData(getUpcomingContests()); + } + } else { + setData(getUpcomingContests()); + } + } catch (err) { + console.error('Contest fetch error:', err); + setData(getUpcomingContests()); + } finally { + setLoading(false); + } + }; + + fetchContests(); + const interval = setInterval(fetchContests, 3600000); // Refresh every hour + return () => clearInterval(interval); + }, []); + + return { data, loading }; + }; + + // Calculate upcoming major contests based on typical schedule + const getUpcomingContests = () => { + const now = new Date(); + const contests = []; + + // Major contest schedule (approximate - these follow patterns) + const majorContests = [ + { name: 'CQ WW DX CW', month: 10, weekend: 4, duration: 48 }, // Last full weekend Nov + { name: 'CQ WW DX SSB', month: 9, weekend: 4, duration: 48 }, // Last full weekend Oct + { name: 'ARRL DX CW', month: 1, weekend: 3, duration: 48 }, // 3rd full weekend Feb + { name: 'ARRL DX SSB', month: 2, weekend: 1, duration: 48 }, // 1st full weekend Mar + { name: 'CQ WPX SSB', month: 2, weekend: 4, duration: 48 }, // Last full weekend Mar + { name: 'CQ WPX CW', month: 4, weekend: 4, duration: 48 }, // Last full weekend May + { name: 'IARU HF Championship', month: 6, weekend: 2, duration: 24 }, // 2nd full weekend Jul + { name: 'ARRL Field Day', month: 5, weekend: 4, duration: 24 }, // 4th full weekend Jun + { name: 'ARRL Sweepstakes CW', month: 10, weekend: 1, duration: 24 }, // 1st full weekend Nov + { name: 'ARRL Sweepstakes SSB', month: 10, weekend: 3, duration: 24 }, // 3rd full weekend Nov + { name: 'ARRL 10m Contest', month: 11, weekend: 2, duration: 48 }, // 2nd full weekend Dec + { name: 'ARRL RTTY Roundup', month: 0, weekend: 1, duration: 24 }, // 1st full weekend Jan + { name: 'NA QSO Party CW', month: 0, weekend: 2, duration: 12 }, // 2nd full weekend Jan + { name: 'NA QSO Party SSB', month: 0, weekend: 3, duration: 12 }, // 3rd full weekend Jan + { name: 'CQ 160m CW', month: 0, weekend: 4, duration: 48 }, // Last full weekend Jan + { name: 'Winter Field Day', month: 0, weekend: 4, duration: 24 }, // Last full weekend Jan + ]; + + // Weekly/recurring contests + const weeklyContests = [ + { name: 'CWT Mini-Test', day: 3, time: '13:00z', duration: 1 }, // Wednesday + { name: 'CWT Mini-Test', day: 3, time: '19:00z', duration: 1 }, // Wednesday + { name: 'CWT Mini-Test', day: 3, time: '03:00z', duration: 1 }, // Thursday morning UTC + { name: 'NCCC Sprint', day: 5, time: '03:30z', duration: 0.5 }, // Friday + { name: 'K1USN SST', day: 1, time: '00:00z', duration: 1 }, // Monday + { name: 'ICWC MST', day: 1, time: '15:00z', duration: 1 }, // Monday + ]; + + // Get next occurrence of weekly contests + weeklyContests.forEach(contest => { + const next = new Date(now); + const daysUntil = (contest.day - now.getUTCDay() + 7) % 7; + next.setUTCDate(next.getUTCDate() + (daysUntil === 0 ? 7 : daysUntil)); + const [hours, mins] = contest.time.replace('z', '').split(':'); + next.setUTCHours(parseInt(hours), parseInt(mins), 0, 0); + + if (next > now) { + contests.push({ + name: contest.name, + start: next.toISOString(), + end: new Date(next.getTime() + contest.duration * 3600000).toISOString(), + mode: contest.name.includes('CW') ? 'CW' : 'Mixed', + status: 'upcoming' + }); + } + }); + + // Calculate next major contest dates + majorContests.forEach(contest => { + const year = now.getFullYear(); + for (let y = year; y <= year + 1; y++) { + const date = getNthWeekendOfMonth(y, contest.month, contest.weekend); + const endDate = new Date(date.getTime() + contest.duration * 3600000); + + if (endDate > now) { + const status = now >= date && now <= endDate ? 'active' : 'upcoming'; + contests.push({ + name: contest.name, + start: date.toISOString(), + end: endDate.toISOString(), + mode: contest.name.includes('CW') ? 'CW' : contest.name.includes('SSB') ? 'SSB' : contest.name.includes('RTTY') ? 'RTTY' : 'Mixed', + status: status + }); + break; + } + } + }); + + // Sort by start date and return top 10 + return contests + .sort((a, b) => new Date(a.start) - new Date(b.start)) + .slice(0, 10); + }; + + // Helper to get nth weekend of a month + const getNthWeekendOfMonth = (year, month, n) => { + const date = new Date(Date.UTC(year, month, 1, 0, 0, 0)); + let weekendCount = 0; + + while (weekendCount < n) { + if (date.getUTCDay() === 6) { // Saturday + weekendCount++; + if (weekendCount === n) break; + } + date.setUTCDate(date.getUTCDate() + 1); + } + + return date; + }; + + // ============================================ + // PROPAGATION PREDICTION HOOK + // ============================================ + const usePropagation = (deLocation, dxLocation) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchPropagation = async () => { + try { + const response = await fetch( + `/api/propagation?deLat=${deLocation.lat}&deLon=${deLocation.lon}&dxLat=${dxLocation.lat}&dxLon=${dxLocation.lon}` + ); + + if (response.ok) { + const result = await response.json(); + setData(result); + } + } catch (err) { + console.error('Propagation fetch error:', err); + } finally { + setLoading(false); + } + }; + + fetchPropagation(); + // Refresh every 15 minutes + const interval = setInterval(fetchPropagation, 900000); + return () => clearInterval(interval); + }, [deLocation.lat, deLocation.lon, dxLocation.lat, dxLocation.lon]); + + return { data, loading }; + }; + + // ============================================ + // DXPEDITION TRACKING HOOK + // ============================================ + const useDXpeditions = () => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchDXpeditions = async () => { + try { + console.log('[DXpeditions] Fetching...'); + const response = await fetch('/api/dxpeditions'); + console.log('[DXpeditions] Response status:', response.status); + if (response.ok) { + const result = await response.json(); + console.log('[DXpeditions] Received:', result.dxpeditions?.length, 'entries'); + setData(result); + } else { + console.error('[DXpeditions] Failed:', response.status); + } + } catch (err) { + console.error('[DXpeditions] Fetch error:', err); + } finally { + setLoading(false); + } + }; + + fetchDXpeditions(); + // Refresh every 30 minutes + const interval = setInterval(fetchDXpeditions, 30 * 60 * 1000); + return () => clearInterval(interval); + }, []); + + return { data, loading }; + }; + + // ============================================ + // SATELLITE TRACKING HOOK + // ============================================ + const useSatellites = (deLocation) => { + const [tleData, setTleData] = useState({}); + const [positions, setPositions] = useState([]); + const [loading, setLoading] = useState(true); + + // Fetch TLE data on mount + useEffect(() => { + const fetchTLE = async () => { + try { + const response = await fetch('/api/satellites/tle'); + if (response.ok) { + const data = await response.json(); + setTleData(data); + console.log('[Satellites] Loaded TLE for', Object.keys(data).length, 'satellites'); + } + } catch (err) { + console.error('Satellite TLE fetch error:', err); + } finally { + setLoading(false); + } + }; + + fetchTLE(); + // Refresh TLE every 6 hours + const interval = setInterval(fetchTLE, 6 * 60 * 60 * 1000); + return () => clearInterval(interval); + }, []); + + // Calculate positions every second + useEffect(() => { + if (Object.keys(tleData).length === 0) return; + + const calculatePositions = () => { + const now = new Date(); + const newPositions = []; + + for (const [key, sat] of Object.entries(tleData)) { + if (!sat.tle1 || !sat.tle2) continue; + + try { + // Parse TLE and create satellite record + const satrec = satellite.twoline2satrec(sat.tle1, sat.tle2); + + // Get current position + const positionAndVelocity = satellite.propagate(satrec, now); + if (!positionAndVelocity.position) continue; + + // Convert to geodetic coordinates + const gmst = satellite.gstime(now); + const position = satellite.eciToGeodetic(positionAndVelocity.position, gmst); + + const lat = satellite.degreesLat(position.latitude); + const lon = satellite.degreesLong(position.longitude); + const alt = position.height; // km + + // Calculate if satellite is visible from DE location (above horizon) + const lookAngles = satellite.ecfToLookAngles( + { latitude: deLocation.lat * Math.PI / 180, longitude: deLocation.lon * Math.PI / 180, height: 0 }, + satellite.eciToEcf(positionAndVelocity.position, gmst) + ); + const elevation = lookAngles.elevation * 180 / Math.PI; + const azimuth = lookAngles.azimuth * 180 / Math.PI; + const isVisible = elevation > 0; + + // Calculate orbit track (next 90 minutes, point every minute) + const track = []; + for (let m = -30; m <= 60; m += 2) { + const futureTime = new Date(now.getTime() + m * 60000); + const futurePos = satellite.propagate(satrec, futureTime); + if (futurePos.position) { + const futureGmst = satellite.gstime(futureTime); + const futureGeo = satellite.eciToGeodetic(futurePos.position, futureGmst); + track.push([ + satellite.degreesLat(futureGeo.latitude), + satellite.degreesLong(futureGeo.longitude) + ]); + } + } + + newPositions.push({ + key, + name: sat.name, + color: sat.color, + lat, + lon, + alt: Math.round(alt), + elevation: elevation.toFixed(1), + azimuth: azimuth.toFixed(1), + isVisible, + track + }); + } catch (e) { + // Skip satellites with calculation errors + } + } + + setPositions(newPositions); + }; + + calculatePositions(); + const interval = setInterval(calculatePositions, 2000); // Update every 2 seconds + return () => clearInterval(interval); + }, [tleData, deLocation.lat, deLocation.lon]); + + return { positions, loading }; + }; + + // ============================================ + // PROPAGATION PANEL COMPONENT (Toggleable views) + // ============================================ + const PropagationPanel = ({ propagation, loading, bandConditions }) => { + // Load view mode preference from localStorage, default to 'chart' + const [viewMode, setViewMode] = useState(() => { + try { + const saved = localStorage.getItem('openhamclock_voacapViewMode'); + if (saved === 'bars' || saved === 'bands') return saved; + return 'chart'; // Default to chart + } catch (e) { return 'chart'; } + }); + + // Cycle through view modes: chart -> bars -> bands -> chart + const cycleViewMode = () => { + const modes = ['chart', 'bars', 'bands']; + const currentIdx = modes.indexOf(viewMode); + const newMode = modes[(currentIdx + 1) % modes.length]; + setViewMode(newMode); + try { + localStorage.setItem('openhamclock_voacapViewMode', newMode); + } catch (e) { console.error('Failed to save view mode:', e); } + }; + + // Get band condition color/style + const getBandStyle = (condition) => ({ + GOOD: { bg: 'rgba(0,255,136,0.2)', color: '#00ff88', border: 'rgba(0,255,136,0.4)' }, + FAIR: { bg: 'rgba(255,180,50,0.2)', color: '#ffb432', border: 'rgba(255,180,50,0.4)' }, + POOR: { bg: 'rgba(255,68,102,0.2)', color: '#ff4466', border: 'rgba(255,68,102,0.4)' } + }[condition] || { bg: 'rgba(255,180,50,0.2)', color: '#ffb432', border: 'rgba(255,180,50,0.4)' }); + + if (loading || !propagation) { + return ( ++ {theme === 'legacy' && '→ Classic green-on-black HamClock style'} + {theme === 'light' && '→ Bright theme for daytime use'} + {theme === 'dark' && '→ Modern dark theme (default)'} +
++ {layout === 'legacy' && '→ Layout inspired by original HamClock'} + {layout === 'modern' && '→ Modern responsive grid layout'} +
++ {dxClusterSource === 'auto' && '→ Tries Proxy first, then HamQTH, then direct telnet'} + {dxClusterSource === 'proxy' && '→ Real-time DX Spider feed via our dedicated proxy service'} + {dxClusterSource === 'hamqth' && '→ HamQTH.com CSV feed (works on all platforms)'} + {dxClusterSource === 'dxspider' && '→ Direct telnet to dxspider.co.uk:7300 (local/Pi only)'} +
+