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.
1036 lines
36 KiB
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>
|