|
|
<!DOCTYPE html>
|
|
|
<html lang="en">
|
|
|
<head>
|
|
|
<meta charset="UTF-8">
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
<title>OpenHamClock - Amateur Radio Dashboard</title>
|
|
|
|
|
|
<!-- Fonts -->
|
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
|
|
|
|
<!-- Leaflet CSS -->
|
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
|
|
|
|
<!-- Leaflet JS -->
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
|
|
|
|
<!-- Leaflet Terminator (day/night) -->
|
|
|
<script src="https://unpkg.com/@joergdietrich/leaflet.terminator@1.0.0/L.Terminator.js"></script>
|
|
|
|
|
|
<!-- Satellite.js for orbital calculations -->
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/satellite.js/5.0.0/satellite.min.js"></script>
|
|
|
|
|
|
<!-- React -->
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.23.5/babel.min.js"></script>
|
|
|
|
|
|
<style>
|
|
|
/* ============================================
|
|
|
THEME: DARK (Default)
|
|
|
============================================ */
|
|
|
:root, [data-theme="dark"] {
|
|
|
--bg-primary: #0a0e14;
|
|
|
--bg-secondary: #111820;
|
|
|
--bg-tertiary: #1a2332;
|
|
|
--bg-panel: rgba(17, 24, 32, 0.92);
|
|
|
--border-color: rgba(255, 180, 50, 0.25);
|
|
|
--text-primary: #f0f4f8;
|
|
|
--text-secondary: #a0b0c0;
|
|
|
--text-muted: #8899aa;
|
|
|
--accent-amber: #ffb432;
|
|
|
--accent-amber-dim: rgba(255, 180, 50, 0.6);
|
|
|
--accent-green: #00ff88;
|
|
|
--accent-green-dim: rgba(0, 255, 136, 0.6);
|
|
|
--accent-red: #ff4466;
|
|
|
--accent-blue: #4488ff;
|
|
|
--accent-cyan: #00ddff;
|
|
|
--accent-purple: #aa66ff;
|
|
|
--map-ocean: #0a0e14;
|
|
|
--scanline-opacity: 0.02;
|
|
|
}
|
|
|
|
|
|
/* ============================================
|
|
|
THEME: LIGHT
|
|
|
============================================ */
|
|
|
[data-theme="light"] {
|
|
|
--bg-primary: #f5f7fa;
|
|
|
--bg-secondary: #ffffff;
|
|
|
--bg-tertiary: #e8ecf0;
|
|
|
--bg-panel: rgba(255, 255, 255, 0.95);
|
|
|
--border-color: rgba(0, 100, 200, 0.2);
|
|
|
--text-primary: #1a2332;
|
|
|
--text-secondary: #4a5a6a;
|
|
|
--text-muted: #7a8a9a;
|
|
|
--accent-amber: #d4940a;
|
|
|
--accent-amber-dim: rgba(212, 148, 10, 0.4);
|
|
|
--accent-green: #00aa55;
|
|
|
--accent-green-dim: rgba(0, 170, 85, 0.4);
|
|
|
--accent-red: #cc3344;
|
|
|
--accent-blue: #2266cc;
|
|
|
--accent-cyan: #0099bb;
|
|
|
--accent-purple: #7744cc;
|
|
|
--map-ocean: #f0f4f8;
|
|
|
--scanline-opacity: 0;
|
|
|
}
|
|
|
|
|
|
/* ============================================
|
|
|
THEME: LEGACY (Classic HamClock Style)
|
|
|
============================================ */
|
|
|
[data-theme="legacy"] {
|
|
|
--bg-primary: #000000;
|
|
|
--bg-secondary: #0a0a0a;
|
|
|
--bg-tertiary: #151515;
|
|
|
--bg-panel: rgba(0, 0, 0, 0.95);
|
|
|
--border-color: rgba(0, 255, 0, 0.3);
|
|
|
--text-primary: #00ff00;
|
|
|
--text-secondary: #00dd00;
|
|
|
--text-muted: #00bb00;
|
|
|
--accent-amber: #ffaa00;
|
|
|
--accent-amber-dim: rgba(255, 170, 0, 0.5);
|
|
|
--accent-green: #00ff00;
|
|
|
--accent-green-dim: rgba(0, 255, 0, 0.5);
|
|
|
--accent-red: #ff0000;
|
|
|
--accent-blue: #00aaff;
|
|
|
--accent-cyan: #00ffff;
|
|
|
--accent-purple: #ff00ff;
|
|
|
--map-ocean: #000008;
|
|
|
--scanline-opacity: 0.05;
|
|
|
}
|
|
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
|
|
body {
|
|
|
font-family: 'Space Grotesk', sans-serif;
|
|
|
background: var(--bg-primary);
|
|
|
color: var(--text-primary);
|
|
|
min-height: 100vh;
|
|
|
overflow-x: hidden;
|
|
|
transition: background 0.3s, color 0.3s;
|
|
|
}
|
|
|
|
|
|
/* Legacy theme uses monospace font */
|
|
|
[data-theme="legacy"] body,
|
|
|
[data-theme="legacy"] * {
|
|
|
font-family: 'JetBrains Mono', monospace !important;
|
|
|
}
|
|
|
|
|
|
/* Subtle scanline effect */
|
|
|
body::before {
|
|
|
content: '';
|
|
|
position: fixed;
|
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
|
background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,var(--scanline-opacity)) 2px, rgba(0,0,0,var(--scanline-opacity)) 4px);
|
|
|
pointer-events: none;
|
|
|
z-index: 9999;
|
|
|
}
|
|
|
|
|
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
|
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
|
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
|
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
|
|
|
|
|
|
.loading-spinner {
|
|
|
width: 14px; height: 14px;
|
|
|
border: 2px solid var(--border-color);
|
|
|
border-top-color: var(--accent-amber);
|
|
|
border-radius: 50%;
|
|
|
animation: spin 1s linear infinite;
|
|
|
display: inline-block;
|
|
|
}
|
|
|
|
|
|
/* Legacy theme specific styles */
|
|
|
[data-theme="legacy"] .loading-spinner {
|
|
|
border-color: var(--accent-green);
|
|
|
border-top-color: var(--accent-amber);
|
|
|
}
|
|
|
|
|
|
/* Leaflet customizations */
|
|
|
.leaflet-container {
|
|
|
background: var(--bg-primary);
|
|
|
font-family: 'Space Grotesk', sans-serif;
|
|
|
}
|
|
|
|
|
|
.leaflet-control-zoom {
|
|
|
border: 1px solid var(--border-color) !important;
|
|
|
border-radius: 6px !important;
|
|
|
overflow: hidden;
|
|
|
}
|
|
|
|
|
|
.leaflet-control-zoom a {
|
|
|
background: var(--bg-secondary) !important;
|
|
|
color: var(--text-primary) !important;
|
|
|
border-bottom: 1px solid var(--border-color) !important;
|
|
|
}
|
|
|
|
|
|
.leaflet-control-zoom a:hover {
|
|
|
background: var(--bg-tertiary) !important;
|
|
|
color: var(--accent-amber) !important;
|
|
|
}
|
|
|
|
|
|
.leaflet-control-attribution {
|
|
|
background: rgba(10, 14, 20, 0.8) !important;
|
|
|
color: var(--text-muted) !important;
|
|
|
font-size: 11px !important;
|
|
|
}
|
|
|
|
|
|
.leaflet-control-attribution a {
|
|
|
color: var(--text-secondary) !important;
|
|
|
}
|
|
|
|
|
|
/* Custom marker styles */
|
|
|
.custom-marker {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
font-family: 'JetBrains Mono', monospace;
|
|
|
font-weight: 700;
|
|
|
font-size: 12px;
|
|
|
border-radius: 50%;
|
|
|
border: 2px solid white;
|
|
|
box-shadow: 0 0 10px rgba(0,0,0,0.5);
|
|
|
}
|
|
|
|
|
|
.de-marker {
|
|
|
background: var(--accent-amber);
|
|
|
color: #000;
|
|
|
width: 32px;
|
|
|
height: 32px;
|
|
|
}
|
|
|
|
|
|
.dx-marker {
|
|
|
background: var(--accent-blue);
|
|
|
color: #fff;
|
|
|
width: 32px;
|
|
|
height: 32px;
|
|
|
}
|
|
|
|
|
|
.sun-marker {
|
|
|
background: radial-gradient(circle, #ffdd00 0%, #ff8800 100%);
|
|
|
width: 24px;
|
|
|
height: 24px;
|
|
|
border: 2px solid #ffaa00;
|
|
|
}
|
|
|
|
|
|
/* Map style selector */
|
|
|
.map-style-control {
|
|
|
position: absolute;
|
|
|
top: 10px;
|
|
|
right: 10px;
|
|
|
z-index: 1000;
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
gap: 4px;
|
|
|
}
|
|
|
|
|
|
.map-style-btn {
|
|
|
background: var(--bg-panel);
|
|
|
border: 1px solid var(--border-color);
|
|
|
color: var(--text-secondary);
|
|
|
padding: 8px 12px;
|
|
|
font-family: 'JetBrains Mono', monospace;
|
|
|
font-size: 12px;
|
|
|
cursor: pointer;
|
|
|
transition: all 0.2s;
|
|
|
border-radius: 4px;
|
|
|
text-transform: uppercase;
|
|
|
letter-spacing: 1px;
|
|
|
}
|
|
|
|
|
|
.map-style-btn:hover {
|
|
|
background: var(--bg-tertiary);
|
|
|
color: var(--text-primary);
|
|
|
border-color: var(--accent-amber);
|
|
|
}
|
|
|
|
|
|
.map-style-btn.active {
|
|
|
background: var(--accent-amber);
|
|
|
color: #000;
|
|
|
border-color: var(--accent-amber);
|
|
|
}
|
|
|
|
|
|
/* Popup styling */
|
|
|
.leaflet-popup-content-wrapper {
|
|
|
background: var(--bg-panel) !important;
|
|
|
border: 1px solid var(--border-color) !important;
|
|
|
border-radius: 8px !important;
|
|
|
color: var(--text-primary) !important;
|
|
|
}
|
|
|
|
|
|
.leaflet-popup-tip {
|
|
|
background: var(--bg-panel) !important;
|
|
|
border: 1px solid var(--border-color) !important;
|
|
|
}
|
|
|
|
|
|
.leaflet-popup-content {
|
|
|
font-family: 'JetBrains Mono', monospace !important;
|
|
|
font-size: 12px !important;
|
|
|
}
|
|
|
|
|
|
/* Panel styling */
|
|
|
.panel {
|
|
|
background: var(--bg-panel);
|
|
|
border: 1px solid var(--border-color);
|
|
|
border-radius: 6px;
|
|
|
padding: 10px;
|
|
|
backdrop-filter: blur(10px);
|
|
|
}
|
|
|
|
|
|
.panel-header {
|
|
|
font-size: 12px;
|
|
|
font-weight: 700;
|
|
|
color: var(--accent-cyan);
|
|
|
margin-bottom: 8px;
|
|
|
letter-spacing: 0.5px;
|
|
|
}
|
|
|
</style>
|
|
|
</head>
|
|
|
<body>
|
|
|
<div id="root"></div>
|
|
|
|
|
|
<script type="text/babel">
|
|
|
const { useState, useEffect, useCallback, useMemo, useRef } = React;
|
|
|
|
|
|
// ============================================
|
|
|
// CONFIGURATION WITH LOCALSTORAGE
|
|
|
// ============================================
|
|
|
const DEFAULT_CONFIG = {
|
|
|
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,
|
|
|
pota: 60000,
|
|
|
dxCluster: 30000,
|
|
|
terminator: 60000
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// Load config from localStorage or use defaults
|
|
|
const loadConfig = () => {
|
|
|
try {
|
|
|
const saved = localStorage.getItem('openhamclock_config');
|
|
|
if (saved) {
|
|
|
const parsed = JSON.parse(saved);
|
|
|
return { ...DEFAULT_CONFIG, ...parsed };
|
|
|
}
|
|
|
} catch (e) {
|
|
|
console.error('Error loading config:', e);
|
|
|
}
|
|
|
return DEFAULT_CONFIG;
|
|
|
};
|
|
|
|
|
|
// Save config to localStorage
|
|
|
const saveConfig = (config) => {
|
|
|
try {
|
|
|
localStorage.setItem('openhamclock_config', JSON.stringify(config));
|
|
|
} catch (e) {
|
|
|
console.error('Error saving config:', e);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// Apply theme to document
|
|
|
const applyTheme = (theme) => {
|
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
|
};
|
|
|
|
|
|
// ============================================
|
|
|
// MAP TILE PROVIDERS
|
|
|
// ============================================
|
|
|
const MAP_STYLES = {
|
|
|
dark: {
|
|
|
name: 'Dark',
|
|
|
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'
|
|
|
},
|
|
|
terrain: {
|
|
|
name: 'Terrain',
|
|
|
url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
|
|
|
attribution: '© <a href="https://opentopomap.org">OpenTopoMap</a>'
|
|
|
},
|
|
|
streets: {
|
|
|
name: 'Streets',
|
|
|
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
|
},
|
|
|
topo: {
|
|
|
name: 'Topo',
|
|
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}',
|
|
|
attribution: '© Esri'
|
|
|
},
|
|
|
watercolor: {
|
|
|
name: 'Ocean',
|
|
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}',
|
|
|
attribution: '© Esri'
|
|
|
},
|
|
|
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'
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// ============================================
|
|
|
// UTILITY FUNCTIONS
|
|
|
// ============================================
|
|
|
const calculateGridSquare = (lat, lon) => {
|
|
|
const lonNorm = lon + 180;
|
|
|
const latNorm = lat + 90;
|
|
|
const field1 = String.fromCharCode(65 + Math.floor(lonNorm / 20));
|
|
|
const field2 = String.fromCharCode(65 + Math.floor(latNorm / 10));
|
|
|
const square1 = Math.floor((lonNorm % 20) / 2);
|
|
|
const square2 = Math.floor(latNorm % 10);
|
|
|
const subsq1 = String.fromCharCode(97 + Math.floor((lonNorm % 2) * 12));
|
|
|
const subsq2 = String.fromCharCode(97 + Math.floor((latNorm % 1) * 24));
|
|
|
return `${field1}${field2}${square1}${square2}${subsq1}${subsq2}`;
|
|
|
};
|
|
|
|
|
|
const calculateBearing = (lat1, lon1, lat2, lon2) => {
|
|
|
const φ1 = lat1 * Math.PI / 180;
|
|
|
const φ2 = lat2 * Math.PI / 180;
|
|
|
const Δλ = (lon2 - lon1) * Math.PI / 180;
|
|
|
const y = Math.sin(Δλ) * Math.cos(φ2);
|
|
|
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
|
|
|
return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
|
|
|
};
|
|
|
|
|
|
const calculateDistance = (lat1, lon1, lat2, lon2) => {
|
|
|
const R = 6371;
|
|
|
const φ1 = lat1 * Math.PI / 180;
|
|
|
const φ2 = lat2 * Math.PI / 180;
|
|
|
const Δφ = (lat2 - lat1) * Math.PI / 180;
|
|
|
const Δλ = (lon2 - lon1) * Math.PI / 180;
|
|
|
const a = Math.sin(Δφ/2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ/2) ** 2;
|
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
|
|
};
|
|
|
|
|
|
const getSunPosition = (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);
|
|
|
const hours = date.getUTCHours() + date.getUTCMinutes() / 60;
|
|
|
const longitude = (12 - hours) * 15;
|
|
|
return { lat: declination, lon: longitude };
|
|
|
};
|
|
|
|
|
|
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);
|
|
|
const latRad = lat * Math.PI / 180;
|
|
|
const decRad = declination * Math.PI / 180;
|
|
|
const cosHA = -Math.tan(latRad) * Math.tan(decRad);
|
|
|
|
|
|
if (cosHA > 1) return { sunrise: 'Polar night', sunset: '' };
|
|
|
if (cosHA < -1) return { sunrise: 'Midnight sun', sunset: '' };
|
|
|
|
|
|
const ha = Math.acos(cosHA) * 180 / Math.PI;
|
|
|
const noon = 12 - lon / 15;
|
|
|
const fmt = (h) => {
|
|
|
const hr = Math.floor(((h % 24) + 24) % 24);
|
|
|
const mn = Math.round((h - Math.floor(h)) * 60);
|
|
|
return `${hr.toString().padStart(2,'0')}:${mn.toString().padStart(2,'0')}`;
|
|
|
};
|
|
|
return { sunrise: fmt(noon - ha/15), sunset: fmt(noon + ha/15) };
|
|
|
};
|
|
|
|
|
|
// Great circle path for Leaflet
|
|
|
const getGreatCirclePoints = (lat1, lon1, lat2, lon2, n = 100) => {
|
|
|
const toRad = d => d * Math.PI / 180;
|
|
|
const toDeg = r => r * 180 / Math.PI;
|
|
|
|
|
|
const φ1 = toRad(lat1), λ1 = toRad(lon1);
|
|
|
const φ2 = toRad(lat2), λ2 = toRad(lon2);
|
|
|
|
|
|
const d = 2 * Math.asin(Math.sqrt(
|
|
|
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);
|
|
|
const B = Math.sin(f*d) / Math.sin(d);
|
|
|
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);
|
|
|
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]);
|
|
|
}
|
|
|
segments.push(currentSegment);
|
|
|
|
|
|
return segments;
|
|
|
};
|
|
|
|
|
|
// ============================================
|
|
|
// API HOOKS
|
|
|
// ============================================
|
|
|
const useSpaceWeather = () => {
|
|
|
const [data, setData] = useState(null);
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
useEffect(() => {
|
|
|
const fetchData = async () => {
|
|
|
try {
|
|
|
const [fluxRes, kIndexRes, sunspotRes] = await Promise.allSettled([
|
|
|
fetch('https://services.swpc.noaa.gov/json/f107_cm_flux.json'),
|
|
|
fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json'),
|
|
|
fetch('https://services.swpc.noaa.gov/json/solar-cycle/observed-solar-cycle-indices.json')
|
|
|
]);
|
|
|
|
|
|
let solarFlux = '--', kIndex = '--', sunspotNumber = '--';
|
|
|
|
|
|
if (fluxRes.status === 'fulfilled' && fluxRes.value.ok) {
|
|
|
const d = await fluxRes.value.json();
|
|
|
if (d?.length) solarFlux = Math.round(d[d.length-1].flux || d[d.length-1].value || 0);
|
|
|
}
|
|
|
if (kIndexRes.status === 'fulfilled' && kIndexRes.value.ok) {
|
|
|
const d = await kIndexRes.value.json();
|
|
|
if (d?.length > 1) kIndex = d[d.length-1][1] || '--';
|
|
|
}
|
|
|
if (sunspotRes.status === 'fulfilled' && sunspotRes.value.ok) {
|
|
|
const d = await sunspotRes.value.json();
|
|
|
if (d?.length) sunspotNumber = Math.round(d[d.length-1].ssn || 0);
|
|
|
}
|
|
|
|
|
|
let conditions = 'UNKNOWN';
|
|
|
const sfi = parseInt(solarFlux), ki = parseInt(kIndex);
|
|
|
if (!isNaN(sfi) && !isNaN(ki)) {
|
|
|
if (sfi >= 150 && ki <= 2) conditions = 'EXCELLENT';
|
|
|
else if (sfi >= 100 && ki <= 3) conditions = 'GOOD';
|
|
|
else if (sfi >= 70 && ki <= 5) conditions = 'FAIR';
|
|
|
else conditions = 'POOR';
|
|
|
}
|
|
|
|
|
|
setData({ solarFlux: String(solarFlux), sunspotNumber: String(sunspotNumber), kIndex: String(kIndex), aIndex: '--', conditions, lastUpdate: new Date() });
|
|
|
} catch (err) {
|
|
|
console.error('Space weather error:', err);
|
|
|
} finally {
|
|
|
setLoading(false);
|
|
|
}
|
|
|
};
|
|
|
fetchData();
|
|
|
const interval = setInterval(fetchData, DEFAULT_CONFIG.refreshIntervals.spaceWeather);
|
|
|
return () => clearInterval(interval);
|
|
|
}, []);
|
|
|
|
|
|
return { data, loading };
|
|
|
};
|
|
|
|
|
|
// 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,
|
|
|
'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,
|
|
|
'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', '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', '10m', '6m', '2m'];
|
|
|
const conditions = bands.map(band => ({
|
|
|
band,
|
|
|
condition: calculateCondition(band)
|
|
|
}));
|
|
|
|
|
|
setData(conditions);
|
|
|
setLoading(false);
|
|
|
}, [spaceWeather?.solarFlux, spaceWeather?.kIndex]);
|
|
|
|
|
|
return { data, loading };
|
|
|
};
|
|
|
|
|
|
const usePOTASpots = () => {
|
|
|
const [data, setData] = useState([]);
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
useEffect(() => {
|
|
|
const fetchPOTA = async () => {
|
|
|
try {
|
|
|
const res = await fetch('https://api.pota.app/spot/activator');
|
|
|
if (res.ok) {
|
|
|
const spots = await res.json();
|
|
|
setData(spots.slice(0, 10).map(s => ({
|
|
|
call: s.activator, ref: s.reference, freq: s.frequency, mode: s.mode,
|
|
|
name: s.name || s.locationDesc, lat: s.latitude, lon: s.longitude,
|
|
|
time: s.spotTime ? new Date(s.spotTime).toISOString().substr(11,5)+'z' : ''
|
|
|
})));
|
|
|
}
|
|
|
} catch (err) { console.error('POTA error:', err); }
|
|
|
finally { setLoading(false); }
|
|
|
};
|
|
|
fetchPOTA();
|
|
|
const interval = setInterval(fetchPOTA, DEFAULT_CONFIG.refreshIntervals.pota);
|
|
|
return () => clearInterval(interval);
|
|
|
}, []);
|
|
|
|
|
|
return { data, loading };
|
|
|
};
|
|
|
|
|
|
const useDXCluster = (source = 'auto') => {
|
|
|
const [data, setData] = useState([]);
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
const [activeSource, setActiveSource] = useState('');
|
|
|
|
|
|
useEffect(() => {
|
|
|
const fetchDX = async () => {
|
|
|
try {
|
|
|
// Use our proxy endpoint with source parameter
|
|
|
const response = await fetch(`/api/dxcluster/spots?source=${source}`);
|
|
|
|
|
|
if (response.ok) {
|
|
|
const spots = await response.json();
|
|
|
if (spots && spots.length > 0) {
|
|
|
// Track the active source from the first spot
|
|
|
if (spots[0].source) {
|
|
|
setActiveSource(spots[0].source);
|
|
|
}
|
|
|
setData(spots.slice(0, 15).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: ''
|
|
|
}]);
|
|
|
setActiveSource('');
|
|
|
}
|
|
|
} else {
|
|
|
setData([{
|
|
|
freq: '---',
|
|
|
call: 'OFFLINE',
|
|
|
comment: 'DX cluster unavailable',
|
|
|
time: '--:--z',
|
|
|
spotter: ''
|
|
|
}]);
|
|
|
setActiveSource('');
|
|
|
}
|
|
|
} catch (err) {
|
|
|
console.error('DX Cluster error:', err);
|
|
|
setData([{
|
|
|
freq: '---',
|
|
|
call: 'ERROR',
|
|
|
comment: 'Failed to fetch',
|
|
|
time: '--:--z',
|
|
|
spotter: ''
|
|
|
}]);
|
|
|
setActiveSource('');
|
|
|
} finally {
|
|
|
setLoading(false);
|
|
|
}
|
|
|
};
|
|
|
fetchDX();
|
|
|
const interval = setInterval(fetchDX, DEFAULT_CONFIG.refreshIntervals.dxCluster);
|
|
|
return () => clearInterval(interval);
|
|
|
}, [source]);
|
|
|
|
|
|
return { data, loading, activeSource };
|
|
|
};
|
|
|
|
|
|
// ============================================
|
|
|
// DX PATHS HOOK - DX spots with locations for map visualization
|
|
|
// ============================================
|
|
|
const useDXPaths = () => {
|
|
|
const [data, setData] = useState([]);
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
useEffect(() => {
|
|
|
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 30 seconds
|
|
|
const interval = setInterval(fetchPaths, 30000);
|
|
|
return () => clearInterval(interval);
|
|
|
}, []);
|
|
|
|
|
|
return { data, loading };
|
|
|
};
|
|
|
|
|
|
// ============================================
|
|
|
// MY SPOTS HOOK - Spots involving the user's callsign
|
|
|
// ============================================
|
|
|
const useMySpots = (callsign) => {
|
|
|
const [data, setData] = useState([]);
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
useEffect(() => {
|
|
|
if (!callsign || callsign === 'N0CALL') {
|
|
|
setData([]);
|
|
|
setLoading(false);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
fetchMySpots();
|
|
|
// Refresh every 2 minutes
|
|
|
const interval = setInterval(fetchMySpots, 120000);
|
|
|
return () => clearInterval(interval);
|
|
|
}, [callsign]);
|
|
|
|
|
|
return { data, loading };
|
|
|
};
|
|
|
|
|
|
// ============================================
|
|
|
// 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 };
|
|
|
};
|
|
|
|
|
|
// ============================================
|
|
|
// 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 }) => {
|
|
|
// Load view mode preference from localStorage, default to 'chart'
|
|
|
const [viewMode, setViewMode] = useState(() => {
|
|
|
try {
|
|
|
const saved = localStorage.getItem('openhamclock_voacapViewMode');
|
|
|
return saved === 'bars' ? 'bars' : 'chart'; // Default to chart
|
|
|
} catch (e) { return 'chart'; }
|
|
|
});
|
|
|
|
|
|
// Save view mode preference when changed
|
|
|
const toggleViewMode = () => {
|
|
|
const newMode = viewMode === 'bars' ? 'chart' : 'bars';
|
|
|
setViewMode(newMode);
|
|
|
try {
|
|
|
localStorage.setItem('openhamclock_voacapViewMode', newMode);
|
|
|
} catch (e) { console.error('Failed to save VOACAP view mode:', e); }
|
|
|
};
|
|
|
|
|
|
if (loading || !propagation) {
|
|
|
return (
|
|
|
<div className="panel">
|
|
|
<div className="panel-header">📡 VOACAP</div>
|
|
|
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)' }}>
|
|
|
Loading predictions...
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
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', '10m'];
|
|
|
|
|
|
return (
|
|
|
<div className="panel" style={{ cursor: 'pointer' }} onClick={toggleViewMode}>
|
|
|
<div className="panel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
|
<span>📡 VOACAP {hasRealData && <span style={{ color: '#00ff88', fontSize: '10px' }}>●</span>}</span>
|
|
|
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
|
|
|
{viewMode === 'bars' ? '▦ bars' : '▤ chart'} • click to toggle
|
|
|
</span>
|
|
|
</div>
|
|
|
|
|
|
{/* MUF/LUF and Data Source Info */}
|
|
|
<div style={{
|
|
|
display: 'flex',
|
|
|
justifyContent: 'space-between',
|
|
|
padding: '4px 8px',
|
|
|
background: hasRealData ? 'rgba(0, 255, 136, 0.1)' : 'var(--bg-tertiary)',
|
|
|
borderRadius: '4px',
|
|
|
marginBottom: '4px',
|
|
|
fontSize: '11px'
|
|
|
}}>
|
|
|
<div style={{ display: 'flex', gap: '12px' }}>
|
|
|
<span>
|
|
|
<span style={{ color: 'var(--text-muted)' }}>MUF </span>
|
|
|
<span style={{ color: '#ff8800', fontWeight: '600' }}>{muf || '?'}</span>
|
|
|
<span style={{ color: 'var(--text-muted)' }}> MHz</span>
|
|
|
</span>
|
|
|
<span>
|
|
|
<span style={{ color: 'var(--text-muted)' }}>LUF </span>
|
|
|
<span style={{ color: '#00aaff', fontWeight: '600' }}>{luf || '?'}</span>
|
|
|
<span style={{ color: 'var(--text-muted)' }}> MHz</span>
|
|
|
</span>
|
|
|
</div>
|
|
|
<span style={{ color: hasRealData ? '#00ff88' : 'var(--text-muted)', fontSize: '10px' }}>
|
|
|
{hasRealData ? `📡 ${ionospheric?.source || 'ionosonde'}` : '⚡ estimated'}
|
|
|
</span>
|
|
|
</div>
|
|
|
|
|
|
{viewMode === 'chart' ? (
|
|
|
/* VOACAP Heat Map Chart View */
|
|
|
<div style={{ padding: '4px' }}>
|
|
|
{/* Heat map grid */}
|
|
|
<div style={{
|
|
|
display: 'grid',
|
|
|
gridTemplateColumns: '28px repeat(24, 1fr)',
|
|
|
gridTemplateRows: `repeat(${bands.length}, 12px)`,
|
|
|
gap: '1px',
|
|
|
fontSize: '12px',
|
|
|
fontFamily: 'JetBrains Mono, monospace'
|
|
|
}}>
|
|
|
{bands.map((band, bandIdx) => (
|
|
|
<React.Fragment key={band}>
|
|
|
{/* Band label */}
|
|
|
<div style={{
|
|
|
display: 'flex',
|
|
|
alignItems: 'center',
|
|
|
justifyContent: 'flex-end',
|
|
|
paddingRight: '4px',
|
|
|
color: 'var(--text-muted)',
|
|
|
fontSize: '12px'
|
|
|
}}>
|
|
|
{band.replace('m', '')}
|
|
|
</div>
|
|
|
{/* 24 hour cells */}
|
|
|
{Array.from({ length: 24 }, (_, hour) => {
|
|
|
const bandData = hourlyPredictions?.[band];
|
|
|
const hourData = bandData?.find(h => h.hour === hour);
|
|
|
const rel = hourData?.reliability || 0;
|
|
|
return (
|
|
|
<div
|
|
|
key={hour}
|
|
|
style={{
|
|
|
background: getHeatColor(rel),
|
|
|
borderRadius: '1px',
|
|
|
border: hour === currentHour ? '1px solid white' : 'none'
|
|
|
}}
|
|
|
title={`${band} @ ${hour}:00 UTC: ${rel}%`}
|
|
|
/>
|
|
|
);
|
|
|
})}
|
|
|
</React.Fragment>
|
|
|
))}
|
|
|
</div>
|
|
|
|
|
|
{/* Hour labels */}
|
|
|
<div style={{
|
|
|
display: 'grid',
|
|
|
gridTemplateColumns: '28px repeat(24, 1fr)',
|
|
|
marginTop: '2px',
|
|
|
fontSize: '9px',
|
|
|
color: 'var(--text-muted)'
|
|
|
}}>
|
|
|
<div>UTC</div>
|
|
|
{[0, '', '', 3, '', '', 6, '', '', 9, '', '', 12, '', '', 15, '', '', 18, '', '', 21, '', ''].map((h, i) => (
|
|
|
<div key={i} style={{ textAlign: 'center' }}>{h}</div>
|
|
|
))}
|
|
|
</div>
|
|
|
|
|
|
{/* Legend & Info */}
|
|
|
<div style={{
|
|
|
marginTop: '6px',
|
|
|
display: 'flex',
|
|
|
justifyContent: 'space-between',
|
|
|
alignItems: 'center',
|
|
|
fontSize: '11px'
|
|
|
}}>
|
|
|
<div style={{ display: 'flex', gap: '2px', alignItems: 'center' }}>
|
|
|
<span style={{ color: 'var(--text-muted)' }}>REL:</span>
|
|
|
<div style={{ width: '8px', height: '8px', background: '#004400', borderRadius: '1px' }} />
|
|
|
<div style={{ width: '8px', height: '8px', background: '#00aa00', borderRadius: '1px' }} />
|
|
|
<div style={{ width: '8px', height: '8px', background: '#88cc00', borderRadius: '1px' }} />
|
|
|
<div style={{ width: '8px', height: '8px', background: '#ffcc00', borderRadius: '1px' }} />
|
|
|
<div style={{ width: '8px', height: '8px', background: '#ff6600', borderRadius: '1px' }} />
|
|
|
<div style={{ width: '8px', height: '8px', background: '#ff0000', borderRadius: '1px' }} />
|
|
|
</div>
|
|
|
<div style={{ color: 'var(--text-muted)' }}>
|
|
|
{Math.round(distance)}km • {ionospheric?.foF2 ? `foF2=${ionospheric.foF2}` : `SSN=${solarData.ssn}`}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
) : (
|
|
|
/* Bar Chart View */
|
|
|
<div style={{ fontSize: '13px' }}>
|
|
|
{/* Solar quick stats */}
|
|
|
<div style={{
|
|
|
display: 'flex',
|
|
|
justifyContent: 'space-around',
|
|
|
padding: '4px',
|
|
|
marginBottom: '4px',
|
|
|
background: 'var(--bg-tertiary)',
|
|
|
borderRadius: '4px',
|
|
|
fontSize: '11px'
|
|
|
}}>
|
|
|
<span><span style={{ color: 'var(--text-muted)' }}>SFI </span><span style={{ color: 'var(--accent-amber)' }}>{solarData.sfi}</span></span>
|
|
|
{ionospheric?.foF2 ? (
|
|
|
<span><span style={{ color: 'var(--text-muted)' }}>foF2 </span><span style={{ color: '#00ff88' }}>{ionospheric.foF2}</span></span>
|
|
|
) : (
|
|
|
<span><span style={{ color: 'var(--text-muted)' }}>SSN </span><span style={{ color: 'var(--accent-cyan)' }}>{solarData.ssn}</span></span>
|
|
|
)}
|
|
|
<span><span style={{ color: 'var(--text-muted)' }}>K </span><span style={{ color: solarData.kIndex >= 4 ? '#ff4444' : '#00ff88' }}>{solarData.kIndex}</span></span>
|
|
|
</div>
|
|
|
|
|
|
{currentBands.slice(0, 8).map((band, idx) => (
|
|
|
<div key={band.band} style={{
|
|
|
display: 'grid',
|
|
|
gridTemplateColumns: '32px 1fr 40px',
|
|
|
gap: '4px',
|
|
|
padding: '2px 0',
|
|
|
alignItems: 'center'
|
|
|
}}>
|
|
|
<span style={{
|
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
|
fontSize: '12px',
|
|
|
color: band.reliability >= 50 ? 'var(--accent-green)' : 'var(--text-muted)'
|
|
|
}}>
|
|
|
{band.band}
|
|
|
</span>
|
|
|
<div style={{ position: 'relative', height: '10px', background: 'var(--bg-tertiary)', borderRadius: '2px' }}>
|
|
|
<div style={{
|
|
|
position: 'absolute',
|
|
|
top: 0,
|
|
|
left: 0,
|
|
|
height: '100%',
|
|
|
width: `${band.reliability}%`,
|
|
|
background: getReliabilityColor(band.reliability),
|
|
|
borderRadius: '2px'
|
|
|
}} />
|
|
|
</div>
|
|
|
<span style={{
|
|
|
textAlign: 'right',
|
|
|
fontSize: '12px',
|
|
|
color: getStatusColor(band.status)
|
|
|
}}>
|
|
|
{band.reliability}%
|
|
|
</span>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
);
|
|
|
};
|
|
|
|
|
|
// ============================================
|
|
|
// 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 (
|
|
|
<div className="panel" style={{ padding: '8px' }}>
|
|
|
{/* Header with toggle */}
|
|
|
<div style={{
|
|
|
display: 'flex',
|
|
|
justifyContent: 'space-between',
|
|
|
alignItems: 'center',
|
|
|
marginBottom: '6px'
|
|
|
}}>
|
|
|
<span style={{ fontSize: '12px', color: 'var(--accent-amber)', fontWeight: '700' }}>
|
|
|
☀ {showIndices ? 'SOLAR INDICES' : 'SOLAR'}
|
|
|
</span>
|
|
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
|
|
{!showIndices && (
|
|
|
<select
|
|
|
value={imageType}
|
|
|
onChange={(e) => setImageType(e.target.value)}
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
style={{
|
|
|
background: 'var(--bg-tertiary)',
|
|
|
border: '1px solid var(--border-color)',
|
|
|
color: 'var(--text-secondary)',
|
|
|
fontSize: '10px',
|
|
|
padding: '2px 4px',
|
|
|
borderRadius: '3px',
|
|
|
cursor: 'pointer'
|
|
|
}}
|
|
|
>
|
|
|
{Object.entries(imageTypes).map(([key, val]) => (
|
|
|
<option key={key} value={key}>{val.desc}</option>
|
|
|
))}
|
|
|
</select>
|
|
|
)}
|
|
|
<button
|
|
|
onClick={toggleMode}
|
|
|
style={{
|
|
|
background: 'var(--bg-tertiary)',
|
|
|
border: '1px solid var(--border-color)',
|
|
|
color: 'var(--text-secondary)',
|
|
|
fontSize: '10px',
|
|
|
padding: '2px 6px',
|
|
|
borderRadius: '3px',
|
|
|
cursor: 'pointer'
|
|
|
}}
|
|
|
title={showIndices ? 'Show solar image' : 'Show solar indices'}
|
|
|
>
|
|
|
{showIndices ? '🖼️' : '📊'}
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{showIndices ? (
|
|
|
/* Solar Indices View */
|
|
|
<div>
|
|
|
{solarIndices?.data ? (
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
|
{/* SFI Row */}
|
|
|
<div style={{ background: 'var(--bg-tertiary)', borderRadius: '6px', padding: '8px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
|
<div style={{ minWidth: '60px' }}>
|
|
|
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>SFI</div>
|
|
|
<div style={{ fontSize: '22px', fontWeight: '700', color: '#ff8800', fontFamily: 'Orbitron, monospace' }}>
|
|
|
{solarIndices.data.sfi?.current || '--'}
|
|
|
</div>
|
|
|
</div>
|
|
|
<div style={{ flex: 1 }}>
|
|
|
{solarIndices.data.sfi?.history?.length > 0 && (
|
|
|
<svg width="100%" height="30" viewBox="0 0 100 30" preserveAspectRatio="none">
|
|
|
{(() => {
|
|
|
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 <polyline points={points} fill="none" stroke="#ff8800" strokeWidth="2" />;
|
|
|
})()}
|
|
|
</svg>
|
|
|
)}
|
|
|
<div style={{ fontSize: '9px', color: 'var(--text-muted)' }}>30 day history</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* SSN Row */}
|
|
|
<div style={{ background: 'var(--bg-tertiary)', borderRadius: '6px', padding: '8px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
|
<div style={{ minWidth: '60px' }}>
|
|
|
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Sunspots</div>
|
|
|
<div style={{ fontSize: '22px', fontWeight: '700', color: '#ffcc00', fontFamily: 'Orbitron, monospace' }}>
|
|
|
{solarIndices.data.ssn?.current || '--'}
|
|
|
</div>
|
|
|
</div>
|
|
|
<div style={{ flex: 1 }}>
|
|
|
{solarIndices.data.ssn?.history?.length > 0 && (
|
|
|
<svg width="100%" height="30" viewBox="0 0 100 30" preserveAspectRatio="none">
|
|
|
{(() => {
|
|
|
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 <polyline points={points} fill="none" stroke="#ffcc00" strokeWidth="2" />;
|
|
|
})()}
|
|
|
</svg>
|
|
|
)}
|
|
|
<div style={{ fontSize: '9px', color: 'var(--text-muted)' }}>12 month history</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* Kp Row */}
|
|
|
<div style={{ background: 'var(--bg-tertiary)', borderRadius: '6px', padding: '8px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
|
<div style={{ minWidth: '60px' }}>
|
|
|
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Kp Index</div>
|
|
|
<div style={{ fontSize: '22px', fontWeight: '700', fontFamily: 'Orbitron, monospace', color: getKpColor(solarIndices.data.kp?.current) }}>
|
|
|
{solarIndices.data.kp?.current?.toFixed(1) || '--'}
|
|
|
</div>
|
|
|
<div style={{ fontSize: '9px', color: getKpColor(solarIndices.data.kp?.current) }}>
|
|
|
{solarIndices.data.kp?.current !== null ? getKpLabel(solarIndices.data.kp?.current) : ''}
|
|
|
</div>
|
|
|
</div>
|
|
|
<div style={{ flex: 1 }}>
|
|
|
{solarIndices.data.kp?.history?.length > 0 && (
|
|
|
<svg width="100%" height="35" viewBox="0 0 100 35" preserveAspectRatio="none">
|
|
|
{(() => {
|
|
|
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 (
|
|
|
<rect
|
|
|
key={i}
|
|
|
x={i * barWidth + 0.5}
|
|
|
y={35 - barHeight}
|
|
|
width={barWidth - 1}
|
|
|
height={barHeight}
|
|
|
fill={getKpColor(d.value)}
|
|
|
opacity={isForecast ? 0.4 : 0.9}
|
|
|
rx="1"
|
|
|
/>
|
|
|
);
|
|
|
});
|
|
|
})()}
|
|
|
</svg>
|
|
|
)}
|
|
|
<div style={{ fontSize: '9px', color: 'var(--text-muted)' }}>3 day history + forecast</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
) : (
|
|
|
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)' }}>
|
|
|
Loading solar data...
|
|
|
</div>
|
|
|
)}
|
|
|
<div style={{ textAlign: 'center', marginTop: '4px', fontSize: '9px', color: 'var(--text-muted)' }}>
|
|
|
NOAA/SWPC
|
|
|
</div>
|
|
|
</div>
|
|
|
) : (
|
|
|
/* Solar Image View */
|
|
|
<div>
|
|
|
<div style={{
|
|
|
position: 'relative',
|
|
|
width: '100%',
|
|
|
paddingBottom: '100%',
|
|
|
background: '#000',
|
|
|
borderRadius: '50%',
|
|
|
overflow: 'hidden'
|
|
|
}}>
|
|
|
<img
|
|
|
src={imageUrl}
|
|
|
alt="Current Sun"
|
|
|
style={{
|
|
|
position: 'absolute',
|
|
|
top: 0,
|
|
|
left: 0,
|
|
|
width: '100%',
|
|
|
height: '100%',
|
|
|
objectFit: 'cover',
|
|
|
borderRadius: '50%'
|
|
|
}}
|
|
|
onError={(e) => {
|
|
|
e.target.style.display = 'none';
|
|
|
}}
|
|
|
/>
|
|
|
</div>
|
|
|
<div style={{
|
|
|
textAlign: 'center',
|
|
|
marginTop: '4px',
|
|
|
fontSize: '10px',
|
|
|
color: 'var(--text-muted)'
|
|
|
}}>
|
|
|
SDO/AIA • Live from NASA
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
);
|
|
|
};
|
|
|
|
|
|
// ============================================
|
|
|
// LEAFLET MAP COMPONENT
|
|
|
// ============================================
|
|
|
const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots, mySpots, dxPaths, satellites, showDXPaths, showPOTA, showSatellites, onToggleSatellites }) => {
|
|
|
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 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');
|
|
|
|
|
|
// Save map style to localStorage when changed
|
|
|
useEffect(() => {
|
|
|
try {
|
|
|
localStorage.setItem('openhamclock_mapSettings', JSON.stringify({
|
|
|
mapStyle
|
|
|
}));
|
|
|
} catch (e) { console.error('Failed to save map settings:', e); }
|
|
|
}, [mapStyle]);
|
|
|
|
|
|
// Initialize map
|
|
|
useEffect(() => {
|
|
|
if (!mapRef.current || mapInstanceRef.current) return;
|
|
|
|
|
|
const map = L.map(mapRef.current, {
|
|
|
center: [20, 0],
|
|
|
zoom: 2.5,
|
|
|
minZoom: 2.3,
|
|
|
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 });
|
|
|
}
|
|
|
});
|
|
|
|
|
|
mapInstanceRef.current = map;
|
|
|
|
|
|
return () => {
|
|
|
clearInterval(terminatorInterval);
|
|
|
map.remove();
|
|
|
mapInstanceRef.current = null;
|
|
|
};
|
|
|
}, []);
|
|
|
|
|
|
// Update tile layer when style changes
|
|
|
useEffect(() => {
|
|
|
if (!mapInstanceRef.current || !tileLayerRef.current) return;
|
|
|
|
|
|
mapInstanceRef.current.removeLayer(tileLayerRef.current);
|
|
|
tileLayerRef.current = L.tileLayer(MAP_STYLES[mapStyle].url, {
|
|
|
attribution: MAP_STYLES[mapStyle].attribution,
|
|
|
noWrap: false,
|
|
|
crossOrigin: 'anonymous',
|
|
|
bounds: [[-85, -180], [85, 180]]
|
|
|
}).addTo(mapInstanceRef.current);
|
|
|
|
|
|
// Ensure terminator is on top
|
|
|
if (terminatorRef.current) {
|
|
|
terminatorRef.current.bringToFront();
|
|
|
}
|
|
|
}, [mapStyle]);
|
|
|
|
|
|
// Update markers and path
|
|
|
useEffect(() => {
|
|
|
if (!mapInstanceRef.current) return;
|
|
|
const map = mapInstanceRef.current;
|
|
|
|
|
|
// Remove old markers
|
|
|
if (deMarkerRef.current) map.removeLayer(deMarkerRef.current);
|
|
|
if (dxMarkerRef.current) map.removeLayer(dxMarkerRef.current);
|
|
|
if (sunMarkerRef.current) map.removeLayer(sunMarkerRef.current);
|
|
|
|
|
|
// DE Marker
|
|
|
const deIcon = L.divIcon({
|
|
|
className: 'custom-marker de-marker',
|
|
|
html: 'DE',
|
|
|
iconSize: [32, 32],
|
|
|
iconAnchor: [16, 16]
|
|
|
});
|
|
|
deMarkerRef.current = L.marker([deLocation.lat, deLocation.lon], { icon: deIcon })
|
|
|
.bindPopup(`<b>DE - Your Location</b><br>${calculateGridSquare(deLocation.lat, deLocation.lon)}<br>${deLocation.lat.toFixed(4)}°, ${deLocation.lon.toFixed(4)}°`)
|
|
|
.addTo(map);
|
|
|
|
|
|
// DX Marker
|
|
|
const dxIcon = L.divIcon({
|
|
|
className: 'custom-marker dx-marker',
|
|
|
html: 'DX',
|
|
|
iconSize: [32, 32],
|
|
|
iconAnchor: [16, 16]
|
|
|
});
|
|
|
dxMarkerRef.current = L.marker([dxLocation.lat, dxLocation.lon], { icon: dxIcon })
|
|
|
.bindPopup(`<b>DX - Target</b><br>${calculateGridSquare(dxLocation.lat, dxLocation.lon)}<br>${dxLocation.lat.toFixed(4)}°, ${dxLocation.lon.toFixed(4)}°`)
|
|
|
.addTo(map);
|
|
|
|
|
|
// Sun marker
|
|
|
const sunPos = getSunPosition(new Date());
|
|
|
const sunIcon = L.divIcon({
|
|
|
className: 'custom-marker sun-marker',
|
|
|
html: '☀',
|
|
|
iconSize: [24, 24],
|
|
|
iconAnchor: [12, 12]
|
|
|
});
|
|
|
sunMarkerRef.current = L.marker([sunPos.lat, sunPos.lon], { icon: sunIcon })
|
|
|
.bindPopup('Subsolar Point')
|
|
|
.addTo(map);
|
|
|
|
|
|
}, [deLocation, dxLocation]);
|
|
|
|
|
|
// 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: `<div style="background: ${markerColor}; color: #000; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-family: JetBrains Mono; white-space: nowrap; border: 2px solid white; font-weight: bold;">${spot.targetCall}</div>`,
|
|
|
iconAnchor: [25, 12]
|
|
|
});
|
|
|
|
|
|
const marker = L.marker([spot.lat, spot.lon], { icon })
|
|
|
.bindPopup(`
|
|
|
<b style="color: ${markerColor}">${spot.targetCall}</b><br>
|
|
|
<span style="color: #888">${spot.isMySpot ? 'You spotted' : 'Spotted you'}</span><br>
|
|
|
<b>${spot.freq} MHz</b><br>
|
|
|
${spot.comment || ''}<br>
|
|
|
<span style="color: #666">${spot.time}</span>
|
|
|
`)
|
|
|
.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 = [];
|
|
|
|
|
|
// Add new DX paths if enabled
|
|
|
if (showDXPaths && dxPaths && dxPaths.length > 0) {
|
|
|
dxPaths.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)
|
|
|
const freq = parseFloat(path.freq);
|
|
|
let color = '#4488ff'; // Default blue
|
|
|
if (freq >= 1.8 && freq < 2) color = '#ff6666'; // 160m - red
|
|
|
else if (freq >= 3.5 && freq < 4) color = '#ff9966'; // 80m - orange
|
|
|
else if (freq >= 7 && freq < 7.5) color = '#ffcc66'; // 40m - yellow
|
|
|
else if (freq >= 10 && freq < 10.5) color = '#99ff66'; // 30m - lime
|
|
|
else if (freq >= 14 && freq < 14.5) color = '#66ff99'; // 20m - green
|
|
|
else if (freq >= 18 && freq < 18.5) color = '#66ffcc'; // 17m - teal
|
|
|
else if (freq >= 21 && freq < 21.5) color = '#66ccff'; // 15m - cyan
|
|
|
else if (freq >= 24 && freq < 25) color = '#6699ff'; // 12m - blue
|
|
|
else if (freq >= 28 && freq < 30) color = '#9966ff'; // 10m - purple
|
|
|
else if (freq >= 50 && freq < 54) color = '#ff66ff'; // 6m - magenta
|
|
|
|
|
|
// 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: color,
|
|
|
weight: 1.5,
|
|
|
opacity: 0.5
|
|
|
}).addTo(map);
|
|
|
dxPathsLinesRef.current.push(line);
|
|
|
}
|
|
|
});
|
|
|
|
|
|
// Add small markers at DX station end only (to reduce clutter)
|
|
|
const dxIcon = L.divIcon({
|
|
|
className: '',
|
|
|
html: `<div style="width: 6px; height: 6px; background: ${color}; border-radius: 50%; border: 1px solid white; box-shadow: 0 0 3px ${color};"></div>`,
|
|
|
iconSize: [6, 6],
|
|
|
iconAnchor: [3, 3]
|
|
|
});
|
|
|
|
|
|
const marker = L.marker([path.dxLat, path.dxLon], { icon: dxIcon })
|
|
|
.bindPopup(`
|
|
|
<div style="font-family: JetBrains Mono, monospace; font-size: 12px;">
|
|
|
<b style="color: ${color}">${path.dxCall}</b><br>
|
|
|
<span style="color: #888">spotted by</span> <b>${path.spotter}</b><br>
|
|
|
<b>${path.freq} MHz</b><br>
|
|
|
${path.comment || ''}<br>
|
|
|
<span style="color: #666">${path.time}</span>
|
|
|
</div>
|
|
|
`)
|
|
|
.addTo(map);
|
|
|
dxPathsMarkersRef.current.push(marker);
|
|
|
} catch (err) {
|
|
|
console.error('[DX Paths] Error rendering path:', err, path);
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
}, [dxPaths, showDXPaths]);
|
|
|
|
|
|
// Update POTA markers
|
|
|
useEffect(() => {
|
|
|
if (!mapInstanceRef.current) return;
|
|
|
const map = mapInstanceRef.current;
|
|
|
|
|
|
// Remove old POTA markers
|
|
|
potaMarkersRef.current.forEach(m => map.removeLayer(m));
|
|
|
potaMarkersRef.current = [];
|
|
|
|
|
|
// Add new POTA markers if enabled
|
|
|
if (showPOTA && potaSpots) {
|
|
|
potaSpots.forEach(spot => {
|
|
|
if (spot.lat && spot.lon) {
|
|
|
const icon = L.divIcon({
|
|
|
className: '',
|
|
|
html: `<div style="background: #aa66ff; color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-family: JetBrains Mono; white-space: nowrap; border: 1px solid white;">${spot.call}</div>`,
|
|
|
iconAnchor: [20, 10]
|
|
|
});
|
|
|
const marker = L.marker([spot.lat, spot.lon], { icon })
|
|
|
.bindPopup(`<b>${spot.call}</b><br>${spot.ref}<br>${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: `<div style="
|
|
|
background: ${sat.color};
|
|
|
color: #000;
|
|
|
padding: ${isISS ? '4px 8px' : '2px 6px'};
|
|
|
border-radius: 4px;
|
|
|
font-size: ${isISS ? '12px' : '10px'};
|
|
|
font-family: JetBrains Mono;
|
|
|
white-space: nowrap;
|
|
|
border: 2px solid ${sat.isVisible ? '#fff' : 'rgba(255,255,255,0.3)'};
|
|
|
font-weight: bold;
|
|
|
opacity: ${sat.isVisible ? 1 : 0.6};
|
|
|
box-shadow: ${sat.isVisible ? '0 0 10px ' + sat.color : 'none'};
|
|
|
">🛰 ${sat.key}</div>`,
|
|
|
iconAnchor: [isISS ? 35 : 25, 12]
|
|
|
});
|
|
|
|
|
|
const marker = L.marker([sat.lat, sat.lon], { icon, zIndexOffset: isISS ? 1000 : 500 })
|
|
|
.bindPopup(`
|
|
|
<div style="font-family: JetBrains Mono; font-size: 12px;">
|
|
|
<b style="color: ${sat.color}; font-size: 14px;">🛰 ${sat.name}</b><br>
|
|
|
<div style="margin-top: 6px;">
|
|
|
<b>Altitude:</b> ${sat.alt} km<br>
|
|
|
<b>Position:</b> ${sat.lat.toFixed(2)}°, ${sat.lon.toFixed(2)}°<br>
|
|
|
${sat.isVisible ?
|
|
|
`<span style="color: #00ff88;"><b>✓ VISIBLE</b></span><br>
|
|
|
<b>Azimuth:</b> ${sat.azimuth}°<br>
|
|
|
<b>Elevation:</b> ${sat.elevation}°` :
|
|
|
`<span style="color: #888;">Below horizon</span>`
|
|
|
}
|
|
|
</div>
|
|
|
</div>
|
|
|
`)
|
|
|
.addTo(map);
|
|
|
satMarkersRef.current.push(marker);
|
|
|
});
|
|
|
}
|
|
|
}, [satellites, showSatellites]);
|
|
|
|
|
|
return (
|
|
|
<div style={{ position: 'relative', height: '100%', minHeight: '200px' }}>
|
|
|
<div ref={mapRef} style={{ height: '100%', width: '100%', borderRadius: '8px' }} />
|
|
|
|
|
|
{/* Map style dropdown */}
|
|
|
<select
|
|
|
value={mapStyle}
|
|
|
onChange={(e) => setMapStyle(e.target.value)}
|
|
|
style={{
|
|
|
position: 'absolute',
|
|
|
top: '10px',
|
|
|
right: '10px',
|
|
|
background: 'rgba(0, 0, 0, 0.8)',
|
|
|
border: '1px solid #444',
|
|
|
color: '#00ffcc',
|
|
|
padding: '6px 10px',
|
|
|
borderRadius: '4px',
|
|
|
fontSize: '11px',
|
|
|
fontFamily: 'JetBrains Mono',
|
|
|
cursor: 'pointer',
|
|
|
zIndex: 1000,
|
|
|
outline: 'none'
|
|
|
}}
|
|
|
>
|
|
|
{Object.entries(MAP_STYLES).map(([key, style]) => (
|
|
|
<option key={key} value={key}>{style.name}</option>
|
|
|
))}
|
|
|
</select>
|
|
|
|
|
|
{/* Satellite toggle button */}
|
|
|
{onToggleSatellites && (
|
|
|
<button
|
|
|
onClick={onToggleSatellites}
|
|
|
style={{
|
|
|
position: 'absolute',
|
|
|
top: '10px',
|
|
|
left: '50px',
|
|
|
background: showSatellites ? 'rgba(0, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.8)',
|
|
|
border: `1px solid ${showSatellites ? '#00ffff' : '#666'}`,
|
|
|
color: showSatellites ? '#00ffff' : '#888',
|
|
|
padding: '6px 10px',
|
|
|
borderRadius: '4px',
|
|
|
fontSize: '11px',
|
|
|
fontFamily: 'JetBrains Mono',
|
|
|
cursor: 'pointer',
|
|
|
zIndex: 1000,
|
|
|
display: 'flex',
|
|
|
alignItems: 'center',
|
|
|
gap: '4px'
|
|
|
}}
|
|
|
title={showSatellites ? 'Hide satellites' : 'Show satellites'}
|
|
|
>
|
|
|
🛰 SAT {showSatellites ? 'ON' : 'OFF'}
|
|
|
</button>
|
|
|
)}
|
|
|
|
|
|
{/* Map Legend - Bottom of map */}
|
|
|
<div style={{
|
|
|
position: 'absolute',
|
|
|
bottom: '8px',
|
|
|
left: '50%',
|
|
|
transform: 'translateX(-50%)',
|
|
|
background: 'rgba(0, 0, 0, 0.85)',
|
|
|
border: '1px solid #444',
|
|
|
borderRadius: '6px',
|
|
|
padding: '6px 12px',
|
|
|
zIndex: 1000,
|
|
|
display: 'flex',
|
|
|
gap: '12px',
|
|
|
alignItems: 'center',
|
|
|
fontSize: '10px',
|
|
|
fontFamily: 'JetBrains Mono, monospace'
|
|
|
}}>
|
|
|
{/* DX Paths Band Colors */}
|
|
|
{showDXPaths && (
|
|
|
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
|
|
<span style={{ color: '#888' }}>DX:</span>
|
|
|
<span style={{ color: '#ff6666' }}>160m</span>
|
|
|
<span style={{ color: '#ff9966' }}>80m</span>
|
|
|
<span style={{ color: '#ffcc66' }}>40m</span>
|
|
|
<span style={{ color: '#99ff66' }}>30m</span>
|
|
|
<span style={{ color: '#66ff99' }}>20m</span>
|
|
|
<span style={{ color: '#66ffcc' }}>17m</span>
|
|
|
<span style={{ color: '#66ccff' }}>15m</span>
|
|
|
<span style={{ color: '#6699ff' }}>12m</span>
|
|
|
<span style={{ color: '#9966ff' }}>10m</span>
|
|
|
<span style={{ color: '#ff66ff' }}>6m</span>
|
|
|
</div>
|
|
|
)}
|
|
|
{showDXPaths && (showPOTA || showSatellites) && <span style={{ color: '#444' }}>|</span>}
|
|
|
|
|
|
{/* POTA */}
|
|
|
{showPOTA && (
|
|
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
|
|
<div style={{ width: '10px', height: '10px', background: '#aa66ff', borderRadius: '2px' }} />
|
|
|
<span style={{ color: '#aa66ff' }}>POTA</span>
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
{/* Satellites */}
|
|
|
{showSatellites && (
|
|
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
|
|
<span style={{ color: '#00ffff' }}>🛰</span>
|
|
|
<span style={{ color: '#00ffff' }}>SAT</span>
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
{/* DE/DX markers */}
|
|
|
<span style={{ color: '#444' }}>|</span>
|
|
|
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
|
|
<span style={{ color: '#00ff00' }}>● DE</span>
|
|
|
<span style={{ color: '#ff4444' }}>● DX</span>
|
|
|
<span style={{ color: '#ffff00' }}>☀ Sun</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
};
|
|
|
|
|
|
// ============================================
|
|
|
// UI COMPONENTS
|
|
|
// ============================================
|
|
|
const Header = ({ callsign, uptime, version, onSettingsClick, onFullscreenToggle, isFullscreen }) => (
|
|
|
<header style={{
|
|
|
background: 'linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%)',
|
|
|
borderBottom: '1px solid var(--border-color)',
|
|
|
padding: '12px 24px',
|
|
|
display: 'flex',
|
|
|
justifyContent: 'space-between',
|
|
|
alignItems: 'center'
|
|
|
}}>
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
|
|
|
<div style={{
|
|
|
fontFamily: 'Orbitron, monospace', fontSize: '28px', fontWeight: '700',
|
|
|
color: 'var(--accent-amber)', textShadow: '0 0 10px var(--accent-amber-dim)', letterSpacing: '2px'
|
|
|
}}>OpenHamClock</div>
|
|
|
<div
|
|
|
onClick={onSettingsClick}
|
|
|
style={{
|
|
|
background: 'var(--bg-tertiary)', padding: '6px 16px', borderRadius: '4px',
|
|
|
border: '1px solid var(--border-color)', fontFamily: 'JetBrains Mono, monospace',
|
|
|
fontSize: '16px', fontWeight: '600', color: 'var(--accent-green)',
|
|
|
textShadow: '0 0 8px var(--accent-green-dim)', cursor: 'pointer',
|
|
|
transition: 'all 0.2s'
|
|
|
}}
|
|
|
title="Click to change settings"
|
|
|
>{callsign}</div>
|
|
|
</div>
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', fontFamily: 'JetBrains Mono, monospace', fontSize: '12px', color: 'var(--text-secondary)' }}>
|
|
|
<span>UPTIME: {uptime}</span>
|
|
|
<span style={{ color: 'var(--accent-cyan)' }}>v{version}</span>
|
|
|
<button
|
|
|
onClick={onSettingsClick}
|
|
|
style={{
|
|
|
background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)',
|
|
|
borderRadius: '6px', padding: '8px 12px', color: 'var(--text-secondary)',
|
|
|
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px',
|
|
|
fontFamily: 'JetBrains Mono, monospace', fontSize: '13px',
|
|
|
transition: 'all 0.2s'
|
|
|
}}
|
|
|
title="Settings"
|
|
|
>
|
|
|
⚙ Settings
|
|
|
</button>
|
|
|
<button
|
|
|
onClick={onFullscreenToggle}
|
|
|
style={{
|
|
|
background: isFullscreen ? 'rgba(0, 255, 136, 0.15)' : 'var(--bg-tertiary)',
|
|
|
border: `1px solid ${isFullscreen ? 'var(--accent-green)' : 'var(--border-color)'}`,
|
|
|
borderRadius: '6px', padding: '8px 12px',
|
|
|
color: isFullscreen ? 'var(--accent-green)' : 'var(--text-secondary)',
|
|
|
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px',
|
|
|
fontFamily: 'JetBrains Mono, monospace', fontSize: '13px',
|
|
|
transition: 'all 0.2s'
|
|
|
}}
|
|
|
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
|
|
>
|
|
|
{isFullscreen ? '⛶' : '⛶'} {isFullscreen ? 'Exit' : 'Full'}
|
|
|
</button>
|
|
|
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: 'var(--accent-green)', boxShadow: '0 0 8px var(--accent-green)', animation: 'pulse 2s infinite' }} />
|
|
|
</div>
|
|
|
</header>
|
|
|
);
|
|
|
|
|
|
const ClockPanel = ({ label, time, date, isUtc }) => (
|
|
|
<div style={{
|
|
|
background: 'var(--bg-panel)', border: '1px solid var(--border-color)',
|
|
|
borderRadius: '8px', padding: '16px 24px', backdropFilter: 'blur(10px)'
|
|
|
}}>
|
|
|
<div style={{ fontSize: '13px', fontWeight: '600', color: isUtc ? 'var(--accent-amber)' : 'var(--text-secondary)', letterSpacing: '2px', marginBottom: '8px', textTransform: 'uppercase' }}>{label}</div>
|
|
|
<div style={{ fontFamily: 'Orbitron, monospace', fontSize: '42px', fontWeight: '700', color: isUtc ? 'var(--accent-amber)' : 'var(--text-primary)', textShadow: isUtc ? '0 0 20px var(--accent-amber-dim)' : 'none', letterSpacing: '3px', lineHeight: 1 }}>{time}</div>
|
|
|
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: '13px', color: 'var(--text-muted)', marginTop: '6px' }}>{date}</div>
|
|
|
</div>
|
|
|
);
|
|
|
|
|
|
const LocationPanel = ({ type, location, gridSquare, sunTimes, otherLocation }) => {
|
|
|
const isDE = type === 'DE';
|
|
|
const bearing = otherLocation ? calculateBearing(location.lat, location.lon, otherLocation.lat, otherLocation.lon).toFixed(0) : null;
|
|
|
const distance = otherLocation ? calculateDistance(location.lat, location.lon, otherLocation.lat, otherLocation.lon).toFixed(0) : null;
|
|
|
|
|
|
return (
|
|
|
<div style={{
|
|
|
background: 'var(--bg-panel)', border: `1px solid ${isDE ? 'var(--border-color)' : 'rgba(68,136,255,0.3)'}`,
|
|
|
borderRadius: '8px', padding: '16px 20px', backdropFilter: 'blur(10px)'
|
|
|
}}>
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
|
|
<div style={{ fontSize: '13px', fontWeight: '700', color: isDE ? 'var(--accent-amber)' : 'var(--accent-blue)', letterSpacing: '3px', padding: '4px 12px', background: isDE ? 'rgba(255,180,50,0.15)' : 'rgba(68,136,255,0.15)', borderRadius: '4px' }}>{type}</div>
|
|
|
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: '18px', fontWeight: '700', color: 'var(--accent-green)', textShadow: '0 0 10px var(--accent-green-dim)' }}>{gridSquare}</div>
|
|
|
</div>
|
|
|
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '12px' }}>
|
|
|
{Math.abs(location.lat).toFixed(4)}°{location.lat >= 0 ? 'N' : 'S'}, {Math.abs(location.lon).toFixed(4)}°{location.lon >= 0 ? 'E' : 'W'}
|
|
|
</div>
|
|
|
{sunTimes && (
|
|
|
<div style={{ display: 'flex', gap: '16px', fontSize: '12px' }}>
|
|
|
<div><span style={{ color: 'var(--accent-amber)' }}>☀↑</span><span style={{ marginLeft: '6px', fontFamily: 'JetBrains Mono, monospace' }}>{sunTimes.sunrise}z</span></div>
|
|
|
<div><span style={{ color: 'var(--accent-amber)' }}>☀↓</span><span style={{ marginLeft: '6px', fontFamily: 'JetBrains Mono, monospace' }}>{sunTimes.sunset}z</span></div>
|
|
|
</div>
|
|
|
)}
|
|
|
{bearing && distance && (
|
|
|
<div style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', gap: '20px', fontSize: '12px' }}>
|
|
|
<div><span style={{ color: 'var(--text-muted)' }}>SP:</span><span style={{ marginLeft: '6px', fontFamily: 'JetBrains Mono, monospace', color: 'var(--accent-cyan)' }}>{bearing}°</span></div>
|
|
|
<div><span style={{ color: 'var(--text-muted)' }}>LP:</span><span style={{ marginLeft: '6px', fontFamily: 'JetBrains Mono, monospace', color: 'var(--accent-cyan)' }}>{(parseFloat(bearing) + 180) % 360}°</span></div>
|
|
|
<div><span style={{ color: 'var(--text-muted)' }}>Dist:</span><span style={{ marginLeft: '6px', fontFamily: 'JetBrains Mono, monospace', color: 'var(--text-primary)' }}>{parseInt(distance).toLocaleString()} km</span></div>
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
);
|
|
|
};
|
|
|
|
|
|
const SpaceWeatherPanel = ({ data, loading }) => {
|
|
|
const getColor = (val, th) => {
|
|
|
const v = parseFloat(val);
|
|
|
if (isNaN(v)) return 'var(--text-primary)';
|
|
|
if (v >= th.bad) return 'var(--accent-red)';
|
|
|
if (v >= th.fair) return 'var(--accent-amber)';
|
|
|
return 'var(--accent-green)';
|
|
|
};
|
|
|
const condColors = { EXCELLENT: 'var(--accent-green)', GOOD: 'var(--accent-green)', FAIR: 'var(--accent-amber)', POOR: 'var(--accent-red)', UNKNOWN: 'var(--text-muted)' };
|
|
|
|
|
|
return (
|
|
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-color)', borderRadius: '8px', padding: '20px', backdropFilter: 'blur(10px)' }}>
|
|
|
<div style={{ fontSize: '12px', fontWeight: '600', color: 'var(--accent-cyan)', letterSpacing: '2px', marginBottom: '16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
|
<span>☀ SPACE WEATHER</span>
|
|
|
{loading && <div className="loading-spinner" />}
|
|
|
</div>
|
|
|
{data ? (
|
|
|
<>
|
|
|
{[
|
|
|
{ label: 'Solar Flux Index', value: data.solarFlux, unit: 'sfu', th: { fair: 70, bad: 0 } },
|
|
|
{ label: 'Sunspot Number', value: data.sunspotNumber },
|
|
|
{ label: 'K-Index', value: data.kIndex, th: { fair: 4, bad: 6 } },
|
|
|
{ label: 'A-Index', value: data.aIndex, th: { fair: 15, bad: 30 } }
|
|
|
].map((item, i) => (
|
|
|
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
|
|
<span style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>{item.label}</span>
|
|
|
<span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: '16px', fontWeight: '600', color: item.th ? getColor(item.value, item.th) : 'var(--text-primary)' }}>
|
|
|
{item.value}{item.unit && <span style={{ fontSize: '13px', marginLeft: '2px' }}>{item.unit}</span>}
|
|
|
</span>
|
|
|
</div>
|
|
|
))}
|
|
|
<div style={{ marginTop: '12px', padding: '10px', background: `${condColors[data.conditions]}15`, border: `1px solid ${condColors[data.conditions]}40`, borderRadius: '4px', textAlign: 'center', fontFamily: 'JetBrains Mono, monospace', fontSize: '12px', color: condColors[data.conditions] }}>
|
|
|
CONDITIONS: {data.conditions}
|
|
|
</div>
|
|
|
</>
|
|
|
) : <div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '20px' }}>Loading...</div>}
|
|
|
</div>
|
|
|
);
|
|
|
};
|
|
|
|
|
|
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)' });
|
|
|
|
|
|
return (
|
|
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-color)', borderRadius: '8px', padding: '20px', backdropFilter: 'blur(10px)' }}>
|
|
|
<div style={{ fontSize: '12px', fontWeight: '600', color: 'var(--accent-cyan)', letterSpacing: '2px', marginBottom: '16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
|
<span>📡 BAND CONDITIONS</span>
|
|
|
{loading && <div className="loading-spinner" />}
|
|
|
</div>
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px' }}>
|
|
|
{bands.map((b, i) => {
|
|
|
const s = getStyle(b.condition);
|
|
|
return (
|
|
|
<div key={i} style={{ background: s.bg, border: `1px solid ${s.border}`, borderRadius: '6px', padding: '10px', textAlign: 'center' }}>
|
|
|
<div style={{ fontFamily: 'Orbitron, monospace', fontSize: '14px', fontWeight: '700', color: s.color }}>{b.band}</div>
|
|
|
<div style={{ fontSize: '12px', fontWeight: '600', color: s.color, marginTop: '4px', opacity: 0.8 }}>{b.condition}</div>
|
|
|
</div>
|
|
|
);
|
|
|
})}
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
};
|
|
|
|
|
|
const DXClusterPanel = ({ spots, loading, activeSource, showOnMap, onToggleMap }) => (
|
|
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-color)', borderRadius: '8px', padding: '20px', backdropFilter: 'blur(10px)', maxHeight: '280px', overflow: 'hidden' }}>
|
|
|
<div style={{ fontSize: '12px', fontWeight: '600', color: 'var(--accent-cyan)', letterSpacing: '2px', marginBottom: '16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
|
<span>🌐 DX CLUSTER</span>
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
|
{loading && <div className="loading-spinner" />}
|
|
|
{activeSource && <span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>{activeSource}</span>}
|
|
|
<button
|
|
|
onClick={onToggleMap}
|
|
|
style={{
|
|
|
background: showOnMap ? 'rgba(68, 136, 255, 0.3)' : 'rgba(100, 100, 100, 0.3)',
|
|
|
border: `1px solid ${showOnMap ? '#4488ff' : '#666'}`,
|
|
|
color: showOnMap ? '#4488ff' : '#888',
|
|
|
padding: '2px 8px',
|
|
|
borderRadius: '4px',
|
|
|
fontSize: '10px',
|
|
|
fontFamily: 'JetBrains Mono',
|
|
|
cursor: 'pointer',
|
|
|
display: 'flex',
|
|
|
alignItems: 'center',
|
|
|
gap: '4px'
|
|
|
}}
|
|
|
title={showOnMap ? 'Hide DX paths on map' : 'Show DX paths on map'}
|
|
|
>
|
|
|
🗺️ {showOnMap ? 'ON' : 'OFF'}
|
|
|
</button>
|
|
|
<span style={{ fontSize: '12px', color: 'var(--accent-green)' }}>● LIVE</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div style={{ overflowY: 'auto', maxHeight: '200px' }}>
|
|
|
{spots.map((s, i) => (
|
|
|
<div key={i} style={{ display: 'grid', gridTemplateColumns: '65px 75px 1fr auto', gap: '10px', padding: '8px 0', borderBottom: '1px solid rgba(255,255,255,0.03)', fontFamily: 'JetBrains Mono, monospace', fontSize: '13px', alignItems: 'center' }}>
|
|
|
<span style={{ color: 'var(--accent-green)' }}>{s.freq}</span>
|
|
|
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{s.call}</span>
|
|
|
<span style={{ color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.comment}</span>
|
|
|
<span style={{ color: 'var(--text-muted)' }}>{s.time}</span>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
|
|
|
const POTAPanel = ({ activities, loading, showOnMap, onToggleMap }) => (
|
|
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-color)', borderRadius: '8px', padding: '20px', backdropFilter: 'blur(10px)' }}>
|
|
|
<div style={{ fontSize: '12px', fontWeight: '600', color: 'var(--accent-cyan)', letterSpacing: '2px', marginBottom: '16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
|
<span>🏕 POTA ACTIVITY</span>
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
|
{loading && <div className="loading-spinner" />}
|
|
|
<button
|
|
|
onClick={onToggleMap}
|
|
|
style={{
|
|
|
background: showOnMap ? 'rgba(170, 102, 255, 0.3)' : 'rgba(100, 100, 100, 0.3)',
|
|
|
border: `1px solid ${showOnMap ? '#aa66ff' : '#666'}`,
|
|
|
color: showOnMap ? '#aa66ff' : '#888',
|
|
|
padding: '2px 8px',
|
|
|
borderRadius: '4px',
|
|
|
fontSize: '10px',
|
|
|
fontFamily: 'JetBrains Mono',
|
|
|
cursor: 'pointer',
|
|
|
display: 'flex',
|
|
|
alignItems: 'center',
|
|
|
gap: '4px'
|
|
|
}}
|
|
|
title={showOnMap ? 'Hide POTA markers on map' : 'Show POTA markers on map'}
|
|
|
>
|
|
|
🗺️ {showOnMap ? 'ON' : 'OFF'}
|
|
|
</button>
|
|
|
<span style={{ fontSize: '12px', color: 'var(--accent-green)' }}>● LIVE</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div style={{ maxHeight: '160px', overflowY: 'auto' }}>
|
|
|
{activities.length > 0 ? activities.map((a, i) => (
|
|
|
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 0', borderBottom: '1px solid rgba(255,255,255,0.03)', fontFamily: 'JetBrains Mono, monospace', fontSize: '13px' }}>
|
|
|
<div>
|
|
|
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{a.call}</span>
|
|
|
<span style={{ color: 'var(--accent-purple)', marginLeft: '8px' }}>{a.ref}</span>
|
|
|
</div>
|
|
|
<div>
|
|
|
<span style={{ color: 'var(--accent-green)' }}>{a.freq}</span>
|
|
|
<span style={{ color: 'var(--text-secondary)', marginLeft: '8px' }}>{a.mode}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
)) : <div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '20px' }}>No active POTA spots</div>}
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
|
|
|
// ============================================
|
|
|
// 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 (
|
|
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-color)', borderRadius: '8px', padding: '20px', backdropFilter: 'blur(10px)' }}>
|
|
|
<div style={{ fontSize: '12px', fontWeight: '600', color: 'var(--accent-cyan)', letterSpacing: '2px', marginBottom: '16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
|
<span>🏆 CONTESTS</span>
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
|
{loading && <div className="loading-spinner" />}
|
|
|
</div>
|
|
|
</div>
|
|
|
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
|
|
|
{contests.length > 0 ? contests.map((c, i) => (
|
|
|
<div key={i} style={{
|
|
|
padding: '8px 0',
|
|
|
borderBottom: '1px solid rgba(255,255,255,0.03)',
|
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
|
fontSize: '13px',
|
|
|
borderLeft: c.status === 'active' ? '3px solid var(--accent-green)' : 'none',
|
|
|
paddingLeft: c.status === 'active' ? '8px' : '0'
|
|
|
}}>
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
|
<span style={{ color: getStatusColor(c), fontWeight: c.status === 'active' ? '700' : '400' }}>
|
|
|
{c.name}
|
|
|
{c.status === 'active' && <span style={{ marginLeft: '6px', animation: 'blink 1s infinite' }}>●</span>}
|
|
|
</span>
|
|
|
<span style={{ color: getModeColor(c.mode), fontSize: '13px', padding: '2px 6px', background: 'var(--bg-tertiary)', borderRadius: '3px' }}>
|
|
|
{c.mode}
|
|
|
</span>
|
|
|
</div>
|
|
|
<div style={{ color: 'var(--text-muted)', fontSize: '12px', marginTop: '2px' }}>
|
|
|
{c.status === 'active' ? 'NOW' : formatContestTime(c.start)}
|
|
|
</div>
|
|
|
</div>
|
|
|
)) : (
|
|
|
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '20px' }}>
|
|
|
No upcoming contests
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
<div style={{ marginTop: '8px', textAlign: 'right', fontSize: '9px' }}>
|
|
|
<a href="https://www.contestcalendar.com" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-muted)', textDecoration: 'none' }}>
|
|
|
WA7BNM Contest Calendar
|
|
|
</a>
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
};
|
|
|
|
|
|
// ============================================
|
|
|
// 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, contests, propagation, mySpots, satellites,
|
|
|
localWeather, use12Hour, onTimeFormatToggle,
|
|
|
onSettingsClick, onFullscreenToggle, isFullscreen,
|
|
|
mapLayers, toggleDXPaths, togglePOTA, toggleSatellites
|
|
|
}) => {
|
|
|
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 (
|
|
|
<div style={{
|
|
|
height: '100vh',
|
|
|
background: 'var(--bg-primary)',
|
|
|
display: 'grid',
|
|
|
gridTemplateColumns: '200px 1fr 220px',
|
|
|
gridTemplateRows: 'auto 1fr auto',
|
|
|
gap: '2px',
|
|
|
padding: '2px',
|
|
|
overflow: 'hidden'
|
|
|
}}>
|
|
|
{/* TOP LEFT - Callsign & Weather */}
|
|
|
<div style={{ ...panelStyle, gridRow: '1', gridColumn: '1', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
|
|
<div
|
|
|
style={{ fontSize: '28px', color: 'var(--accent-green)', fontWeight: '900', cursor: 'pointer', fontFamily: 'Orbitron, monospace' }}
|
|
|
onClick={onSettingsClick}
|
|
|
title="Click to change settings"
|
|
|
>
|
|
|
{config.callsign}
|
|
|
</div>
|
|
|
{localWeather.data ? (
|
|
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>
|
|
|
{localWeather.data.icon} {localWeather.data.temp}°F • {localWeather.data.description}
|
|
|
</div>
|
|
|
) : (
|
|
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>
|
|
|
SFI {spaceWeather.data?.solarFlux || '--'} • K{spaceWeather.data?.kIndex || '-'}
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
{/* TOP CENTER - Large Clock */}
|
|
|
<div style={{ ...panelStyle, gridRow: '1', gridColumn: '2', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '40px' }}>
|
|
|
<div style={{ textAlign: 'center' }}>
|
|
|
<div style={bigValueStyle}>{utcTime}</div>
|
|
|
<div style={{ fontSize: '13px', color: 'var(--text-muted)' }}>{utcDate} UTC</div>
|
|
|
</div>
|
|
|
<div style={{ width: '1px', height: '40px', background: 'var(--border-color)' }} />
|
|
|
<div style={{ textAlign: 'center' }}>
|
|
|
<div
|
|
|
style={{ ...bigValueStyle, color: 'var(--accent-amber)', textShadow: '0 0 10px var(--accent-amber-dim)', cursor: 'pointer' }}
|
|
|
onClick={onTimeFormatToggle}
|
|
|
title={`Click to switch to ${use12Hour ? '24-hour' : '12-hour'} format`}
|
|
|
>{localTime}</div>
|
|
|
<div style={{ fontSize: '13px', color: 'var(--text-muted)' }}>{localDate} Local</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* TOP RIGHT - Solar Indices with Mini Charts */}
|
|
|
<div style={{ ...panelStyle, gridRow: '1', gridColumn: '3', padding: '6px 10px' }}>
|
|
|
<div style={{ ...labelStyle, fontSize: '10px', marginBottom: '4px' }}>☀ SOLAR INDICES</div>
|
|
|
{solarIndices.data ? (
|
|
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch' }}>
|
|
|
{/* SFI */}
|
|
|
<div style={{ flex: 1, background: 'var(--bg-tertiary)', borderRadius: '4px', padding: '4px 6px' }}>
|
|
|
<div style={{ fontSize: '9px', color: 'var(--text-muted)' }}>SFI</div>
|
|
|
<div style={{ fontSize: '16px', color: '#ff8800', fontWeight: '700', fontFamily: 'Orbitron, monospace' }}>
|
|
|
{solarIndices.data.sfi?.current || '--'}
|
|
|
</div>
|
|
|
{solarIndices.data.sfi?.history?.length > 0 && (
|
|
|
<svg width="50" height="18" style={{ display: 'block', marginTop: '2px' }}>
|
|
|
{(() => {
|
|
|
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 <polyline points={points} fill="none" stroke="#ff8800" strokeWidth="1.5" />;
|
|
|
})()}
|
|
|
</svg>
|
|
|
)}
|
|
|
</div>
|
|
|
{/* SSN */}
|
|
|
<div style={{ flex: 1, background: 'var(--bg-tertiary)', borderRadius: '4px', padding: '4px 6px' }}>
|
|
|
<div style={{ fontSize: '9px', color: 'var(--text-muted)' }}>SSN</div>
|
|
|
<div style={{ fontSize: '16px', color: '#ffcc00', fontWeight: '700', fontFamily: 'Orbitron, monospace' }}>
|
|
|
{solarIndices.data.ssn?.current || '--'}
|
|
|
</div>
|
|
|
{solarIndices.data.ssn?.history?.length > 0 && (
|
|
|
<svg width="50" height="18" style={{ display: 'block', marginTop: '2px' }}>
|
|
|
{(() => {
|
|
|
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 <polyline points={points} fill="none" stroke="#ffcc00" strokeWidth="1.5" />;
|
|
|
})()}
|
|
|
</svg>
|
|
|
)}
|
|
|
</div>
|
|
|
{/* Kp */}
|
|
|
<div style={{ flex: 1, background: 'var(--bg-tertiary)', borderRadius: '4px', padding: '4px 6px' }}>
|
|
|
<div style={{ fontSize: '9px', color: 'var(--text-muted)' }}>Kp</div>
|
|
|
<div style={{ fontSize: '16px', fontWeight: '700', fontFamily: 'Orbitron, monospace',
|
|
|
color: solarIndices.data.kp?.current >= 5 ? '#ff6600' : solarIndices.data.kp?.current >= 4 ? '#ffcc00' : '#00ff88'
|
|
|
}}>
|
|
|
{solarIndices.data.kp?.current?.toFixed(1) || '--'}
|
|
|
</div>
|
|
|
{solarIndices.data.kp?.history?.length > 0 && (
|
|
|
<svg width="50" height="18" style={{ display: 'block', marginTop: '2px' }}>
|
|
|
{(() => {
|
|
|
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 (
|
|
|
<rect
|
|
|
key={i}
|
|
|
x={i * barWidth}
|
|
|
y={18 - barHeight}
|
|
|
width={barWidth - 1}
|
|
|
height={barHeight}
|
|
|
fill={color}
|
|
|
opacity={isForecast ? 0.4 : 0.8}
|
|
|
/>
|
|
|
);
|
|
|
});
|
|
|
})()}
|
|
|
</svg>
|
|
|
)}
|
|
|
</div>
|
|
|
</div>
|
|
|
) : (
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginTop: '8px' }}>
|
|
|
<div>
|
|
|
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>SFI</div>
|
|
|
<div style={{ fontSize: '18px', color: 'var(--accent-amber)', fontWeight: '700' }}>{spaceWeather.data?.solarFlux || '--'}</div>
|
|
|
</div>
|
|
|
<div>
|
|
|
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Kp</div>
|
|
|
<div style={{ fontSize: '18px', color: parseInt(spaceWeather.data?.kIndex) > 4 ? 'var(--accent-red)' : 'var(--accent-green)', fontWeight: '700' }}>{spaceWeather.data?.kIndex || '-'}</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
{/* LEFT SIDEBAR - DE/DX Info */}
|
|
|
<div style={{ ...panelStyle, gridRow: '2', gridColumn: '1', display: 'flex', flexDirection: 'column', gap: '12px', overflowY: 'auto' }}>
|
|
|
{/* DE Section */}
|
|
|
<div style={{ borderBottom: '1px solid var(--border-color)', paddingBottom: '12px' }}>
|
|
|
<div style={{ ...labelStyle, marginBottom: '8px' }}>DE:</div>
|
|
|
<div style={{ fontSize: '16px', color: 'var(--accent-green)', fontWeight: '700' }}>{deGrid}</div>
|
|
|
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', marginTop: '4px' }}>
|
|
|
{config.location.lat.toFixed(2)}°{config.location.lat >= 0 ? 'N' : 'S'}, {config.location.lon.toFixed(2)}°{config.location.lon >= 0 ? 'E' : 'W'}
|
|
|
</div>
|
|
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>
|
|
|
☀↑ {deSunTimes.sunrise}z ☀↓ {deSunTimes.sunset}z
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* DX Section */}
|
|
|
<div style={{ borderBottom: '1px solid var(--border-color)', paddingBottom: '12px' }}>
|
|
|
<div style={{ ...labelStyle, marginBottom: '8px', color: 'var(--accent-cyan)' }}>DX:</div>
|
|
|
<div style={{ fontSize: '16px', color: 'var(--accent-cyan)', fontWeight: '700' }}>{dxGrid}</div>
|
|
|
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', marginTop: '4px' }}>
|
|
|
{dxLocation.lat.toFixed(2)}°{dxLocation.lat >= 0 ? 'N' : 'S'}, {dxLocation.lon.toFixed(2)}°{dxLocation.lon >= 0 ? 'E' : 'W'}
|
|
|
</div>
|
|
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>
|
|
|
☀↑ {dxSunTimes.sunrise}z ☀↓ {dxSunTimes.sunset}z
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* Path Info */}
|
|
|
<div>
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
|
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>SP:</span>
|
|
|
<span style={{ fontSize: '12px', color: 'var(--accent-amber)', fontWeight: '600' }}>{bearing.toFixed(0)}°</span>
|
|
|
</div>
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
|
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>LP:</span>
|
|
|
<span style={{ fontSize: '12px', color: 'var(--accent-amber)', fontWeight: '600' }}>{((bearing + 180) % 360).toFixed(0)}°</span>
|
|
|
</div>
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>Dist:</span>
|
|
|
<span style={{ fontSize: '12px', color: 'var(--text-primary)', fontWeight: '600' }}>{Math.round(distance).toLocaleString()} km</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* Band Conditions - Compact */}
|
|
|
<div style={{ marginTop: '8px' }}>
|
|
|
<div style={{ ...labelStyle, marginBottom: '8px' }}>📡 BANDS</div>
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '4px' }}>
|
|
|
{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 (
|
|
|
<div key={i} style={{ textAlign: 'center', padding: '2px', background: 'var(--bg-tertiary)', borderRadius: '2px' }}>
|
|
|
<div style={{ fontSize: '13px', color }}>{b.band}</div>
|
|
|
</div>
|
|
|
);
|
|
|
})}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* CENTER - Map */}
|
|
|
<div style={{ ...panelStyle, gridRow: '2', gridColumn: '2', padding: '0', position: 'relative' }}>
|
|
|
<WorldMap
|
|
|
deLocation={config.location}
|
|
|
dxLocation={dxLocation}
|
|
|
onDXChange={onDXChange}
|
|
|
potaSpots={potaSpots.data}
|
|
|
mySpots={mySpots.data}
|
|
|
dxPaths={dxPaths.data}
|
|
|
satellites={satellites.positions}
|
|
|
showDXPaths={mapLayers.showDXPaths}
|
|
|
showPOTA={mapLayers.showPOTA}
|
|
|
showSatellites={mapLayers.showSatellites}
|
|
|
onToggleSatellites={toggleSatellites}
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
{/* RIGHT SIDEBAR - DX Cluster & Contests */}
|
|
|
<div style={{ ...panelStyle, gridRow: '2', gridColumn: '3', display: 'flex', flexDirection: 'column', gap: '8px', overflowY: 'auto' }}>
|
|
|
{/* DX Cluster */}
|
|
|
<div>
|
|
|
<div style={{ ...labelStyle, marginBottom: '8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
|
<span>🌐 DX CLUSTER</span>
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
|
<button
|
|
|
onClick={toggleDXPaths}
|
|
|
style={{
|
|
|
background: mapLayers.showDXPaths ? 'rgba(68, 136, 255, 0.3)' : 'rgba(100, 100, 100, 0.3)',
|
|
|
border: `1px solid ${mapLayers.showDXPaths ? '#4488ff' : '#666'}`,
|
|
|
color: mapLayers.showDXPaths ? '#4488ff' : '#888',
|
|
|
padding: '1px 6px',
|
|
|
borderRadius: '3px',
|
|
|
fontSize: '9px',
|
|
|
fontFamily: 'JetBrains Mono',
|
|
|
cursor: 'pointer'
|
|
|
}}
|
|
|
title={mapLayers.showDXPaths ? 'Hide on map' : 'Show on map'}
|
|
|
>
|
|
|
🗺️ {mapLayers.showDXPaths ? 'ON' : 'OFF'}
|
|
|
</button>
|
|
|
<span style={{ color: 'var(--accent-green)', fontSize: '10px' }}>● LIVE</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div style={{ maxHeight: '180px', overflowY: 'auto' }}>
|
|
|
{dxCluster.data.slice(0, 8).map((s, i) => (
|
|
|
<div key={i} style={{ padding: '4px 0', borderBottom: '1px solid rgba(255,255,255,0.05)', fontSize: '12px' }}>
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
|
<span style={{ color: 'var(--accent-green)' }}>{s.freq}</span>
|
|
|
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{s.call}</span>
|
|
|
</div>
|
|
|
<div style={{ color: 'var(--text-muted)', fontSize: '13px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.comment}</div>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* Contests */}
|
|
|
<div style={{ marginTop: '8px' }}>
|
|
|
<div style={{ ...labelStyle, marginBottom: '8px' }}>🏆 CONTESTS</div>
|
|
|
<div style={{ maxHeight: '140px', overflowY: 'auto' }}>
|
|
|
{contests.data.slice(0, 5).map((c, i) => (
|
|
|
<div key={i} style={{ padding: '4px 0', borderBottom: '1px solid rgba(255,255,255,0.05)', fontSize: '12px' }}>
|
|
|
<div style={{ color: c.status === 'active' ? 'var(--accent-green)' : 'var(--text-secondary)', fontWeight: c.status === 'active' ? '700' : '400' }}>
|
|
|
{c.name} {c.status === 'active' && <span style={{ animation: 'blink 1s infinite' }}>●</span>}
|
|
|
</div>
|
|
|
<div style={{ color: 'var(--text-muted)', fontSize: '13px' }}>
|
|
|
{c.mode} • {new Date(c.start).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
|
|
</div>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
<div style={{ marginTop: '4px', textAlign: 'right', fontSize: '9px' }}>
|
|
|
<a href="https://www.contestcalendar.com" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-muted)', textDecoration: 'none' }}>
|
|
|
WA7BNM Contest Calendar
|
|
|
</a>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* POTA */}
|
|
|
<div style={{ marginTop: '8px' }}>
|
|
|
<div style={{ ...labelStyle, marginBottom: '8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
|
<span>🏕 POTA</span>
|
|
|
<button
|
|
|
onClick={togglePOTA}
|
|
|
style={{
|
|
|
background: mapLayers.showPOTA ? 'rgba(170, 102, 255, 0.3)' : 'rgba(100, 100, 100, 0.3)',
|
|
|
border: `1px solid ${mapLayers.showPOTA ? '#aa66ff' : '#666'}`,
|
|
|
color: mapLayers.showPOTA ? '#aa66ff' : '#888',
|
|
|
padding: '1px 6px',
|
|
|
borderRadius: '3px',
|
|
|
fontSize: '9px',
|
|
|
fontFamily: 'JetBrains Mono',
|
|
|
cursor: 'pointer'
|
|
|
}}
|
|
|
title={mapLayers.showPOTA ? 'Hide on map' : 'Show on map'}
|
|
|
>
|
|
|
🗺️ {mapLayers.showPOTA ? 'ON' : 'OFF'}
|
|
|
</button>
|
|
|
</div>
|
|
|
<div style={{ maxHeight: '100px', overflowY: 'auto' }}>
|
|
|
{potaSpots.data.slice(0, 4).map((a, i) => (
|
|
|
<div key={i} style={{ padding: '3px 0', fontSize: '12px' }}>
|
|
|
<span style={{ color: 'var(--accent-amber)' }}>{a.call}</span>
|
|
|
<span style={{ color: 'var(--accent-purple)', marginLeft: '4px' }}>{a.ref}</span>
|
|
|
<span style={{ color: 'var(--accent-green)', marginLeft: '4px' }}>{a.freq}</span>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* Propagation */}
|
|
|
{propagation.data && (
|
|
|
<div style={{ marginTop: '8px' }}>
|
|
|
<div style={{ ...labelStyle, marginBottom: '8px' }}>📡 PROPAGATION</div>
|
|
|
<div style={{ fontSize: '12px' }}>
|
|
|
{propagation.data.currentBands.slice(0, 6).map((b, i) => (
|
|
|
<div key={b.band} style={{
|
|
|
display: 'flex',
|
|
|
justifyContent: 'space-between',
|
|
|
alignItems: 'center',
|
|
|
padding: '2px 0',
|
|
|
borderBottom: i < 5 ? '1px solid rgba(255,255,255,0.05)' : 'none'
|
|
|
}}>
|
|
|
<span style={{ color: b.reliability >= 50 ? 'var(--accent-green)' : 'var(--text-muted)' }}>{b.band}</span>
|
|
|
<div style={{
|
|
|
width: '60px',
|
|
|
height: '8px',
|
|
|
background: 'var(--bg-tertiary)',
|
|
|
borderRadius: '2px',
|
|
|
overflow: 'hidden'
|
|
|
}}>
|
|
|
<div style={{
|
|
|
width: `${b.reliability}%`,
|
|
|
height: '100%',
|
|
|
background: b.reliability >= 70 ? '#00ff88' : b.reliability >= 50 ? '#88ff00' : b.reliability >= 30 ? '#ffcc00' : '#ff8800',
|
|
|
borderRadius: '2px'
|
|
|
}} />
|
|
|
</div>
|
|
|
<span style={{
|
|
|
fontSize: '13px',
|
|
|
color: b.reliability >= 70 ? '#00ff88' : b.reliability >= 50 ? '#88ff00' : b.reliability >= 30 ? '#ffcc00' : '#ff8800'
|
|
|
}}>{b.status.substring(0, 4)}</span>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
{/* BOTTOM - Footer */}
|
|
|
<div style={{ ...panelStyle, gridRow: '3', gridColumn: '1 / -1', display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 12px' }}>
|
|
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
|
|
|
OpenHamClock v3.6.0 • In memory of Elwood Downey WB0OEW
|
|
|
</span>
|
|
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
|
|
|
Click map to set DX • 73 de {config.callsign}
|
|
|
</span>
|
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
|
<button
|
|
|
onClick={onSettingsClick}
|
|
|
style={{
|
|
|
background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)',
|
|
|
padding: '4px 10px', borderRadius: '4px', color: 'var(--text-secondary)',
|
|
|
fontSize: '12px', cursor: 'pointer', fontFamily: 'JetBrains Mono, monospace'
|
|
|
}}
|
|
|
>
|
|
|
⚙ Settings
|
|
|
</button>
|
|
|
<button
|
|
|
onClick={onFullscreenToggle}
|
|
|
style={{
|
|
|
background: isFullscreen ? 'rgba(0, 255, 136, 0.15)' : 'var(--bg-tertiary)',
|
|
|
border: `1px solid ${isFullscreen ? 'var(--accent-green)' : 'var(--border-color)'}`,
|
|
|
padding: '4px 10px', borderRadius: '4px',
|
|
|
color: isFullscreen ? 'var(--accent-green)' : 'var(--text-secondary)',
|
|
|
fontSize: '12px', cursor: 'pointer', fontFamily: 'JetBrains Mono, monospace'
|
|
|
}}
|
|
|
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
|
|
>
|
|
|
{isFullscreen ? '⛶ Exit' : '⛶ Full'}
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
};
|
|
|
|
|
|
// ============================================
|
|
|
// SETTINGS PANEL COMPONENT
|
|
|
// ============================================
|
|
|
const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
|
|
|
const [callsign, setCallsign] = useState(config.callsign);
|
|
|
const [lat, setLat] = useState(config.location.lat.toString());
|
|
|
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(() => {
|
|
|
const latNum = parseFloat(lat);
|
|
|
const lonNum = parseFloat(lon);
|
|
|
if (!isNaN(latNum) && !isNaN(lonNum)) {
|
|
|
setGridSquare(calculateGridSquare(latNum, lonNum));
|
|
|
}
|
|
|
}, [lat, lon]);
|
|
|
|
|
|
// Preview theme changes in real-time
|
|
|
useEffect(() => {
|
|
|
applyTheme(theme);
|
|
|
}, [theme]);
|
|
|
|
|
|
const handleGeolocation = () => {
|
|
|
if (navigator.geolocation) {
|
|
|
setUseGeolocation(true);
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
|
(position) => {
|
|
|
setLat(position.coords.latitude.toFixed(6));
|
|
|
setLon(position.coords.longitude.toFixed(6));
|
|
|
setUseGeolocation(false);
|
|
|
},
|
|
|
(error) => {
|
|
|
alert('Could not get location: ' + error.message);
|
|
|
setUseGeolocation(false);
|
|
|
}
|
|
|
);
|
|
|
} else {
|
|
|
alert('Geolocation is not supported by this browser');
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const handleSave = () => {
|
|
|
const latNum = parseFloat(lat);
|
|
|
const lonNum = parseFloat(lon);
|
|
|
|
|
|
if (!callsign.trim()) {
|
|
|
alert('Please enter a callsign');
|
|
|
return;
|
|
|
}
|
|
|
if (isNaN(latNum) || latNum < -90 || latNum > 90) {
|
|
|
alert('Please enter a valid latitude (-90 to 90)');
|
|
|
return;
|
|
|
}
|
|
|
if (isNaN(lonNum) || lonNum < -180 || lonNum > 180) {
|
|
|
alert('Please enter a valid longitude (-180 to 180)');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const newConfig = {
|
|
|
...config,
|
|
|
callsign: callsign.toUpperCase().trim(),
|
|
|
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)
|
|
|
if (gs.length >= 4) {
|
|
|
try {
|
|
|
const gsUpper = gs.toUpperCase();
|
|
|
const lonField = gsUpper.charCodeAt(0) - 65;
|
|
|
const latField = gsUpper.charCodeAt(1) - 65;
|
|
|
const lonSquare = parseInt(gsUpper[2]);
|
|
|
const latSquare = parseInt(gsUpper[3]);
|
|
|
|
|
|
let lonVal = (lonField * 20) + (lonSquare * 2) - 180 + 1;
|
|
|
let latVal = (latField * 10) + latSquare - 90 + 0.5;
|
|
|
|
|
|
if (gs.length >= 6) {
|
|
|
const lonSub = gsUpper.charCodeAt(4) - 65;
|
|
|
const latSub = gsUpper.charCodeAt(5) - 65;
|
|
|
lonVal = (lonField * 20) + (lonSquare * 2) + (lonSub / 12) - 180 + (1/24);
|
|
|
latVal = (latField * 10) + latSquare + (latSub / 24) - 90 + (1/48);
|
|
|
}
|
|
|
|
|
|
if (!isNaN(lonVal) && !isNaN(latVal)) {
|
|
|
setLat(latVal.toFixed(6));
|
|
|
setLon(lonVal.toFixed(6));
|
|
|
}
|
|
|
} catch (e) {
|
|
|
// Invalid grid square, ignore
|
|
|
}
|
|
|
}
|
|
|
};
|
|
|
|
|
|
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 (
|
|
|
<div style={{
|
|
|
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
|
|
background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
|
zIndex: 10000, backdropFilter: 'blur(5px)'
|
|
|
}} onClick={handleClose}>
|
|
|
<div style={{
|
|
|
background: 'var(--bg-secondary)', border: '1px solid var(--border-color)',
|
|
|
borderRadius: '12px', padding: '30px', width: '90%', maxWidth: '500px',
|
|
|
boxShadow: '0 20px 60px rgba(0,0,0,0.5)', maxHeight: '90vh', overflowY: 'auto'
|
|
|
}} onClick={e => e.stopPropagation()}>
|
|
|
<h2 style={{
|
|
|
fontFamily: 'Orbitron, monospace', fontSize: '24px', color: 'var(--accent-amber)',
|
|
|
marginBottom: '24px', textAlign: 'center'
|
|
|
}}>
|
|
|
⚙ Station Settings
|
|
|
</h2>
|
|
|
|
|
|
{/* Callsign */}
|
|
|
<div style={{ marginBottom: '20px' }}>
|
|
|
<label style={labelStyle}>Your Callsign</label>
|
|
|
<input
|
|
|
type="text"
|
|
|
value={callsign}
|
|
|
onChange={(e) => setCallsign(e.target.value.toUpperCase())}
|
|
|
placeholder="W1ABC"
|
|
|
style={inputStyle}
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
{/* Grid Square */}
|
|
|
<div style={{ marginBottom: '20px' }}>
|
|
|
<label style={labelStyle}>Grid Square (or enter lat/lon below)</label>
|
|
|
<input
|
|
|
type="text"
|
|
|
value={gridSquare}
|
|
|
onChange={(e) => handleGridSquareChange(e.target.value)}
|
|
|
placeholder="FN31pr"
|
|
|
maxLength={6}
|
|
|
style={{...inputStyle, color: 'var(--accent-cyan)'}}
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
{/* Lat/Lon */}
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '20px' }}>
|
|
|
<div>
|
|
|
<label style={labelStyle}>Latitude</label>
|
|
|
<input
|
|
|
type="text"
|
|
|
value={lat}
|
|
|
onChange={(e) => setLat(e.target.value)}
|
|
|
placeholder="40.0150"
|
|
|
style={{...inputStyle, fontSize: '14px', color: 'var(--text-primary)'}}
|
|
|
/>
|
|
|
</div>
|
|
|
<div>
|
|
|
<label style={labelStyle}>Longitude</label>
|
|
|
<input
|
|
|
type="text"
|
|
|
value={lon}
|
|
|
onChange={(e) => setLon(e.target.value)}
|
|
|
placeholder="-105.2705"
|
|
|
style={{...inputStyle, fontSize: '14px', color: 'var(--text-primary)'}}
|
|
|
/>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* Get Location Button */}
|
|
|
<button
|
|
|
onClick={handleGeolocation}
|
|
|
disabled={useGeolocation}
|
|
|
style={{
|
|
|
width: '100%', padding: '12px', background: 'var(--bg-tertiary)',
|
|
|
border: '1px solid var(--border-color)', borderRadius: '6px',
|
|
|
color: 'var(--text-secondary)', fontFamily: 'JetBrains Mono, monospace',
|
|
|
fontSize: '12px', cursor: 'pointer', marginBottom: '24px',
|
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px'
|
|
|
}}
|
|
|
>
|
|
|
{useGeolocation ? (
|
|
|
<><div className="loading-spinner" /> Getting location...</>
|
|
|
) : (
|
|
|
<>📍 Use My Current Location</>
|
|
|
)}
|
|
|
</button>
|
|
|
|
|
|
{/* Theme Selection */}
|
|
|
<div style={{ marginBottom: '20px' }}>
|
|
|
<label style={labelStyle}>Theme</label>
|
|
|
<div style={buttonGroupStyle}>
|
|
|
<button style={themeButtonStyle(theme === 'dark')} onClick={() => setTheme('dark')}>
|
|
|
🌙 Dark
|
|
|
</button>
|
|
|
<button style={themeButtonStyle(theme === 'light')} onClick={() => setTheme('light')}>
|
|
|
☀️ Light
|
|
|
</button>
|
|
|
<button style={themeButtonStyle(theme === 'legacy')} onClick={() => setTheme('legacy')}>
|
|
|
📟 Legacy
|
|
|
</button>
|
|
|
</div>
|
|
|
<p style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '-12px', marginBottom: '12px' }}>
|
|
|
{theme === 'legacy' && '→ Classic green-on-black HamClock style'}
|
|
|
{theme === 'light' && '→ Bright theme for daytime use'}
|
|
|
{theme === 'dark' && '→ Modern dark theme (default)'}
|
|
|
</p>
|
|
|
</div>
|
|
|
|
|
|
{/* Layout Selection */}
|
|
|
<div style={{ marginBottom: '24px' }}>
|
|
|
<label style={labelStyle}>Layout</label>
|
|
|
<div style={buttonGroupStyle}>
|
|
|
<button style={themeButtonStyle(layout === 'modern')} onClick={() => setLayout('modern')}>
|
|
|
📊 Modern
|
|
|
</button>
|
|
|
<button style={themeButtonStyle(layout === 'legacy')} onClick={() => setLayout('legacy')}>
|
|
|
📺 Classic
|
|
|
</button>
|
|
|
</div>
|
|
|
<p style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '-12px' }}>
|
|
|
{layout === 'legacy' && '→ Layout inspired by original HamClock'}
|
|
|
{layout === 'modern' && '→ Modern responsive grid layout'}
|
|
|
</p>
|
|
|
</div>
|
|
|
|
|
|
{/* DX Cluster Source Selection */}
|
|
|
<div style={{ marginBottom: '24px' }}>
|
|
|
<label style={labelStyle}>DX Cluster Source</label>
|
|
|
<select
|
|
|
value={dxClusterSource}
|
|
|
onChange={(e) => setDxClusterSource(e.target.value)}
|
|
|
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: '14px', outline: 'none', cursor: 'pointer'
|
|
|
}}
|
|
|
>
|
|
|
<option value="auto">Auto (Best Available)</option>
|
|
|
<option value="hamqth">HamQTH (HTTP)</option>
|
|
|
<option value="dxspider">DX Spider (Telnet)</option>
|
|
|
</select>
|
|
|
<p style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '8px' }}>
|
|
|
{dxClusterSource === 'auto' && '→ Tries HamQTH first, then DX Spider telnet'}
|
|
|
{dxClusterSource === 'hamqth' && '→ HamQTH.com CSV feed (works on all platforms)'}
|
|
|
{dxClusterSource === 'dxspider' && '→ Telnet to dxspider.co.uk:7300 (works locally/Pi, may fail on cloud hosting)'}
|
|
|
</p>
|
|
|
</div>
|
|
|
|
|
|
{/* Buttons */}
|
|
|
<div style={{ display: 'flex', gap: '12px' }}>
|
|
|
<button
|
|
|
onClick={handleClose}
|
|
|
style={{
|
|
|
flex: 1, padding: '14px', background: 'var(--bg-tertiary)',
|
|
|
border: '1px solid var(--border-color)', borderRadius: '6px',
|
|
|
color: 'var(--text-secondary)', fontFamily: 'Space Grotesk, sans-serif',
|
|
|
fontSize: '14px', fontWeight: '600', cursor: 'pointer'
|
|
|
}}
|
|
|
>
|
|
|
Cancel
|
|
|
</button>
|
|
|
<button
|
|
|
onClick={handleSave}
|
|
|
style={{
|
|
|
flex: 1, padding: '14px', background: 'var(--accent-amber)',
|
|
|
border: 'none', borderRadius: '6px',
|
|
|
color: '#000', fontFamily: 'Space Grotesk, sans-serif',
|
|
|
fontSize: '14px', fontWeight: '700', cursor: 'pointer'
|
|
|
}}
|
|
|
>
|
|
|
Save Settings
|
|
|
</button>
|
|
|
</div>
|
|
|
|
|
|
<p style={{
|
|
|
marginTop: '20px', fontSize: '13px', color: 'var(--text-muted)',
|
|
|
textAlign: 'center', fontFamily: 'JetBrains Mono, monospace'
|
|
|
}}>
|
|
|
Settings are saved in your browser
|
|
|
</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
};
|
|
|
|
|
|
// ============================================
|
|
|
// MAIN APP
|
|
|
// ============================================
|
|
|
const App = () => {
|
|
|
const [config, setConfig] = useState(loadConfig);
|
|
|
const [currentTime, setCurrentTime] = useState(new Date());
|
|
|
const [startTime] = useState(Date.now());
|
|
|
const [uptime, setUptime] = useState('0d 0h 0m');
|
|
|
const [dxLocation, setDxLocation] = useState(config.defaultDX);
|
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
|
|
|
|
// Map layer visibility state with localStorage persistence
|
|
|
const [mapLayers, setMapLayers] = useState(() => {
|
|
|
try {
|
|
|
const stored = localStorage.getItem('openhamclock_mapLayers');
|
|
|
const defaults = { showDXPaths: true, showPOTA: true, showSatellites: true };
|
|
|
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
|
|
|
} catch (e) { return { showDXPaths: true, showPOTA: true, showSatellites: true }; }
|
|
|
});
|
|
|
|
|
|
// 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); }
|
|
|
}, [mapLayers]);
|
|
|
|
|
|
// Toggle handlers for map layers
|
|
|
const toggleDXPaths = useCallback(() => setMapLayers(prev => ({ ...prev, showDXPaths: !prev.showDXPaths })), []);
|
|
|
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
|
|
|
const [use12Hour, setUse12Hour] = useState(() => {
|
|
|
try {
|
|
|
const saved = localStorage.getItem('openhamclock_use12Hour');
|
|
|
return saved === '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); }
|
|
|
}, [use12Hour]);
|
|
|
|
|
|
// Toggle time format handler
|
|
|
const handleTimeFormatToggle = useCallback(() => {
|
|
|
setUse12Hour(prev => !prev);
|
|
|
}, []);
|
|
|
|
|
|
// Fullscreen toggle handler
|
|
|
const handleFullscreenToggle = useCallback(() => {
|
|
|
if (!document.fullscreenElement) {
|
|
|
document.documentElement.requestFullscreen().then(() => {
|
|
|
setIsFullscreen(true);
|
|
|
}).catch(err => {
|
|
|
console.error('Fullscreen error:', err);
|
|
|
});
|
|
|
} else {
|
|
|
document.exitFullscreen().then(() => {
|
|
|
setIsFullscreen(false);
|
|
|
}).catch(err => {
|
|
|
console.error('Exit fullscreen error:', err);
|
|
|
});
|
|
|
}
|
|
|
}, []);
|
|
|
|
|
|
// Listen for fullscreen changes (e.g., user presses Escape)
|
|
|
useEffect(() => {
|
|
|
const handleFullscreenChange = () => {
|
|
|
setIsFullscreen(!!document.fullscreenElement);
|
|
|
};
|
|
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
|
|
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
|
|
}, []);
|
|
|
|
|
|
// Apply theme on initial load
|
|
|
useEffect(() => {
|
|
|
applyTheme(config.theme || 'dark');
|
|
|
}, []);
|
|
|
|
|
|
// Check if this is first run (no config saved)
|
|
|
useEffect(() => {
|
|
|
const saved = localStorage.getItem('openhamclock_config');
|
|
|
if (!saved) {
|
|
|
// First time user - show settings
|
|
|
setShowSettings(true);
|
|
|
}
|
|
|
}, []);
|
|
|
|
|
|
const handleSaveConfig = (newConfig) => {
|
|
|
setConfig(newConfig);
|
|
|
saveConfig(newConfig);
|
|
|
applyTheme(newConfig.theme || 'dark');
|
|
|
};
|
|
|
|
|
|
const spaceWeather = useSpaceWeather();
|
|
|
const bandConditions = useBandConditions(spaceWeather.data);
|
|
|
const solarIndices = useSolarIndices();
|
|
|
const potaSpots = usePOTASpots();
|
|
|
const dxCluster = useDXCluster(config.dxClusterSource || 'auto');
|
|
|
const dxPaths = useDXPaths();
|
|
|
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 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]);
|
|
|
|
|
|
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 });
|
|
|
}, []);
|
|
|
|
|
|
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' });
|
|
|
|
|
|
// Calculate scale factor for modern layout (must be before conditional return for React hooks rules)
|
|
|
const [scale, setScale] = useState(1);
|
|
|
|
|
|
useEffect(() => {
|
|
|
// Only calculate scale for modern layout
|
|
|
if (config.layout === 'legacy') return;
|
|
|
|
|
|
const calculateScale = () => {
|
|
|
const minHeight = 900; // Design height
|
|
|
const minWidth = 1400; // Design width
|
|
|
const vh = window.innerHeight;
|
|
|
const vw = window.innerWidth;
|
|
|
|
|
|
const scaleH = vh / minHeight;
|
|
|
const scaleW = vw / minWidth;
|
|
|
const newScale = Math.min(scaleH, scaleW, 1); // Never scale above 1
|
|
|
|
|
|
setScale(Math.max(newScale, 0.5)); // Minimum 50% scale
|
|
|
};
|
|
|
|
|
|
calculateScale();
|
|
|
window.addEventListener('resize', calculateScale);
|
|
|
return () => window.removeEventListener('resize', calculateScale);
|
|
|
}, [config.layout]);
|
|
|
|
|
|
// Use Legacy Layout if selected
|
|
|
if (config.layout === 'legacy') {
|
|
|
return (
|
|
|
<>
|
|
|
<LegacyLayout
|
|
|
config={config}
|
|
|
currentTime={currentTime}
|
|
|
utcTime={utcTime}
|
|
|
utcDate={utcDate}
|
|
|
localTime={localTime}
|
|
|
localDate={localDate}
|
|
|
deGrid={deGrid}
|
|
|
dxGrid={dxGrid}
|
|
|
deSunTimes={deSunTimes}
|
|
|
dxSunTimes={dxSunTimes}
|
|
|
dxLocation={dxLocation}
|
|
|
onDXChange={handleDXChange}
|
|
|
spaceWeather={spaceWeather}
|
|
|
bandConditions={bandConditions}
|
|
|
solarIndices={solarIndices}
|
|
|
potaSpots={potaSpots}
|
|
|
dxCluster={dxCluster}
|
|
|
dxPaths={dxPaths}
|
|
|
contests={contests}
|
|
|
propagation={propagation}
|
|
|
mySpots={mySpots}
|
|
|
satellites={satellites}
|
|
|
localWeather={localWeather}
|
|
|
use12Hour={use12Hour}
|
|
|
onTimeFormatToggle={handleTimeFormatToggle}
|
|
|
onSettingsClick={() => setShowSettings(true)}
|
|
|
onFullscreenToggle={handleFullscreenToggle}
|
|
|
isFullscreen={isFullscreen}
|
|
|
mapLayers={mapLayers}
|
|
|
toggleDXPaths={toggleDXPaths}
|
|
|
togglePOTA={togglePOTA}
|
|
|
toggleSatellites={toggleSatellites}
|
|
|
/>
|
|
|
<SettingsPanel
|
|
|
isOpen={showSettings}
|
|
|
onClose={() => setShowSettings(false)}
|
|
|
config={config}
|
|
|
onSave={handleSaveConfig}
|
|
|
/>
|
|
|
</>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// Modern Layout (default) - Compact single-screen design with viewport scaling
|
|
|
|
|
|
return (
|
|
|
<div style={{
|
|
|
width: '100vw',
|
|
|
height: '100vh',
|
|
|
background: 'var(--bg-primary)',
|
|
|
display: 'flex',
|
|
|
justifyContent: 'center',
|
|
|
alignItems: 'center',
|
|
|
overflow: 'hidden'
|
|
|
}}>
|
|
|
<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: '280px 1fr 280px',
|
|
|
gridTemplateRows: '50px 1fr',
|
|
|
gap: '8px',
|
|
|
padding: '8px',
|
|
|
overflow: 'hidden',
|
|
|
boxSizing: 'border-box'
|
|
|
}}>
|
|
|
{/* TOP BAR - spans full width */}
|
|
|
<div style={{
|
|
|
gridColumn: '1 / -1',
|
|
|
display: 'flex',
|
|
|
alignItems: 'center',
|
|
|
justifyContent: 'space-between',
|
|
|
background: 'var(--bg-panel)',
|
|
|
border: '1px solid var(--border-color)',
|
|
|
borderRadius: '6px',
|
|
|
padding: '0 16px',
|
|
|
fontFamily: 'JetBrains Mono, monospace'
|
|
|
}}>
|
|
|
{/* Callsign & Settings */}
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
|
|
<span
|
|
|
style={{ fontSize: '20px', fontWeight: '900', color: 'var(--accent-amber)', cursor: 'pointer', fontFamily: 'Orbitron, monospace' }}
|
|
|
onClick={() => setShowSettings(true)}
|
|
|
title="Click for settings"
|
|
|
>
|
|
|
{config.callsign}
|
|
|
</span>
|
|
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>v3.6.0</span>
|
|
|
</div>
|
|
|
|
|
|
{/* UTC Clock */}
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
|
<span style={{ fontSize: '12px', color: 'var(--accent-cyan)' }}>UTC</span>
|
|
|
<span style={{ fontSize: '22px', fontWeight: '700', color: 'var(--accent-cyan)', fontFamily: 'Orbitron, monospace' }}>{utcTime}</span>
|
|
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>{utcDate}</span>
|
|
|
</div>
|
|
|
|
|
|
{/* Local Clock - Clickable to toggle 12/24 hour format */}
|
|
|
<div
|
|
|
style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}
|
|
|
onClick={handleTimeFormatToggle}
|
|
|
title={`Click to switch to ${use12Hour ? '24-hour' : '12-hour'} format`}
|
|
|
>
|
|
|
<span style={{ fontSize: '12px', color: 'var(--accent-amber)' }}>LOCAL</span>
|
|
|
<span style={{ fontSize: '22px', fontWeight: '700', color: 'var(--accent-amber)', fontFamily: 'Orbitron, monospace' }}>{localTime}</span>
|
|
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>{localDate}</span>
|
|
|
</div>
|
|
|
|
|
|
{/* Weather & Solar Stats */}
|
|
|
<div style={{ display: 'flex', gap: '16px', fontSize: '13px' }}>
|
|
|
{localWeather.data && (
|
|
|
<div title={`${localWeather.data.description} • Wind: ${localWeather.data.windSpeed} mph`}>
|
|
|
<span style={{ marginRight: '4px' }}>{localWeather.data.icon}</span>
|
|
|
<span style={{ color: 'var(--accent-cyan)', fontWeight: '600' }}>{localWeather.data.temp}°F</span>
|
|
|
</div>
|
|
|
)}
|
|
|
<div><span style={{ color: 'var(--text-muted)' }}>SFI </span><span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{spaceWeather.data?.solarFlux || '--'}</span></div>
|
|
|
<div><span style={{ color: 'var(--text-muted)' }}>K </span><span style={{ color: spaceWeather.data?.kIndex >= 4 ? 'var(--accent-red)' : 'var(--accent-green)', fontWeight: '600' }}>{spaceWeather.data?.kIndex ?? '--'}</span></div>
|
|
|
<div><span style={{ color: 'var(--text-muted)' }}>SSN </span><span style={{ color: 'var(--accent-cyan)', fontWeight: '600' }}>{spaceWeather.data?.sunspotNumber || '--'}</span></div>
|
|
|
</div>
|
|
|
|
|
|
{/* Settings & Fullscreen Buttons */}
|
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
|
<button
|
|
|
onClick={() => setShowSettings(true)}
|
|
|
style={{ background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)', padding: '6px 12px', borderRadius: '4px', color: 'var(--text-secondary)', fontSize: '13px', cursor: 'pointer' }}
|
|
|
>
|
|
|
⚙ Settings
|
|
|
</button>
|
|
|
<button
|
|
|
onClick={handleFullscreenToggle}
|
|
|
style={{
|
|
|
background: isFullscreen ? 'rgba(0, 255, 136, 0.15)' : 'var(--bg-tertiary)',
|
|
|
border: `1px solid ${isFullscreen ? 'var(--accent-green)' : 'var(--border-color)'}`,
|
|
|
padding: '6px 12px', borderRadius: '4px',
|
|
|
color: isFullscreen ? 'var(--accent-green)' : 'var(--text-secondary)',
|
|
|
fontSize: '13px', cursor: 'pointer'
|
|
|
}}
|
|
|
title={isFullscreen ? "Exit Fullscreen (Esc)" : "Enter Fullscreen"}
|
|
|
>
|
|
|
{isFullscreen ? '⛶ Exit' : '⛶ Full'}
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* LEFT SIDEBAR */}
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', overflowY: 'auto', overflowX: 'hidden' }}>
|
|
|
{/* DE Location */}
|
|
|
<div className="panel" style={{ padding: '12px', flex: '0 0 auto' }}>
|
|
|
<div style={{ fontSize: '13px', color: 'var(--accent-cyan)', fontWeight: '700', marginBottom: '8px' }}>📍 DE - YOUR LOCATION</div>
|
|
|
<div style={{ fontFamily: 'JetBrains Mono', fontSize: '13px' }}>
|
|
|
<div style={{ color: 'var(--accent-amber)', fontSize: '18px', fontWeight: '700' }}>{deGrid}</div>
|
|
|
<div style={{ color: 'var(--text-secondary)', fontSize: '12px', marginTop: '2px' }}>{config.location.lat.toFixed(2)}°, {config.location.lon.toFixed(2)}°</div>
|
|
|
<div style={{ marginTop: '6px', fontSize: '12px' }}>
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>☀ </span>
|
|
|
<span style={{ color: 'var(--accent-amber)' }}>{deSunTimes.sunrise}</span>
|
|
|
<span style={{ color: 'var(--text-secondary)' }}> → </span>
|
|
|
<span style={{ color: 'var(--accent-purple)' }}>{deSunTimes.sunset}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* DX Location */}
|
|
|
<div className="panel" style={{ padding: '12px', flex: '0 0 auto' }}>
|
|
|
<div style={{ fontSize: '13px', color: 'var(--accent-green)', fontWeight: '700', marginBottom: '8px' }}>🎯 DX - TARGET</div>
|
|
|
<div style={{ fontFamily: 'JetBrains Mono', fontSize: '13px' }}>
|
|
|
<div style={{ color: 'var(--accent-amber)', fontSize: '18px', fontWeight: '700' }}>{dxGrid}</div>
|
|
|
<div style={{ color: 'var(--text-secondary)', fontSize: '12px', marginTop: '2px' }}>{dxLocation.lat.toFixed(2)}°, {dxLocation.lon.toFixed(2)}°</div>
|
|
|
<div style={{ marginTop: '6px', fontSize: '12px' }}>
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>☀ </span>
|
|
|
<span style={{ color: 'var(--accent-amber)' }}>{dxSunTimes.sunrise}</span>
|
|
|
<span style={{ color: 'var(--text-secondary)' }}> → </span>
|
|
|
<span style={{ color: 'var(--accent-purple)' }}>{dxSunTimes.sunset}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* Band Conditions - Compact with color coding */}
|
|
|
<div className="panel" style={{ padding: '10px', flex: '0 0 auto', overflow: 'hidden' }}>
|
|
|
<div style={{ fontSize: '12px', color: 'var(--accent-purple)', fontWeight: '700', marginBottom: '6px' }}>📊 BAND CONDITIONS</div>
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '3px', fontSize: '13px', fontFamily: 'JetBrains Mono' }}>
|
|
|
{bandConditions.data.slice(0, 12).map(band => {
|
|
|
const colors = {
|
|
|
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)' }
|
|
|
};
|
|
|
const style = colors[band.condition] || colors.FAIR;
|
|
|
return (
|
|
|
<div key={band.band} style={{
|
|
|
textAlign: 'center',
|
|
|
padding: '3px 1px',
|
|
|
background: style.bg,
|
|
|
border: `1px solid ${style.border}`,
|
|
|
borderRadius: '2px'
|
|
|
}}>
|
|
|
<div style={{ fontWeight: '600', color: style.color, fontSize: '12px' }}>{band.band}</div>
|
|
|
</div>
|
|
|
);
|
|
|
})}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* Solar Panel (toggleable between image and indices) */}
|
|
|
<SolarPanel solarIndices={solarIndices} />
|
|
|
|
|
|
{/* VOACAP Propagation - Toggleable */}
|
|
|
<PropagationPanel propagation={propagation.data} loading={propagation.loading} />
|
|
|
</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}
|
|
|
satellites={satellites.positions}
|
|
|
showDXPaths={mapLayers.showDXPaths}
|
|
|
showPOTA={mapLayers.showPOTA}
|
|
|
showSatellites={mapLayers.showSatellites}
|
|
|
onToggleSatellites={toggleSatellites}
|
|
|
/>
|
|
|
<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: '8px', overflow: 'hidden' }}>
|
|
|
{/* DX Cluster - Compact */}
|
|
|
<div className="panel" style={{ padding: '10px', flex: '1 1 auto', overflow: 'hidden', minHeight: 0 }}>
|
|
|
<div style={{ fontSize: '12px', color: 'var(--accent-green)', fontWeight: '700', marginBottom: '6px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
|
<span>🌐 DX CLUSTER <span style={{ color: 'var(--accent-green)', fontSize: '10px' }}>● LIVE</span></span>
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
|
<button
|
|
|
onClick={toggleDXPaths}
|
|
|
style={{
|
|
|
background: mapLayers.showDXPaths ? 'rgba(68, 136, 255, 0.3)' : 'rgba(100, 100, 100, 0.3)',
|
|
|
border: `1px solid ${mapLayers.showDXPaths ? '#4488ff' : '#666'}`,
|
|
|
color: mapLayers.showDXPaths ? '#4488ff' : '#888',
|
|
|
padding: '2px 8px',
|
|
|
borderRadius: '4px',
|
|
|
fontSize: '10px',
|
|
|
fontFamily: 'JetBrains Mono',
|
|
|
cursor: 'pointer'
|
|
|
}}
|
|
|
title={mapLayers.showDXPaths ? 'Hide on map' : 'Show on map'}
|
|
|
>
|
|
|
🗺️ {mapLayers.showDXPaths ? 'ON' : 'OFF'}
|
|
|
</button>
|
|
|
{dxCluster.activeSource && <span style={{ fontSize: '9px', color: 'var(--text-muted)', fontWeight: '400' }}>{dxCluster.activeSource}</span>}
|
|
|
</div>
|
|
|
</div>
|
|
|
<div style={{ overflow: 'auto', maxHeight: 'calc(100% - 20px)' }}>
|
|
|
{dxCluster.data.slice(0, 8).map((s, i) => (
|
|
|
<div key={i} style={{ padding: '3px 0', borderBottom: '1px solid rgba(255,255,255,0.05)', fontSize: '12px', fontFamily: 'JetBrains Mono' }}>
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
|
<span style={{ color: 'var(--accent-cyan)' }}>{s.freq}</span>
|
|
|
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{s.call}</span>
|
|
|
<span style={{ color: 'var(--text-muted)', fontSize: '13px' }}>{s.time}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
))}
|
|
|
{dxCluster.data.length === 0 && <div style={{ color: 'var(--text-muted)', fontSize: '12px' }}>No spots</div>}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* POTA - Compact */}
|
|
|
<div className="panel" style={{ padding: '10px', flex: '0 0 auto' }}>
|
|
|
<div style={{ fontSize: '12px', color: 'var(--accent-amber)', fontWeight: '700', marginBottom: '6px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
|
<span>🏕 POTA ACTIVATORS</span>
|
|
|
<button
|
|
|
onClick={togglePOTA}
|
|
|
style={{
|
|
|
background: mapLayers.showPOTA ? 'rgba(170, 102, 255, 0.3)' : 'rgba(100, 100, 100, 0.3)',
|
|
|
border: `1px solid ${mapLayers.showPOTA ? '#aa66ff' : '#666'}`,
|
|
|
color: mapLayers.showPOTA ? '#aa66ff' : '#888',
|
|
|
padding: '2px 8px',
|
|
|
borderRadius: '4px',
|
|
|
fontSize: '10px',
|
|
|
fontFamily: 'JetBrains Mono',
|
|
|
cursor: 'pointer'
|
|
|
}}
|
|
|
title={mapLayers.showPOTA ? 'Hide on map' : 'Show on map'}
|
|
|
>
|
|
|
🗺️ {mapLayers.showPOTA ? 'ON' : 'OFF'}
|
|
|
</button>
|
|
|
</div>
|
|
|
<div style={{ fontSize: '12px', fontFamily: 'JetBrains Mono' }}>
|
|
|
{potaSpots.data.slice(0, 5).map((a, i) => (
|
|
|
<div key={i} style={{ padding: '2px 0', display: 'flex', justifyContent: 'space-between' }}>
|
|
|
<span style={{ color: 'var(--accent-amber)' }}>{a.call}</span>
|
|
|
<span style={{ color: 'var(--accent-purple)' }}>{a.ref}</span>
|
|
|
<span style={{ color: 'var(--accent-green)' }}>{a.freq}</span>
|
|
|
</div>
|
|
|
))}
|
|
|
{potaSpots.data.length === 0 && <div style={{ color: 'var(--text-muted)' }}>No activators</div>}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* Contests - Compact */}
|
|
|
<div className="panel" style={{ padding: '10px', flex: '1 1 auto', overflow: 'hidden', minHeight: 0 }}>
|
|
|
<div style={{ fontSize: '12px', color: 'var(--accent-purple)', fontWeight: '700', marginBottom: '6px' }}>🏆 CONTESTS</div>
|
|
|
<div style={{ overflow: 'auto', maxHeight: 'calc(100% - 30px)', fontSize: '12px', fontFamily: 'JetBrains Mono' }}>
|
|
|
{contests.data.slice(0, 6).map((c, i) => (
|
|
|
<div key={i} style={{ padding: '3px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
|
|
<div style={{ color: c.status === 'active' ? 'var(--accent-green)' : 'var(--text-secondary)', fontWeight: c.status === 'active' ? '700' : '400' }}>
|
|
|
{c.name} {c.status === 'active' && <span style={{ animation: 'blink 1s infinite' }}>●</span>}
|
|
|
</div>
|
|
|
<div style={{ color: 'var(--text-muted)', fontSize: '13px' }}>
|
|
|
{c.mode} • {new Date(c.start).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
|
|
</div>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
<div style={{ marginTop: '4px', textAlign: 'right', fontSize: '9px' }}>
|
|
|
<a href="https://www.contestcalendar.com" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-muted)', textDecoration: 'none' }}>
|
|
|
WA7BNM Contest Calendar
|
|
|
</a>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* Settings Panel */}
|
|
|
<SettingsPanel
|
|
|
isOpen={showSettings}
|
|
|
onClose={() => setShowSettings(false)}
|
|
|
config={config}
|
|
|
onSave={handleSaveConfig}
|
|
|
/>
|
|
|
</div>
|
|
|
);
|
|
|
};
|
|
|
|
|
|
ReactDOM.render(<App />, document.getElementById('root'));
|
|
|
</script>
|
|
|
</body>
|
|
|
</html>
|