|
|
|
|
@ -0,0 +1,947 @@
|
|
|
|
|
/**
|
|
|
|
|
* OpenHamClock - Main Application Component
|
|
|
|
|
* Amateur Radio Dashboard
|
|
|
|
|
*/
|
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|
|
|
|
|
|
|
|
|
// Components
|
|
|
|
|
import {
|
|
|
|
|
Header,
|
|
|
|
|
WorldMap,
|
|
|
|
|
DXClusterPanel,
|
|
|
|
|
POTAPanel,
|
|
|
|
|
ContestPanel,
|
|
|
|
|
SettingsPanel,
|
|
|
|
|
DXFilterManager,
|
|
|
|
|
PSKFilterManager,
|
|
|
|
|
SolarPanel,
|
|
|
|
|
PropagationPanel,
|
|
|
|
|
DXpeditionPanel,
|
|
|
|
|
PSKReporterPanel
|
|
|
|
|
} from './components';
|
|
|
|
|
|
|
|
|
|
// Hooks
|
|
|
|
|
import {
|
|
|
|
|
useSpaceWeather,
|
|
|
|
|
useBandConditions,
|
|
|
|
|
useDXCluster,
|
|
|
|
|
useDXPaths,
|
|
|
|
|
usePOTASpots,
|
|
|
|
|
useContests,
|
|
|
|
|
useLocalWeather,
|
|
|
|
|
usePropagation,
|
|
|
|
|
useMySpots,
|
|
|
|
|
useDXpeditions,
|
|
|
|
|
useSatellites,
|
|
|
|
|
useSolarIndices,
|
|
|
|
|
usePSKReporter,
|
|
|
|
|
useWSJTX
|
|
|
|
|
} 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 [showPSKFilters, setShowPSKFilters] = useState(false);
|
|
|
|
|
const [weatherExpanded, setWeatherExpanded] = useState(() => {
|
|
|
|
|
try { return localStorage.getItem('openhamclock_weatherExpanded') === 'true'; } catch { return false; }
|
|
|
|
|
});
|
|
|
|
|
const [tempUnit, setTempUnit] = useState(() => {
|
|
|
|
|
try { return localStorage.getItem('openhamclock_tempUnit') || 'F'; } catch { return 'F'; }
|
|
|
|
|
});
|
|
|
|
|
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, showWSJTX: true };
|
|
|
|
|
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
|
|
|
|
|
} catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true, showWSJTX: 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 })), []);
|
|
|
|
|
const toggleWSJTX = useCallback(() => setMapLayers(prev => ({ ...prev, showWSJTX: !prev.showWSJTX })), []);
|
|
|
|
|
|
|
|
|
|
// 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]);
|
|
|
|
|
|
|
|
|
|
// PSKReporter Filters
|
|
|
|
|
const [pskFilters, setPskFilters] = useState(() => {
|
|
|
|
|
try {
|
|
|
|
|
const stored = localStorage.getItem('openhamclock_pskFilters');
|
|
|
|
|
return stored ? JSON.parse(stored) : {};
|
|
|
|
|
} catch (e) { return {}; }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem('openhamclock_pskFilters', JSON.stringify(pskFilters));
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
}, [pskFilters]);
|
|
|
|
|
|
|
|
|
|
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, tempUnit);
|
|
|
|
|
const pskReporter = usePSKReporter(config.callsign, { minutes: 15, enabled: config.callsign !== 'N0CALL' });
|
|
|
|
|
const wsjtx = useWSJTX();
|
|
|
|
|
|
|
|
|
|
// Filter PSKReporter spots for map display
|
|
|
|
|
const filteredPskSpots = useMemo(() => {
|
|
|
|
|
const allSpots = [...(pskReporter.txReports || []), ...(pskReporter.rxReports || [])];
|
|
|
|
|
if (!pskFilters?.bands?.length && !pskFilters?.grids?.length && !pskFilters?.modes?.length) {
|
|
|
|
|
return allSpots;
|
|
|
|
|
}
|
|
|
|
|
return allSpots.filter(spot => {
|
|
|
|
|
if (pskFilters?.bands?.length && !pskFilters.bands.includes(spot.band)) return false;
|
|
|
|
|
if (pskFilters?.modes?.length && !pskFilters.modes.includes(spot.mode)) return false;
|
|
|
|
|
if (pskFilters?.grids?.length) {
|
|
|
|
|
const grid = spot.receiverGrid || spot.senderGrid;
|
|
|
|
|
if (!grid) return false;
|
|
|
|
|
const gridPrefix = grid.substring(0, 2).toUpperCase();
|
|
|
|
|
if (!pskFilters.grids.includes(gridPrefix)) return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
}, [pskReporter.txReports, pskReporter.rxReports, pskFilters]);
|
|
|
|
|
|
|
|
|
|
// Filter WSJT-X decodes for map display (only those with lat/lon from grid)
|
|
|
|
|
const wsjtxMapSpots = useMemo(() => {
|
|
|
|
|
return wsjtx.decodes.filter(d => d.lat && d.lon && d.type === 'CQ');
|
|
|
|
|
}, [wsjtx.decodes]);
|
|
|
|
|
|
|
|
|
|
// 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 — use explicit timezone if configured (fixes privacy browsers like Librewolf
|
|
|
|
|
// that spoof timezone to UTC via privacy.resistFingerprinting)
|
|
|
|
|
const utcTime = currentTime.toISOString().substr(11, 8);
|
|
|
|
|
const utcDate = currentTime.toISOString().substr(0, 10);
|
|
|
|
|
const localTimeOpts = { hour12: use12Hour };
|
|
|
|
|
const localDateOpts = { weekday: 'short', month: 'short', day: 'numeric' };
|
|
|
|
|
if (config.timezone) {
|
|
|
|
|
localTimeOpts.timeZone = config.timezone;
|
|
|
|
|
localDateOpts.timeZone = config.timezone;
|
|
|
|
|
}
|
|
|
|
|
const localTime = currentTime.toLocaleTimeString('en-US', localTimeOpts);
|
|
|
|
|
const localDate = currentTime.toLocaleDateString('en-US', localDateOpts);
|
|
|
|
|
|
|
|
|
|
// 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 (
|
|
|
|
|
<div style={{
|
|
|
|
|
width: '100vw',
|
|
|
|
|
height: '100vh',
|
|
|
|
|
background: 'var(--bg-primary)',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
overflow: 'hidden'
|
|
|
|
|
}}>
|
|
|
|
|
{config.layout === 'classic' ? (
|
|
|
|
|
/* CLASSIC HAMCLOCK-STYLE LAYOUT */
|
|
|
|
|
<div style={{
|
|
|
|
|
width: '100vw',
|
|
|
|
|
height: '100vh',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
background: '#000000',
|
|
|
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
|
|
|
overflow: 'hidden'
|
|
|
|
|
}}>
|
|
|
|
|
{/* TOP BAR - HamClock style */}
|
|
|
|
|
<div style={{
|
|
|
|
|
display: 'grid',
|
|
|
|
|
gridTemplateColumns: '280px 1fr 300px',
|
|
|
|
|
height: '130px',
|
|
|
|
|
borderBottom: '2px solid #333',
|
|
|
|
|
background: '#000'
|
|
|
|
|
}}>
|
|
|
|
|
{/* Callsign & Time */}
|
|
|
|
|
<div style={{ padding: '8px 12px', borderRight: '1px solid #333' }}>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
fontSize: '42px',
|
|
|
|
|
fontWeight: '900',
|
|
|
|
|
color: '#ff4444',
|
|
|
|
|
fontFamily: 'Orbitron, monospace',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
lineHeight: 1
|
|
|
|
|
}}
|
|
|
|
|
onClick={() => setShowSettings(true)}
|
|
|
|
|
title="Click for settings"
|
|
|
|
|
>
|
|
|
|
|
{config.callsign}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ fontSize: '11px', color: '#888', marginTop: '2px' }}>
|
|
|
|
|
Up 35d 18h • v4.20
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ marginTop: '8px' }}>
|
|
|
|
|
<div style={{ fontSize: '36px', fontWeight: '700', color: '#00ff00', fontFamily: 'JetBrains Mono, Consolas, monospace', lineHeight: 1, width: '180px' }}>
|
|
|
|
|
{utcTime}<span style={{ fontSize: '20px', color: '#00cc00' }}>:{String(new Date().getUTCSeconds()).padStart(2, '0')}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ fontSize: '14px', color: '#00cc00', marginTop: '2px' }}>
|
|
|
|
|
{utcDate} <span style={{ color: '#666', marginLeft: '8px' }}>UTC</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Solar Indices - SSN & SFI */}
|
|
|
|
|
<div style={{ display: 'flex', borderRight: '1px solid #333' }}>
|
|
|
|
|
{/* SSN */}
|
|
|
|
|
<div style={{ flex: 1, padding: '8px', borderRight: '1px solid #333' }}>
|
|
|
|
|
<div style={{ fontSize: '10px', color: '#888', textAlign: 'center' }}>Sunspot Number</div>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
|
|
|
<div style={{ flex: 1, height: '70px', background: '#001100', border: '1px solid #333', borderRadius: '2px', padding: '4px' }}>
|
|
|
|
|
{solarIndices?.data?.ssn?.history?.length > 0 && (
|
|
|
|
|
<svg width="100%" height="100%" viewBox="0 0 100 60" preserveAspectRatio="none">
|
|
|
|
|
{(() => {
|
|
|
|
|
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 <polyline points={points} fill="none" stroke="#00ff00" strokeWidth="1.5" />;
|
|
|
|
|
})()}
|
|
|
|
|
</svg>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ fontSize: '48px', fontWeight: '700', color: '#00ffff', fontFamily: 'Orbitron, monospace' }}>
|
|
|
|
|
{solarIndices?.data?.ssn?.current || '--'}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ fontSize: '10px', color: '#666', textAlign: 'center', marginTop: '2px' }}>-30 Days</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* SFI */}
|
|
|
|
|
<div style={{ flex: 1, padding: '8px' }}>
|
|
|
|
|
<div style={{ fontSize: '10px', color: '#888', textAlign: 'center' }}>10.7 cm Solar flux</div>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
|
|
|
<div style={{ flex: 1, height: '70px', background: '#001100', border: '1px solid #333', borderRadius: '2px', padding: '4px' }}>
|
|
|
|
|
{solarIndices?.data?.sfi?.history?.length > 0 && (
|
|
|
|
|
<svg width="100%" height="100%" viewBox="0 0 100 60" preserveAspectRatio="none">
|
|
|
|
|
{(() => {
|
|
|
|
|
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 <polyline points={points} fill="none" stroke="#00ff00" strokeWidth="1.5" />;
|
|
|
|
|
})()}
|
|
|
|
|
</svg>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ fontSize: '48px', fontWeight: '700', color: '#ff66ff', fontFamily: 'Orbitron, monospace' }}>
|
|
|
|
|
{solarIndices?.data?.sfi?.current || '--'}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ fontSize: '10px', color: '#666', textAlign: 'center', marginTop: '2px' }}>-30 Days +7</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Live Spots & Indices */}
|
|
|
|
|
<div style={{ display: 'flex' }}>
|
|
|
|
|
{/* Live Spots by Band */}
|
|
|
|
|
<div style={{ flex: 1, padding: '8px', borderRight: '1px solid #333' }}>
|
|
|
|
|
<div style={{ fontSize: '12px', color: '#ff6666', fontWeight: '700' }}>Live Spots</div>
|
|
|
|
|
<div style={{ fontSize: '9px', color: '#888', marginBottom: '4px' }}>of {deGrid} - 15 mins</div>
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px', fontSize: '10px' }}>
|
|
|
|
|
{[
|
|
|
|
|
{ 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 => (
|
|
|
|
|
<div key={b.band} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
|
|
|
<span style={{ color: b.color }}>{b.band}</span>
|
|
|
|
|
<span style={{ color: '#fff' }}>
|
|
|
|
|
{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}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Space Weather Indices */}
|
|
|
|
|
<div style={{ width: '70px', padding: '8px', fontSize: '11px' }}>
|
|
|
|
|
<div style={{ marginBottom: '6px' }}>
|
|
|
|
|
<div style={{ color: '#888' }}>X-Ray</div>
|
|
|
|
|
<div style={{ color: '#ffff00', fontSize: '16px', fontWeight: '700' }}>M3.0</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ marginBottom: '6px' }}>
|
|
|
|
|
<div style={{ color: '#888' }}>Kp</div>
|
|
|
|
|
<div style={{ color: '#00ff00', fontSize: '16px', fontWeight: '700' }}>{solarIndices?.data?.kp?.current ?? spaceWeather?.data?.kIndex ?? '--'}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ marginBottom: '6px' }}>
|
|
|
|
|
<div style={{ color: '#888' }}>Bz</div>
|
|
|
|
|
<div style={{ color: '#00ffff', fontSize: '16px', fontWeight: '700' }}>-0</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<div style={{ color: '#888' }}>Aurora</div>
|
|
|
|
|
<div style={{ color: '#ff00ff', fontSize: '16px', fontWeight: '700' }}>18</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* MAIN AREA */}
|
|
|
|
|
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
|
|
|
|
{/* DX Cluster List */}
|
|
|
|
|
<div style={{ width: '220px', borderRight: '1px solid #333', display: 'flex', flexDirection: 'column', background: '#000' }}>
|
|
|
|
|
<div style={{ padding: '4px 8px', borderBottom: '1px solid #333', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
|
|
|
<span style={{ color: '#ff6666', fontSize: '14px', fontWeight: '700' }}>Cluster</span>
|
|
|
|
|
<span style={{ color: '#00ff00', fontSize: '10px' }}>dxspider.co.uk:7300</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ flex: 1, overflow: 'auto', fontSize: '11px' }}>
|
|
|
|
|
{dxCluster.data?.slice(0, 25).map((spot, i) => (
|
|
|
|
|
<div
|
|
|
|
|
key={i}
|
|
|
|
|
style={{
|
|
|
|
|
padding: '2px 6px',
|
|
|
|
|
display: 'grid',
|
|
|
|
|
gridTemplateColumns: '65px 1fr 35px',
|
|
|
|
|
gap: '4px',
|
|
|
|
|
borderBottom: '1px solid #111',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
background: hoveredSpot?.call === spot.call ? '#333' : 'transparent'
|
|
|
|
|
}}
|
|
|
|
|
onMouseEnter={() => setHoveredSpot(spot)}
|
|
|
|
|
onMouseLeave={() => setHoveredSpot(null)}
|
|
|
|
|
>
|
|
|
|
|
<span style={{ color: '#ffff00' }}>{parseFloat(spot.freq).toFixed(1)}</span>
|
|
|
|
|
<span style={{ color: '#00ffff' }}>{spot.call}</span>
|
|
|
|
|
<span style={{ color: '#888' }}>{spot.time || '--'}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Map */}
|
|
|
|
|
<div style={{ flex: 1, position: 'relative' }}>
|
|
|
|
|
<WorldMap
|
|
|
|
|
deLocation={config.location}
|
|
|
|
|
dxLocation={dxLocation}
|
|
|
|
|
onDXChange={handleDXChange}
|
|
|
|
|
potaSpots={potaSpots.data}
|
|
|
|
|
mySpots={mySpots.data}
|
|
|
|
|
dxPaths={dxPaths.data}
|
|
|
|
|
dxFilters={dxFilters}
|
|
|
|
|
satellites={satellites.data}
|
|
|
|
|
pskReporterSpots={filteredPskSpots}
|
|
|
|
|
showDXPaths={mapLayers.showDXPaths}
|
|
|
|
|
showDXLabels={mapLayers.showDXLabels}
|
|
|
|
|
onToggleDXLabels={toggleDXLabels}
|
|
|
|
|
showPOTA={mapLayers.showPOTA}
|
|
|
|
|
showSatellites={mapLayers.showSatellites}
|
|
|
|
|
showPSKReporter={mapLayers.showPSKReporter}
|
|
|
|
|
wsjtxSpots={wsjtxMapSpots}
|
|
|
|
|
showWSJTX={mapLayers.showWSJTX}
|
|
|
|
|
onToggleSatellites={toggleSatellites}
|
|
|
|
|
hoveredSpot={hoveredSpot}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Settings button overlay */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowSettings(true)}
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: '10px',
|
|
|
|
|
left: '10px',
|
|
|
|
|
background: 'rgba(0,0,0,0.7)',
|
|
|
|
|
border: '1px solid #444',
|
|
|
|
|
color: '#fff',
|
|
|
|
|
padding: '6px 12px',
|
|
|
|
|
fontSize: '12px',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
borderRadius: '4px'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
⚙ Settings
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* BOTTOM - Frequency Scale */}
|
|
|
|
|
<div style={{
|
|
|
|
|
height: '24px',
|
|
|
|
|
background: 'linear-gradient(90deg, #ff0000 0%, #ff8800 15%, #ffff00 30%, #00ff00 45%, #00ffff 60%, #0088ff 75%, #8800ff 90%, #ff00ff 100%)',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'space-around',
|
|
|
|
|
fontSize: '10px',
|
|
|
|
|
color: '#000',
|
|
|
|
|
fontWeight: '700'
|
|
|
|
|
}}>
|
|
|
|
|
<span>MHz</span>
|
|
|
|
|
<span>5</span>
|
|
|
|
|
<span>10</span>
|
|
|
|
|
<span>15</span>
|
|
|
|
|
<span>20</span>
|
|
|
|
|
<span>25</span>
|
|
|
|
|
<span>30</span>
|
|
|
|
|
<span>35</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
/* MODERN LAYOUT */
|
|
|
|
|
<div style={{
|
|
|
|
|
width: scale < 1 ? `${100 / scale}vw` : '100vw',
|
|
|
|
|
height: scale < 1 ? `${100 / scale}vh` : '100vh',
|
|
|
|
|
transform: `scale(${scale})`,
|
|
|
|
|
transformOrigin: 'center center',
|
|
|
|
|
display: 'grid',
|
|
|
|
|
gridTemplateColumns: '270px 1fr 300px',
|
|
|
|
|
gridTemplateRows: '55px 1fr',
|
|
|
|
|
gap: '8px',
|
|
|
|
|
padding: '8px',
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
boxSizing: 'border-box'
|
|
|
|
|
}}>
|
|
|
|
|
{/* TOP BAR */}
|
|
|
|
|
<Header
|
|
|
|
|
config={config}
|
|
|
|
|
utcTime={utcTime}
|
|
|
|
|
utcDate={utcDate}
|
|
|
|
|
localTime={localTime}
|
|
|
|
|
localDate={localDate}
|
|
|
|
|
localWeather={localWeather}
|
|
|
|
|
spaceWeather={spaceWeather}
|
|
|
|
|
solarIndices={solarIndices}
|
|
|
|
|
use12Hour={use12Hour}
|
|
|
|
|
onTimeFormatToggle={handleTimeFormatToggle}
|
|
|
|
|
onSettingsClick={() => setShowSettings(true)}
|
|
|
|
|
onFullscreenToggle={handleFullscreenToggle}
|
|
|
|
|
isFullscreen={isFullscreen}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* LEFT SIDEBAR */}
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', overflowY: 'auto', overflowX: 'hidden' }}>
|
|
|
|
|
{/* DE Location + Weather */}
|
|
|
|
|
<div className="panel" style={{ padding: '14px', flex: '0 0 auto' }}>
|
|
|
|
|
<div style={{ fontSize: '14px', color: 'var(--accent-cyan)', fontWeight: '700', marginBottom: '10px' }}>📍 DE - YOUR LOCATION</div>
|
|
|
|
|
<div style={{ fontFamily: 'JetBrains Mono', fontSize: '14px' }}>
|
|
|
|
|
<div style={{ color: 'var(--accent-amber)', fontSize: '22px', fontWeight: '700', letterSpacing: '1px' }}>{deGrid}</div>
|
|
|
|
|
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', marginTop: '4px' }}>{config.location.lat.toFixed(4)}°, {config.location.lon.toFixed(4)}°</div>
|
|
|
|
|
<div style={{ marginTop: '8px', fontSize: '13px' }}>
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>☀ </span>
|
|
|
|
|
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{deSunTimes.sunrise}</span>
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}> → </span>
|
|
|
|
|
<span style={{ color: 'var(--accent-purple)', fontWeight: '600' }}>{deSunTimes.sunset}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Local Weather — compact by default, click to expand */}
|
|
|
|
|
{localWeather.data && (() => {
|
|
|
|
|
const w = localWeather.data;
|
|
|
|
|
const deg = `°${w.tempUnit || tempUnit}`;
|
|
|
|
|
const wind = w.windUnit || 'mph';
|
|
|
|
|
const vis = w.visUnit || 'mi';
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ marginTop: '12px', borderTop: '1px solid var(--border-color)', paddingTop: '10px' }}>
|
|
|
|
|
{/* Compact summary row — always visible */}
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
|
|
|
<div
|
|
|
|
|
onClick={() => { const next = !weatherExpanded; setWeatherExpanded(next); try { localStorage.setItem('openhamclock_weatherExpanded', next.toString()); } catch {} }}
|
|
|
|
|
style={{
|
|
|
|
|
display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer',
|
|
|
|
|
userSelect: 'none', flex: 1, minWidth: 0,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span style={{ fontSize: '20px', lineHeight: 1 }}>{w.icon}</span>
|
|
|
|
|
<span style={{ fontSize: '18px', fontWeight: '700', color: 'var(--text-primary)', fontFamily: 'Orbitron, monospace' }}>
|
|
|
|
|
{w.temp}{deg}
|
|
|
|
|
</span>
|
|
|
|
|
<span style={{ fontSize: '11px', color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{w.description}</span>
|
|
|
|
|
<span style={{ fontSize: '11px', color: 'var(--text-muted)', fontFamily: 'JetBrains Mono, monospace' }}>
|
|
|
|
|
💨{w.windSpeed}
|
|
|
|
|
</span>
|
|
|
|
|
<span style={{
|
|
|
|
|
fontSize: '10px', color: 'var(--text-muted)',
|
|
|
|
|
transform: weatherExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
|
|
|
transition: 'transform 0.2s',
|
|
|
|
|
}}>▼</span>
|
|
|
|
|
</div>
|
|
|
|
|
{/* F/C toggle */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
const next = tempUnit === 'F' ? 'C' : 'F';
|
|
|
|
|
setTempUnit(next);
|
|
|
|
|
try { localStorage.setItem('openhamclock_tempUnit', next); } catch {}
|
|
|
|
|
}}
|
|
|
|
|
style={{
|
|
|
|
|
background: 'var(--bg-tertiary)',
|
|
|
|
|
border: '1px solid var(--border-color)',
|
|
|
|
|
color: 'var(--text-secondary)',
|
|
|
|
|
fontSize: '10px',
|
|
|
|
|
padding: '1px 5px',
|
|
|
|
|
borderRadius: '3px',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
}}
|
|
|
|
|
title={`Switch to °${tempUnit === 'F' ? 'C' : 'F'}`}
|
|
|
|
|
>
|
|
|
|
|
°{tempUnit === 'F' ? 'C' : 'F'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Expanded details */}
|
|
|
|
|
{weatherExpanded && (
|
|
|
|
|
<div style={{ marginTop: '10px' }}>
|
|
|
|
|
{/* Feels like + hi/lo */}
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '11px', marginBottom: '8px', fontFamily: 'JetBrains Mono, monospace' }}>
|
|
|
|
|
{w.feelsLike !== w.temp && (
|
|
|
|
|
<span style={{ color: 'var(--text-muted)' }}>Feels like {w.feelsLike}{deg}</span>
|
|
|
|
|
)}
|
|
|
|
|
{w.todayHigh != null && (
|
|
|
|
|
<span style={{ color: 'var(--text-muted)', marginLeft: 'auto' }}>
|
|
|
|
|
<span style={{ color: 'var(--accent-amber)' }}>▲{w.todayHigh}°</span>
|
|
|
|
|
{' '}
|
|
|
|
|
<span style={{ color: 'var(--accent-blue)' }}>▼{w.todayLow}°</span>
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Detail grid */}
|
|
|
|
|
<div style={{
|
|
|
|
|
display: 'grid',
|
|
|
|
|
gridTemplateColumns: '1fr 1fr',
|
|
|
|
|
gap: '6px 12px',
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
|
|
|
}}>
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
|
|
|
<span style={{ color: 'var(--text-muted)' }}>💨 Wind</span>
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>{w.windDir} {w.windSpeed} {wind}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
|
|
|
<span style={{ color: 'var(--text-muted)' }}>💧 Humidity</span>
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>{w.humidity}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
{w.windGusts > w.windSpeed + 5 && (
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
|
|
|
<span style={{ color: 'var(--text-muted)' }}>🌬️ Gusts</span>
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>{w.windGusts} {wind}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
|
|
|
<span style={{ color: 'var(--text-muted)' }}>🌡️ Dew Pt</span>
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>{w.dewPoint}{deg}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{w.pressure && (
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
|
|
|
<span style={{ color: 'var(--text-muted)' }}>🔵 Pressure</span>
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>{w.pressure} hPa</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
|
|
|
<span style={{ color: 'var(--text-muted)' }}>☁️ Clouds</span>
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>{w.cloudCover}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
{w.visibility && (
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
|
|
|
<span style={{ color: 'var(--text-muted)' }}>👁️ Vis</span>
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>{w.visibility} {vis}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{w.uvIndex > 0 && (
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
|
|
|
<span style={{ color: 'var(--text-muted)' }}>☀️ UV</span>
|
|
|
|
|
<span style={{ color: w.uvIndex >= 8 ? '#ef4444' : w.uvIndex >= 6 ? '#f97316' : w.uvIndex >= 3 ? '#eab308' : 'var(--text-secondary)' }}>
|
|
|
|
|
{w.uvIndex.toFixed(1)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 3-Day Forecast */}
|
|
|
|
|
{w.daily?.length > 0 && (
|
|
|
|
|
<div style={{
|
|
|
|
|
marginTop: '10px',
|
|
|
|
|
paddingTop: '8px',
|
|
|
|
|
borderTop: '1px solid var(--border-color)',
|
|
|
|
|
}}>
|
|
|
|
|
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginBottom: '6px', fontWeight: '600' }}>FORECAST</div>
|
|
|
|
|
<div style={{ display: 'flex', gap: '4px' }}>
|
|
|
|
|
{w.daily.map((day, i) => (
|
|
|
|
|
<div key={i} style={{
|
|
|
|
|
flex: 1,
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
padding: '6px 2px',
|
|
|
|
|
background: 'var(--bg-tertiary)',
|
|
|
|
|
borderRadius: '4px',
|
|
|
|
|
fontSize: '10px',
|
|
|
|
|
}}>
|
|
|
|
|
<div style={{ color: 'var(--text-muted)', fontWeight: '600', marginBottom: '2px' }}>{i === 0 ? 'Today' : day.date}</div>
|
|
|
|
|
<div style={{ fontSize: '16px', lineHeight: 1.2 }}>{day.icon}</div>
|
|
|
|
|
<div style={{ fontFamily: 'JetBrains Mono, monospace', marginTop: '2px' }}>
|
|
|
|
|
<span style={{ color: 'var(--accent-amber)' }}>{day.high}°</span>
|
|
|
|
|
<span style={{ color: 'var(--text-muted)' }}>/</span>
|
|
|
|
|
<span style={{ color: 'var(--accent-blue)' }}>{day.low}°</span>
|
|
|
|
|
</div>
|
|
|
|
|
{day.precipProb > 0 && (
|
|
|
|
|
<div style={{ color: 'var(--accent-blue)', fontSize: '9px', marginTop: '1px' }}>
|
|
|
|
|
💧{day.precipProb}%
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* DX Location */}
|
|
|
|
|
<div className="panel" style={{ padding: '14px', flex: '0 0 auto' }}>
|
|
|
|
|
<div style={{ fontSize: '14px', color: 'var(--accent-green)', fontWeight: '700', marginBottom: '10px' }}>🎯 DX - TARGET</div>
|
|
|
|
|
<div style={{ fontFamily: 'JetBrains Mono', fontSize: '14px' }}>
|
|
|
|
|
<div style={{ color: 'var(--accent-amber)', fontSize: '22px', fontWeight: '700', letterSpacing: '1px' }}>{dxGrid}</div>
|
|
|
|
|
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', marginTop: '4px' }}>{dxLocation.lat.toFixed(4)}°, {dxLocation.lon.toFixed(4)}°</div>
|
|
|
|
|
<div style={{ marginTop: '8px', fontSize: '13px' }}>
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>☀ </span>
|
|
|
|
|
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{dxSunTimes.sunrise}</span>
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}> → </span>
|
|
|
|
|
<span style={{ color: 'var(--accent-purple)', fontWeight: '600' }}>{dxSunTimes.sunset}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Solar Panel */}
|
|
|
|
|
<SolarPanel solarIndices={solarIndices} />
|
|
|
|
|
|
|
|
|
|
{/* VOACAP/Propagation Panel */}
|
|
|
|
|
<PropagationPanel
|
|
|
|
|
propagation={propagation.data}
|
|
|
|
|
loading={propagation.loading}
|
|
|
|
|
bandConditions={bandConditions}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* CENTER - MAP */}
|
|
|
|
|
<div style={{ position: 'relative', borderRadius: '6px', overflow: 'hidden' }}>
|
|
|
|
|
<WorldMap
|
|
|
|
|
deLocation={config.location}
|
|
|
|
|
dxLocation={dxLocation}
|
|
|
|
|
onDXChange={handleDXChange}
|
|
|
|
|
potaSpots={potaSpots.data}
|
|
|
|
|
mySpots={mySpots.data}
|
|
|
|
|
dxPaths={dxPaths.data}
|
|
|
|
|
dxFilters={dxFilters}
|
|
|
|
|
satellites={satellites.data}
|
|
|
|
|
pskReporterSpots={filteredPskSpots}
|
|
|
|
|
showDXPaths={mapLayers.showDXPaths}
|
|
|
|
|
showDXLabels={mapLayers.showDXLabels}
|
|
|
|
|
onToggleDXLabels={toggleDXLabels}
|
|
|
|
|
showPOTA={mapLayers.showPOTA}
|
|
|
|
|
showSatellites={mapLayers.showSatellites}
|
|
|
|
|
showPSKReporter={mapLayers.showPSKReporter}
|
|
|
|
|
wsjtxSpots={wsjtxMapSpots}
|
|
|
|
|
showWSJTX={mapLayers.showWSJTX}
|
|
|
|
|
onToggleSatellites={toggleSatellites}
|
|
|
|
|
hoveredSpot={hoveredSpot}
|
|
|
|
|
/>
|
|
|
|
|
<div style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
bottom: '8px',
|
|
|
|
|
left: '50%',
|
|
|
|
|
transform: 'translateX(-50%)',
|
|
|
|
|
fontSize: '13px',
|
|
|
|
|
color: 'var(--text-muted)',
|
|
|
|
|
background: 'rgba(0,0,0,0.7)',
|
|
|
|
|
padding: '2px 8px',
|
|
|
|
|
borderRadius: '4px'
|
|
|
|
|
}}>
|
|
|
|
|
Click map to set DX • 73 de {config.callsign}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* RIGHT SIDEBAR */}
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', overflow: 'hidden' }}>
|
|
|
|
|
{/* DX Cluster - primary panel, takes most space */}
|
|
|
|
|
<div style={{ flex: '2 1 auto', minHeight: '180px', overflow: 'hidden' }}>
|
|
|
|
|
<DXClusterPanel
|
|
|
|
|
data={dxCluster.data}
|
|
|
|
|
loading={dxCluster.loading}
|
|
|
|
|
totalSpots={dxCluster.totalSpots}
|
|
|
|
|
filters={dxFilters}
|
|
|
|
|
onFilterChange={setDxFilters}
|
|
|
|
|
onOpenFilters={() => setShowDXFilters(true)}
|
|
|
|
|
onHoverSpot={setHoveredSpot}
|
|
|
|
|
hoveredSpot={hoveredSpot}
|
|
|
|
|
showOnMap={mapLayers.showDXPaths}
|
|
|
|
|
onToggleMap={toggleDXPaths}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* PSKReporter + WSJT-X - digital mode spots */}
|
|
|
|
|
<div style={{ flex: '1 1 auto', minHeight: '140px', overflow: 'hidden' }}>
|
|
|
|
|
<PSKReporterPanel
|
|
|
|
|
callsign={config.callsign}
|
|
|
|
|
showOnMap={mapLayers.showPSKReporter}
|
|
|
|
|
onToggleMap={togglePSKReporter}
|
|
|
|
|
filters={pskFilters}
|
|
|
|
|
onOpenFilters={() => setShowPSKFilters(true)}
|
|
|
|
|
onShowOnMap={(report) => {
|
|
|
|
|
if (report.lat && report.lon) {
|
|
|
|
|
setDxLocation({ lat: report.lat, lon: report.lon, call: report.receiver || report.sender });
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
wsjtxDecodes={wsjtx.decodes}
|
|
|
|
|
wsjtxClients={wsjtx.clients}
|
|
|
|
|
wsjtxQsos={wsjtx.qsos}
|
|
|
|
|
wsjtxStats={wsjtx.stats}
|
|
|
|
|
wsjtxLoading={wsjtx.loading}
|
|
|
|
|
wsjtxEnabled={wsjtx.enabled}
|
|
|
|
|
wsjtxPort={wsjtx.port}
|
|
|
|
|
wsjtxRelayEnabled={wsjtx.relayEnabled}
|
|
|
|
|
wsjtxRelayConnected={wsjtx.relayConnected}
|
|
|
|
|
wsjtxSessionId={wsjtx.sessionId}
|
|
|
|
|
showWSJTXOnMap={mapLayers.showWSJTX}
|
|
|
|
|
onToggleWSJTXMap={toggleWSJTX}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* DXpeditions */}
|
|
|
|
|
<div style={{ flex: '0 0 auto', minHeight: '70px', maxHeight: '100px', overflow: 'hidden' }}>
|
|
|
|
|
<DXpeditionPanel data={dxpeditions.data} loading={dxpeditions.loading} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* POTA */}
|
|
|
|
|
<div style={{ flex: '0 0 auto', minHeight: '60px', maxHeight: '90px', overflow: 'hidden' }}>
|
|
|
|
|
<POTAPanel
|
|
|
|
|
data={potaSpots.data}
|
|
|
|
|
loading={potaSpots.loading}
|
|
|
|
|
showOnMap={mapLayers.showPOTA}
|
|
|
|
|
onToggleMap={togglePOTA}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Contests - at bottom, compact */}
|
|
|
|
|
<div style={{ flex: '0 0 auto', minHeight: '80px', maxHeight: '120px', overflow: 'hidden' }}>
|
|
|
|
|
<ContestPanel data={contests.data} loading={contests.loading} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Modals */}
|
|
|
|
|
<SettingsPanel
|
|
|
|
|
isOpen={showSettings}
|
|
|
|
|
onClose={() => setShowSettings(false)}
|
|
|
|
|
config={config}
|
|
|
|
|
onSave={handleSaveConfig}
|
|
|
|
|
/>
|
|
|
|
|
<DXFilterManager
|
|
|
|
|
filters={dxFilters}
|
|
|
|
|
onFilterChange={setDxFilters}
|
|
|
|
|
isOpen={showDXFilters}
|
|
|
|
|
onClose={() => setShowDXFilters(false)}
|
|
|
|
|
/>
|
|
|
|
|
<PSKFilterManager
|
|
|
|
|
filters={pskFilters}
|
|
|
|
|
onFilterChange={setPskFilters}
|
|
|
|
|
isOpen={showPSKFilters}
|
|
|
|
|
onClose={() => setShowPSKFilters(false)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default App;
|