From 976db065ca2ec934ad294cbcc3c9fa45e02fa4df Mon Sep 17 00:00:00 2001 From: accius Date: Sun, 1 Feb 2026 09:52:58 -0500 Subject: [PATCH] 11m support --- dxspider-proxy/server.js | 1 + index.html | 4943 ++++++++++++++++++++++++++++++++-- iturhfprop-service/server.js | 1 + server.js | 4 +- 4 files changed, 4667 insertions(+), 282 deletions(-) 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 ( +
+
📡 VOACAP
+
+ Loading predictions... +
+
+ ); + } + + const { solarData, distance, currentBands, currentHour, hourlyPredictions, muf, luf, ionospheric, dataSource } = propagation; + + // Check if we have real ionosonde data + const hasRealData = ionospheric?.method === 'direct' || ionospheric?.method === 'interpolated'; + + // Get reliability color for heat map (VOACAP style - red=good, green=poor) + const getHeatColor = (rel) => { + if (rel >= 80) return '#ff0000'; // Red - excellent + if (rel >= 60) return '#ff6600'; // Orange - good + if (rel >= 40) return '#ffcc00'; // Yellow - fair + if (rel >= 20) return '#88cc00'; // Yellow-green - poor + if (rel >= 10) return '#00aa00'; // Green - marginal + return '#004400'; // Dark green - closed + }; + + // Get reliability bar color (standard - green=good) + const getReliabilityColor = (rel) => { + if (rel >= 70) return '#00ff88'; + if (rel >= 50) return '#88ff00'; + if (rel >= 30) return '#ffcc00'; + if (rel >= 15) return '#ff8800'; + return '#ff4444'; + }; + + const getStatusColor = (status) => { + switch (status) { + case 'EXCELLENT': return '#00ff88'; + case 'GOOD': return '#88ff00'; + case 'FAIR': return '#ffcc00'; + case 'POOR': return '#ff8800'; + case 'CLOSED': return '#ff4444'; + default: return 'var(--text-muted)'; + } + }; + + const bands = ['80m', '40m', '30m', '20m', '17m', '15m', '12m', '11m', '10m']; + + const viewModeLabels = { + chart: '▤ chart', + bars: '▦ bars', + bands: '◫ bands' + }; + + return ( +
+
+ + {viewMode === 'bands' ? '📊 BAND CONDITIONS' : '📡 VOACAP'} + {hasRealData && viewMode !== 'bands' && } + + + {viewModeLabels[viewMode]} • click to toggle + +
+ + {viewMode === 'bands' ? ( + /* Band Conditions Grid View */ +
+
+ {(bandConditions?.data || []).slice(0, 12).map((band, idx) => { + const style = getBandStyle(band.condition); + return ( +
+
+ {band.band} +
+
+ {band.condition} +
+
+ ); + })} +
+
+ SFI {solarData.sfi} • K {solarData.kIndex} • General conditions for all paths +
+
+ ) : ( + <> + {/* MUF/LUF and Data Source Info */} +
+
+ + MUF + {muf || '?'} + MHz + + + LUF + {luf || '?'} + MHz + +
+ + {hasRealData + ? `📡 ${ionospheric?.source || 'ionosonde'}${ionospheric?.distance ? ` (${ionospheric.distance}km)` : ''}` + : ionospheric?.nearestDistance + ? `⚡ estimated (nearest: ${ionospheric.nearestStation}, ${ionospheric.nearestDistance}km - too far)` + : '⚡ estimated' + } + + {dataSource && dataSource.includes('ITU') && ( + + 🔬 ITU-R P.533 + + )} +
+ + {viewMode === 'chart' ? ( + /* VOACAP Heat Map Chart View */ +
+ {/* Heat map grid */} +
+ {bands.map((band, bandIdx) => ( + + {/* Band label */} +
+ {band.replace('m', '')} +
+ {/* 24 hour cells */} + {Array.from({ length: 24 }, (_, hour) => { + // For current hour, use currentBands (same hybrid data as bars view) + // For other hours, use hourlyPredictions + let rel; + if (hour === currentHour) { + const currentBandData = currentBands.find(b => b.band === band); + rel = currentBandData?.reliability || 0; + } else { + const bandData = hourlyPredictions?.[band]; + const hourData = bandData?.find(h => h.hour === hour); + rel = hourData?.reliability || 0; + } + return ( +
+ ); + })} + + ))} +
+ + {/* Hour labels */} +
+
UTC
+ {[0, '', '', 3, '', '', 6, '', '', 9, '', '', 12, '', '', 15, '', '', 18, '', '', 21, '', ''].map((h, i) => ( +
{h}
+ ))} +
+ + {/* Legend & Info */} +
+
+ REL: +
+
+
+
+
+
+
+
+ {Math.round(distance)}km • {ionospheric?.foF2 ? `foF2=${ionospheric.foF2}` : `SSN=${solarData.ssn}`} +
+
+
+ ) : ( + /* Bar Chart View */ +
+ {/* Solar quick stats */} +
+ SFI {solarData.sfi} + {ionospheric?.foF2 ? ( + foF2 {ionospheric.foF2} + ) : ( + SSN {solarData.ssn} + )} + K = 4 ? '#ff4444' : '#00ff88' }}>{solarData.kIndex} +
+ + {currentBands.slice(0, 8).map((band, idx) => ( +
+ = 50 ? 'var(--accent-green)' : 'var(--text-muted)' + }}> + {band.band} + +
+
+
+ + {band.reliability}% + +
+ ))} +
+ )} + + )} +
+ ); + }; + + // ============================================ + // SOLAR IMAGE COMPONENT + // ============================================ + // Combined Solar Panel - toggleable between image and indices + const SolarPanel = ({ solarIndices }) => { + const [showIndices, setShowIndices] = useState(() => { + try { + const saved = localStorage.getItem('openhamclock_solarPanelMode'); + return saved === 'indices'; + } catch (e) { return false; } + }); + const [imageType, setImageType] = useState('0193'); // AIA 193 (corona) + + // Save preference + const toggleMode = () => { + const newMode = !showIndices; + setShowIndices(newMode); + try { + localStorage.setItem('openhamclock_solarPanelMode', newMode ? 'indices' : 'image'); + } catch (e) {} + }; + + // SDO/AIA image types + const imageTypes = { + '0193': { name: 'AIA 193Å', desc: 'Corona' }, + '0304': { name: 'AIA 304Å', desc: 'Chromosphere' }, + '0171': { name: 'AIA 171Å', desc: 'Quiet Corona' }, + '0094': { name: 'AIA 94Å', desc: 'Flaring' }, + 'HMIIC': { name: 'HMI Int', desc: 'Visible' } + }; + + // SDO images update every ~15 minutes + const timestamp = Math.floor(Date.now() / 900000) * 900000; + const imageUrl = `https://sdo.gsfc.nasa.gov/assets/img/latest/latest_256_${imageType}.jpg?t=${timestamp}`; + + // Kp color helper + const getKpColor = (value) => { + if (value >= 7) return '#ff0000'; + if (value >= 5) return '#ff6600'; + if (value >= 4) return '#ffcc00'; + if (value >= 3) return '#88cc00'; + return '#00ff88'; + }; + + const getKpLabel = (value) => { + if (value >= 7) return 'SEVERE'; + if (value >= 5) return 'STORM'; + if (value >= 4) return 'ACTIVE'; + if (value >= 3) return 'UNSETTLED'; + return 'QUIET'; + }; + + return ( +
+ {/* Header with toggle */} +
+ + ☀ {showIndices ? 'SOLAR INDICES' : 'SOLAR'} + +
+ {!showIndices && ( + + )} + +
+
+ + {showIndices ? ( + /* Solar Indices View */ +
+ {solarIndices?.data ? ( +
+ {/* SFI Row */} +
+
+
SFI
+
+ {solarIndices.data.sfi?.current || '--'} +
+
+
+ {solarIndices.data.sfi?.history?.length > 0 && ( + + {(() => { + const data = solarIndices.data.sfi.history.slice(-20); + const values = data.map(d => d.value); + const max = Math.max(...values, 1); + const min = Math.min(...values, 0); + const range = max - min || 1; + const points = data.map((d, i) => { + const x = (i / (data.length - 1)) * 100; + const y = 30 - ((d.value - min) / range) * 28; + return `${x},${y}`; + }).join(' '); + return ; + })()} + + )} +
30 day history
+
+
+ + {/* SSN Row */} +
+
+
Sunspots
+
+ {solarIndices.data.ssn?.current || '--'} +
+
+
+ {solarIndices.data.ssn?.history?.length > 0 && ( + + {(() => { + const data = solarIndices.data.ssn.history; + const values = data.map(d => d.value); + const max = Math.max(...values, 1); + const min = Math.min(...values, 0); + const range = max - min || 1; + const points = data.map((d, i) => { + const x = (i / (data.length - 1)) * 100; + const y = 30 - ((d.value - min) / range) * 28; + return `${x},${y}`; + }).join(' '); + return ; + })()} + + )} +
12 month history
+
+
+ + {/* Kp Row */} +
+
+
Kp Index
+
+ {solarIndices.data.kp?.current?.toFixed(1) || '--'} +
+
+ {solarIndices.data.kp?.current !== null ? getKpLabel(solarIndices.data.kp?.current) : ''} +
+
+
+ {solarIndices.data.kp?.history?.length > 0 && ( + + {(() => { + const hist = solarIndices.data.kp.history.slice(-16); + const forecast = (solarIndices.data.kp.forecast || []).slice(0, 8); + const allData = [...hist, ...forecast]; + const barWidth = 100 / allData.length; + return allData.map((d, i) => { + const isForecast = i >= hist.length; + const barHeight = (d.value / 9) * 32; + return ( + + ); + }); + })()} + + )} +
3 day history + forecast
+
+
+
+ ) : ( +
+ Loading solar data... +
+ )} +
+ NOAA/SWPC +
+
+ ) : ( + /* Solar Image View */ +
+
+ Current Sun { + e.target.style.display = 'none'; + }} + /> +
+
+ SDO/AIA • Live from NASA +
+
+ )} +
+ ); + }; + + // ============================================ + // LEAFLET MAP COMPONENT + // ============================================ + const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots, mySpots, dxPaths, dxFilters, satellites, showDXPaths, showDXLabels, onToggleDXLabels, showPOTA, showSatellites, onToggleSatellites, hoveredSpot }) => { + const mapRef = useRef(null); + const mapInstanceRef = useRef(null); + const tileLayerRef = useRef(null); + const terminatorRef = useRef(null); + const deMarkerRef = useRef(null); + const dxMarkerRef = useRef(null); + const sunMarkerRef = useRef(null); + const moonMarkerRef = useRef(null); + const potaMarkersRef = useRef([]); + const mySpotsMarkersRef = useRef([]); + const mySpotsLinesRef = useRef([]); + const dxPathsLinesRef = useRef([]); + const dxPathsMarkersRef = useRef([]); + const satMarkersRef = useRef([]); + const satTracksRef = useRef([]); + + // Load map style from localStorage + const getStoredMapSettings = () => { + try { + const stored = localStorage.getItem('openhamclock_mapSettings'); + return stored ? JSON.parse(stored) : {}; + } catch (e) { return {}; } + }; + const storedSettings = getStoredMapSettings(); + + const [mapStyle, setMapStyle] = useState(storedSettings.mapStyle || 'dark'); + const [mapView, setMapView] = useState({ + center: storedSettings.center || [20, 0], + zoom: storedSettings.zoom || 2.5 + }); + + // Save map settings to localStorage when changed + useEffect(() => { + try { + localStorage.setItem('openhamclock_mapSettings', JSON.stringify({ + mapStyle, + center: mapView.center, + zoom: mapView.zoom + })); + } catch (e) { console.error('Failed to save map settings:', e); } + }, [mapStyle, mapView]); + + // Initialize map + useEffect(() => { + if (!mapRef.current || mapInstanceRef.current) return; + + const map = L.map(mapRef.current, { + center: mapView.center, + zoom: mapView.zoom, + minZoom: 1, + maxZoom: 18, + worldCopyJump: true, + zoomControl: true, + maxBounds: [[-90, -Infinity], [90, Infinity]], + maxBoundsViscosity: 0.8 + }); + + // Initial tile layer with bounds to prevent edge requests + tileLayerRef.current = L.tileLayer(MAP_STYLES[mapStyle].url, { + attribution: MAP_STYLES[mapStyle].attribution, + noWrap: false, + crossOrigin: 'anonymous', + bounds: [[-85, -180], [85, 180]] + }).addTo(map); + + // Day/night terminator with resolution option for smoother rendering + terminatorRef.current = L.terminator({ + resolution: 2, + fillOpacity: 0.35, + fillColor: '#000020', + color: '#ffaa00', + weight: 2, + dashArray: '5, 5' + }).addTo(map); + + // Refresh terminator after a short delay to ensure proper rendering + setTimeout(() => { + if (terminatorRef.current) { + terminatorRef.current.setTime(); + } + }, 100); + + // Update terminator every minute + const terminatorInterval = setInterval(() => { + if (terminatorRef.current) { + terminatorRef.current.setTime(); + } + }, 60000); + + // Click handler for setting DX + map.on('click', (e) => { + if (onDXChange) { + onDXChange({ lat: e.latlng.lat, lon: e.latlng.lng }); + } + }); + + // Save map view when user pans or zooms + map.on('moveend', () => { + const center = map.getCenter(); + const zoom = map.getZoom(); + setMapView({ center: [center.lat, center.lng], zoom }); + }); + + mapInstanceRef.current = map; return () => { + clearInterval(terminatorInterval); map.remove(); mapInstanceRef.current = null; }; @@ -603,7 +2403,9 @@ mapInstanceRef.current.removeLayer(tileLayerRef.current); tileLayerRef.current = L.tileLayer(MAP_STYLES[mapStyle].url, { attribution: MAP_STYLES[mapStyle].attribution, - noWrap: false + noWrap: false, + crossOrigin: 'anonymous', + bounds: [[-85, -180], [85, 180]] }).addTo(mapInstanceRef.current); // Ensure terminator is on top @@ -621,7 +2423,7 @@ if (deMarkerRef.current) map.removeLayer(deMarkerRef.current); if (dxMarkerRef.current) map.removeLayer(dxMarkerRef.current); if (sunMarkerRef.current) map.removeLayer(sunMarkerRef.current); - if (pathRef.current) map.removeLayer(pathRef.current); + if (moonMarkerRef.current) map.removeLayer(moonMarkerRef.current); // DE Marker const deIcon = L.divIcon({ @@ -657,16 +2459,300 @@ .bindPopup('Subsolar Point') .addTo(map); - // Great circle path - const pathPoints = getGreatCirclePoints(deLocation.lat, deLocation.lon, dxLocation.lat, dxLocation.lon); - pathRef.current = L.polyline(pathPoints, { - color: '#00ddff', - weight: 3, - opacity: 0.8, - dashArray: '10, 6' - }).addTo(map); + // Moon marker + const moonPos = getMoonPosition(new Date()); + const moonPhase = getMoonPhase(new Date()); + const moonEmoji = getMoonPhaseEmoji(moonPhase); + const moonIcon = L.divIcon({ + className: 'custom-marker moon-marker', + html: moonEmoji, + iconSize: [24, 24], + iconAnchor: [12, 12] + }); + moonMarkerRef.current = L.marker([moonPos.lat, moonPos.lon], { icon: moonIcon }) + .bindPopup(`Sublunar Point
${moonEmoji}`) + .addTo(map); + + }, [deLocation, dxLocation]); + + // Update sun and moon positions every minute + useEffect(() => { + if (!mapInstanceRef.current) return; + const map = mapInstanceRef.current; + + const updateCelestialBodies = () => { + // Update sun position + if (sunMarkerRef.current) { + const sunPos = getSunPosition(new Date()); + sunMarkerRef.current.setLatLng([sunPos.lat, sunPos.lon]); + } + + // Update moon position + if (moonMarkerRef.current) { + const moonPos = getMoonPosition(new Date()); + const moonPhase = getMoonPhase(new Date()); + const moonEmoji = getMoonPhaseEmoji(moonPhase); + moonMarkerRef.current.setLatLng([moonPos.lat, moonPos.lon]); + // Update moon icon to reflect current phase + const moonIcon = L.divIcon({ + className: 'custom-marker moon-marker', + html: moonEmoji, + iconSize: [24, 24], + iconAnchor: [12, 12] + }); + moonMarkerRef.current.setIcon(moonIcon); + moonMarkerRef.current.setPopupContent(`Sublunar Point
${moonEmoji}`); + } + }; + + const interval = setInterval(updateCelestialBodies, 60000); // Every minute + return () => clearInterval(interval); + }, []); + + // Update POTA markers + // Update my spots markers and connection lines + useEffect(() => { + if (!mapInstanceRef.current) return; + const map = mapInstanceRef.current; + + // Remove old my spots markers and lines + mySpotsMarkersRef.current.forEach(m => map.removeLayer(m)); + mySpotsMarkersRef.current = []; + mySpotsLinesRef.current.forEach(l => map.removeLayer(l)); + mySpotsLinesRef.current = []; + + // Add new my spots markers and lines + if (mySpots && mySpots.length > 0) { + mySpots.forEach(spot => { + if (spot.lat && spot.lon) { + // Draw great circle line from DE to spot location + const pathPoints = getGreatCirclePoints( + deLocation.lat, deLocation.lon, + spot.lat, spot.lon + ); + + // Handle antimeridian crossing - pathPoints may be array of segments + const segments = Array.isArray(pathPoints[0]) ? pathPoints : [pathPoints]; + segments.forEach(segment => { + const line = L.polyline(segment, { + color: spot.isMySpot ? '#00ffaa' : '#ffaa00', // Green if I spotted, amber if spotted me + weight: 2, + opacity: 0.7, + dashArray: '5, 10' + }).addTo(map); + mySpotsLinesRef.current.push(line); + }); + + // Create marker for the spot + const markerColor = spot.isMySpot ? '#00ffaa' : '#ffaa00'; + const icon = L.divIcon({ + className: '', + html: `
${spot.targetCall}
`, + iconAnchor: [25, 12] + }); + + const marker = L.marker([spot.lat, spot.lon], { icon }) + .bindPopup(` + ${spot.targetCall}
+ ${spot.isMySpot ? 'You spotted' : 'Spotted you'}
+ ${spot.freq} MHz
+ ${spot.comment || ''}
+ ${spot.time} + `) + .addTo(map); + mySpotsMarkersRef.current.push(marker); + } + }); + } + }, [mySpots, deLocation]); + + // Update DX paths - lines showing who spotted whom + useEffect(() => { + if (!mapInstanceRef.current) return; + const map = mapInstanceRef.current; + + // Remove old DX paths + dxPathsLinesRef.current.forEach(l => map.removeLayer(l)); + dxPathsLinesRef.current = []; + dxPathsMarkersRef.current.forEach(m => map.removeLayer(m)); + dxPathsMarkersRef.current = []; - }, [deLocation, dxLocation]); + // Add new DX paths if enabled + if (showDXPaths && dxPaths && dxPaths.length > 0) { + // Apply filters to paths + const filteredPaths = filterDXPaths(dxPaths, dxFilters); + + filteredPaths.forEach((path, index) => { + try { + // Skip if missing or invalid coordinates + if (!path.spotterLat || !path.spotterLon || !path.dxLat || !path.dxLon) return; + if (isNaN(path.spotterLat) || isNaN(path.spotterLon) || isNaN(path.dxLat) || isNaN(path.dxLon)) return; + + // Draw great circle line from spotter to DX station + const pathPoints = getGreatCirclePoints( + path.spotterLat, path.spotterLon, + path.dxLat, path.dxLon + ); + + // Skip if no valid path points + if (!pathPoints || !Array.isArray(pathPoints) || pathPoints.length === 0) return; + + // Use different colors based on band (derived from frequency) + // Include text color for readability (dark text on light backgrounds) + const freq = parseFloat(path.freq); + let color = '#4488ff'; + let textColor = '#000'; // Default dark text + if (freq >= 1.8 && freq < 2) { color = '#ff6666'; textColor = '#000'; } // 160m - red + else if (freq >= 3.5 && freq < 4) { color = '#ff9966'; textColor = '#000'; } // 80m - orange + else if (freq >= 7 && freq < 7.5) { color = '#ffcc66'; textColor = '#000'; } // 40m - yellow + else if (freq >= 10 && freq < 10.5) { color = '#99ff66'; textColor = '#000'; } // 30m - lime + else if (freq >= 14 && freq < 14.5) { color = '#66ff99'; textColor = '#000'; } // 20m - green + else if (freq >= 18 && freq < 18.5) { color = '#66ffcc'; textColor = '#000'; } // 17m - teal + else if (freq >= 21 && freq < 21.5) { color = '#66ccff'; textColor = '#000'; } // 15m - cyan + else if (freq >= 24 && freq < 25) { color = '#6699ff'; textColor = '#fff'; } // 12m - blue (darker, white text) + else if (freq >= 26 && freq < 28) { color = '#8866ff'; textColor = '#fff'; } // 11m - violet (CB band) + else if (freq >= 28 && freq < 30) { color = '#9966ff'; textColor = '#fff'; } // 10m - purple (darker, white text) + else if (freq >= 50 && freq < 54) { color = '#ff66ff'; textColor = '#000'; } // 6m - magenta + + // Check if this path is hovered + const isHovered = hoveredSpot && hoveredSpot.call === path.dxCall && + Math.abs(parseFloat(hoveredSpot.freq) - parseFloat(path.freq)) < 0.01; + + // Handle antimeridian crossing - pathPoints may be array of segments or single segment + // Check if first element's first element is an array (segment structure) vs just [lat, lon] + const isSegmented = Array.isArray(pathPoints[0]) && pathPoints[0].length > 0 && Array.isArray(pathPoints[0][0]); + const segments = isSegmented ? pathPoints : [pathPoints]; + + segments.forEach(segment => { + if (segment && Array.isArray(segment) && segment.length > 1) { + const line = L.polyline(segment, { + color: isHovered ? '#ffffff' : color, + weight: isHovered ? 4 : 1.5, + opacity: isHovered ? 1 : 0.5 + }).addTo(map); + if (isHovered) { + line.bringToFront(); + } + dxPathsLinesRef.current.push(line); + } + }); + + // 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 spotterPopupContent = ` +
+
${path.spotter}
+ ${path.spotterGrid ? `
📍 ${path.spotterGrid}
` : ''} +
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: isHovered ? 10 : 6, + fillColor: isHovered ? '#ffffff' : color, + color: isHovered ? color : '#fff', + weight: isHovered ? 3 : 1.5, + opacity: 1, + fillOpacity: isHovered ? 1 : 0.9 + }) + .bindPopup(dxPopupContent) + .addTo(map); + if (isHovered) { + dxCircle.bringToFront(); + } + dxPathsMarkersRef.current.push(dxCircle); + + // Add colored callsign label for DX station (if labels enabled or hovered) + if (showDXLabels || isHovered) { + const labelBg = isHovered ? '#ffffff' : color; + const labelText = isHovered ? color : textColor; + const labelIcon = L.divIcon({ + className: '', + html: `
${path.dxCall}
`, + iconSize: [0, 0], + iconAnchor: [-8, 20] + }); + const labelMarker = L.marker([path.dxLat, path.dxLon], { icon: labelIcon, interactive: false }) + .addTo(map); + if (isHovered) { + labelMarker.setZIndexOffset(1000); + } + dxPathsMarkersRef.current.push(labelMarker); + } + + // Add hoverable circle at spotter end (smaller, different style) + const spotterCircle = L.circleMarker([path.spotterLat, path.spotterLon], { + radius: isHovered ? 6 : 4, + fillColor: isHovered ? '#ffffff' : '#00aaff', + color: isHovered ? '#00aaff' : '#fff', + weight: isHovered ? 2 : 1, + opacity: isHovered ? 1 : 0.8, + fillOpacity: isHovered ? 1 : 0.7 + }) + .bindPopup(spotterPopupContent) + .addTo(map); + if (isHovered) { + spotterCircle.bringToFront(); + } + dxPathsMarkersRef.current.push(spotterCircle); + + // Add spotter label only when hovered + if (isHovered) { + const spotterLabelIcon = L.divIcon({ + className: '', + html: `
${path.spotter}
`, + iconSize: [0, 0], + iconAnchor: [-8, 16] + }); + const spotterLabel = L.marker([path.spotterLat, path.spotterLon], { icon: spotterLabelIcon, interactive: false }) + .addTo(map); + spotterLabel.setZIndexOffset(999); + dxPathsMarkersRef.current.push(spotterLabel); + } + + } catch (err) { + console.error('[DX Paths] Error rendering path:', err, path); + } + }); + } + }, [dxPaths, dxFilters, showDXPaths, showDXLabels, hoveredSpot]); // Update POTA markers useEffect(() => { @@ -677,37 +2763,253 @@ potaMarkersRef.current.forEach(m => map.removeLayer(m)); potaMarkersRef.current = []; - // Add new POTA markers - potaSpots.forEach(spot => { - if (spot.lat && spot.lon) { + // Add new POTA markers if enabled + if (showPOTA && potaSpots) { + potaSpots.forEach(spot => { + if (spot.lat && spot.lon) { + const icon = L.divIcon({ + className: '', + html: `
${spot.call}
`, + iconAnchor: [20, 10] + }); + const marker = L.marker([spot.lat, spot.lon], { icon }) + .bindPopup(`${spot.call}
${spot.ref}
${spot.freq} ${spot.mode}`) + .addTo(map); + potaMarkersRef.current.push(marker); + } + }); + } + }, [potaSpots, showPOTA]); + + // Update satellite markers and tracks + useEffect(() => { + if (!mapInstanceRef.current) return; + const map = mapInstanceRef.current; + + // Remove old satellite markers and tracks + satMarkersRef.current.forEach(m => map.removeLayer(m)); + satMarkersRef.current = []; + satTracksRef.current.forEach(t => map.removeLayer(t)); + satTracksRef.current = []; + + // Add satellite markers and tracks if enabled + if (showSatellites && satellites && satellites.length > 0) { + satellites.forEach(sat => { + // Draw orbit track + if (sat.track && sat.track.length > 1) { + // Split track at antimeridian crossings + let segments = []; + let currentSegment = [sat.track[0]]; + + for (let i = 1; i < sat.track.length; i++) { + const prevLon = sat.track[i-1][1]; + const currLon = sat.track[i][1]; + + if (Math.abs(currLon - prevLon) > 180) { + segments.push(currentSegment); + currentSegment = []; + } + currentSegment.push(sat.track[i]); + } + segments.push(currentSegment); + + segments.forEach(segment => { + if (segment.length > 1) { + const track = L.polyline(segment, { + color: sat.color, + weight: 1.5, + opacity: 0.5, + dashArray: '4, 8' + }).addTo(map); + satTracksRef.current.push(track); + } + }); + } + + // Create satellite marker + const isISS = sat.key === 'ISS'; const icon = L.divIcon({ className: '', - html: `
${spot.call}
`, - iconAnchor: [20, 10] + html: `
🛰 ${sat.key}
`, + iconAnchor: [isISS ? 35 : 25, 12] }); - const marker = L.marker([spot.lat, spot.lon], { icon }) - .bindPopup(`${spot.call}
${spot.ref}
${spot.freq} ${spot.mode}`) + + const marker = L.marker([sat.lat, sat.lon], { icon, zIndexOffset: isISS ? 1000 : 500 }) + .bindPopup(` +
+ 🛰 ${sat.name}
+
+ Altitude: ${sat.alt} km
+ Position: ${sat.lat.toFixed(2)}°, ${sat.lon.toFixed(2)}°
+ ${sat.isVisible ? + `✓ VISIBLE
+ Azimuth: ${sat.azimuth}°
+ Elevation: ${sat.elevation}°` : + `Below horizon` + } +
+
+ `) .addTo(map); - potaMarkersRef.current.push(marker); - } - }); - }, [potaSpots]); + satMarkersRef.current.push(marker); + }); + } + }, [satellites, showSatellites]); return ( -
+
- {/* Map style selector */} -
+ {/* Map style dropdown */} + + + {/* Satellite toggle button */} + {onToggleSatellites && ( + + )} + + {/* Labels toggle button */} + {onToggleDXLabels && showDXPaths && ( + + )} + + {/* Map Legend - Bottom of map */} +
+ {/* DX Paths Band Colors */} + {showDXPaths && ( +
+ DX: + 160m + 80m + 40m + 30m + 20m + 17m + 15m + 12m + 10m + 6m +
+ )} + {showDXPaths && (showPOTA || showSatellites) && |} + + {/* POTA */} + {showPOTA && ( +
+
+ POTA +
+ )} + + {/* Satellites */} + {showSatellites && ( +
+ 🛰 + SAT +
+ )} + + {/* DE/DX markers */} + | +
+ ● DE + ● DX + ☀ Sun + 🌙 Moon +
); @@ -716,7 +3018,7 @@ // ============================================ // UI COMPONENTS // ============================================ - const Header = ({ callsign, uptime, version, onSettingsClick }) => ( + const Header = ({ callsign, uptime, version, onSettingsClick, onFullscreenToggle, isFullscreen }) => (
{callsign}
-
+
UPTIME: {uptime} v{version} +
@@ -768,7 +3085,7 @@ background: 'var(--bg-panel)', border: '1px solid var(--border-color)', borderRadius: '8px', padding: '16px 24px', backdropFilter: 'blur(10px)' }}> -
{label}
+
{label}
{time}
{date}
@@ -785,7 +3102,7 @@ borderRadius: '8px', padding: '16px 20px', backdropFilter: 'blur(10px)' }}>
-
{type}
+
{type}
{gridSquare}
@@ -835,7 +3152,7 @@
{item.label} - {item.value}{item.unit && {item.unit}} + {item.value}{item.unit && {item.unit}}
))} @@ -848,77 +3165,1478 @@ ); }; - const BandConditionsPanel = ({ bands, loading }) => { - const getStyle = (c) => ({ GOOD: { bg: 'rgba(0,255,136,0.15)', color: 'var(--accent-green)', border: 'rgba(0,255,136,0.3)' }, FAIR: { bg: 'rgba(255,180,50,0.15)', color: 'var(--accent-amber)', border: 'rgba(255,180,50,0.3)' }, POOR: { bg: 'rgba(255,68,102,0.15)', color: 'var(--accent-red)', border: 'rgba(255,68,102,0.3)' } }[c] || { bg: 'rgba(255,180,50,0.15)', color: 'var(--accent-amber)', border: 'rgba(255,180,50,0.3)' }); + // ============================================ + // DX CLUSTER FILTER MANAGER - Comprehensive filtering system + // ============================================ + const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose }) => { + const [activeTab, setActiveTab] = useState('zones'); + const [newWatchlistCall, setNewWatchlistCall] = useState(''); + const [newExcludeCall, setNewExcludeCall] = useState(''); + + // CQ Zones (1-40) + const cqZones = Array.from({ length: 40 }, (_, i) => i + 1); + + // ITU Zones (1-90) + const ituZones = Array.from({ length: 90 }, (_, i) => i + 1); + + // Continents + const continents = [ + { code: 'NA', name: 'North America' }, + { code: 'SA', name: 'South America' }, + { code: 'EU', name: 'Europe' }, + { code: 'AF', name: 'Africa' }, + { code: 'AS', name: 'Asia' }, + { code: 'OC', name: 'Oceania' }, + { code: 'AN', name: 'Antarctica' } + ]; + + // Bands + const bands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '11m', '10m', '6m', '2m', '70cm']; + + // Modes + const modes = ['CW', 'SSB', 'FT8', 'FT4', 'RTTY', 'PSK', 'AM', 'FM']; + + // Toggle functions + const toggleArrayItem = (key, item) => { + const current = filters?.[key] || []; + const newArray = current.includes(item) + ? current.filter(i => i !== item) + : [...current, item]; + onFilterChange({ ...filters, [key]: newArray.length > 0 ? newArray : undefined }); + }; + + const selectAllZones = (type, zones) => { + onFilterChange({ ...filters, [type]: [...zones] }); + }; + + const clearZones = (type) => { + onFilterChange({ ...filters, [type]: undefined }); + }; + + const addToWatchlist = () => { + if (!newWatchlistCall.trim()) return; + const current = filters?.watchlist || []; + const call = newWatchlistCall.toUpperCase().trim(); + if (!current.includes(call)) { + onFilterChange({ ...filters, watchlist: [...current, call] }); + } + setNewWatchlistCall(''); + }; + + const removeFromWatchlist = (call) => { + const current = filters?.watchlist || []; + onFilterChange({ ...filters, watchlist: current.filter(c => c !== call) }); + }; + + const addToExclude = () => { + if (!newExcludeCall.trim()) return; + const current = filters?.excludeList || []; + const call = newExcludeCall.toUpperCase().trim(); + if (!current.includes(call)) { + onFilterChange({ ...filters, excludeList: [...current, call] }); + } + setNewExcludeCall(''); + }; + + const removeFromExclude = (call) => { + const current = filters?.excludeList || []; + onFilterChange({ ...filters, excludeList: current.filter(c => c !== call) }); + }; + + const clearAllFilters = () => { + onFilterChange({}); + }; + + const getActiveFilterCount = () => { + let count = 0; + if (filters?.cqZones?.length) count++; + if (filters?.ituZones?.length) count++; + if (filters?.continents?.length) count++; + if (filters?.bands?.length) count++; + if (filters?.modes?.length) count++; + if (filters?.watchlist?.length) count++; + if (filters?.excludeList?.length) count++; + if (filters?.callsign) count++; + if (filters?.watchlistOnly) count++; + return count; + }; + + if (!isOpen) return null; + + const tabStyle = (active) => ({ + padding: '8px 16px', + background: active ? 'var(--accent-cyan)' : 'transparent', + color: active ? '#000' : 'var(--text-secondary)', + border: 'none', + borderRadius: '4px 4px 0 0', + cursor: 'pointer', + fontFamily: 'JetBrains Mono', + fontSize: '11px', + fontWeight: active ? '700' : '400' + }); + + const pillStyle = (active) => ({ + padding: '4px 10px', + background: active ? 'rgba(0, 255, 136, 0.3)' : 'rgba(60,60,60,0.5)', + border: `1px solid ${active ? '#00ff88' : '#444'}`, + color: active ? '#00ff88' : '#888', + borderRadius: '4px', + fontSize: '10px', + cursor: 'pointer', + fontFamily: 'JetBrains Mono', + transition: 'all 0.15s' + }); + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+
+

🔍 DX Cluster Filters

+
+ {getActiveFilterCount()} filter{getActiveFilterCount() !== 1 ? 's' : ''} active +
+
+
+ + +
+
+ + {/* Tabs */} +
+ + + + + +
+ + {/* Tab Content */} +
+ + {/* ZONES TAB */} + {activeTab === 'zones' && ( +
+ {/* Continents */} +
+
+ Continents +
+
+ {continents.map(cont => ( + + ))} +
+
+ + {/* CQ Zones */} +
+
+ CQ Zones +
+ + +
+
+
+ {cqZones.map(zone => ( + + ))} +
+
+ + {/* ITU Zones */} +
+
+ ITU Zones +
+ + +
+
+
+ {ituZones.map(zone => ( + + ))} +
+
+
+ )} + + {/* BANDS TAB */} + {activeTab === 'bands' && ( +
+
+ Select bands to show (leave empty for all bands) +
+
+ {bands.map(band => ( + + ))} +
+ + {/* Quick select groups */} +
+
Quick Select:
+
+ + + + + +
+
+
+ )} + + {/* MODES TAB */} + {activeTab === 'modes' && ( +
+
+ Select modes to show (leave empty for all modes) +
+
+ {modes.map(mode => ( + + ))} +
+ + {/* Quick select groups */} +
+
Quick Select:
+
+ + + + +
+
+
+ )} + + {/* WATCHLIST TAB */} + {activeTab === 'watchlist' && ( +
+
+ Callsign Watchlist +
+
+ Add callsigns you want to watch for. Matching spots will be highlighted. +
+ + {/* Watchlist Only toggle */} +
+ +
+ + {/* Add callsign */} +
+ setNewWatchlistCall(e.target.value.toUpperCase())} + onKeyDown={(e) => e.key === 'Enter' && addToWatchlist()} + style={{ + flex: 1, + padding: '10px 12px', + background: 'var(--bg-secondary)', + border: '1px solid var(--border-color)', + borderRadius: '6px', + color: 'var(--text-primary)', + fontSize: '14px', + fontFamily: 'JetBrains Mono' + }} + /> + +
+ + {/* Watchlist items */} +
+ {(filters?.watchlist || []).map(call => ( +
+ {call} + +
+ ))} + {(!filters?.watchlist || filters.watchlist.length === 0) && ( +
No callsigns in watchlist
+ )} +
+
+ )} + + {/* EXCLUDE TAB */} + {activeTab === 'exclude' && ( +
+
+ Exclude Callsigns +
+
+ Add callsigns or prefixes to hide from the cluster. Partial matches work (e.g., "W3" hides all W3 calls). +
+ + {/* Add exclude */} +
+ setNewExcludeCall(e.target.value.toUpperCase())} + onKeyDown={(e) => e.key === 'Enter' && addToExclude()} + style={{ + flex: 1, + padding: '10px 12px', + background: 'var(--bg-secondary)', + border: '1px solid var(--border-color)', + borderRadius: '6px', + color: 'var(--text-primary)', + fontSize: '14px', + fontFamily: 'JetBrains Mono' + }} + /> + +
+ + {/* Exclude items */} +
+ {(filters?.excludeList || []).map(call => ( +
+ {call} + +
+ ))} + {(!filters?.excludeList || filters.excludeList.length === 0) && ( +
No callsigns excluded
+ )} +
+
+ )} +
+
+
+ ); + }; + + // Simple DX Cluster Panel (for sidebars) + const DXClusterPanel = ({ spots, loading, activeSource, showOnMap, onToggleMap, spotCount, filteredCount, filters, onFilterChange, onOpenFilters, hoveredSpot, onHoverSpot }) => { + const hasActiveFilters = (filters?.bands?.length > 0) || (filters?.modes?.length > 0) || + (filters?.cqZones?.length > 0) || (filters?.ituZones?.length > 0) || + (filters?.continents?.length > 0) || (filters?.watchlist?.length > 0) || + (filters?.excludeList?.length > 0) || filters?.watchlistOnly; + + // Check if spot matches watchlist for highlighting + const isWatchlistMatch = (spot) => { + if (!filters?.watchlist?.length) return false; + return filters.watchlist.some(w => + spot.call?.toUpperCase().includes(w) || + spot.spotter?.toUpperCase().includes(w) + ); + }; + + return ( +
+
+ 🌐 DX CLUSTER +
+ {loading &&
} + + {filteredCount !== undefined && spotCount !== undefined ? `${filteredCount}/${spotCount}` : ''} + + + + ● LIVE +
+
+ + {activeSource &&
Source: {activeSource}
} + +
+ {spots.length > 0 ? spots.map((s, i) => { + const isHighlighted = isWatchlistMatch(s); + const isHovered = hoveredSpot && hoveredSpot.call === s.call && hoveredSpot.freq === s.freq; + return ( +
onHoverSpot && onHoverSpot(s)} + onMouseLeave={() => onHoverSpot && onHoverSpot(null)} + style={{ + display: 'grid', + gridTemplateColumns: '65px 75px 1fr auto', + gap: '10px', + padding: '8px 4px', + borderBottom: '1px solid rgba(255,255,255,0.03)', + fontFamily: 'JetBrains Mono, monospace', + fontSize: '13px', + alignItems: 'center', + background: isHovered ? 'rgba(68, 136, 255, 0.3)' : isHighlighted ? 'rgba(0, 255, 136, 0.15)' : 'transparent', + borderLeft: isHovered ? '3px solid #4488ff' : isHighlighted ? '3px solid #00ff88' : '3px solid transparent', + marginLeft: '-4px', + cursor: 'pointer', + transition: 'background 0.15s ease' + }}> + {s.freq} + {s.call} + {s.comment} + {s.time} +
+ ); + }) : ( +
+ {hasActiveFilters ? 'No spots match filters' : 'No spots available'} +
+ )} +
+
+ ); + }; + + const POTAPanel = ({ activities, loading, showOnMap, onToggleMap }) => ( +
+
+ 🏕 POTA ACTIVITY +
+ {loading &&
} + + ● LIVE +
+
+
+ {activities.length > 0 ? activities.map((a, i) => ( +
+
+ {a.call} + {a.ref} +
+
+ {a.freq} + {a.mode} +
+
+ )) :
No active POTA spots
} +
+
+ ); + + // ============================================ + // DXPEDITION PANEL + // ============================================ + const DXpeditionPanel = ({ data, loading }) => { + const formatDate = (isoString) => { + if (!isoString) return ''; + const date = new Date(isoString); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }; + + const getStatusStyle = (expedition) => { + if (expedition.isActive) { + return { bg: 'rgba(0, 255, 136, 0.15)', border: 'var(--accent-green)', color: 'var(--accent-green)' }; + } + if (expedition.isUpcoming) { + return { bg: 'rgba(0, 170, 255, 0.15)', border: 'var(--accent-cyan)', color: 'var(--accent-cyan)' }; + } + return { bg: 'var(--bg-tertiary)', border: 'var(--border-color)', color: 'var(--text-muted)' }; + }; + + return ( +
+
+ 🌍 DXPEDITIONS +
+ {loading &&
} + {data && ( + + {data.active > 0 && {data.active} active} + {data.active > 0 && data.upcoming > 0 && ' • '} + {data.upcoming > 0 && {data.upcoming} upcoming} + + )} +
+
+ +
+ {data?.dxpeditions?.length > 0 ? ( + data.dxpeditions.slice(0, 15).map((exp, idx) => { + const style = getStatusStyle(exp); + return ( +
+
+ {exp.callsign} + + {exp.isActive ? '● ACTIVE' : exp.isUpcoming ? 'UPCOMING' : 'PAST'} + +
+
+ {exp.entity} +
+
+ {exp.dates} +
+ {exp.bands && {exp.bands.split(' ').slice(0, 3).join(' ')}} + {exp.modes && {exp.modes.split(' ').slice(0, 2).join(' ')}} +
+
+
+ ); + }) + ) : ( +
+ {loading ? 'Loading DXpeditions...' : 'No DXpedition data available'} +
+ )} +
+ + {data && ( + + )} +
+ ); + }; + + // ============================================ + // CONTEST CALENDAR PANEL + // ============================================ + const ContestPanel = ({ contests, loading }) => { + const formatContestTime = (isoString) => { + const date = new Date(isoString); + const now = new Date(); + const diffMs = date - now; + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffHours / 24); + + if (diffMs < 0) { + // Contest is active or past + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } else if (diffHours < 24) { + return `In ${diffHours}h`; + } else if (diffDays < 7) { + return `In ${diffDays}d`; + } else { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } + }; + + const getStatusColor = (contest) => { + if (contest.status === 'active') return 'var(--accent-green)'; + const start = new Date(contest.start); + const now = new Date(); + const hoursUntil = (start - now) / 3600000; + if (hoursUntil < 24) return 'var(--accent-amber)'; + return 'var(--text-secondary)'; + }; + + const getModeColor = (mode) => { + switch(mode) { + case 'CW': return 'var(--accent-cyan)'; + case 'SSB': return 'var(--accent-amber)'; + case 'RTTY': return 'var(--accent-purple)'; + default: return 'var(--text-secondary)'; + } + }; + + return ( +
+
+ 🏆 CONTESTS +
+ {loading &&
} +
+
+
+ {contests.length > 0 ? contests.map((c, i) => ( +
+
+ + {c.name} + {c.status === 'active' && } + + + {c.mode} + +
+
+ {c.status === 'active' ? 'NOW' : formatContestTime(c.start)} +
+
+ )) : ( +
+ No upcoming contests +
+ )} +
+ +
+ ); + }; + + // ============================================ + // LEGACY LAYOUT (Classic HamClock Style) + // ============================================ + const LegacyLayout = ({ + config, currentTime, utcTime, utcDate, localTime, localDate, + deGrid, dxGrid, deSunTimes, dxSunTimes, dxLocation, onDXChange, + spaceWeather, bandConditions, solarIndices, potaSpots, dxCluster, dxPaths, dxpeditions, contests, propagation, mySpots, satellites, + localWeather, use12Hour, onTimeFormatToggle, + onSettingsClick, onFullscreenToggle, isFullscreen, + mapLayers, toggleDXPaths, togglePOTA, toggleSatellites, toggleDXLabels, + dxFilters, onFilterChange, + hoveredSpot, onHoverSpot, onOpenDXFilters + }) => { + // Alias for setDxFilters used in the component + const setDxFilters = onFilterChange; + const setHoveredSpot = onHoverSpot; + const setShowDXFilters = onOpenDXFilters; + const bearing = calculateBearing(config.location.lat, config.location.lon, dxLocation.lat, dxLocation.lon); + const distance = calculateDistance(config.location.lat, config.location.lon, dxLocation.lat, dxLocation.lon); + + const panelStyle = { + background: 'var(--bg-panel)', + border: '1px solid var(--border-color)', + padding: '8px 12px', + fontFamily: 'JetBrains Mono, monospace' + }; + + const labelStyle = { + fontSize: '12px', + color: 'var(--accent-green)', + fontWeight: '700', + letterSpacing: '1px' + }; + + const valueStyle = { + fontSize: '14px', + color: 'var(--text-primary)', + fontWeight: '600' + }; + + const bigValueStyle = { + fontSize: '32px', + color: 'var(--accent-green)', + fontWeight: '700', + fontFamily: 'Orbitron, JetBrains Mono, monospace', + textShadow: '0 0 10px var(--accent-green-dim)' + }; return ( -
-
- 📡 BAND CONDITIONS - {loading &&
} +
+ {/* TOP LEFT - Callsign & Weather */} +
+
+ {config.callsign} +
+ {localWeather.data ? ( +
+ {localWeather.data.icon} {localWeather.data.temp}°F • {localWeather.data.description} +
+ ) : ( +
+ SFI {spaceWeather.data?.solarFlux || '--'} • K{spaceWeather.data?.kIndex || '-'} +
+ )}
-
- {bands.map((b, i) => { - const s = getStyle(b.condition); - return ( -
-
{b.band}
-
{b.condition}
-
- ); - })} + + {/* TOP CENTER - Large Clock */} +
+
+
{utcTime}
+
{utcDate} UTC
+
+
+
+
{localTime}
+
{localDate} Local
+
-
- ); - }; - const DXClusterPanel = ({ spots, loading }) => ( -
-
- 🌐 DX CLUSTER -
- {loading &&
} - ● LIVE + {/* TOP RIGHT - Solar Indices with Mini Charts */} +
+
☀ SOLAR INDICES
+ {solarIndices.data ? ( +
+ {/* SFI */} +
+
SFI
+
+ {solarIndices.data.sfi?.current || '--'} +
+ {solarIndices.data.sfi?.history?.length > 0 && ( + + {(() => { + const data = solarIndices.data.sfi.history.slice(-14); + const values = data.map(d => d.value); + const max = Math.max(...values, 1); + const min = Math.min(...values, 0); + const range = max - min || 1; + const points = data.map((d, i) => { + const x = (i / (data.length - 1)) * 50; + const y = 18 - ((d.value - min) / range) * 18; + return `${x},${y}`; + }).join(' '); + return ; + })()} + + )} +
+ {/* SSN */} +
+
SSN
+
+ {solarIndices.data.ssn?.current || '--'} +
+ {solarIndices.data.ssn?.history?.length > 0 && ( + + {(() => { + const data = solarIndices.data.ssn.history; + const values = data.map(d => d.value); + const max = Math.max(...values, 1); + const min = Math.min(...values, 0); + const range = max - min || 1; + const points = data.map((d, i) => { + const x = (i / (data.length - 1)) * 50; + const y = 18 - ((d.value - min) / range) * 18; + return `${x},${y}`; + }).join(' '); + return ; + })()} + + )} +
+ {/* Kp */} +
+
Kp
+
= 5 ? '#ff6600' : solarIndices.data.kp?.current >= 4 ? '#ffcc00' : '#00ff88' + }}> + {solarIndices.data.kp?.current?.toFixed(1) || '--'} +
+ {solarIndices.data.kp?.history?.length > 0 && ( + + {(() => { + const hist = solarIndices.data.kp.history.slice(-12); + const forecast = (solarIndices.data.kp.forecast || []).slice(0, 4); + const allData = [...hist, ...forecast]; + const barWidth = 50 / allData.length; + return allData.map((d, i) => { + const isForecast = i >= hist.length; + const barHeight = (d.value / 9) * 18; + const color = d.value >= 5 ? '#ff6600' : d.value >= 4 ? '#ffcc00' : '#00ff88'; + return ( + + ); + }); + })()} + + )} +
+
+ ) : ( +
+
+
SFI
+
{spaceWeather.data?.solarFlux || '--'}
+
+
+
Kp
+
4 ? 'var(--accent-red)' : 'var(--accent-green)', fontWeight: '700' }}>{spaceWeather.data?.kIndex || '-'}
+
+
+ )}
-
-
- {spots.map((s, i) => ( -
- {s.freq} - {s.call} - {s.comment} - {s.time} + + {/* LEFT SIDEBAR - DE/DX Info */} +
+ {/* DE Section */} +
+
DE:
+
{deGrid}
+
+ {config.location.lat.toFixed(2)}°{config.location.lat >= 0 ? 'N' : 'S'}, {config.location.lon.toFixed(2)}°{config.location.lon >= 0 ? 'E' : 'W'} +
+
+ ☀↑ {deSunTimes.sunrise}z ☀↓ {deSunTimes.sunset}z +
- ))} -
-
- ); - const POTAPanel = ({ activities, loading }) => ( -
-
- 🏕 POTA ACTIVITY -
- {loading &&
} - ● LIVE + {/* DX Section */} +
+
DX:
+
{dxGrid}
+
+ {dxLocation.lat.toFixed(2)}°{dxLocation.lat >= 0 ? 'N' : 'S'}, {dxLocation.lon.toFixed(2)}°{dxLocation.lon >= 0 ? 'E' : 'W'} +
+
+ ☀↑ {dxSunTimes.sunrise}z ☀↓ {dxSunTimes.sunset}z +
+
+ + {/* Path Info */} +
+
+ SP: + {bearing.toFixed(0)}° +
+
+ LP: + {((bearing + 180) % 360).toFixed(0)}° +
+
+ Dist: + {Math.round(distance).toLocaleString()} km +
+
+ + {/* Band Conditions - Compact */} +
+
📡 BANDS
+
+ {bandConditions.data.slice(0, 9).map((b, i) => { + const color = b.condition === 'GOOD' ? 'var(--accent-green)' : b.condition === 'FAIR' ? 'var(--accent-amber)' : 'var(--accent-red)'; + return ( +
+
{b.band}
+
+ ); + })} +
+
-
-
- {activities.length > 0 ? activities.map((a, i) => ( -
-
- {a.call} - {a.ref} + + {/* CENTER - Map */} +
+ +
+ + {/* RIGHT SIDEBAR - DX Cluster & Contests */} +
+ {/* DX Cluster */} +
+
+ 🌐 DX CLUSTER {dxCluster.filteredCount}/{dxCluster.spotCount} +
+ + + ● LIVE +
-
- {a.freq} - {a.mode} + {/* Quick search 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' + }} + /> + {Object.keys(dxFilters || {}).length > 0 && ( + + )} +
+ {/* Active filters summary */} + {(dxFilters?.bands?.length || dxFilters?.cqZones?.length || dxFilters?.watchlist?.length) && ( +
+ {dxFilters.bands?.length > 0 && {dxFilters.bands.join(', ')} } + {dxFilters.cqZones?.length > 0 && | CQ: {dxFilters.cqZones.length} zones } + {dxFilters.watchlist?.length > 0 && | {dxFilters.watchlist.length} watched} +
+ )} +
+ {dxCluster.data.slice(0, 8).map((s, i) => { + const isHovered = hoveredSpot && hoveredSpot.call === s.call && hoveredSpot.freq === s.freq; + return ( +
setHoveredSpot(s)} + onMouseLeave={() => setHoveredSpot(null)} + style={{ + padding: '4px 0', + borderBottom: '1px solid rgba(255,255,255,0.05)', + fontSize: '12px', + background: isHovered ? 'rgba(68, 136, 255, 0.3)' : 'transparent', + borderLeft: isHovered ? '3px solid #4488ff' : '3px solid transparent', + paddingLeft: '4px', + marginLeft: '-4px', + cursor: 'pointer', + transition: 'background 0.15s ease' + }} + > +
+ {s.freq} + {s.call} +
+
{s.comment}
+
+ ); + })} + {dxCluster.data.length === 0 &&
No spots match filter
}
- )) :
No active POTA spots
} + + {/* DXpeditions */} +
+
+ 🌍 DXPEDITIONS + {dxpeditions.data?.active > 0 && ( + {dxpeditions.data.active} active + )} +
+
+ {dxpeditions.data?.dxpeditions?.slice(0, 6).map((exp, i) => ( +
+
+ {exp.callsign} + + {exp.isActive ? '● NOW' : exp.dates?.split('-')[0]?.trim() || ''} + +
+
+ {exp.entity} + {exp.bands && {exp.bands.split(' ').slice(0,2).join(' ')}} +
+
+ ))} + {!dxpeditions.data?.dxpeditions?.length && ( +
+ {dxpeditions.loading ? 'Loading...' : 'No data'} +
+ )} +
+
+ + {/* Contests */} +
+
🏆 CONTESTS
+
+ {contests.data.slice(0, 5).map((c, i) => ( +
+
+ {c.name} {c.status === 'active' && } +
+
+ {c.mode} • {new Date(c.start).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} +
+
+ ))} +
+ +
+ + {/* POTA */} +
+
+ 🏕 POTA + +
+
+ {potaSpots.data.slice(0, 4).map((a, i) => ( +
+ {a.call} + {a.ref} + {a.freq} +
+ ))} +
+
+ + {/* Propagation */} + {propagation.data && ( +
+
📡 PROPAGATION
+
+ {propagation.data.currentBands.slice(0, 6).map((b, i) => ( +
+ = 50 ? 'var(--accent-green)' : 'var(--text-muted)' }}>{b.band} +
+
= 70 ? '#00ff88' : b.reliability >= 50 ? '#88ff00' : b.reliability >= 30 ? '#ffcc00' : '#ff8800', + borderRadius: '2px' + }} /> +
+ = 70 ? '#00ff88' : b.reliability >= 50 ? '#88ff00' : b.reliability >= 30 ? '#ffcc00' : '#ff8800' + }}>{b.status.substring(0, 4)} +
+ ))} +
+
+ )} +
+ + {/* BOTTOM - Footer */} +
+ + OpenHamClock v3.6.0 • In memory of Elwood Downey WB0OEW + + + Click map to set DX • 73 de {config.callsign} + +
+ + +
+
-
- ); + ); + }; // ============================================ // SETTINGS PANEL COMPONENT @@ -929,6 +4647,21 @@ const [lon, setLon] = useState(config.location.lon.toString()); const [gridSquare, setGridSquare] = useState(''); const [useGeolocation, setUseGeolocation] = useState(false); + const [theme, setTheme] = useState(config.theme || 'dark'); + const [layout, setLayout] = useState(config.layout || 'modern'); + const [dxClusterSource, setDxClusterSource] = useState(config.dxClusterSource || 'auto'); + + // Sync local state when config changes or panel opens + useEffect(() => { + if (isOpen) { + setCallsign(config.callsign); + setLat(config.location.lat.toString()); + setLon(config.location.lon.toString()); + setTheme(config.theme || 'dark'); + setLayout(config.layout || 'modern'); + setDxClusterSource(config.dxClusterSource || 'auto'); + } + }, [isOpen, config]); // Calculate grid square from lat/lon useEffect(() => { @@ -939,6 +4672,11 @@ } }, [lat, lon]); + // Preview theme changes in real-time + useEffect(() => { + applyTheme(theme); + }, [theme]); + const handleGeolocation = () => { if (navigator.geolocation) { setUseGeolocation(true); @@ -978,13 +4716,22 @@ const newConfig = { ...config, callsign: callsign.toUpperCase().trim(), - location: { lat: latNum, lon: lonNum } + location: { lat: latNum, lon: lonNum }, + theme: theme, + layout: layout, + dxClusterSource: dxClusterSource }; onSave(newConfig); onClose(); }; + const handleClose = () => { + // Revert theme if cancelled + applyTheme(config.theme || 'dark'); + onClose(); + }; + const handleGridSquareChange = (gs) => { setGridSquare(gs.toUpperCase()); // Convert grid square to lat/lon if valid (4 or 6 char) @@ -1018,16 +4765,40 @@ if (!isOpen) return null; + const inputStyle = { + width: '100%', padding: '12px 16px', background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', borderRadius: '6px', + color: 'var(--accent-green)', fontFamily: 'JetBrains Mono, monospace', + fontSize: '18px', outline: 'none', textTransform: 'uppercase' + }; + + const labelStyle = { + display: 'block', color: 'var(--text-secondary)', fontSize: '12px', + marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '1px' + }; + + const buttonGroupStyle = { + display: 'flex', gap: '8px', marginBottom: '20px' + }; + + const themeButtonStyle = (isActive) => ({ + flex: 1, padding: '10px', background: isActive ? 'var(--accent-amber)' : 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', borderRadius: '6px', + color: isActive ? '#000' : 'var(--text-secondary)', fontFamily: 'JetBrains Mono, monospace', + fontSize: '13px', cursor: 'pointer', fontWeight: isActive ? '700' : '400', + transition: 'all 0.2s' + }); + return (
+ }} onClick={handleClose}>
e.stopPropagation()}>

- + setCallsign(e.target.value.toUpperCase())} placeholder="W1ABC" - style={{ - width: '100%', padding: '12px 16px', background: 'var(--bg-tertiary)', - border: '1px solid var(--border-color)', borderRadius: '6px', - color: 'var(--accent-green)', fontFamily: 'JetBrains Mono, monospace', - fontSize: '18px', outline: 'none', textTransform: 'uppercase' - }} + style={inputStyle} />

{/* Grid Square */}
- + handleGridSquareChange(e.target.value)} placeholder="FN31pr" maxLength={6} - style={{ - width: '100%', padding: '12px 16px', background: 'var(--bg-tertiary)', - border: '1px solid var(--border-color)', borderRadius: '6px', - color: 'var(--accent-cyan)', fontFamily: 'JetBrains Mono, monospace', - fontSize: '18px', outline: 'none', textTransform: 'uppercase' - }} + style={{...inputStyle, color: 'var(--accent-cyan)'}} />
{/* Lat/Lon */}
- + setLat(e.target.value)} placeholder="40.0150" - style={{ - width: '100%', padding: '10px 12px', background: 'var(--bg-tertiary)', - border: '1px solid var(--border-color)', borderRadius: '6px', - color: 'var(--text-primary)', fontFamily: 'JetBrains Mono, monospace', - fontSize: '14px', outline: 'none' - }} + style={{...inputStyle, fontSize: '14px', color: 'var(--text-primary)'}} />
- + setLon(e.target.value)} placeholder="-105.2705" - style={{ - width: '100%', padding: '10px 12px', background: 'var(--bg-tertiary)', - border: '1px solid var(--border-color)', borderRadius: '6px', - color: 'var(--text-primary)', fontFamily: 'JetBrains Mono, monospace', - fontSize: '14px', outline: 'none' - }} + style={{...inputStyle, fontSize: '14px', color: 'var(--text-primary)'}} />
@@ -1132,10 +4875,74 @@ )} + {/* Theme Selection */} +
+ +
+ + + +
+

+ {theme === 'legacy' && '→ Classic green-on-black HamClock style'} + {theme === 'light' && '→ Bright theme for daytime use'} + {theme === 'dark' && '→ Modern dark theme (default)'} +

+
+ + {/* Layout Selection */} +
+ +
+ + +
+

+ {layout === 'legacy' && '→ Layout inspired by original HamClock'} + {layout === 'modern' && '→ Modern responsive grid layout'} +

+
+ + {/* DX Cluster Source Selection */} +
+ + +

+ {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)'} +

+
+ {/* Buttons */}
+ +
+
+ + {/* LEFT SIDEBAR */} +
+ {/* DE Location */} +
+
📍 DE - YOUR LOCATION
+
+
{deGrid}
+
{config.location.lat.toFixed(2)}°, {config.location.lon.toFixed(2)}°
+
+ + {deSunTimes.sunrise} + + {deSunTimes.sunset} +
+
+
+ + {/* DX Location */} +
+
🎯 DX - TARGET
+
+
{dxGrid}
+
{dxLocation.lat.toFixed(2)}°, {dxLocation.lon.toFixed(2)}°
+
+ + {dxSunTimes.sunrise} + + {dxSunTimes.sunset} +
+
+
+ + {/* Solar Panel (toggleable between image and indices) */} + + + {/* VOACAP/Propagation/Band Conditions - Toggleable */} + +
+ + {/* CENTER - MAP */} +
+ +
+ Click map to set DX • 73 de {config.callsign} +
+
-
- {/* Row 1 */} - - - + {/* RIGHT SIDEBAR */} +
+ {/* DX Cluster - Compact with filters */} +
+
+ 🌐 DX CLUSTER ● LIVE +
+ {dxCluster.filteredCount}/{dxCluster.spotCount} + + +
+
+ {/* Quick search 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' + }} + /> + {Object.keys(dxFilters || {}).length > 0 && ( + + )} +
+ {/* Active filters summary */} + {(dxFilters?.bands?.length || dxFilters?.modes?.length || dxFilters?.cqZones?.length || dxFilters?.watchlist?.length) && ( +
+ {dxFilters.bands?.length > 0 && 📻 {dxFilters.bands.join(', ')}} + {dxFilters.modes?.length > 0 && | 📡 {dxFilters.modes.join(', ')}} + {dxFilters.cqZones?.length > 0 && | 🌍 CQ: {dxFilters.cqZones.length} zones} + {dxFilters.watchlist?.length > 0 && | 👀 {dxFilters.watchlist.length} watched} +
+ )} +
+ {dxCluster.data.slice(0, 8).map((s, i) => { + const isHovered = hoveredSpot && hoveredSpot.call === s.call && hoveredSpot.freq === s.freq; + return ( +
setHoveredSpot(s)} + onMouseLeave={() => setHoveredSpot(null)} + style={{ + padding: '3px 0', + borderBottom: '1px solid rgba(255,255,255,0.05)', + fontSize: '12px', + fontFamily: 'JetBrains Mono', + background: isHovered ? 'rgba(68, 136, 255, 0.3)' : 'transparent', + borderLeft: isHovered ? '3px solid #4488ff' : '3px solid transparent', + paddingLeft: '4px', + marginLeft: '-4px', + cursor: 'pointer', + transition: 'background 0.15s ease' + }} + > +
+ {s.freq} + {s.call} + {s.time} +
+
+ ); + })} + {dxCluster.data.length === 0 &&
No spots
} +
+
- {/* Row 2: Map */} -
- -
- Click anywhere on map to set DX location • Use buttons to change map style + {/* DXpeditions - Compact */} +
+
+ 🌍 DXPEDITIONS + {dxpeditions.data && ( + + {dxpeditions.data.active > 0 && {dxpeditions.data.active} active} + + )} +
+
+ {dxpeditions.data?.dxpeditions?.slice(0, 8).map((exp, i) => ( +
+
+ {exp.callsign} + + {exp.isActive ? '● NOW' : exp.dates?.split('-')[0]?.trim() || ''} + +
+
+ + {exp.entity} + + {exp.bands && ( + + {exp.bands.split(' ').slice(0, 3).join(' ')} + + )} +
+
+ ))} + {(!dxpeditions.data?.dxpeditions || dxpeditions.data.dxpeditions.length === 0) && +
{dxpeditions.loading ? 'Loading...' : 'No DXpeditions'}
+ } +
+
-
- - + + {/* POTA - Compact */} +
+
+ 🏕 POTA ACTIVATORS + +
+
+ {potaSpots.data.slice(0, 5).map((a, i) => ( +
+ {a.call} + {a.ref} + {a.freq} +
+ ))} + {potaSpots.data.length === 0 &&
No activators
} +
- {/* Row 3 */} - - - -
- -
- OpenHamClock v3.1.0 | In memory of Elwood Downey WB0OEW | Map tiles © OpenStreetMap, ESRI, CARTO | 73 de {config.callsign} -
+ {/* Contests - Compact */} +
+
🏆 CONTESTS
+
+ {contests.data.slice(0, 6).map((c, i) => ( +
+
+ {c.name} {c.status === 'active' && } +
+
+ {c.mode} • {new Date(c.start).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} +
+
+ ))} +
+ +
+
+
{/* Settings Panel */} + + {/* DX Cluster Filter Manager */} + setShowDXFilters(false)} + />
); }; diff --git a/iturhfprop-service/server.js b/iturhfprop-service/server.js index 8e08928..3b0c0c2 100644 --- a/iturhfprop-service/server.js +++ b/iturhfprop-service/server.js @@ -47,6 +47,7 @@ const HF_BANDS = { '17m': 18.1, '15m': 21.1, '12m': 24.9, + '11m': 27.0, // CB band (26.965-27.405 MHz) '10m': 28.1 // Note: 6m (50 MHz) excluded - outside P.533 HF range (2-30 MHz) }; diff --git a/server.js b/server.js index f7e4219..192cb82 100644 --- a/server.js +++ b/server.js @@ -2060,8 +2060,8 @@ app.get('/api/propagation', async (req, res) => { } // ===== FALLBACK: Built-in calculations ===== - const bands = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m']; - const bandFreqs = [1.8, 3.5, 7, 10, 14, 18, 21, 24, 28, 50]; + const bands = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '11m', '10m', '6m']; + const bandFreqs = [1.8, 3.5, 7, 10, 14, 18, 21, 24, 27, 28, 50]; // Generate predictions (hybrid or fallback) const effectiveIonoData = hasValidIonoData ? ionoData : null;