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.
openhamclock/public/index-monolithic.html

5714 lines
254 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>
<!-- 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;
}
.moon-marker {
background: radial-gradient(circle, #e8e8f0 0%, #8888aa 100%);
width: 24px;
height: 24px;
border: 2px solid #aaaacc;
}
/* 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;
}
/* DX Cluster map tooltips */
.dx-tooltip {
background: rgba(20, 20, 30, 0.95) !important;
border: 1px solid rgba(0, 170, 255, 0.5) !important;
border-radius: 4px !important;
padding: 4px 8px !important;
font-family: 'JetBrains Mono', monospace !important;
font-size: 11px !important;
color: #00aaff !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5) !important;
}
.dx-tooltip::before {
border-top-color: rgba(0, 170, 255, 0.5) !important;
}
.dx-tooltip-highlighted {
background: rgba(68, 136, 255, 0.95) !important;
border: 2px solid #ffffff !important;
color: #ffffff !important;
font-weight: bold !important;
font-size: 13px !important;
box-shadow: 0 4px 16px rgba(68, 136, 255, 0.8) !important;
}
.dx-tooltip-highlighted::before {
border-top-color: #ffffff !important;
}
/* Leaflet popup styling for DX spots */
.leaflet-popup-content-wrapper {
background: rgba(20, 20, 30, 0.95) !important;
border: 1px solid rgba(100, 100, 100, 0.5) !important;
border-radius: 8px !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5) !important;
}
.leaflet-popup-content {
margin: 10px 12px !important;
}
.leaflet-popup-tip {
background: rgba(20, 20, 30, 0.95) !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://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; Esri'
},
satellite: {
name: 'Satellite',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; Esri'
},
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; Esri'
},
watercolor: {
name: 'Ocean',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; Esri'
},
hybrid: {
name: 'Hybrid',
url: 'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}',
attribution: '&copy; Google'
},
gray: {
name: 'Gray',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; 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 };
};
// Calculate moon position (sublunar point)
const getMoonPosition = (date) => {
// Julian date calculation
const JD = date.getTime() / 86400000 + 2440587.5;
const T = (JD - 2451545.0) / 36525; // Julian centuries from J2000
// Moon's mean longitude
const L0 = (218.316 + 481267.8813 * T) % 360;
// Moon's mean anomaly
const M = (134.963 + 477198.8676 * T) % 360;
const MRad = M * Math.PI / 180;
// Moon's mean elongation
const D = (297.850 + 445267.1115 * T) % 360;
const DRad = D * Math.PI / 180;
// Sun's mean anomaly
const Ms = (357.529 + 35999.0503 * T) % 360;
const MsRad = Ms * Math.PI / 180;
// Moon's argument of latitude
const F = (93.272 + 483202.0175 * T) % 360;
const FRad = F * Math.PI / 180;
// Longitude corrections (simplified)
const dL = 6.289 * Math.sin(MRad)
+ 1.274 * Math.sin(2 * DRad - MRad)
+ 0.658 * Math.sin(2 * DRad)
+ 0.214 * Math.sin(2 * MRad)
- 0.186 * Math.sin(MsRad)
- 0.114 * Math.sin(2 * FRad);
// Moon's ecliptic longitude
const moonLon = ((L0 + dL) % 360 + 360) % 360;
// Moon's ecliptic latitude (simplified)
const moonLat = 5.128 * Math.sin(FRad)
+ 0.281 * Math.sin(MRad + FRad)
+ 0.278 * Math.sin(MRad - FRad);
// Convert ecliptic to equatorial coordinates
const obliquity = 23.439 - 0.0000004 * (JD - 2451545.0);
const oblRad = obliquity * Math.PI / 180;
const moonLonRad = moonLon * Math.PI / 180;
const moonLatRad = moonLat * Math.PI / 180;
// Right ascension
const RA = Math.atan2(
Math.sin(moonLonRad) * Math.cos(oblRad) - Math.tan(moonLatRad) * Math.sin(oblRad),
Math.cos(moonLonRad)
) * 180 / Math.PI;
// Declination
const dec = Math.asin(
Math.sin(moonLatRad) * Math.cos(oblRad) +
Math.cos(moonLatRad) * Math.sin(oblRad) * Math.sin(moonLonRad)
) * 180 / Math.PI;
// Greenwich Mean Sidereal Time
const GMST = (280.46061837 + 360.98564736629 * (JD - 2451545.0)) % 360;
// Sublunar point longitude
const sublunarLon = ((RA - GMST) % 360 + 540) % 360 - 180;
return { lat: dec, lon: sublunarLon };
};
// Calculate moon phase (0-1, 0=new, 0.5=full)
const getMoonPhase = (date) => {
const JD = date.getTime() / 86400000 + 2440587.5;
const T = (JD - 2451545.0) / 36525;
const D = (297.850 + 445267.1115 * T) % 360; // Mean elongation
// Phase angle (simplified)
const phase = ((D + 180) % 360) / 360;
return phase;
};
// Get moon phase emoji
const getMoonPhaseEmoji = (phase) => {
if (phase < 0.0625) return '🌑'; // New moon
if (phase < 0.1875) return '🌒'; // Waxing crescent
if (phase < 0.3125) return '🌓'; // First quarter
if (phase < 0.4375) return '🌔'; // Waxing gibbous
if (phase < 0.5625) return '🌕'; // Full moon
if (phase < 0.6875) return '🌖'; // Waning gibbous
if (phase < 0.8125) return '🌗'; // Last quarter
if (phase < 0.9375) return '🌘'; // Waning crescent
return '🌑'; // New moon
};
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,
'11m': (sfi - 100) * 0.52, // CB band - similar to 12m/10m
'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,
'11m': isDaytime ? 15 : -32, // CB band - day band, needs high SFI
'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', '11m', '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', '11m', '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 };
};
// ============================================
// SHARED FILTER HELPER FUNCTIONS
// Used by both DX Cluster hook and Map component
// ============================================
// Helper to get band from frequency (in kHz)
const getBandFromFreq = (freq) => {
const f = parseFloat(freq);
// Handle MHz input (convert to kHz)
const freqKhz = f < 1000 ? f * 1000 : f;
if (freqKhz >= 1800 && freqKhz <= 2000) return '160m';
if (freqKhz >= 3500 && freqKhz <= 4000) return '80m';
if (freqKhz >= 5330 && freqKhz <= 5405) return '60m';
if (freqKhz >= 7000 && freqKhz <= 7300) return '40m';
if (freqKhz >= 10100 && freqKhz <= 10150) return '30m';
if (freqKhz >= 14000 && freqKhz <= 14350) return '20m';
if (freqKhz >= 18068 && freqKhz <= 18168) return '17m';
if (freqKhz >= 21000 && freqKhz <= 21450) return '15m';
if (freqKhz >= 24890 && freqKhz <= 24990) return '12m';
if (freqKhz >= 28000 && freqKhz <= 29700) return '10m';
if (freqKhz >= 50000 && freqKhz <= 54000) return '6m';
if (freqKhz >= 144000 && freqKhz <= 148000) return '2m';
if (freqKhz >= 420000 && freqKhz <= 450000) return '70cm';
return 'other';
};
// Helper to detect mode from comment
const detectMode = (comment) => {
if (!comment) return null;
const upper = comment.toUpperCase();
if (upper.includes('FT8')) return 'FT8';
if (upper.includes('FT4')) return 'FT4';
if (upper.includes('CW')) return 'CW';
if (upper.includes('SSB') || upper.includes('LSB') || upper.includes('USB')) return 'SSB';
if (upper.includes('RTTY')) return 'RTTY';
if (upper.includes('PSK')) return 'PSK';
if (upper.includes('AM')) return 'AM';
if (upper.includes('FM')) return 'FM';
return null;
};
// Callsign prefix to CQ/ITU zone and continent mapping
const getCallsignInfo = (call) => {
if (!call) return { cqZone: null, ituZone: null, continent: null };
const upper = call.toUpperCase();
// Simplified prefix->zone mapping (common prefixes)
const prefixMap = {
// North America
'W': { cq: 5, itu: 8, cont: 'NA' }, 'K': { cq: 5, itu: 8, cont: 'NA' }, 'N': { cq: 5, itu: 8, cont: 'NA' }, 'AA': { cq: 5, itu: 8, cont: 'NA' },
'VE': { cq: 5, itu: 4, cont: 'NA' }, 'VA': { cq: 5, itu: 4, cont: 'NA' }, 'VY': { cq: 2, itu: 4, cont: 'NA' },
'XE': { cq: 6, itu: 10, cont: 'NA' }, 'XF': { cq: 6, itu: 10, cont: 'NA' },
// Europe
'G': { cq: 14, itu: 27, cont: 'EU' }, 'M': { cq: 14, itu: 27, cont: 'EU' }, '2E': { cq: 14, itu: 27, cont: 'EU' },
'F': { cq: 14, itu: 27, cont: 'EU' }, 'DL': { cq: 14, itu: 28, cont: 'EU' }, 'D': { cq: 14, itu: 28, cont: 'EU' },
'I': { cq: 15, itu: 28, cont: 'EU' }, 'IK': { cq: 15, itu: 28, cont: 'EU' },
'EA': { cq: 14, itu: 37, cont: 'EU' }, 'EC': { cq: 14, itu: 37, cont: 'EU' },
'PA': { cq: 14, itu: 27, cont: 'EU' }, 'PD': { cq: 14, itu: 27, cont: 'EU' },
'ON': { cq: 14, itu: 27, cont: 'EU' }, 'OZ': { cq: 14, itu: 18, cont: 'EU' },
'SM': { cq: 14, itu: 18, cont: 'EU' }, 'LA': { cq: 14, itu: 18, cont: 'EU' },
'OH': { cq: 15, itu: 18, cont: 'EU' }, 'SP': { cq: 15, itu: 28, cont: 'EU' },
'OK': { cq: 15, itu: 28, cont: 'EU' }, 'OM': { cq: 15, itu: 28, cont: 'EU' },
'HA': { cq: 15, itu: 28, cont: 'EU' }, 'HB': { cq: 14, itu: 28, cont: 'EU' },
'OE': { cq: 15, itu: 28, cont: 'EU' }, 'LZ': { cq: 20, itu: 28, cont: 'EU' },
'YO': { cq: 20, itu: 28, cont: 'EU' }, 'YU': { cq: 15, itu: 28, cont: 'EU' },
'UA': { cq: 16, itu: 29, cont: 'EU' }, 'R': { cq: 16, itu: 29, cont: 'EU' },
'UR': { cq: 16, itu: 29, cont: 'EU' }, 'UT': { cq: 16, itu: 29, cont: 'EU' },
'LY': { cq: 15, itu: 29, cont: 'EU' }, 'ES': { cq: 15, itu: 29, cont: 'EU' },
'YL': { cq: 15, itu: 29, cont: 'EU' }, 'CT': { cq: 14, itu: 37, cont: 'EU' },
'EI': { cq: 14, itu: 27, cont: 'EU' }, 'GI': { cq: 14, itu: 27, cont: 'EU' },
'GM': { cq: 14, itu: 27, cont: 'EU' }, 'GW': { cq: 14, itu: 27, cont: 'EU' },
'SV': { cq: 20, itu: 28, cont: 'EU' }, '9A': { cq: 15, itu: 28, cont: 'EU' },
'S5': { cq: 15, itu: 28, cont: 'EU' },
// Asia
'JA': { cq: 25, itu: 45, cont: 'AS' }, 'JH': { cq: 25, itu: 45, cont: 'AS' }, 'JR': { cq: 25, itu: 45, cont: 'AS' },
'HL': { cq: 25, itu: 44, cont: 'AS' }, 'DS': { cq: 25, itu: 44, cont: 'AS' },
'BY': { cq: 24, itu: 44, cont: 'AS' }, 'BV': { cq: 24, itu: 44, cont: 'AS' },
'VU': { cq: 22, itu: 41, cont: 'AS' }, 'VK': { cq: 30, itu: 59, cont: 'OC' },
'DU': { cq: 27, itu: 50, cont: 'OC' }, '9M': { cq: 28, itu: 54, cont: 'AS' },
'HS': { cq: 26, itu: 49, cont: 'AS' }, 'XV': { cq: 26, itu: 49, cont: 'AS' },
// South America
'LU': { cq: 13, itu: 14, cont: 'SA' }, 'PY': { cq: 11, itu: 15, cont: 'SA' },
'CE': { cq: 12, itu: 14, cont: 'SA' }, 'CX': { cq: 13, itu: 14, cont: 'SA' },
'HK': { cq: 9, itu: 12, cont: 'SA' }, 'YV': { cq: 9, itu: 12, cont: 'SA' },
'HC': { cq: 10, itu: 12, cont: 'SA' }, 'OA': { cq: 10, itu: 12, cont: 'SA' },
// Africa
'ZS': { cq: 38, itu: 57, cont: 'AF' }, '5N': { cq: 35, itu: 46, cont: 'AF' },
'EA8': { cq: 33, itu: 36, cont: 'AF' }, 'CN': { cq: 33, itu: 37, cont: 'AF' },
'7X': { cq: 33, itu: 37, cont: 'AF' }, 'SU': { cq: 34, itu: 38, cont: 'AF' },
'ST': { cq: 34, itu: 47, cont: 'AF' }, 'ET': { cq: 37, itu: 48, cont: 'AF' },
'5Z': { cq: 37, itu: 48, cont: 'AF' }, '5H': { cq: 37, itu: 53, cont: 'AF' },
// Oceania
'ZL': { cq: 32, itu: 60, cont: 'OC' }, 'FK': { cq: 32, itu: 56, cont: 'OC' },
'VK9': { cq: 30, itu: 60, cont: 'OC' }, 'YB': { cq: 28, itu: 51, cont: 'OC' },
'KH6': { cq: 31, itu: 61, cont: 'OC' }, 'KH2': { cq: 27, itu: 64, cont: 'OC' },
// Caribbean
'VP5': { cq: 8, itu: 11, cont: 'NA' }, 'PJ': { cq: 9, itu: 11, cont: 'SA' },
'HI': { cq: 8, itu: 11, cont: 'NA' }, 'CO': { cq: 8, itu: 11, cont: 'NA' },
'KP4': { cq: 8, itu: 11, cont: 'NA' }, 'FG': { cq: 8, itu: 11, cont: 'NA' },
// Antarctica
'DP0': { cq: 38, itu: 67, cont: 'AN' }, 'VP8': { cq: 13, itu: 73, cont: 'AN' },
'KC4': { cq: 13, itu: 67, cont: 'AN' }
};
// Try to match prefix (longest match first)
for (let len = 4; len >= 1; len--) {
const prefix = upper.substring(0, len);
if (prefixMap[prefix]) {
return {
cqZone: prefixMap[prefix].cq,
ituZone: prefixMap[prefix].itu,
continent: prefixMap[prefix].cont
};
}
}
// Fallback based on first character
const firstChar = upper[0];
const fallbackMap = {
'A': { cq: 21, itu: 39, cont: 'AS' },
'B': { cq: 24, itu: 44, cont: 'AS' },
'C': { cq: 14, itu: 27, cont: 'EU' },
'D': { cq: 14, itu: 28, cont: 'EU' },
'E': { cq: 14, itu: 27, cont: 'EU' },
'F': { cq: 14, itu: 27, cont: 'EU' },
'G': { cq: 14, itu: 27, cont: 'EU' },
'H': { cq: 14, itu: 27, cont: 'EU' },
'I': { cq: 15, itu: 28, cont: 'EU' },
'J': { cq: 25, itu: 45, cont: 'AS' },
'K': { cq: 5, itu: 8, cont: 'NA' },
'L': { cq: 13, itu: 14, cont: 'SA' },
'M': { cq: 14, itu: 27, cont: 'EU' },
'N': { cq: 5, itu: 8, cont: 'NA' },
'O': { cq: 15, itu: 18, cont: 'EU' },
'P': { cq: 11, itu: 15, cont: 'SA' },
'R': { cq: 16, itu: 29, cont: 'EU' },
'S': { cq: 15, itu: 28, cont: 'EU' },
'T': { cq: 37, itu: 48, cont: 'AF' },
'U': { cq: 16, itu: 29, cont: 'EU' },
'V': { cq: 5, itu: 4, cont: 'NA' },
'W': { cq: 5, itu: 8, cont: 'NA' },
'X': { cq: 6, itu: 10, cont: 'NA' },
'Y': { cq: 15, itu: 28, cont: 'EU' },
'Z': { cq: 38, itu: 57, cont: 'AF' }
};
if (fallbackMap[firstChar]) {
return {
cqZone: fallbackMap[firstChar].cq,
ituZone: fallbackMap[firstChar].itu,
continent: fallbackMap[firstChar].cont
};
}
return { cqZone: null, ituZone: null, continent: null };
};
// Filter DX paths based on filters (filter by SPOTTER origin)
const filterDXPaths = (paths, filters) => {
if (!paths || !filters) return paths;
if (Object.keys(filters).length === 0) return paths;
return paths.filter(path => {
// Get info for spotter (origin) - this is what we filter by
const spotterInfo = getCallsignInfo(path.spotter);
// Watchlist filter - show ONLY watchlist if enabled
if (filters.watchlistOnly && filters.watchlist?.length > 0) {
const inWatchlist = filters.watchlist.some(w =>
path.dxCall?.toUpperCase().includes(w.toUpperCase()) ||
path.spotter?.toUpperCase().includes(w.toUpperCase())
);
if (!inWatchlist) return false;
}
// Exclude list - hide matching callsigns
if (filters.excludeList?.length > 0) {
const isExcluded = filters.excludeList.some(e =>
path.dxCall?.toUpperCase().includes(e.toUpperCase()) ||
path.spotter?.toUpperCase().includes(e.toUpperCase())
);
if (isExcluded) return false;
}
// CQ Zone filter - filter by SPOTTER's zone (origin)
if (filters.cqZones?.length > 0) {
if (!spotterInfo.cqZone || !filters.cqZones.includes(spotterInfo.cqZone)) {
return false;
}
}
// ITU Zone filter - filter by SPOTTER's zone (origin)
if (filters.ituZones?.length > 0) {
if (!spotterInfo.ituZone || !filters.ituZones.includes(spotterInfo.ituZone)) {
return false;
}
}
// Continent filter - filter by SPOTTER's continent (origin)
if (filters.continents?.length > 0) {
if (!spotterInfo.continent || !filters.continents.includes(spotterInfo.continent)) {
return false;
}
}
// Band filter
if (filters.bands?.length > 0) {
const freqKhz = parseFloat(path.freq) * 1000; // Convert MHz to kHz
const band = getBandFromFreq(freqKhz);
if (!filters.bands.includes(band)) return false;
}
// Mode filter
if (filters.modes?.length > 0) {
const mode = detectMode(path.comment);
if (!mode || !filters.modes.includes(mode)) return false;
}
// Callsign search filter
if (filters.callsign && filters.callsign.trim()) {
const search = filters.callsign.trim().toUpperCase();
const matchesDX = path.dxCall?.toUpperCase().includes(search);
const matchesSpotter = path.spotter?.toUpperCase().includes(search);
if (!matchesDX && !matchesSpotter) return false;
}
return true;
});
};
const useDXCluster = (source = 'auto', filters = {}) => {
const [allSpots, setAllSpots] = useState([]); // All accumulated spots
const [data, setData] = useState([]); // Filtered spots for display
const [loading, setLoading] = useState(true);
const [activeSource, setActiveSource] = useState('');
const spotRetentionMs = 30 * 60 * 1000; // 30 minutes
const pollInterval = 5000; // 5 seconds
// Apply filters to spots
const applyFilters = useCallback((spots, filters) => {
if (!filters || Object.keys(filters).length === 0) return spots;
return spots.filter(spot => {
// Get spotter info for origin-based filtering
const spotterInfo = getCallsignInfo(spot.spotter);
// Watchlist only mode - must match watchlist
if (filters.watchlistOnly && filters.watchlist?.length > 0) {
const matchesWatchlist = filters.watchlist.some(w =>
spot.call?.toUpperCase().includes(w.toUpperCase()) ||
spot.spotter?.toUpperCase().includes(w.toUpperCase())
);
if (!matchesWatchlist) return false;
}
// Exclude list - hide matching calls
if (filters.excludeList?.length > 0) {
const isExcluded = filters.excludeList.some(exc =>
spot.call?.toUpperCase().includes(exc.toUpperCase()) ||
spot.spotter?.toUpperCase().includes(exc.toUpperCase())
);
if (isExcluded) return false;
}
// CQ Zone filter - filter by SPOTTER's zone (origin)
if (filters.cqZones?.length > 0) {
if (!spotterInfo.cqZone || !filters.cqZones.includes(spotterInfo.cqZone)) return false;
}
// ITU Zone filter - filter by SPOTTER's zone (origin)
if (filters.ituZones?.length > 0) {
if (!spotterInfo.ituZone || !filters.ituZones.includes(spotterInfo.ituZone)) return false;
}
// Continent filter - filter by SPOTTER's continent (origin)
if (filters.continents?.length > 0) {
if (!spotterInfo.continent || !filters.continents.includes(spotterInfo.continent)) return false;
}
// Band filter
if (filters.bands?.length > 0) {
const freq = parseFloat(spot.freq);
const band = getBandFromFreq(freq);
if (!filters.bands.includes(band)) return false;
}
// Mode filter
if (filters.modes?.length > 0) {
const mode = spot.mode || detectMode(spot.comment);
if (!mode || !filters.modes.includes(mode)) return false;
}
// Simple callsign search filter
if (filters.callsign?.trim()) {
const search = filters.callsign.toUpperCase().trim();
const matchesDX = spot.call?.toUpperCase().includes(search);
const matchesSpotter = spot.spotter?.toUpperCase().includes(search);
if (!matchesDX && !matchesSpotter) return false;
}
return true;
});
}, []);
useEffect(() => {
const fetchDX = async () => {
try {
const response = await fetch(`/api/dxcluster/spots?source=${source}`);
if (response.ok) {
const newSpots = await response.json();
const now = Date.now();
if (newSpots && newSpots.length > 0) {
if (newSpots[0].source) {
setActiveSource(newSpots[0].source);
}
// Process new spots with timestamps
const processedSpots = newSpots.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 || '',
zone: s.zone || null,
mode: s.mode || null,
timestamp: now,
id: `${s.call || s.dx_call}-${s.freq}-${s.spotter}-${now}`
}));
setAllSpots(prev => {
// Remove expired spots (older than 30 minutes)
const validSpots = prev.filter(s => (now - s.timestamp) < spotRetentionMs);
// Merge new spots, avoiding duplicates (same call+freq within 2 minutes)
const merged = [...validSpots];
for (const newSpot of processedSpots) {
const isDuplicate = merged.some(existing =>
existing.call === newSpot.call &&
existing.freq === newSpot.freq &&
(now - existing.timestamp) < 120000 // 2 minute dedup window
);
if (!isDuplicate) {
merged.push(newSpot);
}
}
// Sort by timestamp (newest first) and limit
return merged.sort((a, b) => b.timestamp - a.timestamp).slice(0, 200);
});
}
}
} catch (err) {
console.error('DX Cluster error:', err);
} finally {
setLoading(false);
}
};
fetchDX();
const interval = setInterval(fetchDX, pollInterval);
return () => clearInterval(interval);
}, [source]);
// Update filtered data when allSpots or filters change
useEffect(() => {
const filtered = applyFilters(allSpots, filters);
setData(filtered.slice(0, 50)); // Return up to 50 filtered spots
}, [allSpots, filters, applyFilters]);
return {
data,
allSpots, // All accumulated spots (unfiltered)
loading,
activeSource,
spotCount: allSpots.length,
filteredCount: data.length
};
};
// ============================================
// 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 10 seconds to keep up with faster cluster polling
const interval = setInterval(fetchPaths, 10000);
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}&current=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 };
};
// ============================================
// DXPEDITION TRACKING HOOK
// ============================================
const useDXpeditions = () => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchDXpeditions = async () => {
try {
console.log('[DXpeditions] Fetching...');
const response = await fetch('/api/dxpeditions');
console.log('[DXpeditions] Response status:', response.status);
if (response.ok) {
const result = await response.json();
console.log('[DXpeditions] Received:', result.dxpeditions?.length, 'entries');
setData(result);
} else {
console.error('[DXpeditions] Failed:', response.status);
}
} catch (err) {
console.error('[DXpeditions] Fetch error:', err);
} finally {
setLoading(false);
}
};
fetchDXpeditions();
// Refresh every 30 minutes
const interval = setInterval(fetchDXpeditions, 30 * 60 * 1000);
return () => clearInterval(interval);
}, []);
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, bandConditions }) => {
// Load view mode preference from localStorage, default to 'chart'
const [viewMode, setViewMode] = useState(() => {
try {
const saved = localStorage.getItem('openhamclock_voacapViewMode');
if (saved === 'bars' || saved === 'bands') return saved;
return 'chart'; // Default to chart
} catch (e) { return 'chart'; }
});
// Cycle through view modes: chart -> bars -> bands -> chart
const cycleViewMode = () => {
const modes = ['chart', 'bars', 'bands'];
const currentIdx = modes.indexOf(viewMode);
const newMode = modes[(currentIdx + 1) % modes.length];
setViewMode(newMode);
try {
localStorage.setItem('openhamclock_voacapViewMode', newMode);
} catch (e) { console.error('Failed to save view mode:', e); }
};
// Get band condition color/style
const getBandStyle = (condition) => ({
GOOD: { bg: 'rgba(0,255,136,0.2)', color: '#00ff88', border: 'rgba(0,255,136,0.4)' },
FAIR: { bg: 'rgba(255,180,50,0.2)', color: '#ffb432', border: 'rgba(255,180,50,0.4)' },
POOR: { bg: 'rgba(255,68,102,0.2)', color: '#ff4466', border: 'rgba(255,68,102,0.4)' }
}[condition] || { bg: 'rgba(255,180,50,0.2)', color: '#ffb432', border: 'rgba(255,180,50,0.4)' });
if (loading || !propagation) {
return (
<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', '11m', '10m'];
const viewModeLabels = {
chart: '▤ chart',
bars: '▦ bars',
bands: '◫ bands'
};
return (
<div className="panel" style={{ cursor: 'pointer' }} onClick={cycleViewMode}>
<div className="panel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>
{viewMode === 'bands' ? '📊 BAND CONDITIONS' : '📡 VOACAP'}
{hasRealData && viewMode !== 'bands' && <span style={{ color: '#00ff88', fontSize: '10px', marginLeft: '4px' }}></span>}
</span>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>
{viewModeLabels[viewMode]} click to toggle
</span>
</div>
{viewMode === 'bands' ? (
/* Band Conditions Grid View */
<div style={{ padding: '4px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '4px' }}>
{(bandConditions?.data || []).slice(0, 13).map((band, idx) => {
const style = getBandStyle(band.condition);
return (
<div key={idx} style={{
background: style.bg,
border: `1px solid ${style.border}`,
borderRadius: '4px',
padding: '6px 2px',
textAlign: 'center'
}}>
<div style={{ fontFamily: 'Orbitron, monospace', fontSize: '13px', fontWeight: '700', color: style.color }}>
{band.band}
</div>
<div style={{ fontSize: '9px', fontWeight: '600', color: style.color, marginTop: '2px', opacity: 0.8 }}>
{band.condition}
</div>
</div>
);
})}
</div>
<div style={{ marginTop: '6px', fontSize: '10px', color: 'var(--text-muted)', textAlign: 'center' }}>
SFI {solarData.sfi} K {solarData.kIndex} General conditions for all paths
</div>
</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'}${ionospheric?.distance ? ` (${ionospheric.distance}km)` : ''}`
: ionospheric?.nearestDistance
? `⚡ estimated (nearest: ${ionospheric.nearestStation}, ${ionospheric.nearestDistance}km - too far)`
: '⚡ estimated'
}
</span>
{dataSource && dataSource.includes('ITU') && (
<span style={{
color: '#ff6b35',
fontSize: '9px',
marginLeft: '8px',
padding: '1px 4px',
background: 'rgba(255,107,53,0.15)',
borderRadius: '3px'
}}>
🔬 ITU-R P.533
</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) => {
// For current hour, try to use currentBands (same hybrid data as bars view)
// Fall back to hourlyPredictions if currentBands doesn't have this band
let rel = 0;
if (hour === currentHour && currentBands?.length > 0) {
const currentBandData = currentBands.find(b => b.band === band);
if (currentBandData) {
rel = currentBandData.reliability || 0;
} else {
// Band not in currentBands, use hourlyPredictions
const bandData = hourlyPredictions?.[band];
const hourData = bandData?.find(h => h.hour === hour);
rel = hourData?.reliability || 0;
}
} else {
const bandData = hourlyPredictions?.[band];
const hourData = bandData?.find(h => h.hour === hour);
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, 11).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, dxFilters, satellites, showDXPaths, showDXLabels, onToggleDXLabels, showPOTA, showSatellites, onToggleSatellites, hoveredSpot }) => {
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 moonMarkerRef = 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');
const [mapView, setMapView] = useState({
center: storedSettings.center || [20, 0],
zoom: storedSettings.zoom || 2.5
});
// Save map settings to localStorage when changed
useEffect(() => {
try {
localStorage.setItem('openhamclock_mapSettings', JSON.stringify({
mapStyle,
center: mapView.center,
zoom: mapView.zoom
}));
} catch (e) { console.error('Failed to save map settings:', e); }
}, [mapStyle, mapView]);
// Initialize map
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
const map = L.map(mapRef.current, {
center: mapView.center,
zoom: mapView.zoom,
minZoom: 1,
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 });
}
});
// Save map view when user pans or zooms
map.on('moveend', () => {
const center = map.getCenter();
const zoom = map.getZoom();
setMapView({ center: [center.lat, center.lng], zoom });
});
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);
if (moonMarkerRef.current) map.removeLayer(moonMarkerRef.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);
// Moon marker
const moonPos = getMoonPosition(new Date());
const moonPhase = getMoonPhase(new Date());
const moonEmoji = getMoonPhaseEmoji(moonPhase);
const moonIcon = L.divIcon({
className: 'custom-marker moon-marker',
html: moonEmoji,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
moonMarkerRef.current = L.marker([moonPos.lat, moonPos.lon], { icon: moonIcon })
.bindPopup(`Sublunar Point<br><span style="font-size: 24px">${moonEmoji}</span>`)
.addTo(map);
}, [deLocation, dxLocation]);
// Update sun and moon positions every minute
useEffect(() => {
if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current;
const updateCelestialBodies = () => {
// Update sun position
if (sunMarkerRef.current) {
const sunPos = getSunPosition(new Date());
sunMarkerRef.current.setLatLng([sunPos.lat, sunPos.lon]);
}
// Update moon position
if (moonMarkerRef.current) {
const moonPos = getMoonPosition(new Date());
const moonPhase = getMoonPhase(new Date());
const moonEmoji = getMoonPhaseEmoji(moonPhase);
moonMarkerRef.current.setLatLng([moonPos.lat, moonPos.lon]);
// Update moon icon to reflect current phase
const moonIcon = L.divIcon({
className: 'custom-marker moon-marker',
html: moonEmoji,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
moonMarkerRef.current.setIcon(moonIcon);
moonMarkerRef.current.setPopupContent(`Sublunar Point<br><span style="font-size: 24px">${moonEmoji}</span>`);
}
};
const interval = setInterval(updateCelestialBodies, 60000); // Every minute
return () => clearInterval(interval);
}, []);
// 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) {
// Apply filters to paths
const filteredPaths = filterDXPaths(dxPaths, dxFilters);
filteredPaths.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)
// Include text color for readability (dark text on light backgrounds)
const freq = parseFloat(path.freq);
let color = '#4488ff';
let textColor = '#000'; // Default dark text
if (freq >= 1.8 && freq < 2) { color = '#ff6666'; textColor = '#000'; } // 160m - red
else if (freq >= 3.5 && freq < 4) { color = '#ff9966'; textColor = '#000'; } // 80m - orange
else if (freq >= 7 && freq < 7.5) { color = '#ffcc66'; textColor = '#000'; } // 40m - yellow
else if (freq >= 10 && freq < 10.5) { color = '#99ff66'; textColor = '#000'; } // 30m - lime
else if (freq >= 14 && freq < 14.5) { color = '#66ff99'; textColor = '#000'; } // 20m - green
else if (freq >= 18 && freq < 18.5) { color = '#66ffcc'; textColor = '#000'; } // 17m - teal
else if (freq >= 21 && freq < 21.5) { color = '#66ccff'; textColor = '#000'; } // 15m - cyan
else if (freq >= 24 && freq < 25) { color = '#6699ff'; textColor = '#fff'; } // 12m - blue (darker, white text)
else if (freq >= 26 && freq < 28) { color = '#8866ff'; textColor = '#fff'; } // 11m - violet (CB band)
else if (freq >= 28 && freq < 30) { color = '#9966ff'; textColor = '#fff'; } // 10m - purple (darker, white text)
else if (freq >= 50 && freq < 54) { color = '#ff66ff'; textColor = '#000'; } // 6m - magenta
// Check if this path is hovered
const isHovered = hoveredSpot && hoveredSpot.call === path.dxCall &&
Math.abs(parseFloat(hoveredSpot.freq) - parseFloat(path.freq)) < 0.01;
// 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: isHovered ? '#ffffff' : color,
weight: isHovered ? 4 : 1.5,
opacity: isHovered ? 1 : 0.5
}).addTo(map);
if (isHovered) {
line.bringToFront();
}
dxPathsLinesRef.current.push(line);
}
});
// Create popup content for spots
const dxPopupContent = `
<div style="font-family: JetBrains Mono, monospace; font-size: 12px; min-width: 150px;">
<div style="font-weight: bold; color: ${color}; font-size: 14px; margin-bottom: 4px;">${path.dxCall}</div>
${path.dxGrid ? `<div style="color: #00ff88; font-size: 11px;">📍 ${path.dxGrid}</div>` : ''}
<div style="color: #aaa; margin-top: 4px;"><b>${path.freq} MHz</b></div>
<div style="color: #888; font-size: 11px; margin-top: 2px;">spotted by <b>${path.spotter}</b></div>
${path.comment ? `<div style="color: #666; font-size: 10px; margin-top: 4px; max-width: 180px; word-wrap: break-word;">${path.comment}</div>` : ''}
<div style="color: #555; font-size: 10px; margin-top: 4px;">${path.time}</div>
</div>
`;
const spotterPopupContent = `
<div style="font-family: JetBrains Mono, monospace; font-size: 12px; min-width: 150px;">
<div style="font-weight: bold; color: #00aaff; font-size: 14px; margin-bottom: 4px;">${path.spotter}</div>
${path.spotterGrid ? `<div style="color: #00ff88; font-size: 11px;">📍 ${path.spotterGrid}</div>` : ''}
<div style="color: #888; font-size: 11px;">Spotter</div>
<div style="color: #aaa; margin-top: 4px;">spotted <b style="color: ${color}">${path.dxCall}</b></div>
<div style="color: #666; font-size: 11px; margin-top: 2px;">on ${path.freq} MHz</div>
<div style="color: #555; font-size: 10px; margin-top: 4px;">${path.time}</div>
</div>
`;
// Add hoverable circle at DX station end
const dxCircle = L.circleMarker([path.dxLat, path.dxLon], {
radius: isHovered ? 10 : 6,
fillColor: isHovered ? '#ffffff' : color,
color: isHovered ? color : '#fff',
weight: isHovered ? 3 : 1.5,
opacity: 1,
fillOpacity: isHovered ? 1 : 0.9
})
.bindPopup(dxPopupContent)
.addTo(map);
if (isHovered) {
dxCircle.bringToFront();
}
dxPathsMarkersRef.current.push(dxCircle);
// Add colored callsign label for DX station (if labels enabled or hovered)
if (showDXLabels || isHovered) {
const labelBg = isHovered ? '#ffffff' : color;
const labelText = isHovered ? color : textColor;
const labelIcon = L.divIcon({
className: '',
html: `<div style="
background: ${labelBg};
color: ${labelText};
padding: 2px 6px;
border-radius: 3px;
font-family: JetBrains Mono, monospace;
font-size: 11px;
font-weight: ${isHovered ? '700' : '600'};
white-space: nowrap;
border: 1px solid ${isHovered ? color : 'rgba(0,0,0,0.3)'};
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
${isHovered ? 'transform: scale(1.1);' : ''}
">${path.dxCall}</div>`,
iconSize: [0, 0],
iconAnchor: [-8, 20]
});
const labelMarker = L.marker([path.dxLat, path.dxLon], { icon: labelIcon, interactive: false })
.addTo(map);
if (isHovered) {
labelMarker.setZIndexOffset(1000);
}
dxPathsMarkersRef.current.push(labelMarker);
}
// Add hoverable circle at spotter end (smaller, different style)
const spotterCircle = L.circleMarker([path.spotterLat, path.spotterLon], {
radius: isHovered ? 6 : 4,
fillColor: isHovered ? '#ffffff' : '#00aaff',
color: isHovered ? '#00aaff' : '#fff',
weight: isHovered ? 2 : 1,
opacity: isHovered ? 1 : 0.8,
fillOpacity: isHovered ? 1 : 0.7
})
.bindPopup(spotterPopupContent)
.addTo(map);
if (isHovered) {
spotterCircle.bringToFront();
}
dxPathsMarkersRef.current.push(spotterCircle);
// Add spotter label only when hovered
if (isHovered) {
const spotterLabelIcon = L.divIcon({
className: '',
html: `<div style="
background: #00aaff;
color: #000;
padding: 2px 6px;
border-radius: 3px;
font-family: JetBrains Mono, monospace;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
border: 1px solid rgba(0,0,0,0.3);
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
">${path.spotter}</div>`,
iconSize: [0, 0],
iconAnchor: [-8, 16]
});
const spotterLabel = L.marker([path.spotterLat, path.spotterLon], { icon: spotterLabelIcon, interactive: false })
.addTo(map);
spotterLabel.setZIndexOffset(999);
dxPathsMarkersRef.current.push(spotterLabel);
}
} catch (err) {
console.error('[DX Paths] Error rendering path:', err, path);
}
});
}
}, [dxPaths, dxFilters, showDXPaths, showDXLabels, hoveredSpot]);
// 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>
)}
{/* Labels toggle button */}
{onToggleDXLabels && showDXPaths && (
<button
onClick={onToggleDXLabels}
style={{
position: 'absolute',
top: '10px',
left: '145px',
background: showDXLabels ? 'rgba(255, 170, 0, 0.2)' : 'rgba(0, 0, 0, 0.8)',
border: `1px solid ${showDXLabels ? '#ffaa00' : '#666'}`,
color: showDXLabels ? '#ffaa00' : '#888',
padding: '6px 10px',
borderRadius: '4px',
fontSize: '11px',
fontFamily: 'JetBrains Mono',
cursor: 'pointer',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
title={showDXLabels ? 'Hide callsign labels (hover to see)' : 'Show callsign labels'}
>
🏷 CALLS {showDXLabels ? '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>
<span style={{ color: '#aaccff' }}>🌙 Moon</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>
<a
href="https://buymeacoffee.com/k0cjh"
target="_blank"
rel="noopener noreferrer"
style={{
background: 'linear-gradient(135deg, #ff813f 0%, #ffdd00 100%)',
border: 'none',
borderRadius: '6px', padding: '8px 12px', color: '#000',
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px',
fontFamily: 'JetBrains Mono, monospace', fontSize: '13px',
fontWeight: '600', textDecoration: 'none',
transition: 'all 0.2s',
boxShadow: '0 2px 8px rgba(255, 129, 63, 0.3)'
}}
title="Buy me a coffee!"
>
Donate
</a>
<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>
);
};
// ============================================
// DX CLUSTER FILTER MANAGER - Comprehensive filtering system
// ============================================
const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose }) => {
const [activeTab, setActiveTab] = useState('zones');
const [newWatchlistCall, setNewWatchlistCall] = useState('');
const [newExcludeCall, setNewExcludeCall] = useState('');
// CQ Zones (1-40)
const cqZones = Array.from({ length: 40 }, (_, i) => i + 1);
// ITU Zones (1-90)
const ituZones = Array.from({ length: 90 }, (_, i) => i + 1);
// Continents
const continents = [
{ code: 'NA', name: 'North America' },
{ code: 'SA', name: 'South America' },
{ code: 'EU', name: 'Europe' },
{ code: 'AF', name: 'Africa' },
{ code: 'AS', name: 'Asia' },
{ code: 'OC', name: 'Oceania' },
{ code: 'AN', name: 'Antarctica' }
];
// Bands
const bands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '11m', '10m', '6m', '2m', '70cm'];
// Modes
const modes = ['CW', 'SSB', 'FT8', 'FT4', 'RTTY', 'PSK', 'AM', 'FM'];
// Toggle functions
const toggleArrayItem = (key, item) => {
const current = filters?.[key] || [];
const newArray = current.includes(item)
? current.filter(i => i !== item)
: [...current, item];
onFilterChange({ ...filters, [key]: newArray.length > 0 ? newArray : undefined });
};
const selectAllZones = (type, zones) => {
onFilterChange({ ...filters, [type]: [...zones] });
};
const clearZones = (type) => {
onFilterChange({ ...filters, [type]: undefined });
};
const addToWatchlist = () => {
if (!newWatchlistCall.trim()) return;
const current = filters?.watchlist || [];
const call = newWatchlistCall.toUpperCase().trim();
if (!current.includes(call)) {
onFilterChange({ ...filters, watchlist: [...current, call] });
}
setNewWatchlistCall('');
};
const removeFromWatchlist = (call) => {
const current = filters?.watchlist || [];
onFilterChange({ ...filters, watchlist: current.filter(c => c !== call) });
};
const addToExclude = () => {
if (!newExcludeCall.trim()) return;
const current = filters?.excludeList || [];
const call = newExcludeCall.toUpperCase().trim();
if (!current.includes(call)) {
onFilterChange({ ...filters, excludeList: [...current, call] });
}
setNewExcludeCall('');
};
const removeFromExclude = (call) => {
const current = filters?.excludeList || [];
onFilterChange({ ...filters, excludeList: current.filter(c => c !== call) });
};
const clearAllFilters = () => {
onFilterChange({});
};
const getActiveFilterCount = () => {
let count = 0;
if (filters?.cqZones?.length) count++;
if (filters?.ituZones?.length) count++;
if (filters?.continents?.length) count++;
if (filters?.bands?.length) count++;
if (filters?.modes?.length) count++;
if (filters?.watchlist?.length) count++;
if (filters?.excludeList?.length) count++;
if (filters?.callsign) count++;
if (filters?.watchlistOnly) count++;
return count;
};
if (!isOpen) return null;
const tabStyle = (active) => ({
padding: '8px 16px',
background: active ? 'var(--accent-cyan)' : 'transparent',
color: active ? '#000' : 'var(--text-secondary)',
border: 'none',
borderRadius: '4px 4px 0 0',
cursor: 'pointer',
fontFamily: 'JetBrains Mono',
fontSize: '11px',
fontWeight: active ? '700' : '400'
});
const pillStyle = (active) => ({
padding: '4px 10px',
background: active ? 'rgba(0, 255, 136, 0.3)' : 'rgba(60,60,60,0.5)',
border: `1px solid ${active ? '#00ff88' : '#444'}`,
color: active ? '#00ff88' : '#888',
borderRadius: '4px',
fontSize: '10px',
cursor: 'pointer',
fontFamily: 'JetBrains Mono',
transition: 'all 0.15s'
});
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
}} onClick={onClose}>
<div style={{
background: 'var(--bg-primary)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
width: '90%',
maxWidth: '700px',
maxHeight: '85vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={{
padding: '16px 20px',
borderBottom: '1px solid var(--border-color)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div>
<h2 style={{ margin: 0, fontSize: '18px', color: 'var(--accent-cyan)' }}>🔍 DX Cluster Filters</h2>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '4px' }}>
{getActiveFilterCount()} filter{getActiveFilterCount() !== 1 ? 's' : ''} active
</div>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={clearAllFilters}
style={{
padding: '8px 16px',
background: 'rgba(255, 100, 100, 0.2)',
border: '1px solid #ff6666',
color: '#ff6666',
borderRadius: '6px',
cursor: 'pointer',
fontFamily: 'JetBrains Mono',
fontSize: '11px'
}}
>
Clear All
</button>
<button
onClick={onClose}
style={{
padding: '8px 16px',
background: 'var(--accent-cyan)',
border: 'none',
color: '#000',
borderRadius: '6px',
cursor: 'pointer',
fontFamily: 'JetBrains Mono',
fontSize: '11px',
fontWeight: '700'
}}
>
Done
</button>
</div>
</div>
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color)', padding: '0 16px' }}>
<button style={tabStyle(activeTab === 'zones')} onClick={() => setActiveTab('zones')}>
Zones {(filters?.cqZones?.length || filters?.ituZones?.length) ? '●' : ''}
</button>
<button style={tabStyle(activeTab === 'bands')} onClick={() => setActiveTab('bands')}>
Bands {filters?.bands?.length ? '●' : ''}
</button>
<button style={tabStyle(activeTab === 'modes')} onClick={() => setActiveTab('modes')}>
Modes {filters?.modes?.length ? '●' : ''}
</button>
<button style={tabStyle(activeTab === 'watchlist')} onClick={() => setActiveTab('watchlist')}>
Watchlist {filters?.watchlist?.length ? `(${filters.watchlist.length})` : ''}
</button>
<button style={tabStyle(activeTab === 'exclude')} onClick={() => setActiveTab('exclude')}>
Exclude {filters?.excludeList?.length ? `(${filters.excludeList.length})` : ''}
</button>
</div>
{/* Tab Content */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
{/* ZONES TAB */}
{activeTab === 'zones' && (
<div>
{/* Continents */}
<div style={{ marginBottom: '20px' }}>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)', marginBottom: '10px' }}>
Continents
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{continents.map(cont => (
<button
key={cont.code}
onClick={() => toggleArrayItem('continents', cont.code)}
style={pillStyle(filters?.continents?.includes(cont.code))}
>
{cont.code} - {cont.name}
</button>
))}
</div>
</div>
{/* CQ Zones */}
<div style={{ marginBottom: '20px' }}>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)', marginBottom: '10px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>CQ Zones</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => selectAllZones('cqZones', cqZones)} style={{ fontSize: '10px', color: 'var(--accent-cyan)', background: 'none', border: 'none', cursor: 'pointer' }}>Select All</button>
<button onClick={() => clearZones('cqZones')} style={{ fontSize: '10px', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer' }}>Clear</button>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{cqZones.map(zone => (
<button
key={zone}
onClick={() => toggleArrayItem('cqZones', zone)}
style={{
...pillStyle(filters?.cqZones?.includes(zone)),
width: '36px',
padding: '6px 0',
textAlign: 'center'
}}
>
{zone}
</button>
))}
</div>
</div>
{/* ITU Zones */}
<div>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)', marginBottom: '10px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>ITU Zones</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => selectAllZones('ituZones', ituZones)} style={{ fontSize: '10px', color: 'var(--accent-cyan)', background: 'none', border: 'none', cursor: 'pointer' }}>Select All</button>
<button onClick={() => clearZones('ituZones')} style={{ fontSize: '10px', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer' }}>Clear</button>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{ituZones.map(zone => (
<button
key={zone}
onClick={() => toggleArrayItem('ituZones', zone)}
style={{
...pillStyle(filters?.ituZones?.includes(zone)),
width: '36px',
padding: '6px 0',
textAlign: 'center'
}}
>
{zone}
</button>
))}
</div>
</div>
</div>
)}
{/* BANDS TAB */}
{activeTab === 'bands' && (
<div>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)', marginBottom: '16px' }}>
Select bands to show (leave empty for all bands)
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px' }}>
{bands.map(band => (
<button
key={band}
onClick={() => toggleArrayItem('bands', band)}
style={{
...pillStyle(filters?.bands?.includes(band)),
padding: '12px 24px',
fontSize: '14px'
}}
>
{band}
</button>
))}
</div>
{/* Quick select groups */}
<div style={{ marginTop: '20px' }}>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '10px' }}>Quick Select:</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
<button
onClick={() => onFilterChange({ ...filters, bands: ['80m', '40m', '20m', '15m', '10m'] })}
style={{ padding: '6px 12px', background: 'rgba(100,100,100,0.3)', border: '1px solid #666', color: '#aaa', borderRadius: '4px', fontSize: '10px', cursor: 'pointer' }}
>
HF Contest (80-10m)
</button>
<button
onClick={() => onFilterChange({ ...filters, bands: ['20m', '17m', '15m', '12m', '10m'] })}
style={{ padding: '6px 12px', background: 'rgba(100,100,100,0.3)', border: '1px solid #666', color: '#aaa', borderRadius: '4px', fontSize: '10px', cursor: 'pointer' }}
>
Daytime (20-10m)
</button>
<button
onClick={() => onFilterChange({ ...filters, bands: ['160m', '80m', '40m'] })}
style={{ padding: '6px 12px', background: 'rgba(100,100,100,0.3)', border: '1px solid #666', color: '#aaa', borderRadius: '4px', fontSize: '10px', cursor: 'pointer' }}
>
Nighttime (160-40m)
</button>
<button
onClick={() => onFilterChange({ ...filters, bands: ['6m', '2m', '70cm'] })}
style={{ padding: '6px 12px', background: 'rgba(100,100,100,0.3)', border: '1px solid #666', color: '#aaa', borderRadius: '4px', fontSize: '10px', cursor: 'pointer' }}
>
VHF/UHF
</button>
<button
onClick={() => onFilterChange({ ...filters, bands: ['30m', '17m', '12m'] })}
style={{ padding: '6px 12px', background: 'rgba(100,100,100,0.3)', border: '1px solid #666', color: '#aaa', borderRadius: '4px', fontSize: '10px', cursor: 'pointer' }}
>
WARC Bands
</button>
</div>
</div>
</div>
)}
{/* MODES TAB */}
{activeTab === 'modes' && (
<div>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)', marginBottom: '16px' }}>
Select modes to show (leave empty for all modes)
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px' }}>
{modes.map(mode => (
<button
key={mode}
onClick={() => toggleArrayItem('modes', mode)}
style={{
...pillStyle(filters?.modes?.includes(mode)),
padding: '12px 24px',
fontSize: '14px'
}}
>
{mode}
</button>
))}
</div>
{/* Quick select groups */}
<div style={{ marginTop: '20px' }}>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '10px' }}>Quick Select:</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
<button
onClick={() => onFilterChange({ ...filters, modes: ['FT8', 'FT4'] })}
style={{ padding: '6px 12px', background: 'rgba(100,100,100,0.3)', border: '1px solid #666', color: '#aaa', borderRadius: '4px', fontSize: '10px', cursor: 'pointer' }}
>
FT8/FT4 Only
</button>
<button
onClick={() => onFilterChange({ ...filters, modes: ['CW'] })}
style={{ padding: '6px 12px', background: 'rgba(100,100,100,0.3)', border: '1px solid #666', color: '#aaa', borderRadius: '4px', fontSize: '10px', cursor: 'pointer' }}
>
CW Only
</button>
<button
onClick={() => onFilterChange({ ...filters, modes: ['SSB'] })}
style={{ padding: '6px 12px', background: 'rgba(100,100,100,0.3)', border: '1px solid #666', color: '#aaa', borderRadius: '4px', fontSize: '10px', cursor: 'pointer' }}
>
SSB Only
</button>
<button
onClick={() => onFilterChange({ ...filters, modes: ['FT8', 'FT4', 'RTTY', 'PSK'] })}
style={{ padding: '6px 12px', background: 'rgba(100,100,100,0.3)', border: '1px solid #666', color: '#aaa', borderRadius: '4px', fontSize: '10px', cursor: 'pointer' }}
>
All Digital
</button>
</div>
</div>
</div>
)}
{/* WATCHLIST TAB */}
{activeTab === 'watchlist' && (
<div>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)', marginBottom: '8px' }}>
Callsign Watchlist
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: '16px' }}>
Add callsigns you want to watch for. Matching spots will be highlighted.
</div>
{/* Watchlist Only toggle */}
<div style={{ marginBottom: '16px', padding: '12px', background: 'rgba(0,170,255,0.1)', borderRadius: '6px', border: '1px solid rgba(0,170,255,0.3)' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={filters?.watchlistOnly || false}
onChange={(e) => onFilterChange({ ...filters, watchlistOnly: e.target.checked || undefined })}
style={{ width: '18px', height: '18px' }}
/>
<span style={{ fontSize: '12px', color: 'var(--accent-cyan)' }}>
Show ONLY watchlist callsigns (hide all others)
</span>
</label>
</div>
{/* Add callsign */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<input
type="text"
placeholder="Enter callsign..."
value={newWatchlistCall}
onChange={(e) => setNewWatchlistCall(e.target.value.toUpperCase())}
onKeyDown={(e) => e.key === 'Enter' && addToWatchlist()}
style={{
flex: 1,
padding: '10px 12px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
color: 'var(--text-primary)',
fontSize: '14px',
fontFamily: 'JetBrains Mono'
}}
/>
<button
onClick={addToWatchlist}
style={{
padding: '10px 20px',
background: 'var(--accent-green)',
border: 'none',
color: '#000',
borderRadius: '6px',
cursor: 'pointer',
fontFamily: 'JetBrains Mono',
fontWeight: '700'
}}
>
Add
</button>
</div>
{/* Watchlist items */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{(filters?.watchlist || []).map(call => (
<div
key={call}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
background: 'rgba(0, 255, 136, 0.2)',
border: '1px solid #00ff88',
borderRadius: '6px',
fontSize: '13px',
fontFamily: 'JetBrains Mono',
color: '#00ff88'
}}
>
{call}
<button
onClick={() => removeFromWatchlist(call)}
style={{ background: 'none', border: 'none', color: '#ff6666', cursor: 'pointer', fontSize: '14px', padding: 0 }}
>
</button>
</div>
))}
{(!filters?.watchlist || filters.watchlist.length === 0) && (
<div style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>No callsigns in watchlist</div>
)}
</div>
</div>
)}
{/* EXCLUDE TAB */}
{activeTab === 'exclude' && (
<div>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)', marginBottom: '8px' }}>
Exclude Callsigns
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: '16px' }}>
Add callsigns or prefixes to hide from the cluster. Partial matches work (e.g., "W3" hides all W3 calls).
</div>
{/* Add exclude */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<input
type="text"
placeholder="Enter callsign or prefix to exclude..."
value={newExcludeCall}
onChange={(e) => setNewExcludeCall(e.target.value.toUpperCase())}
onKeyDown={(e) => e.key === 'Enter' && addToExclude()}
style={{
flex: 1,
padding: '10px 12px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
color: 'var(--text-primary)',
fontSize: '14px',
fontFamily: 'JetBrains Mono'
}}
/>
<button
onClick={addToExclude}
style={{
padding: '10px 20px',
background: '#ff6666',
border: 'none',
color: '#000',
borderRadius: '6px',
cursor: 'pointer',
fontFamily: 'JetBrains Mono',
fontWeight: '700'
}}
>
Exclude
</button>
</div>
{/* Exclude items */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{(filters?.excludeList || []).map(call => (
<div
key={call}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
background: 'rgba(255, 100, 100, 0.2)',
border: '1px solid #ff6666',
borderRadius: '6px',
fontSize: '13px',
fontFamily: 'JetBrains Mono',
color: '#ff6666'
}}
>
{call}
<button
onClick={() => removeFromExclude(call)}
style={{ background: 'none', border: 'none', color: '#888', cursor: 'pointer', fontSize: '14px', padding: 0 }}
>
</button>
</div>
))}
{(!filters?.excludeList || filters.excludeList.length === 0) && (
<div style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>No callsigns excluded</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
};
// Simple DX Cluster Panel (for sidebars)
const DXClusterPanel = ({ spots, loading, activeSource, showOnMap, onToggleMap, spotCount, filteredCount, filters, onFilterChange, onOpenFilters, hoveredSpot, onHoverSpot }) => {
const hasActiveFilters = (filters?.bands?.length > 0) || (filters?.modes?.length > 0) ||
(filters?.cqZones?.length > 0) || (filters?.ituZones?.length > 0) ||
(filters?.continents?.length > 0) || (filters?.watchlist?.length > 0) ||
(filters?.excludeList?.length > 0) || filters?.watchlistOnly;
// Check if spot matches watchlist for highlighting
const isWatchlistMatch = (spot) => {
if (!filters?.watchlist?.length) return false;
return filters.watchlist.some(w =>
spot.call?.toUpperCase().includes(w) ||
spot.spotter?.toUpperCase().includes(w)
);
};
return (
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-color)', borderRadius: '8px', padding: '20px', backdropFilter: 'blur(10px)', maxHeight: '320px', overflow: 'hidden' }}>
<div style={{ fontSize: '12px', fontWeight: '600', color: 'var(--accent-cyan)', letterSpacing: '2px', marginBottom: '12px', 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: '9px', color: 'var(--text-muted)' }}>
{filteredCount !== undefined && spotCount !== undefined ? `${filteredCount}/${spotCount}` : ''}
</span>
<button
onClick={onOpenFilters}
style={{
background: hasActiveFilters ? 'rgba(255, 170, 0, 0.3)' : 'rgba(100, 100, 100, 0.3)',
border: `1px solid ${hasActiveFilters ? '#ffaa00' : '#666'}`,
color: hasActiveFilters ? '#ffaa00' : '#888',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '10px',
fontFamily: 'JetBrains Mono',
cursor: 'pointer'
}}
title="Open filter manager"
>
🔍 Filters
</button>
<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'
}}
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>
{activeSource && <div style={{ fontSize: '9px', color: 'var(--text-muted)', marginBottom: '8px' }}>Source: {activeSource}</div>}
<div style={{ overflowY: 'auto', maxHeight: '220px' }}>
{spots.length > 0 ? spots.map((s, i) => {
const isHighlighted = isWatchlistMatch(s);
const isHovered = hoveredSpot && hoveredSpot.call === s.call && hoveredSpot.freq === s.freq;
return (
<div
key={i}
onMouseEnter={() => onHoverSpot && onHoverSpot(s)}
onMouseLeave={() => onHoverSpot && onHoverSpot(null)}
style={{
display: 'grid',
gridTemplateColumns: '65px 75px 1fr auto',
gap: '10px',
padding: '8px 4px',
borderBottom: '1px solid rgba(255,255,255,0.03)',
fontFamily: 'JetBrains Mono, monospace',
fontSize: '13px',
alignItems: 'center',
background: isHovered ? 'rgba(68, 136, 255, 0.3)' : isHighlighted ? 'rgba(0, 255, 136, 0.15)' : 'transparent',
borderLeft: isHovered ? '3px solid #4488ff' : isHighlighted ? '3px solid #00ff88' : '3px solid transparent',
marginLeft: '-4px',
cursor: 'pointer',
transition: 'background 0.15s ease'
}}>
<span style={{ color: 'var(--accent-green)' }}>{s.freq}</span>
<span style={{ color: isHovered ? '#4488ff' : isHighlighted ? '#00ff88' : 'var(--accent-amber)', fontWeight: isHovered ? '700' : '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 style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '20px' }}>
{hasActiveFilters ? 'No spots match filters' : 'No spots available'}
</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>
);
// ============================================
// DXPEDITION PANEL
// ============================================
const DXpeditionPanel = ({ data, loading }) => {
const formatDate = (isoString) => {
if (!isoString) return '';
const date = new Date(isoString);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
const getStatusStyle = (expedition) => {
if (expedition.isActive) {
return { bg: 'rgba(0, 255, 136, 0.15)', border: 'var(--accent-green)', color: 'var(--accent-green)' };
}
if (expedition.isUpcoming) {
return { bg: 'rgba(0, 170, 255, 0.15)', border: 'var(--accent-cyan)', color: 'var(--accent-cyan)' };
}
return { bg: 'var(--bg-tertiary)', border: 'var(--border-color)', color: 'var(--text-muted)' };
};
return (
<div className="panel" style={{ padding: '12px' }}>
<div className="panel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<span>🌍 DXPEDITIONS</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{loading && <div className="loading-spinner" />}
{data && (
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>
{data.active > 0 && <span style={{ color: 'var(--accent-green)' }}>{data.active} active</span>}
{data.active > 0 && data.upcoming > 0 && ' • '}
{data.upcoming > 0 && <span style={{ color: 'var(--accent-cyan)' }}>{data.upcoming} upcoming</span>}
</span>
)}
</div>
</div>
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
{data?.dxpeditions?.length > 0 ? (
data.dxpeditions.slice(0, 15).map((exp, idx) => {
const style = getStatusStyle(exp);
return (
<div key={idx} style={{
padding: '6px 8px',
marginBottom: '4px',
background: style.bg,
borderLeft: `3px solid ${style.border}`,
borderRadius: '4px',
fontSize: '12px',
fontFamily: 'JetBrains Mono, monospace'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: 'var(--accent-amber)', fontWeight: '700', fontSize: '13px' }}>{exp.callsign}</span>
<span style={{ color: style.color, fontSize: '10px' }}>
{exp.isActive ? '● ACTIVE' : exp.isUpcoming ? 'UPCOMING' : 'PAST'}
</span>
</div>
<div style={{ color: 'var(--text-secondary)', marginTop: '2px' }}>
{exp.entity}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '3px' }}>
<span style={{ color: 'var(--text-muted)', fontSize: '11px' }}>{exp.dates}</span>
<div style={{ display: 'flex', gap: '6px', fontSize: '10px' }}>
{exp.bands && <span style={{ color: 'var(--accent-purple)' }}>{exp.bands.split(' ').slice(0, 3).join(' ')}</span>}
{exp.modes && <span style={{ color: 'var(--accent-cyan)' }}>{exp.modes.split(' ').slice(0, 2).join(' ')}</span>}
</div>
</div>
</div>
);
})
) : (
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '20px' }}>
{loading ? 'Loading DXpeditions...' : 'No DXpedition data available'}
</div>
)}
</div>
{data && (
<div style={{ marginTop: '6px', textAlign: 'right', fontSize: '9px' }}>
<a href="https://www.ng3k.com/misc/adxo.html" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-muted)', textDecoration: 'none' }}>
NG3K ADXO Calendar
</a>
</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, dxpeditions, contests, propagation, mySpots, satellites,
localWeather, use12Hour, onTimeFormatToggle,
onSettingsClick, onFullscreenToggle, isFullscreen,
mapLayers, toggleDXPaths, togglePOTA, toggleSatellites, toggleDXLabels,
dxFilters, onFilterChange,
hoveredSpot, onHoverSpot, onOpenDXFilters
}) => {
// Alias for setDxFilters used in the component
const setDxFilters = onFilterChange;
const setHoveredSpot = onHoverSpot;
const setShowDXFilters = onOpenDXFilters;
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 / {Math.round((localWeather.data.temp - 32) * 5/9)}°C {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}
dxFilters={dxFilters}
satellites={satellites.positions}
showDXPaths={mapLayers.showDXPaths}
showDXLabels={mapLayers.showDXLabels}
onToggleDXLabels={toggleDXLabels}
showPOTA={mapLayers.showPOTA}
showSatellites={mapLayers.showSatellites}
onToggleSatellites={toggleSatellites}
hoveredSpot={hoveredSpot}
/>
</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 style={{ fontSize: '8px', color: 'var(--text-muted)' }}>{dxCluster.filteredCount}/{dxCluster.spotCount}</span></span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<button
onClick={() => setShowDXFilters(true)}
style={{
background: (dxFilters?.cqZones?.length || dxFilters?.bands?.length || dxFilters?.watchlist?.length) ? 'rgba(255, 170, 0, 0.3)' : 'rgba(100, 100, 100, 0.3)',
border: `1px solid ${(dxFilters?.cqZones?.length || dxFilters?.bands?.length || dxFilters?.watchlist?.length) ? '#ffaa00' : '#666'}`,
color: (dxFilters?.cqZones?.length || dxFilters?.bands?.length || dxFilters?.watchlist?.length) ? '#ffaa00' : '#888',
padding: '1px 6px',
borderRadius: '3px',
fontSize: '9px',
fontFamily: 'JetBrains Mono',
cursor: 'pointer'
}}
title="Open filter manager"
>
🔍
</button>
<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>
{/* Quick search bar */}
<div style={{ display: 'flex', gap: '4px', marginBottom: '6px' }}>
<input
type="text"
placeholder="Quick search..."
value={dxFilters?.callsign || ''}
onChange={(e) => setDxFilters(prev => ({ ...prev, callsign: e.target.value || undefined }))}
style={{
flex: 1,
padding: '3px 6px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '3px',
color: 'var(--text-primary)',
fontSize: '10px',
fontFamily: 'JetBrains Mono'
}}
/>
{Object.keys(dxFilters || {}).length > 0 && (
<button
onClick={() => setDxFilters({})}
style={{
padding: '3px 6px',
background: 'rgba(255,100,100,0.2)',
border: '1px solid #ff6666',
color: '#ff6666',
borderRadius: '3px',
fontSize: '9px',
cursor: 'pointer'
}}
title="Clear all filters"
>
</button>
)}
</div>
{/* Active filters summary */}
{(dxFilters?.bands?.length || dxFilters?.cqZones?.length || dxFilters?.watchlist?.length) && (
<div style={{ fontSize: '8px', color: 'var(--accent-amber)', marginBottom: '4px' }}>
{dxFilters.bands?.length > 0 && <span>{dxFilters.bands.join(', ')} </span>}
{dxFilters.cqZones?.length > 0 && <span>| CQ: {dxFilters.cqZones.length} zones </span>}
{dxFilters.watchlist?.length > 0 && <span>| {dxFilters.watchlist.length} watched</span>}
</div>
)}
<div style={{ maxHeight: '180px', overflowY: 'auto' }}>
{dxCluster.data.slice(0, 8).map((s, i) => {
const isHovered = hoveredSpot && hoveredSpot.call === s.call && hoveredSpot.freq === s.freq;
return (
<div
key={i}
onMouseEnter={() => setHoveredSpot(s)}
onMouseLeave={() => setHoveredSpot(null)}
style={{
padding: '4px 0',
borderBottom: '1px solid rgba(255,255,255,0.05)',
fontSize: '12px',
background: isHovered ? 'rgba(68, 136, 255, 0.3)' : 'transparent',
borderLeft: isHovered ? '3px solid #4488ff' : '3px solid transparent',
paddingLeft: '4px',
marginLeft: '-4px',
cursor: 'pointer',
transition: 'background 0.15s ease'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--accent-green)' }}>{s.freq}</span>
<span style={{ color: isHovered ? '#4488ff' : 'var(--accent-amber)', fontWeight: isHovered ? '700' : '600' }}>{s.call}</span>
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '13px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.comment}</div>
</div>
);
})}
{dxCluster.data.length === 0 && <div style={{ color: 'var(--text-muted)', fontSize: '11px', textAlign: 'center', padding: '10px' }}>No spots match filter</div>}
</div>
</div>
{/* DXpeditions */}
<div style={{ marginTop: '8px' }}>
<div style={{ ...labelStyle, marginBottom: '8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>🌍 DXPEDITIONS</span>
{dxpeditions.data?.active > 0 && (
<span style={{ fontSize: '9px', color: 'var(--accent-green)' }}>{dxpeditions.data.active} active</span>
)}
</div>
<div style={{ maxHeight: '120px', overflowY: 'auto' }}>
{dxpeditions.data?.dxpeditions?.slice(0, 6).map((exp, i) => (
<div key={i} style={{
padding: '3px 0',
borderBottom: '1px solid rgba(255,255,255,0.05)',
fontSize: '12px',
borderLeft: exp.isActive ? '2px solid var(--accent-green)' : 'none',
paddingLeft: exp.isActive ? '6px' : '0'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--accent-amber)', fontWeight: '700' }}>{exp.callsign}</span>
<span style={{ color: exp.isActive ? 'var(--accent-green)' : 'var(--text-muted)', fontSize: '10px' }}>
{exp.isActive ? '● NOW' : exp.dates?.split('-')[0]?.trim() || ''}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)', fontSize: '11px' }}>{exp.entity}</span>
{exp.bands && <span style={{ color: 'var(--accent-purple)', fontSize: '9px' }}>{exp.bands.split(' ').slice(0,2).join(' ')}</span>}
</div>
</div>
))}
{!dxpeditions.data?.dxpeditions?.length && (
<div style={{ color: 'var(--text-muted)', fontSize: '11px' }}>
{dxpeditions.loading ? 'Loading...' : 'No data'}
</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' }}>
<a
href="https://buymeacoffee.com/k0cjh"
target="_blank"
rel="noopener noreferrer"
style={{
background: 'linear-gradient(135deg, #ff813f 0%, #ffdd00 100%)',
border: 'none',
padding: '4px 10px', borderRadius: '4px', color: '#000',
fontSize: '12px', cursor: 'pointer', fontFamily: 'JetBrains Mono, monospace',
fontWeight: '600', textDecoration: 'none',
display: 'flex', alignItems: 'center', gap: '4px'
}}
title="Buy me a coffee!"
>
Donate
</a>
<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="proxy"> DX Spider Proxy (Recommended)</option>
<option value="hamqth">HamQTH (HTTP)</option>
<option value="dxspider">DX Spider Direct (Telnet)</option>
</select>
<p style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '8px' }}>
{dxClusterSource === 'auto' && '→ Tries Proxy first, then HamQTH, then direct telnet'}
{dxClusterSource === 'proxy' && '→ Real-time DX Spider feed via our dedicated proxy service'}
{dxClusterSource === 'hamqth' && '→ HamQTH.com CSV feed (works on all platforms)'}
{dxClusterSource === 'dxspider' && '→ Direct telnet to dxspider.co.uk:7300 (local/Pi only)'}
</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');
// DX Location with localStorage persistence
const [dxLocation, setDxLocation] = useState(() => {
try {
const stored = localStorage.getItem('openhamclock_dxLocation');
if (stored) {
const parsed = JSON.parse(stored);
if (parsed.lat && parsed.lon) return parsed;
}
} catch (e) {}
return config.defaultDX;
});
// Save DX location when changed
useEffect(() => {
try {
localStorage.setItem('openhamclock_dxLocation', JSON.stringify(dxLocation));
} catch (e) { console.error('Failed to save DX location:', e); }
}, [dxLocation]);
const [showSettings, setShowSettings] = useState(false);
const [showDXFilters, setShowDXFilters] = 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, showDXLabels: true, showPOTA: true, showSatellites: true };
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
} catch (e) { return { showDXPaths: true, showDXLabels: 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]);
// Hovered spot state for highlighting paths on map
const [hoveredSpot, setHoveredSpot] = useState(null);
// Toggle handlers for map layers
const toggleDXPaths = useCallback(() => setMapLayers(prev => ({ ...prev, showDXPaths: !prev.showDXPaths })), []);
const toggleDXLabels = useCallback(() => setMapLayers(prev => ({ ...prev, showDXLabels: !prev.showDXLabels })), []);
const togglePOTA = useCallback(() => setMapLayers(prev => ({ ...prev, showPOTA: !prev.showPOTA })), []);
const toggleSatellites = useCallback(() => setMapLayers(prev => ({ ...prev, showSatellites: !prev.showSatellites })), []);
// 12/24 hour format preference with localStorage persistence
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();
// DX Cluster filters with localStorage persistence
const [dxFilters, setDxFilters] = useState(() => {
try {
const stored = localStorage.getItem('openhamclock_dxFilters');
return stored ? JSON.parse(stored) : {};
} catch (e) { return {}; }
});
// Save DX filters when changed
useEffect(() => {
try {
localStorage.setItem('openhamclock_dxFilters', JSON.stringify(dxFilters));
} catch (e) {}
}, [dxFilters]);
const dxCluster = useDXCluster(config.dxClusterSource || 'auto', dxFilters);
const dxPaths = useDXPaths();
const dxpeditions = useDXpeditions();
const contests = useContests();
const propagation = usePropagation(config.location, dxLocation);
const mySpots = useMySpots(config.callsign);
const satellites = useSatellites(config.location);
const localWeather = useLocalWeather(config.location);
const 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}
dxpeditions={dxpeditions}
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}
toggleDXLabels={toggleDXLabels}
dxFilters={dxFilters}
onFilterChange={setDxFilters}
hoveredSpot={hoveredSpot}
onHoverSpot={setHoveredSpot}
onOpenDXFilters={() => setShowDXFilters(true)}
/>
<SettingsPanel
isOpen={showSettings}
onClose={() => setShowSettings(false)}
config={config}
onSave={handleSaveConfig}
/>
<DXFilterManager
filters={dxFilters}
onFilterChange={setDxFilters}
isOpen={showDXFilters}
onClose={() => setShowDXFilters(false)}
/>
</>
);
}
// 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 / {Math.round((localWeather.data.temp - 32) * 5/9)}°C</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' }}>
<a
href="https://buymeacoffee.com/k0cjh"
target="_blank"
rel="noopener noreferrer"
style={{
background: 'linear-gradient(135deg, #ff813f 0%, #ffdd00 100%)',
border: 'none',
padding: '6px 12px', borderRadius: '4px', color: '#000',
fontSize: '13px', cursor: 'pointer',
fontWeight: '600', textDecoration: 'none',
display: 'flex', alignItems: 'center', gap: '4px'
}}
title="Buy me a coffee!"
>
Donate
</a>
<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>
{/* Solar Panel (toggleable between image and indices) */}
<SolarPanel solarIndices={solarIndices} />
{/* VOACAP/Propagation/Band Conditions - Toggleable */}
<PropagationPanel propagation={propagation.data} loading={propagation.loading} bandConditions={bandConditions} />
</div>
{/* CENTER - MAP */}
<div style={{ position: 'relative', borderRadius: '6px', overflow: 'hidden' }}>
<WorldMap
deLocation={config.location}
dxLocation={dxLocation}
onDXChange={handleDXChange}
potaSpots={potaSpots.data}
mySpots={mySpots.data}
dxPaths={dxPaths.data}
dxFilters={dxFilters}
satellites={satellites.positions}
showDXPaths={mapLayers.showDXPaths}
showDXLabels={mapLayers.showDXLabels}
onToggleDXLabels={toggleDXLabels}
showPOTA={mapLayers.showPOTA}
showSatellites={mapLayers.showSatellites}
onToggleSatellites={toggleSatellites}
hoveredSpot={hoveredSpot}
/>
<div style={{ position: 'absolute', bottom: '8px', left: '50%', transform: 'translateX(-50%)', fontSize: '13px', color: 'var(--text-muted)', background: 'rgba(0,0,0,0.7)', padding: '2px 8px', borderRadius: '4px' }}>
Click map to set DX 73 de {config.callsign}
</div>
</div>
{/* RIGHT SIDEBAR */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', overflow: 'hidden' }}>
{/* DX Cluster - Compact with filters */}
<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' }}>
<span style={{ fontSize: '8px', color: 'var(--text-muted)' }}>{dxCluster.filteredCount}/{dxCluster.spotCount}</span>
<button
onClick={() => setShowDXFilters(true)}
style={{
background: (dxFilters?.cqZones?.length || dxFilters?.bands?.length || dxFilters?.watchlist?.length) ? 'rgba(255, 170, 0, 0.3)' : 'rgba(100, 100, 100, 0.3)',
border: `1px solid ${(dxFilters?.cqZones?.length || dxFilters?.bands?.length || dxFilters?.watchlist?.length) ? '#ffaa00' : '#666'}`,
color: (dxFilters?.cqZones?.length || dxFilters?.bands?.length || dxFilters?.watchlist?.length) ? '#ffaa00' : '#888',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '10px',
fontFamily: 'JetBrains Mono',
cursor: 'pointer'
}}
title="Open filter manager"
>
🔍 Filters
</button>
<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>
</div>
</div>
{/* Quick search bar */}
<div style={{ display: 'flex', gap: '4px', marginBottom: '6px' }}>
<input
type="text"
placeholder="Quick search..."
value={dxFilters?.callsign || ''}
onChange={(e) => setDxFilters(prev => ({ ...prev, callsign: e.target.value || undefined }))}
style={{
flex: 1,
padding: '3px 6px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '3px',
color: 'var(--text-primary)',
fontSize: '10px',
fontFamily: 'JetBrains Mono'
}}
/>
{Object.keys(dxFilters || {}).length > 0 && (
<button
onClick={() => setDxFilters({})}
style={{
padding: '3px 6px',
background: 'rgba(255,100,100,0.2)',
border: '1px solid #ff6666',
color: '#ff6666',
borderRadius: '3px',
fontSize: '9px',
cursor: 'pointer'
}}
title="Clear all filters"
>
</button>
)}
</div>
{/* Active filters summary */}
{(dxFilters?.bands?.length || dxFilters?.modes?.length || dxFilters?.cqZones?.length || dxFilters?.watchlist?.length) && (
<div style={{ fontSize: '9px', color: 'var(--accent-amber)', marginBottom: '4px', display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{dxFilters.bands?.length > 0 && <span>📻 {dxFilters.bands.join(', ')}</span>}
{dxFilters.modes?.length > 0 && <span>| 📡 {dxFilters.modes.join(', ')}</span>}
{dxFilters.cqZones?.length > 0 && <span>| 🌍 CQ: {dxFilters.cqZones.length} zones</span>}
{dxFilters.watchlist?.length > 0 && <span>| 👀 {dxFilters.watchlist.length} watched</span>}
</div>
)}
<div style={{ overflow: 'auto', maxHeight: 'calc(100% - 60px)' }}>
{dxCluster.data.slice(0, 8).map((s, i) => {
const isHovered = hoveredSpot && hoveredSpot.call === s.call && hoveredSpot.freq === s.freq;
return (
<div
key={i}
onMouseEnter={() => setHoveredSpot(s)}
onMouseLeave={() => setHoveredSpot(null)}
style={{
padding: '3px 0',
borderBottom: '1px solid rgba(255,255,255,0.05)',
fontSize: '12px',
fontFamily: 'JetBrains Mono',
background: isHovered ? 'rgba(68, 136, 255, 0.3)' : 'transparent',
borderLeft: isHovered ? '3px solid #4488ff' : '3px solid transparent',
paddingLeft: '4px',
marginLeft: '-4px',
cursor: 'pointer',
transition: 'background 0.15s ease'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--accent-cyan)' }}>{s.freq}</span>
<span style={{ color: isHovered ? '#4488ff' : 'var(--accent-amber)', fontWeight: isHovered ? '700' : '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>
{/* DXpeditions - Compact */}
<div className="panel" style={{ padding: '10px', flex: '0 0 auto', maxHeight: '160px', overflow: 'hidden' }}>
<div style={{ fontSize: '12px', color: 'var(--accent-cyan)', fontWeight: '700', marginBottom: '6px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>🌍 DXPEDITIONS</span>
{dxpeditions.data && (
<span style={{ fontSize: '9px', color: 'var(--text-muted)', fontWeight: '400' }}>
{dxpeditions.data.active > 0 && <span style={{ color: 'var(--accent-green)' }}>{dxpeditions.data.active} active</span>}
</span>
)}
</div>
<div style={{ overflow: 'auto', maxHeight: '120px', fontSize: '11px', fontFamily: 'JetBrains Mono' }}>
{dxpeditions.data?.dxpeditions?.slice(0, 8).map((exp, i) => (
<div key={i} style={{
padding: '4px 0',
borderBottom: '1px solid rgba(255,255,255,0.05)',
borderLeft: exp.isActive ? '2px solid var(--accent-green)' : exp.isUpcoming ? '2px solid var(--accent-cyan)' : 'none',
paddingLeft: (exp.isActive || exp.isUpcoming) ? '6px' : '0'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: 'var(--accent-amber)', fontWeight: '700', fontSize: '12px' }}>{exp.callsign}</span>
<span style={{ color: exp.isActive ? 'var(--accent-green)' : 'var(--text-muted)', fontSize: '9px' }}>
{exp.isActive ? '● NOW' : exp.dates?.split('-')[0]?.trim() || ''}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '1px' }}>
<span style={{ color: 'var(--text-secondary)', fontSize: '10px', flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{exp.entity}
</span>
{exp.bands && (
<span style={{ color: 'var(--accent-purple)', fontSize: '9px', marginLeft: '4px' }}>
{exp.bands.split(' ').slice(0, 3).join(' ')}
</span>
)}
</div>
</div>
))}
{(!dxpeditions.data?.dxpeditions || dxpeditions.data.dxpeditions.length === 0) &&
<div style={{ color: 'var(--text-muted)' }}>{dxpeditions.loading ? 'Loading...' : 'No DXpeditions'}</div>
}
</div>
<div style={{ marginTop: '4px', textAlign: 'right' }}>
<a href="https://www.ng3k.com/misc/adxo.html" target="_blank" rel="noopener noreferrer"
style={{ fontSize: '8px', color: 'var(--text-muted)', textDecoration: 'none' }}>
NG3K ADXO
</a>
</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}
/>
{/* DX Cluster Filter Manager */}
<DXFilterManager
filters={dxFilters}
onFilterChange={setDxFilters}
isOpen={showDXFilters}
onClose={() => setShowDXFilters(false)}
/>
</div>
);
};
ReactDOM.render(<App />, document.getElementById('root'));
</script>
</body>
</html>

Powered by TurnKey Linux.