You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1985 lines
86 KiB

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

<!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>
<!-- 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: #8a9aaa;
--text-muted: #5a6a7a;
--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: #00cc00;
--text-muted: #008800;
--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: 9px !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: 10px;
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: 10px;
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;
}
</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://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>'
},
satellite: {
name: 'Satellite',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; <a href="https://www.esri.com/">Esri</a>'
},
terrain: {
name: 'Terrain',
url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://opentopomap.org">OpenTopoMap</a>'
},
streets: {
name: 'Streets',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <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: '&copy; <a href="https://www.esri.com/">Esri</a>'
},
ocean: {
name: 'Ocean',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; <a href="https://www.esri.com/">Esri</a>'
},
natgeo: {
name: 'NatGeo',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; <a href="https://www.esri.com/">Esri</a> &copy; National Geographic'
},
gray: {
name: 'Gray',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; <a href="https://www.esri.com/">Esri</a>'
}
};
// ============================================
// 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 };
};
const useBandConditions = () => {
const [data, setData] = useState([
{ band: '160m', condition: 'FAIR' }, { band: '80m', condition: 'GOOD' },
{ band: '40m', condition: 'GOOD' }, { band: '30m', condition: 'GOOD' },
{ band: '20m', condition: 'GOOD' }, { band: '17m', condition: 'GOOD' },
{ band: '15m', condition: 'FAIR' }, { band: '12m', condition: 'FAIR' },
{ band: '10m', condition: 'POOR' }, { band: '6m', condition: 'POOR' },
{ band: '2m', condition: 'GOOD' }, { band: '70cm', condition: 'GOOD' }
]);
const [loading, setLoading] = useState(false);
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 = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchDX = async () => {
try {
// Use our proxy endpoint (works when running via server.js)
const response = await fetch('/api/dxcluster/spots');
if (response.ok) {
const spots = await response.json();
if (spots && spots.length > 0) {
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: ''
}]);
}
} else {
setData([{
freq: '---',
call: 'OFFLINE',
comment: 'DX cluster unavailable',
time: '--:--z',
spotter: ''
}]);
}
} catch (err) {
console.error('DX Cluster error:', err);
setData([{
freq: '---',
call: 'ERROR',
comment: 'Could not connect to DX cluster',
time: '--:--z',
spotter: ''
}]);
}
finally { setLoading(false); }
};
fetchDX();
const interval = setInterval(fetchDX, 30000); // 30 second refresh
return () => clearInterval(interval);
}, []);
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;
};
// ============================================
// LEAFLET MAP COMPONENT
// ============================================
const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots }) => {
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
const tileLayerRef = useRef(null);
const terminatorRef = useRef(null);
const pathRef = useRef(null);
const deMarkerRef = useRef(null);
const dxMarkerRef = useRef(null);
const sunMarkerRef = useRef(null);
const potaMarkersRef = useRef([]);
const [mapStyle, setMapStyle] = useState('dark');
// Initialize map
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
const map = L.map(mapRef.current, {
center: [20, 0],
zoom: 2,
minZoom: 2,
maxZoom: 18,
worldCopyJump: false,
zoomControl: true,
maxBounds: [[-85, -180], [85, 180]],
maxBoundsViscosity: 1.0
});
// Initial tile layer
tileLayerRef.current = L.tileLayer(MAP_STYLES[mapStyle].url, {
attribution: MAP_STYLES[mapStyle].attribution,
noWrap: true
}).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
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 () => {
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: true
}).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);
if (pathRef.current) {
if (Array.isArray(pathRef.current)) {
pathRef.current.forEach(p => map.removeLayer(p));
} else {
map.removeLayer(pathRef.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);
// Great circle path (handles antimeridian crossing)
const pathSegments = getGreatCirclePoints(deLocation.lat, deLocation.lon, dxLocation.lat, dxLocation.lon);
// pathRef now holds an array of polylines for each segment
if (!Array.isArray(pathRef.current)) {
pathRef.current = [];
}
// Clear old paths
pathRef.current.forEach(p => map.removeLayer(p));
pathRef.current = [];
// Draw each segment
pathSegments.forEach(segment => {
if (segment.length > 1) {
const polyline = L.polyline(segment, {
color: '#00ddff',
weight: 3,
opacity: 0.8,
dashArray: '10, 6'
}).addTo(map);
pathRef.current.push(polyline);
}
});
}, [deLocation, dxLocation]);
// 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
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: 10px; 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]);
return (
<div style={{ position: 'relative', height: '100%', minHeight: '350px' }}>
<div ref={mapRef} style={{ height: '100%', width: '100%', borderRadius: '8px' }} />
{/* Map style selector */}
<div className="map-style-control">
{Object.entries(MAP_STYLES).map(([key, style]) => (
<button
key={key}
className={`map-style-btn ${mapStyle === key ? 'active' : ''}`}
onClick={() => setMapStyle(key)}
>
{style.name}
</button>
))}
</div>
</div>
);
};
// ============================================
// UI COMPONENTS
// ============================================
const Header = ({ callsign, uptime, version, onSettingsClick }) => (
<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: '24px', 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: '11px',
transition: 'all 0.2s'
}}
title="Settings"
>
Settings
</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: '11px', 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: '11px', 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: '11px', 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: '8px', fontWeight: '600', color: s.color, marginTop: '4px', opacity: 0.8 }}>{b.condition}</div>
</div>
);
})}
</div>
</div>
);
};
const DXClusterPanel = ({ spots, loading }) => (
<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" />}
<span style={{ fontSize: '10px', 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: '11px', 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 }) => (
<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" />}
<span style={{ fontSize: '10px', 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: '11px' }}>
<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: '11px',
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: '9px', padding: '2px 6px', background: 'var(--bg-tertiary)', borderRadius: '3px' }}>
{c.mode}
</span>
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '10px', 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>
);
};
// ============================================
// LEGACY LAYOUT (Classic HamClock Style)
// ============================================
const LegacyLayout = ({
config, currentTime, utcTime, utcDate, localTime, localDate,
deGrid, dxGrid, deSunTimes, dxSunTimes, dxLocation, onDXChange,
spaceWeather, bandConditions, potaSpots, dxCluster, contests,
onSettingsClick
}) => {
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: '10px',
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 & Time */}
<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>
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginTop: '4px' }}>
SFI {spaceWeather.data?.solarFlux || '--'} K{spaceWeather.data?.kIndex || '-'} SSN {spaceWeather.data?.sunspotNumber || '--'}
</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: '11px', 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)' }}>{localTime}</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>{localDate} Local</div>
</div>
</div>
{/* TOP RIGHT - Space Weather */}
<div style={{ ...panelStyle, gridRow: '1', gridColumn: '3' }}>
<div style={labelStyle}> SOLAR CONDITIONS</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginTop: '8px' }}>
<div>
<div style={{ fontSize: '9px', 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: '9px', color: 'var(--text-muted)' }}>K-Index</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 style={{ fontSize: '9px', color: 'var(--text-muted)' }}>SSN</div>
<div style={{ fontSize: '18px', color: 'var(--text-primary)', fontWeight: '700' }}>{spaceWeather.data?.sunspotNumber || '--'}</div>
</div>
<div>
<div style={{ fontSize: '9px', color: 'var(--text-muted)' }}>Conditions</div>
<div style={{ fontSize: '12px', color: spaceWeather.data?.conditions === 'GOOD' || spaceWeather.data?.conditions === 'EXCELLENT' ? 'var(--accent-green)' : 'var(--accent-amber)', fontWeight: '700' }}>{spaceWeather.data?.conditions || '--'}</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: '10px', 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: '10px', 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: '10px', 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: '10px', 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: '10px', 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: '10px', 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: '10px', 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: '9px', 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}
/>
</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' }}>🌐 DX CLUSTER <span style={{ color: 'var(--accent-green)', fontSize: '8px' }}> LIVE</span></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: '10px' }}>
<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: '9px', 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: '10px' }}>
<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: '9px' }}>
{c.mode} {new Date(c.start).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</div>
</div>
))}
</div>
</div>
{/* POTA */}
<div style={{ marginTop: '8px' }}>
<div style={{ ...labelStyle, marginBottom: '8px' }}>🏕 POTA</div>
<div style={{ maxHeight: '100px', overflowY: 'auto' }}>
{potaSpots.data.slice(0, 4).map((a, i) => (
<div key={i} style={{ padding: '3px 0', fontSize: '10px' }}>
<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>
</div>
{/* BOTTOM - Footer */}
<div style={{ ...panelStyle, gridRow: '3', gridColumn: '1 / -1', display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 12px' }}>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>
OpenHamClock v3.3.0 In memory of Elwood Downey WB0OEW
</span>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>
Click map to set DX 73 de {config.callsign}
</span>
<button
onClick={onSettingsClick}
style={{
background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)',
padding: '4px 10px', borderRadius: '4px', color: 'var(--text-secondary)',
fontSize: '10px', cursor: 'pointer', fontFamily: 'JetBrains Mono, monospace'
}}
>
Settings
</button>
</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');
// 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
};
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: '11px', 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: '10px', 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: '10px', color: 'var(--text-muted)', marginTop: '-12px' }}>
{layout === 'legacy' && '→ Layout inspired by original HamClock'}
{layout === 'modern' && '→ Modern responsive grid layout'}
</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: '11px', 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);
// 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();
const potaSpots = usePOTASpots();
const dxCluster = useDXCluster();
const contests = useContests();
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: false });
const utcDate = currentTime.toISOString().substr(0, 10);
const localDate = currentTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
// 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}
potaSpots={potaSpots}
dxCluster={dxCluster}
contests={contests}
onSettingsClick={() => setShowSettings(true)}
/>
<SettingsPanel
isOpen={showSettings}
onClose={() => setShowSettings(false)}
config={config}
onSave={handleSaveConfig}
/>
</>
);
}
// Modern Layout (default)
return (
<div style={{ minHeight: '100vh', background: 'var(--bg-primary)' }}>
<Header callsign={config.callsign} uptime={uptime} version="3.3.0" onSettingsClick={() => setShowSettings(true)} />
<main style={{ padding: '20px', display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gridTemplateRows: 'auto 1fr auto', gap: '16px', maxWidth: '1920px', margin: '0 auto', minHeight: 'calc(100vh - 120px)' }}>
{/* Row 1 */}
<ClockPanel label="UTC Time" time={utcTime} date={utcDate} isUtc={true} />
<ClockPanel label="Local Time" time={localTime} date={localDate} isUtc={false} />
<LocationPanel type="DE" location={config.location} gridSquare={deGrid} sunTimes={deSunTimes} otherLocation={dxLocation} />
<LocationPanel type="DX" location={dxLocation} gridSquare={dxGrid} sunTimes={dxSunTimes} otherLocation={config.location} />
{/* Row 2: Map + Side Panel */}
<div style={{ gridColumn: 'span 3', minHeight: '400px' }}>
<WorldMap deLocation={config.location} dxLocation={dxLocation} onDXChange={handleDXChange} potaSpots={potaSpots.data} />
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginTop: '8px', fontFamily: 'JetBrains Mono', textAlign: 'center' }}>
Click anywhere on map to set DX location Use buttons to change map style
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<SpaceWeatherPanel data={spaceWeather.data} loading={spaceWeather.loading} />
<ContestPanel contests={contests.data} loading={contests.loading} />
</div>
{/* Row 3 */}
<BandConditionsPanel bands={bandConditions.data} loading={bandConditions.loading} />
<DXClusterPanel spots={dxCluster.data} loading={dxCluster.loading} />
<POTAPanel activities={potaSpots.data} loading={potaSpots.loading} />
<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-purple)', letterSpacing: '2px', marginBottom: '16px' }}>
📊 QUICK STATS
</div>
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: '11px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<span style={{ color: 'var(--text-secondary)' }}>Active Contests</span>
<span style={{ color: 'var(--accent-green)', fontWeight: '600' }}>{contests.data.filter(c => c.status === 'active').length}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<span style={{ color: 'var(--text-secondary)' }}>POTA Activators</span>
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{potaSpots.data.length}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<span style={{ color: 'var(--text-secondary)' }}>DX Spots</span>
<span style={{ color: 'var(--accent-cyan)', fontWeight: '600' }}>{dxCluster.data.length}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<span style={{ color: 'var(--text-secondary)' }}>Solar Flux</span>
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{spaceWeather.data?.solarFlux || '--'}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0' }}>
<span style={{ color: 'var(--text-secondary)' }}>Uptime</span>
<span style={{ color: 'var(--text-primary)', fontWeight: '600' }}>{uptime}</span>
</div>
</div>
</div>
</main>
<footer style={{ textAlign: 'center', padding: '20px', color: 'var(--text-muted)', fontSize: '11px', fontFamily: 'JetBrains Mono, monospace' }}>
OpenHamClock v3.3.0 | In memory of Elwood Downey WB0OEW | Map tiles © OpenStreetMap, ESRI, CARTO | 73 de {config.callsign}
</footer>
{/* Settings Panel */}
<SettingsPanel
isOpen={showSettings}
onClose={() => setShowSettings(false)}
config={config}
onSave={handleSaveConfig}
/>
</div>
);
};
ReactDOM.render(<App />, document.getElementById('root'));
</script>
</body>
</html>

Powered by TurnKey Linux.