diff --git a/src/App.jsx b/src/App.jsx index ea86005..7d98379 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,14 +8,14 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Header, WorldMap, - SpaceWeatherPanel, - BandConditionsPanel, DXClusterPanel, POTAPanel, ContestPanel, - LocationPanel, SettingsPanel, - DXFilterManager + DXFilterManager, + SolarPanel, + PropagationPanel, + DXpeditionPanel } from './components'; // Hooks @@ -62,11 +62,10 @@ const App = () => { return config.defaultDX; }); - // Save DX location when changed useEffect(() => { try { localStorage.setItem('openhamclock_dxLocation', JSON.stringify(dxLocation)); - } catch (e) { console.error('Failed to save DX location:', e); } + } catch (e) {} }, [dxLocation]); // UI state @@ -74,88 +73,65 @@ const App = () => { const [showDXFilters, setShowDXFilters] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); - // Map layer visibility state with localStorage persistence + // Map layer visibility const [mapLayers, setMapLayers] = useState(() => { try { const stored = localStorage.getItem('openhamclock_mapLayers'); - const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: true }; + const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false }; return stored ? { ...defaults, ...JSON.parse(stored) } : defaults; - } catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: true }; } + } catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false }; } }); - // Save map layer preferences when changed useEffect(() => { try { localStorage.setItem('openhamclock_mapLayers', JSON.stringify(mapLayers)); - } catch (e) { console.error('Failed to save map layers:', e); } + } catch (e) {} }, [mapLayers]); - // Hovered spot state for highlighting paths on map const [hoveredSpot, setHoveredSpot] = useState(null); - // Toggle handlers for map layers const toggleDXPaths = useCallback(() => setMapLayers(prev => ({ ...prev, showDXPaths: !prev.showDXPaths })), []); const toggleDXLabels = useCallback(() => setMapLayers(prev => ({ ...prev, showDXLabels: !prev.showDXLabels })), []); const togglePOTA = useCallback(() => setMapLayers(prev => ({ ...prev, showPOTA: !prev.showPOTA })), []); const toggleSatellites = useCallback(() => setMapLayers(prev => ({ ...prev, showSatellites: !prev.showSatellites })), []); - // 12/24 hour format preference with localStorage persistence + // 12/24 hour format const [use12Hour, setUse12Hour] = useState(() => { try { - const saved = localStorage.getItem('openhamclock_use12Hour'); - return saved === 'true'; + return localStorage.getItem('openhamclock_use12Hour') === 'true'; } catch (e) { return false; } }); - // Save 12/24 hour preference when changed useEffect(() => { try { localStorage.setItem('openhamclock_use12Hour', use12Hour.toString()); - } catch (e) { console.error('Failed to save time format:', e); } + } catch (e) {} }, [use12Hour]); - // Toggle time format handler - const handleTimeFormatToggle = useCallback(() => { - setUse12Hour(prev => !prev); - }, []); + const handleTimeFormatToggle = useCallback(() => setUse12Hour(prev => !prev), []); - // Fullscreen toggle handler + // Fullscreen const handleFullscreenToggle = useCallback(() => { if (!document.fullscreenElement) { - document.documentElement.requestFullscreen().then(() => { - setIsFullscreen(true); - }).catch(err => { - console.error('Fullscreen error:', err); - }); + document.documentElement.requestFullscreen().then(() => setIsFullscreen(true)).catch(() => {}); } else { - document.exitFullscreen().then(() => { - setIsFullscreen(false); - }).catch(err => { - console.error('Exit fullscreen error:', err); - }); + document.exitFullscreen().then(() => setIsFullscreen(false)).catch(() => {}); } }, []); - // Listen for fullscreen changes useEffect(() => { - const handleFullscreenChange = () => { - setIsFullscreen(!!document.fullscreenElement); - }; - document.addEventListener('fullscreenchange', handleFullscreenChange); - return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); + const handler = () => setIsFullscreen(!!document.fullscreenElement); + document.addEventListener('fullscreenchange', handler); + return () => document.removeEventListener('fullscreenchange', handler); }, []); - // Apply theme on initial load useEffect(() => { applyTheme(config.theme || 'dark'); }, []); - // Check if this is first run useEffect(() => { const saved = localStorage.getItem('openhamclock_config'); - if (!saved) { - setShowSettings(true); - } + if (!saved) setShowSettings(true); }, []); const handleSaveConfig = (newConfig) => { @@ -170,7 +146,7 @@ const App = () => { const solarIndices = useSolarIndices(); const potaSpots = usePOTASpots(); - // DX Cluster filters with localStorage persistence + // DX Filters const [dxFilters, setDxFilters] = useState(() => { try { const stored = localStorage.getItem('openhamclock_dxFilters'); @@ -178,7 +154,6 @@ const App = () => { } catch (e) { return {}; } }); - // Save DX filters when changed useEffect(() => { try { localStorage.setItem('openhamclock_dxFilters', JSON.stringify(dxFilters)); @@ -200,7 +175,7 @@ const App = () => { const deSunTimes = useMemo(() => calculateSunTimes(config.location.lat, config.location.lon, currentTime), [config.location, currentTime]); const dxSunTimes = useMemo(() => calculateSunTimes(dxLocation.lat, dxLocation.lon, currentTime), [dxLocation, currentTime]); - // Time and uptime update + // Time update useEffect(() => { const timer = setInterval(() => { setCurrentTime(new Date()); @@ -223,9 +198,8 @@ const App = () => { const utcDate = currentTime.toISOString().substr(0, 10); const localDate = currentTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); - // Scale factor for modern layout + // Scale for small screens const [scale, setScale] = useState(1); - useEffect(() => { const calculateScale = () => { const minWidth = 1200; @@ -239,7 +213,6 @@ const App = () => { return () => window.removeEventListener('resize', calculateScale); }, []); - // Modern Layout return (
{ transform: `scale(${scale})`, transformOrigin: 'center center', display: 'grid', - gridTemplateColumns: '280px 1fr 280px', + gridTemplateColumns: '260px 1fr 300px', gridTemplateRows: '50px 1fr', gap: '8px', padding: '8px', @@ -279,31 +252,51 @@ const App = () => { isFullscreen={isFullscreen} /> - {/* LEFT COLUMN */} -
- + {/* 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 */} + + + {/* VOACAP/Propagation Panel */} + - -
{/* CENTER - MAP */} -
+
{ onToggleSatellites={toggleSatellites} hoveredSpot={hoveredSpot} /> +
+ Click map to set DX β€’ 73 de {config.callsign} +
- {/* RIGHT COLUMN */} -
+ {/* RIGHT SIDEBAR */} +
+ {/* DX Cluster */} setShowDXFilters(true)} onHoverSpot={setHoveredSpot} hoveredSpot={hoveredSpot} + showOnMap={mapLayers.showDXPaths} + onToggleMap={toggleDXPaths} + /> + + {/* DXpeditions */} + + + {/* POTA */} + - + + {/* Contests */}
diff --git a/src/components/DXClusterPanel.jsx b/src/components/DXClusterPanel.jsx index a2c88bd..78137ce 100644 --- a/src/components/DXClusterPanel.jsx +++ b/src/components/DXClusterPanel.jsx @@ -1,6 +1,6 @@ /** * DXClusterPanel Component - * Displays DX cluster spots with filtering controls + * Displays DX cluster spots with filtering controls and ON/OFF toggle */ import React from 'react'; import { getBandColor } from '../utils/callsign.js'; @@ -10,9 +10,12 @@ export const DXClusterPanel = ({ loading, totalSpots, filters, + onFilterChange, onOpenFilters, onHoverSpot, - hoveredSpot + hoveredSpot, + showOnMap, + onToggleMap }) => { const getActiveFilterCount = () => { let count = 0; @@ -32,45 +35,77 @@ export const DXClusterPanel = ({ return (
- {/* Header with filter button */} + {/* Header */}
-
- πŸ“» DX CLUSTER - - {data.length}/{totalSpots || 0} - + 🌐 DX CLUSTER ● LIVE +
+ {data?.length || 0}/{totalSpots || 0} + +
- + />
{/* Spots list */} @@ -78,7 +113,7 @@ export const DXClusterPanel = ({
- ) : data.length === 0 ? ( + ) : !data || data.length === 0 ? (
- {data.slice(0, 15).map((spot, i) => { + {data.slice(0, 20).map((spot, i) => { const freq = parseFloat(spot.freq); - const color = getBandColor(freq / 1000); // Convert kHz to MHz for color + const color = getBandColor(freq / 1000); const isHovered = hoveredSpot?.call === spot.call && Math.abs(parseFloat(hoveredSpot?.freq) - freq) < 1; @@ -107,11 +142,11 @@ export const DXClusterPanel = ({ onMouseLeave={() => onHoverSpot?.(null)} style={{ display: 'grid', - gridTemplateColumns: '70px 1fr auto', - gap: '8px', - padding: '6px 8px', - borderRadius: '4px', - marginBottom: '2px', + gridTemplateColumns: '55px 1fr auto', + gap: '6px', + padding: '4px 6px', + borderRadius: '3px', + marginBottom: '1px', background: isHovered ? 'rgba(68, 136, 255, 0.2)' : (i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent'), cursor: 'pointer', transition: 'background 0.15s' diff --git a/src/components/DXpeditionPanel.jsx b/src/components/DXpeditionPanel.jsx new file mode 100644 index 0000000..69ddbaa --- /dev/null +++ b/src/components/DXpeditionPanel.jsx @@ -0,0 +1,85 @@ +/** + * DXpeditionPanel Component + * Shows active and upcoming DXpeditions + */ +import React from 'react'; + +export const DXpeditionPanel = ({ data, loading }) => { + 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 ? '● NOW' : 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 && ( + + )} +
+ ); +}; + +export default DXpeditionPanel; diff --git a/src/components/POTAPanel.jsx b/src/components/POTAPanel.jsx index 0327edf..ade41bb 100644 --- a/src/components/POTAPanel.jsx +++ b/src/components/POTAPanel.jsx @@ -1,72 +1,64 @@ /** * POTAPanel Component - * Displays Parks on the Air activations + * Displays Parks on the Air activations with ON/OFF toggle */ import React from 'react'; -export const POTAPanel = ({ data, loading }) => { +export const POTAPanel = ({ data, loading, showOnMap, onToggleMap }) => { return (
-
🌲 POTA ACTIVATIONS
+
+ πŸ•οΈ POTA ACTIVATORS + +
+ {loading ? (
- ) : data.length === 0 ? ( -
- No active POTA spots -
- ) : ( -
+ ) : data && data.length > 0 ? ( +
{data.slice(0, 5).map((spot, i) => ( -
-
- - {spot.call} - - - {spot.freq} {spot.mode} - -
-
- - {spot.ref} - {spot.name} - - {spot.time} -
+ + {spot.call} + + + {spot.ref} + + + {spot.freq} +
))}
+ ) : ( +
+ No active POTA spots +
)}
); diff --git a/src/components/PropagationPanel.jsx b/src/components/PropagationPanel.jsx new file mode 100644 index 0000000..71e6396 --- /dev/null +++ b/src/components/PropagationPanel.jsx @@ -0,0 +1,312 @@ +/** + * PropagationPanel Component (VOACAP) + * Toggleable between heatmap chart, bar chart, and band conditions view + */ +import React, { useState } from 'react'; + +export const PropagationPanel = ({ propagation, loading, bandConditions }) => { + // Load view mode preference from localStorage + const [viewMode, setViewMode] = useState(() => { + try { + const saved = localStorage.getItem('openhamclock_voacapViewMode'); + if (saved === 'bars' || saved === 'bands') return saved; + return 'chart'; + } catch (e) { return 'chart'; } + }); + + // Cycle through view modes + 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) {} + }; + + 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; + const hasRealData = ionospheric?.method === 'direct' || ionospheric?.method === 'interpolated'; + + // Heat map colors (VOACAP style - red=good, green=poor) + const getHeatColor = (rel) => { + if (rel >= 80) return '#ff0000'; + if (rel >= 60) return '#ff6600'; + if (rel >= 40) return '#ffcc00'; + if (rel >= 20) return '#88cc00'; + if (rel >= 10) return '#00aa00'; + return '#004400'; + }; + + 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, 13).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)` : ''}` + : '⚑ estimated' + } + + {dataSource && dataSource.includes('ITU') && ( + + πŸ”¬ ITU-R P.533 + + )} +
+ + {viewMode === 'chart' ? ( + /* VOACAP Heat Map Chart View */ +
+
+ {bands.map((band) => ( + +
+ {band.replace('m', '')} +
+ {Array.from({ length: 24 }, (_, hour) => { + let rel = 0; + if (hour === currentHour && currentBands?.length > 0) { + const currentBandData = currentBands.find(b => b.band === band); + if (currentBandData) { + 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 */} +
+
+ REL: + {['#004400', '#00aa00', '#88cc00', '#ffcc00', '#ff6600', '#ff0000'].map((c, i) => ( +
+ ))} +
+
+ {Math.round(distance || 0)}km β€’ {ionospheric?.foF2 ? `foF2=${ionospheric.foF2}` : `SSN=${solarData?.ssn}`} +
+
+
+ ) : ( + /* Bar Chart View */ +
+
+ SFI {solarData?.sfi} + {ionospheric?.foF2 ? ( + foF2 {ionospheric.foF2} + ) : ( + SSN {solarData?.ssn} + )} + K = 4 ? '#ff4444' : '#00ff88' }}>{solarData?.kIndex} +
+ + {(currentBands || []).slice(0, 11).map((band) => ( +
+ = 50 ? 'var(--accent-green)' : 'var(--text-muted)' + }}> + {band.band} + +
+
+
+ + {band.reliability}% + +
+ ))} +
+ )} + + )} +
+ ); +}; + +export default PropagationPanel; diff --git a/src/components/SolarPanel.jsx b/src/components/SolarPanel.jsx new file mode 100644 index 0000000..f6eabee --- /dev/null +++ b/src/components/SolarPanel.jsx @@ -0,0 +1,215 @@ +/** + * SolarPanel Component + * Toggleable between live sun image from NASA SDO and solar indices display + */ +import React, { useState } from 'react'; + +export 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) + + 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}`; + + 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'; + }; + + 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); + 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) * 25; + return `${x},${y}`; + }).join(' '); + return ; + })()} + + )} +
+
+ + {/* K-Index Row */} +
+
+
K-Index
+
+ {solarIndices.data.kIndex?.current ?? '--'} +
+
+
+ {solarIndices.data.kIndex?.forecast?.length > 0 && ( +
+ {solarIndices.data.kIndex.forecast.slice(0, 8).map((kp, i) => ( +
+ ))} +
+ )} +
+
+ + {/* SSN Row */} +
+
+
SSN
+
+ {solarIndices.data.ssn?.current || '--'} +
+
+
+ {solarIndices.data.ssn?.history?.length > 0 && ( + + {(() => { + const data = solarIndices.data.ssn.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) * 25; + return `${x},${y}`; + }).join(' '); + return ; + })()} + + )} +
+
+
+ ) : ( +
+ Loading solar data... +
+ )} +
+ ) : ( + /* Solar Image View */ +
+ SDO Solar Image { + e.target.style.display = 'none'; + }} + /> +
+ SDO/AIA β€’ Live from NASA +
+
+ )} +
+ ); +}; + +export default SolarPanel; diff --git a/src/components/index.js b/src/components/index.js index 4ae269b..27279cc 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -13,3 +13,6 @@ export { ContestPanel } from './ContestPanel.jsx'; export { LocationPanel } from './LocationPanel.jsx'; export { SettingsPanel } from './SettingsPanel.jsx'; export { DXFilterManager } from './DXFilterManager.jsx'; +export { SolarPanel } from './SolarPanel.jsx'; +export { PropagationPanel } from './PropagationPanel.jsx'; +export { DXpeditionPanel } from './DXpeditionPanel.jsx';