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

1036 lines
36 KiB

<!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>
<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">
<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>
:root {
--bg-primary: #0a0e14;
--bg-secondary: #111820;
--bg-tertiary: #1a2332;
--bg-panel: rgba(17, 24, 32, 0.85);
--border-color: rgba(255, 180, 50, 0.2);
--border-glow: rgba(255, 180, 50, 0.4);
--text-primary: #f0f4f8;
--text-secondary: #8a9aaa;
--text-muted: #5a6a7a;
--accent-amber: #ffb432;
--accent-amber-dim: rgba(255, 180, 50, 0.6);
--accent-green: #00ff88;
--accent-green-dim: rgba(0, 255, 136, 0.6);
--accent-red: #ff4466;
--accent-blue: #4488ff;
--accent-cyan: #00ddff;
--night-overlay: rgba(10, 20, 40, 0.6);
--day-overlay: rgba(255, 200, 100, 0.08);
}
* {
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;
}
/* Scanline effect for retro CRT feel */
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, 0.03) 2px,
rgba(0, 0, 0, 0.03) 4px
);
pointer-events: none;
z-index: 9999;
}
#root {
min-height: 100vh;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
@keyframes glow {
0%, 100% {
box-shadow: 0 0 5px var(--accent-amber-dim),
0 0 10px rgba(255, 180, 50, 0.2);
}
50% {
box-shadow: 0 0 10px var(--accent-amber),
0 0 20px rgba(255, 180, 50, 0.4);
}
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes terminator-sweep {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useCallback, useMemo } = React;
// ============================================
// UTILITY FUNCTIONS
// ============================================
// Calculate Maidenhead Grid Locator from lat/lon
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}`;
};
// Calculate bearing between two points
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(Δλ);
let bearing = Math.atan2(y, x) * 180 / Math.PI;
return (bearing + 360) % 360;
};
// Calculate distance between two points (km)
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) * Math.sin(Δφ/2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ/2) * Math.sin(Δλ/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
};
// Calculate sun position for day/night terminator
const getSunPosition = (date) => {
const dayOfYear = Math.floor((date - new Date(date.getFullYear(), 0, 0)) / (1000 * 60 * 60 * 24));
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 sunrise/sunset times
const calculateSunTimes = (lat, lon, date) => {
const dayOfYear = Math.floor((date - new Date(date.getFullYear(), 0, 0)) / (1000 * 60 * 60 * 24));
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 hourAngle = Math.acos(-Math.tan(latRad) * Math.tan(decRad)) * 180 / Math.PI;
const solarNoon = 12 - lon / 15;
const sunrise = solarNoon - hourAngle / 15;
const sunset = solarNoon + hourAngle / 15;
const formatTime = (hours) => {
const h = Math.floor(hours);
const m = Math.round((hours - h) * 60);
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
};
return {
sunrise: formatTime(sunrise),
sunset: formatTime(sunset)
};
};
// ============================================
// COMPONENTS
// ============================================
// Header Component
const Header = ({ callsign, uptime, version }) => (
<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',
animation: 'fadeIn 0.5s ease-out'
}}>
<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 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)'
}}>
{callsign}
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '24px',
fontFamily: 'JetBrains Mono, monospace',
fontSize: '12px',
color: 'var(--text-secondary)'
}}>
<span>UPTIME: {uptime}</span>
<span>v{version}</span>
<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>
);
// Clock Panel Component
const ClockPanel = ({ label, time, date, isUtc, style }) => (
<div style={{
background: 'var(--bg-panel)',
border: '1px solid var(--border-color)',
borderRadius: '8px',
padding: '16px 24px',
backdropFilter: 'blur(10px)',
animation: 'fadeIn 0.6s ease-out',
...style
}}>
<div style={{
fontSize: '11px',
fontWeight: '600',
color: isUtc ? 'var(--accent-amber)' : 'var(--text-secondary)',
letterSpacing: '2px',
marginBottom: '8px',
textTransform: 'uppercase'
}}>
{label}
</div>
<div style={{
fontFamily: 'Orbitron, monospace',
fontSize: '42px',
fontWeight: '700',
color: isUtc ? 'var(--accent-amber)' : 'var(--text-primary)',
textShadow: isUtc ? '0 0 20px var(--accent-amber-dim)' : 'none',
letterSpacing: '3px',
lineHeight: 1
}}>
{time}
</div>
<div style={{
fontFamily: 'JetBrains Mono, monospace',
fontSize: '13px',
color: 'var(--text-muted)',
marginTop: '6px'
}}>
{date}
</div>
</div>
);
// Space Weather Panel
const SpaceWeatherPanel = ({ data }) => {
const getConditionColor = (value, thresholds) => {
if (value >= thresholds.bad) return 'var(--accent-red)';
if (value >= thresholds.fair) return 'var(--accent-amber)';
return 'var(--accent-green)';
};
const WeatherItem = ({ label, value, unit, thresholds }) => (
<div 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',
fontWeight: '500'
}}>
{label}
</span>
<span style={{
fontFamily: 'JetBrains Mono, monospace',
fontSize: '16px',
fontWeight: '600',
color: thresholds ? getConditionColor(parseFloat(value), thresholds) : 'var(--text-primary)'
}}>
{value}{unit && <span style={{ fontSize: '11px', marginLeft: '2px' }}>{unit}</span>}
</span>
</div>
);
return (
<div style={{
background: 'var(--bg-panel)',
border: '1px solid var(--border-color)',
borderRadius: '8px',
padding: '20px',
backdropFilter: 'blur(10px)',
animation: 'fadeIn 0.7s ease-out'
}}>
<div style={{
fontSize: '12px',
fontWeight: '600',
color: 'var(--accent-cyan)',
letterSpacing: '2px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span></span> SPACE WEATHER
</div>
<WeatherItem
label="Solar Flux Index"
value={data.solarFlux}
unit="sfu"
thresholds={{ fair: 100, bad: 0 }}
/>
<WeatherItem
label="Sunspot Number"
value={data.sunspotNumber}
/>
<WeatherItem
label="K-Index"
value={data.kIndex}
thresholds={{ fair: 4, bad: 6 }}
/>
<WeatherItem
label="A-Index"
value={data.aIndex}
thresholds={{ fair: 15, bad: 30 }}
/>
<WeatherItem
label="X-Ray Flux"
value={data.xrayFlux}
/>
<div style={{
marginTop: '12px',
padding: '10px',
background: 'rgba(0, 255, 136, 0.1)',
borderRadius: '4px',
textAlign: 'center',
fontFamily: 'JetBrains Mono, monospace',
fontSize: '12px',
color: 'var(--accent-green)'
}}>
CONDITIONS: {data.conditions}
</div>
</div>
);
};
// Band Conditions Panel
const BandConditionsPanel = ({ bands }) => {
const getConditionStyle = (condition) => {
const styles = {
'GOOD': { bg: 'rgba(0, 255, 136, 0.15)', color: 'var(--accent-green)', border: 'rgba(0, 255, 136, 0.3)' },
'FAIR': { bg: 'rgba(255, 180, 50, 0.15)', color: 'var(--accent-amber)', border: 'rgba(255, 180, 50, 0.3)' },
'POOR': { bg: 'rgba(255, 68, 102, 0.15)', color: 'var(--accent-red)', border: 'rgba(255, 68, 102, 0.3)' }
};
return styles[condition] || styles['FAIR'];
};
return (
<div style={{
background: 'var(--bg-panel)',
border: '1px solid var(--border-color)',
borderRadius: '8px',
padding: '20px',
backdropFilter: 'blur(10px)',
animation: 'fadeIn 0.8s ease-out'
}}>
<div style={{
fontSize: '12px',
fontWeight: '600',
color: 'var(--accent-cyan)',
letterSpacing: '2px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span>📡</span> BAND CONDITIONS
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '8px'
}}>
{bands.map((band, i) => {
const style = getConditionStyle(band.condition);
return (
<div key={i} style={{
background: style.bg,
border: `1px solid ${style.border}`,
borderRadius: '6px',
padding: '10px',
textAlign: 'center'
}}>
<div style={{
fontFamily: 'Orbitron, monospace',
fontSize: '16px',
fontWeight: '700',
color: style.color
}}>
{band.band}
</div>
<div style={{
fontSize: '9px',
fontWeight: '600',
color: style.color,
marginTop: '4px',
opacity: 0.8
}}>
{band.condition}
</div>
</div>
);
})}
</div>
</div>
);
};
// Location Panel (DE or DX)
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)',
animation: `fadeIn ${isDE ? '0.5' : '0.6'}s ease-out`
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px'
}}>
<div style={{
fontSize: '11px',
fontWeight: '700',
color: isDE ? 'var(--accent-amber)' : 'var(--accent-blue)',
letterSpacing: '3px',
padding: '4px 12px',
background: isDE ? 'rgba(255, 180, 50, 0.15)' : 'rgba(68, 136, 255, 0.15)',
borderRadius: '4px'
}}>
{type}
</div>
<div style={{
fontFamily: 'JetBrains Mono, monospace',
fontSize: '18px',
fontWeight: '700',
color: 'var(--accent-green)',
textShadow: '0 0 10px var(--accent-green-dim)'
}}>
{gridSquare}
</div>
</div>
<div style={{
fontFamily: 'JetBrains Mono, monospace',
fontSize: '12px',
color: 'var(--text-secondary)',
marginBottom: '12px'
}}>
{location.lat.toFixed(4)}°{location.lat >= 0 ? 'N' : 'S'}, {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>
);
};
// World Map Component with Day/Night Terminator
const WorldMap = ({ deLocation, dxLocation, sunPosition }) => {
const width = 800;
const height = 400;
// Convert lat/lon to map coordinates
const toMapCoords = (lat, lon) => ({
x: ((lon + 180) / 360) * width,
y: ((90 - lat) / 180) * height
});
// Generate terminator path
const generateTerminatorPath = () => {
const points = [];
const sunLat = sunPosition.lat;
const sunLon = sunPosition.lon;
for (let lon = -180; lon <= 180; lon += 2) {
const lonRad = lon * Math.PI / 180;
const sunLonRad = sunLon * Math.PI / 180;
const sunLatRad = sunLat * Math.PI / 180;
const hourAngle = lonRad - sunLonRad;
const lat = Math.atan(-Math.cos(hourAngle) / Math.tan(sunLatRad)) * 180 / Math.PI;
const coords = toMapCoords(lat, lon);
points.push(`${coords.x},${coords.y}`);
}
// Close the path for the night side
const nightPath = `M ${points.join(' L ')} L ${width},${sunLat > 0 ? height : 0} L 0,${sunLat > 0 ? height : 0} Z`;
return nightPath;
};
const sunCoords = toMapCoords(sunPosition.lat, sunPosition.lon);
const deCoords = deLocation ? toMapCoords(deLocation.lat, deLocation.lon) : null;
const dxCoords = dxLocation ? toMapCoords(dxLocation.lat, dxLocation.lon) : null;
return (
<div style={{
background: 'var(--bg-panel)',
border: '1px solid var(--border-color)',
borderRadius: '8px',
overflow: 'hidden',
animation: 'fadeIn 0.6s ease-out'
}}>
<svg
viewBox={`0 0 ${width} ${height}`}
style={{ width: '100%', height: 'auto', display: 'block' }}
>
{/* Map background with grid */}
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(255,255,255,0.03)" strokeWidth="0.5"/>
</pattern>
<pattern id="smallGrid" width="8" height="8" patternUnits="userSpaceOnUse">
<path d="M 8 0 L 0 0 0 8" fill="none" stroke="rgba(255,255,255,0.015)" strokeWidth="0.3"/>
</pattern>
<linearGradient id="dayGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style={{stopColor: '#1a3a5c', stopOpacity: 1}} />
<stop offset="50%" style={{stopColor: '#0d2840', stopOpacity: 1}} />
<stop offset="100%" style={{stopColor: '#1a3a5c', stopOpacity: 1}} />
</linearGradient>
<radialGradient id="sunGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" style={{stopColor: 'var(--accent-amber)', stopOpacity: 0.6}} />
<stop offset="100%" style={{stopColor: 'var(--accent-amber)', stopOpacity: 0}} />
</radialGradient>
</defs>
{/* Ocean/Background */}
<rect width={width} height={height} fill="url(#dayGradient)"/>
<rect width={width} height={height} fill="url(#smallGrid)"/>
<rect width={width} height={height} fill="url(#grid)"/>
{/* Simplified continents - key landmasses */}
<g fill="rgba(50, 80, 60, 0.5)" stroke="rgba(100, 150, 100, 0.3)" strokeWidth="0.5">
{/* North America */}
<path d="M 80,80 L 120,60 L 180,70 L 200,100 L 190,140 L 160,160 L 130,150 L 100,140 L 80,110 Z"/>
{/* South America */}
<path d="M 140,180 L 170,170 L 190,200 L 180,260 L 160,300 L 130,290 L 120,240 L 130,200 Z"/>
{/* Europe */}
<path d="M 380,70 L 420,60 L 460,70 L 470,90 L 450,110 L 410,120 L 380,100 Z"/>
{/* Africa */}
<path d="M 400,130 L 460,120 L 500,160 L 490,240 L 450,280 L 400,260 L 380,200 L 390,150 Z"/>
{/* Asia */}
<path d="M 480,50 L 600,40 L 700,60 L 740,100 L 720,140 L 640,150 L 580,130 L 520,110 L 480,80 Z"/>
{/* Australia */}
<path d="M 640,220 L 700,210 L 740,240 L 730,280 L 680,290 L 640,270 L 630,240 Z"/>
{/* Antarctica hint */}
<path d="M 100,380 L 700,380 L 700,400 L 100,400 Z" fill="rgba(200, 220, 240, 0.3)"/>
</g>
{/* Night overlay */}
<path
d={generateTerminatorPath()}
fill="rgba(5, 10, 20, 0.65)"
style={{ transition: 'all 1s ease' }}
/>
{/* Terminator line (gray line) */}
<path
d={generateTerminatorPath().replace(/ L \d+,\d+ L \d+,\d+ Z/, '')}
fill="none"
stroke="rgba(255, 180, 50, 0.4)"
strokeWidth="2"
strokeDasharray="4,4"
/>
{/* Sun position */}
<circle cx={sunCoords.x} cy={sunCoords.y} r="30" fill="url(#sunGlow)"/>
<circle cx={sunCoords.x} cy={sunCoords.y} r="6" fill="var(--accent-amber)"/>
{/* Path between DE and DX */}
{deCoords && dxCoords && (
<line
x1={deCoords.x} y1={deCoords.y}
x2={dxCoords.x} y2={dxCoords.y}
stroke="var(--accent-cyan)"
strokeWidth="1.5"
strokeDasharray="6,3"
opacity="0.7"
/>
)}
{/* DE marker */}
{deCoords && (
<g>
<circle cx={deCoords.x} cy={deCoords.y} r="12" fill="rgba(255, 180, 50, 0.2)"/>
<circle cx={deCoords.x} cy={deCoords.y} r="6" fill="var(--accent-amber)" stroke="#fff" strokeWidth="1.5"/>
<text x={deCoords.x + 12} y={deCoords.y + 4} fill="var(--accent-amber)" fontSize="10" fontWeight="bold">DE</text>
</g>
)}
{/* DX marker */}
{dxCoords && (
<g>
<circle cx={dxCoords.x} cy={dxCoords.y} r="12" fill="rgba(68, 136, 255, 0.2)"/>
<circle cx={dxCoords.x} cy={dxCoords.y} r="6" fill="var(--accent-blue)" stroke="#fff" strokeWidth="1.5"/>
<text x={dxCoords.x + 12} y={dxCoords.y + 4} fill="var(--accent-blue)" fontSize="10" fontWeight="bold">DX</text>
</g>
)}
{/* Latitude lines labels */}
<text x="5" y="108" fill="rgba(255,255,255,0.3)" fontSize="8">60°N</text>
<text x="5" y="200" fill="rgba(255,255,255,0.3)" fontSize="8">EQ</text>
<text x="5" y="292" fill="rgba(255,255,255,0.3)" fontSize="8">60°S</text>
</svg>
</div>
);
};
// DX Cluster Panel (placeholder for future API integration)
const DXClusterPanel = ({ spots }) => (
<div style={{
background: 'var(--bg-panel)',
border: '1px solid var(--border-color)',
borderRadius: '8px',
padding: '20px',
backdropFilter: 'blur(10px)',
animation: 'fadeIn 0.9s ease-out',
maxHeight: '300px',
overflow: 'hidden'
}}>
<div style={{
fontSize: '12px',
fontWeight: '600',
color: 'var(--accent-cyan)',
letterSpacing: '2px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<span>🌐 DX CLUSTER</span>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Live Feed</span>
</div>
<div style={{ overflowY: 'auto', maxHeight: '220px' }}>
{spots.map((spot, i) => (
<div key={i} style={{
display: 'grid',
gridTemplateColumns: '70px 90px 1fr auto',
gap: '12px',
padding: '8px 0',
borderBottom: '1px solid rgba(255,255,255,0.03)',
fontFamily: 'JetBrains Mono, monospace',
fontSize: '11px',
alignItems: 'center'
}}>
<span style={{ color: 'var(--accent-green)' }}>{spot.freq}</span>
<span style={{ color: 'var(--accent-amber)' }}>{spot.call}</span>
<span style={{ color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{spot.comment}</span>
<span style={{ color: 'var(--text-muted)' }}>{spot.time}</span>
</div>
))}
</div>
</div>
);
// POTA/SOTA Activity Panel
const ActivityPanel = ({ title, icon, activities }) => (
<div style={{
background: 'var(--bg-panel)',
border: '1px solid var(--border-color)',
borderRadius: '8px',
padding: '20px',
backdropFilter: 'blur(10px)',
animation: 'fadeIn 1s ease-out'
}}>
<div style={{
fontSize: '12px',
fontWeight: '600',
color: 'var(--accent-cyan)',
letterSpacing: '2px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span>{icon}</span> {title}
</div>
<div style={{ maxHeight: '180px', overflowY: 'auto' }}>
{activities.map((act, i) => (
<div key={i} style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 0',
borderBottom: '1px solid rgba(255,255,255,0.03)',
fontFamily: 'JetBrains Mono, monospace',
fontSize: '11px'
}}>
<div>
<span style={{ color: 'var(--accent-amber)' }}>{act.call}</span>
<span style={{ color: 'var(--text-muted)', marginLeft: '8px' }}>{act.ref}</span>
</div>
<div>
<span style={{ color: 'var(--accent-green)' }}>{act.freq}</span>
<span style={{ color: 'var(--text-secondary)', marginLeft: '8px' }}>{act.mode}</span>
</div>
</div>
))}
</div>
</div>
);
// Settings Modal (future expansion)
const SettingsButton = ({ onClick }) => (
<button onClick={onClick} style={{
position: 'fixed',
bottom: '20px',
right: '20px',
width: '48px',
height: '48px',
borderRadius: '50%',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
color: 'var(--text-secondary)',
cursor: 'pointer',
fontSize: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease',
zIndex: 100
}}>
</button>
);
// ============================================
// MAIN APP COMPONENT
// ============================================
const App = () => {
const [currentTime, setCurrentTime] = useState(new Date());
const [callsign, setCallsign] = useState('K0CJH');
const [startTime] = useState(Date.now());
const [uptime, setUptime] = useState('0d 0h 0m');
// DE Location (user's location) - default to Denver area for K0CJH
const [deLocation, setDeLocation] = useState({ lat: 39.7392, lon: -104.9903 });
// DX Location (target location) - default to Tokyo
const [dxLocation, setDxLocation] = useState({ lat: 35.6762, lon: 139.6503 });
// Calculate grid squares
const deGrid = useMemo(() => calculateGridSquare(deLocation.lat, deLocation.lon), [deLocation]);
const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]);
// Calculate sun times
const deSunTimes = useMemo(() => calculateSunTimes(deLocation.lat, deLocation.lon, currentTime), [deLocation, currentTime]);
const dxSunTimes = useMemo(() => calculateSunTimes(dxLocation.lat, dxLocation.lon, currentTime), [dxLocation, currentTime]);
// Sun position for terminator
const sunPosition = useMemo(() => getSunPosition(currentTime), [currentTime]);
// Mock space weather data (would be fetched from APIs in production)
const [spaceWeather] = useState({
solarFlux: '148',
sunspotNumber: '112',
kIndex: '2',
aIndex: '8',
xrayFlux: 'B4.2',
conditions: 'GOOD'
});
// Mock band conditions
const [bandConditions] = useState([
{ band: '160m', condition: 'FAIR' },
{ band: '80m', condition: 'GOOD' },
{ band: '40m', condition: 'GOOD' },
{ band: '30m', condition: 'GOOD' },
{ band: '20m', condition: 'GOOD' },
{ band: '17m', condition: 'GOOD' },
{ band: '15m', condition: 'FAIR' },
{ band: '12m', condition: 'FAIR' },
{ band: '10m', condition: 'POOR' },
{ band: '6m', condition: 'POOR' },
{ band: '2m', condition: 'GOOD' },
{ band: '70cm', condition: 'GOOD' }
]);
// Mock DX Cluster spots
const [dxSpots] = useState([
{ freq: '14.074', call: 'JA1ABC', comment: 'FT8 -12dB', time: '1423z' },
{ freq: '21.074', call: 'VK2DEF', comment: 'FT8 -08dB', time: '1422z' },
{ freq: '7.040', call: 'DL1XYZ', comment: 'CW 599', time: '1421z' },
{ freq: '14.200', call: 'ZL3QRS', comment: 'SSB 59', time: '1420z' },
{ freq: '28.074', call: 'LU5TUV', comment: 'FT8 -15dB', time: '1419z' },
]);
// Mock POTA activities
const [potaActivity] = useState([
{ call: 'W5ABC', ref: 'K-1234', freq: '14.290', mode: 'SSB' },
{ call: 'K3XYZ', ref: 'K-5678', freq: '7.040', mode: 'CW' },
{ call: 'N4QRS', ref: 'K-9012', freq: '14.074', mode: 'FT8' },
]);
// Update time every second
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
// Update uptime
const elapsed = Date.now() - startTime;
const days = Math.floor(elapsed / (1000 * 60 * 60 * 24));
const hours = Math.floor((elapsed % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((elapsed % (1000 * 60 * 60)) / (1000 * 60));
setUptime(`${days}d ${hours}h ${minutes}m`);
}, 1000);
return () => clearInterval(timer);
}, [startTime]);
// Format times
const utcTime = currentTime.toISOString().substr(11, 8);
const localTime = currentTime.toLocaleTimeString('en-US', { hour12: false });
const utcDate = currentTime.toISOString().substr(0, 10);
const localDate = currentTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
return (
<div style={{
minHeight: '100vh',
background: 'var(--bg-primary)'
}}>
<Header callsign={callsign} uptime={uptime} version="1.0.0" />
<main style={{
padding: '20px',
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gridTemplateRows: 'auto auto auto',
gap: '16px',
maxWidth: '1800px',
margin: '0 auto'
}}>
{/* Row 1: Clocks and Location Info */}
<ClockPanel
label="UTC Time"
time={utcTime}
date={utcDate}
isUtc={true}
/>
<ClockPanel
label="Local Time"
time={localTime}
date={localDate}
isUtc={false}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<LocationPanel
type="DE"
location={deLocation}
gridSquare={deGrid}
sunTimes={deSunTimes}
otherLocation={dxLocation}
/>
</div>
{/* Row 2: World Map (spans 2 cols) and Space Weather */}
<div style={{ gridColumn: 'span 2' }}>
<WorldMap
deLocation={deLocation}
dxLocation={dxLocation}
sunPosition={sunPosition}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<LocationPanel
type="DX"
location={dxLocation}
gridSquare={dxGrid}
sunTimes={dxSunTimes}
otherLocation={deLocation}
/>
<SpaceWeatherPanel data={spaceWeather} />
</div>
{/* Row 3: Band Conditions, DX Cluster, POTA */}
<BandConditionsPanel bands={bandConditions} />
<DXClusterPanel spots={dxSpots} />
<ActivityPanel
title="POTA ACTIVITY"
icon="🏕"
activities={potaActivity}
/>
</main>
<SettingsButton onClick={() => alert('Settings panel coming soon!')} />
{/* Footer */}
<footer style={{
textAlign: 'center',
padding: '20px',
color: 'var(--text-muted)',
fontSize: '11px',
fontFamily: 'JetBrains Mono, monospace'
}}>
OpenHamClock v1.0.0 | In memory of Elwood Downey WB0OEW | 73 de {callsign}
</footer>
</div>
);
};
// Render the app
ReactDOM.render(<App />, document.getElementById('root'));
</script>
</body>
</html>

Powered by TurnKey Linux.