/** * OpenHamClock - Main Application Component * Amateur Radio Dashboard v3.7.0 */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; // Components import { Header, WorldMap, DXClusterPanel, POTAPanel, ContestPanel, SettingsPanel, DXFilterManager, SolarPanel, PropagationPanel, DXpeditionPanel, PSKReporterPanel } from './components'; // Hooks import { useSpaceWeather, useBandConditions, useDXCluster, useDXPaths, usePOTASpots, useContests, useLocalWeather, usePropagation, useMySpots, useDXpeditions, useSatellites, useSolarIndices, usePSKReporter } from './hooks'; // Utils import { loadConfig, saveConfig, applyTheme, fetchServerConfig, calculateGridSquare, calculateSunTimes } from './utils'; const App = () => { // Configuration state - initially use defaults, then load from server const [config, setConfig] = useState(loadConfig); const [configLoaded, setConfigLoaded] = useState(false); const [currentTime, setCurrentTime] = useState(new Date()); const [startTime] = useState(Date.now()); const [uptime, setUptime] = useState('0d 0h 0m'); // Load server configuration on startup (only matters for first-time users) useEffect(() => { const initConfig = async () => { // Fetch server config (provides defaults for new users without localStorage) await fetchServerConfig(); // Load config - localStorage takes priority over server config const loadedConfig = loadConfig(); setConfig(loadedConfig); setConfigLoaded(true); // Only show settings if user has no saved config AND no valid callsign // This prevents the popup from appearing every refresh const hasLocalStorage = localStorage.getItem('openhamclock_config'); if (!hasLocalStorage && loadedConfig.callsign === 'N0CALL') { setShowSettings(true); } }; initConfig(); }, []); // DX Location with localStorage persistence const [dxLocation, setDxLocation] = useState(() => { try { const stored = localStorage.getItem('openhamclock_dxLocation'); if (stored) { const parsed = JSON.parse(stored); if (parsed.lat && parsed.lon) return parsed; } } catch (e) {} return config.defaultDX; }); useEffect(() => { try { localStorage.setItem('openhamclock_dxLocation', JSON.stringify(dxLocation)); } catch (e) {} }, [dxLocation]); // UI state const [showSettings, setShowSettings] = useState(false); const [showDXFilters, setShowDXFilters] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); // Map layer visibility const [mapLayers, setMapLayers] = useState(() => { try { const stored = localStorage.getItem('openhamclock_mapLayers'); const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true }; return stored ? { ...defaults, ...JSON.parse(stored) } : defaults; } catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true }; } }); useEffect(() => { try { localStorage.setItem('openhamclock_mapLayers', JSON.stringify(mapLayers)); } catch (e) {} }, [mapLayers]); const [hoveredSpot, setHoveredSpot] = useState(null); 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 })), []); const togglePSKReporter = useCallback(() => setMapLayers(prev => ({ ...prev, showPSKReporter: !prev.showPSKReporter })), []); // 12/24 hour format const [use12Hour, setUse12Hour] = useState(() => { try { return localStorage.getItem('openhamclock_use12Hour') === 'true'; } catch (e) { return false; } }); useEffect(() => { try { localStorage.setItem('openhamclock_use12Hour', use12Hour.toString()); } catch (e) {} }, [use12Hour]); const handleTimeFormatToggle = useCallback(() => setUse12Hour(prev => !prev), []); // Fullscreen const handleFullscreenToggle = useCallback(() => { if (!document.fullscreenElement) { document.documentElement.requestFullscreen().then(() => setIsFullscreen(true)).catch(() => {}); } else { document.exitFullscreen().then(() => setIsFullscreen(false)).catch(() => {}); } }, []); useEffect(() => { const handler = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', handler); return () => document.removeEventListener('fullscreenchange', handler); }, []); useEffect(() => { applyTheme(config.theme || 'dark'); }, []); // Config save handler - persists to localStorage const handleSaveConfig = (newConfig) => { setConfig(newConfig); saveConfig(newConfig); applyTheme(newConfig.theme || 'dark'); console.log('[Config] Saved to localStorage:', newConfig.callsign); }; // Data hooks const spaceWeather = useSpaceWeather(); const bandConditions = useBandConditions(spaceWeather.data); const solarIndices = useSolarIndices(); const potaSpots = usePOTASpots(); // DX Filters const [dxFilters, setDxFilters] = useState(() => { try { const stored = localStorage.getItem('openhamclock_dxFilters'); return stored ? JSON.parse(stored) : {}; } catch (e) { return {}; } }); useEffect(() => { try { localStorage.setItem('openhamclock_dxFilters', JSON.stringify(dxFilters)); } catch (e) {} }, [dxFilters]); const dxCluster = useDXCluster(config.dxClusterSource || 'auto', dxFilters); const dxPaths = useDXPaths(); const dxpeditions = useDXpeditions(); const contests = useContests(); const propagation = usePropagation(config.location, dxLocation); const mySpots = useMySpots(config.callsign); const satellites = useSatellites(config.location); const localWeather = useLocalWeather(config.location); const pskReporter = usePSKReporter(config.callsign, { minutes: 15, enabled: config.callsign !== 'N0CALL' }); // Computed values const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]); const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]); 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 update useEffect(() => { const timer = setInterval(() => { setCurrentTime(new Date()); const elapsed = Date.now() - startTime; const d = Math.floor(elapsed / 86400000); const h = Math.floor((elapsed % 86400000) / 3600000); const m = Math.floor((elapsed % 3600000) / 60000); setUptime(`${d}d ${h}h ${m}m`); }, 1000); return () => clearInterval(timer); }, [startTime]); const handleDXChange = useCallback((coords) => { setDxLocation({ lat: coords.lat, lon: coords.lon }); }, []); // Format times const utcTime = currentTime.toISOString().substr(11, 8); const localTime = currentTime.toLocaleTimeString('en-US', { hour12: use12Hour }); const utcDate = currentTime.toISOString().substr(0, 10); const localDate = currentTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); // Scale for small screens const [scale, setScale] = useState(1); useEffect(() => { const calculateScale = () => { const minWidth = 1200; const minHeight = 800; const scaleX = window.innerWidth / minWidth; const scaleY = window.innerHeight / minHeight; setScale(Math.min(scaleX, scaleY, 1)); }; calculateScale(); window.addEventListener('resize', calculateScale); return () => window.removeEventListener('resize', calculateScale); }, []); return (
{config.layout === 'classic' ? ( /* CLASSIC HAMCLOCK-STYLE LAYOUT */
{/* TOP BAR - HamClock style */}
{/* Callsign & Time */}
setShowSettings(true)} title="Click for settings" > {config.callsign}
Up 35d 18h • v4.20
{utcTime}:{String(new Date().getUTCSeconds()).padStart(2, '0')}
{utcDate} UTC
{/* Solar Indices - SSN & SFI */}
{/* SSN */}
Sunspot Number
{solarIndices?.data?.ssn?.history?.length > 0 && ( {(() => { const data = solarIndices.data.ssn.history.slice(-30); 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 = 60 - ((d.value - min) / range) * 55; return `${x},${y}`; }).join(' '); return ; })()} )}
{solarIndices?.data?.ssn?.current || '--'}
-30 Days
{/* SFI */}
10.7 cm Solar flux
{solarIndices?.data?.sfi?.history?.length > 0 && ( {(() => { const data = solarIndices.data.sfi.history.slice(-30); 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 = 60 - ((d.value - min) / range) * 55; return `${x},${y}`; }).join(' '); return ; })()} )}
{solarIndices?.data?.sfi?.current || '--'}
-30 Days +7
{/* Live Spots & Indices */}
{/* Live Spots by Band */}
Live Spots
of {deGrid} - 15 mins
{[ { band: '160m', color: '#ff6666' }, { band: '80m', color: '#ff9966' }, { band: '60m', color: '#ffcc66' }, { band: '40m', color: '#ccff66' }, { band: '30m', color: '#66ff99' }, { band: '20m', color: '#66ffcc' }, { band: '17m', color: '#66ccff' }, { band: '15m', color: '#6699ff' }, { band: '12m', color: '#9966ff' }, { band: '10m', color: '#cc66ff' }, ].map(b => (
{b.band} {dxCluster.data?.filter(s => { const freq = parseFloat(s.freq); const bands = { '160m': [1.8, 2], '80m': [3.5, 4], '60m': [5.3, 5.4], '40m': [7, 7.3], '30m': [10.1, 10.15], '20m': [14, 14.35], '17m': [18.068, 18.168], '15m': [21, 21.45], '12m': [24.89, 24.99], '10m': [28, 29.7] }; const r = bands[b.band]; return r && freq >= r[0] && freq <= r[1]; }).length || 0}
))}
{/* Space Weather Indices */}
X-Ray
M3.0
Kp
{spaceWeather?.data?.kIndex ?? '--'}
Bz
-0
Aurora
18
{/* MAIN AREA */}
{/* DX Cluster List */}
Cluster dxspider.co.uk:7300
{dxCluster.data?.slice(0, 25).map((spot, i) => (
setHoveredSpot(spot)} onMouseLeave={() => setHoveredSpot(null)} > {parseFloat(spot.freq).toFixed(1)} {spot.call} {spot.time || '--'}
))}
{/* Map */}
{/* Settings button overlay */}
{/* BOTTOM - Frequency Scale */}
MHz 5 10 15 20 25 30 35
) : ( /* MODERN LAYOUT */
{/* TOP BAR */}
setShowSettings(true)} onFullscreenToggle={handleFullscreenToggle} isFullscreen={isFullscreen} /> {/* LEFT SIDEBAR */}
{/* DE Location */}
📍 DE - YOUR LOCATION
{deGrid}
{config.location.lat.toFixed(4)}°, {config.location.lon.toFixed(4)}°
{deSunTimes.sunrise} {deSunTimes.sunset}
{/* DX Location */}
🎯 DX - TARGET
{dxGrid}
{dxLocation.lat.toFixed(4)}°, {dxLocation.lon.toFixed(4)}°
{dxSunTimes.sunrise} {dxSunTimes.sunset}
{/* Solar Panel */} {/* VOACAP/Propagation Panel */}
{/* CENTER - MAP */}
Click map to set DX • 73 de {config.callsign}
{/* RIGHT SIDEBAR */}
{/* DX Cluster - primary panel */}
setShowDXFilters(true)} onHoverSpot={setHoveredSpot} hoveredSpot={hoveredSpot} showOnMap={mapLayers.showDXPaths} onToggleMap={toggleDXPaths} />
{/* PSKReporter - digital mode spots */}
{ if (report.lat && report.lon) { setDxLocation({ lat: report.lat, lon: report.lon, call: report.receiver || report.sender }); } }} />
{/* DXpeditions */}
{/* POTA */}
{/* Contests */}
)} {/* Modals */} setShowSettings(false)} config={config} onSave={handleSaveConfig} /> setShowDXFilters(false)} />
); }; export default App;