Modular init commit

pull/6/head
accius 3 days ago
parent c60c318c00
commit 0eef840530

@ -1,219 +1,222 @@
# Contributing to OpenHamClock
First off, thank you for considering contributing to OpenHamClock! It's people like you that make the amateur radio community great. 73!
Thank you for your interest in contributing! This document explains how to work with the modular codebase.
## Table of Contents
## 📐 Architecture Overview
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [How Can I Contribute?](#how-can-i-contribute)
- [Development Setup](#development-setup)
- [Pull Request Process](#pull-request-process)
- [Style Guidelines](#style-guidelines)
OpenHamClock uses a clean separation of concerns:
## Code of Conduct
This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
## Getting Started
### Issues
- **Bug Reports**: If you find a bug, please create an issue with a clear title and description. Include as much relevant information as possible, including steps to reproduce.
- **Feature Requests**: We welcome feature suggestions! Open an issue describing the feature and why it would be useful.
- **Questions**: Use GitHub Discussions for questions about usage or development.
### Good First Issues
Looking for something to work on? Check out issues labeled [`good first issue`](https://github.com/accius/openhamclock/labels/good%20first%20issue) - these are great for newcomers!
## How Can I Contribute?
### Reporting Bugs
Before creating a bug report, please check existing issues to avoid duplicates. When you create a bug report, include:
- **Clear title** describing the issue
- **Steps to reproduce** the behavior
- **Expected behavior** vs **actual behavior**
- **Screenshots** if applicable
- **Environment details**: OS, browser, Node.js version, Pi model, etc.
### Suggesting Features
We love hearing ideas from the community! When suggesting a feature:
- **Use a clear title** for the issue
- **Provide a detailed description** of the proposed feature
- **Explain the use case** - how would this benefit ham radio operators?
- **Consider implementation** - any ideas on how to build it?
### Priority Contribution Areas
We especially welcome contributions in these areas:
1. **Satellite Tracking**
- TLE parsing and SGP4 propagation
- Pass predictions and AOS/LOS times
- Satellite footprint visualization
2. **Real-time DX Cluster**
- WebSocket connection to Telnet clusters
- Spot filtering and alerting
- Clickable spots to set DX
3. **Contest Integration**
- Contest calendar from WA7BNM or similar
- Contest-specific band plans
- Rate/multiplier tracking
4. **Hardware Integration**
- Hamlib radio control (frequency, mode)
- Rotator control
- External GPIO for Pi (PTT, etc.)
5. **Accessibility**
- Screen reader support
- High contrast themes
- Keyboard navigation
6. **Internationalization**
- Translation framework
- Localized date/time formats
- Multi-language support
## Development Setup
### Prerequisites
- Node.js 18 or later
- Git
- A modern web browser
### Local Development
```bash
# Clone your fork
git clone https://github.com/YOUR_USERNAME/openhamclock.git
cd openhamclock
# Add upstream remote
git remote add upstream https://github.com/accius/openhamclock.git
```
src/
├── components/ # React UI components
├── hooks/ # Data fetching & state management
├── utils/ # Pure utility functions
└── styles/ # CSS with theme variables
```
# Install dependencies
npm install
## 🔧 Working on Components
# Start development server
npm run dev
Each component is self-contained in its own file. To modify a component:
# In another terminal, run Electron (optional)
npm run electron
1. Open the component file in `src/components/`
2. Make your changes
3. Test with `npm run dev`
4. Ensure all three themes still work
### Component Guidelines
```jsx
// Good component structure
export const MyComponent = ({ prop1, prop2, onAction }) => {
// Hooks at the top
const [state, setState] = useState(initial);
// Event handlers
const handleClick = () => {
onAction?.(state);
};
// Early returns for loading/empty states
if (!prop1) return null;
// Main render
return (
<div className="panel">
{/* Use CSS variables for colors */}
<div style={{ color: 'var(--accent-cyan)' }}>
{prop1}
</div>
</div>
);
};
```
### Project Structure
```
openhamclock/
├── public/index.html # Main application (React + Leaflet)
├── server.js # Express API proxy server
├── electron/main.js # Desktop app wrapper
├── scripts/ # Platform setup scripts
└── package.json # Dependencies and scripts
## 🪝 Working on Hooks
Hooks handle data fetching and state. Each hook:
- Fetches from a specific API endpoint
- Manages loading state
- Handles errors gracefully
- Returns consistent shape: `{ data, loading, error? }`
### Hook Guidelines
```jsx
// Good hook structure
export const useMyData = (param) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!param) {
setLoading(false);
return;
}
const fetchData = async () => {
try {
const response = await fetch(`/api/endpoint/${param}`);
if (response.ok) {
const result = await response.json();
setData(result);
}
} catch (err) {
console.error('MyData error:', err);
} finally {
setLoading(false);
}
};
fetchData();
const interval = setInterval(fetchData, 30000); // 30 sec refresh
return () => clearInterval(interval);
}, [param]);
return { data, loading };
};
```
### Making Changes
## 🛠️ Working on Utilities
1. Create a new branch from `main`:
```bash
git checkout -b feature/your-feature-name
```
Utilities are pure functions with no side effects:
2. Make your changes
```jsx
// Good utility
export const calculateSomething = (input1, input2) => {
// Pure calculation, no API calls or DOM access
return result;
};
```
3. Test thoroughly:
- Test in multiple browsers (Chrome, Firefox, Safari)
- Test on desktop and mobile viewports
- Test the Electron app if applicable
- Verify API proxy endpoints work
## 🎨 CSS & Theming
4. Commit with clear messages:
```bash
git commit -m "Add satellite tracking panel with TLE parser"
```
Use CSS variables for all colors:
## Pull Request Process
```css
/* ✅ Good - uses theme variable */
.my-element {
color: var(--accent-cyan);
background: var(--bg-panel);
border: 1px solid var(--border-color);
}
1. **Update documentation** if needed (README, inline comments)
/* ❌ Bad - hardcoded color */
.my-element {
color: #00ddff;
}
```
2. **Ensure your code follows style guidelines** (see below)
Available theme variables:
- `--bg-primary`, `--bg-secondary`, `--bg-tertiary`, `--bg-panel`
- `--border-color`
- `--text-primary`, `--text-secondary`, `--text-muted`
- `--accent-amber`, `--accent-green`, `--accent-red`, `--accent-blue`, `--accent-cyan`, `--accent-purple`
3. **Test your changes** on multiple platforms if possible
## 📝 Adding a New Feature
4. **Create the Pull Request**:
- Use a clear, descriptive title
- Reference any related issues (`Fixes #123`)
- Describe what changes you made and why
- Include screenshots for UI changes
### New Component
5. **Respond to feedback** - maintainers may request changes
1. Create `src/components/MyComponent.jsx`
2. Export from `src/components/index.js`
3. Import and use in `App.jsx`
6. **Once approved**, a maintainer will merge your PR
### New Hook
### PR Title Format
1. Create `src/hooks/useMyHook.js`
2. Export from `src/hooks/index.js`
3. Import and use in component
Use conventional commit style:
- `feat: Add satellite tracking panel`
- `fix: Correct timezone calculation for DST`
- `docs: Update Pi installation instructions`
- `style: Improve mobile responsive layout`
- `refactor: Simplify API proxy endpoints`
### New Utility
## Style Guidelines
1. Add function to appropriate file in `src/utils/`
2. Export from `src/utils/index.js`
3. Import where needed
### JavaScript
## 🧪 Testing Your Changes
- Use modern ES6+ syntax
- Prefer `const` over `let`, avoid `var`
- Use meaningful variable and function names
- Add comments for complex logic
- Keep functions focused and small
```bash
# Start dev servers
node server.js # Terminal 1
npm run dev # Terminal 2
# Test checklist:
# [ ] Component renders correctly
# [ ] Works in Dark theme
# [ ] Works in Light theme
# [ ] Works in Legacy theme
# [ ] Responsive on smaller screens
# [ ] No console errors
# [ ] Data fetches correctly
```
### CSS
## 📋 Pull Request Checklist
- Use CSS custom properties (variables) for theming
- Follow the existing naming conventions
- Prefer flexbox/grid over floats
- Test responsive breakpoints
- [ ] Code follows existing patterns
- [ ] All themes work correctly
- [ ] No console errors/warnings
- [ ] Component is exported from index.js
- [ ] Added JSDoc comments if needed
- [ ] Tested on different screen sizes
### React Components
## 🐛 Reporting Bugs
- Use functional components with hooks
- Keep components focused on single responsibilities
- Extract reusable logic into custom hooks
- Use meaningful prop names
1. Check existing issues first
2. Include browser and screen size
3. Include console errors if any
4. Include steps to reproduce
### Git Commits
## 💡 Feature Requests
- Write clear, concise commit messages
- Use present tense ("Add feature" not "Added feature")
- Reference issues when applicable
1. Describe the feature
2. Explain the use case
3. Show how it would work (mockups welcome)
## Recognition
## 🏗️ Reference Implementation
Contributors will be recognized in:
- The README contributors section
- Release notes for significant contributions
- The project's GitHub contributors page
The original monolithic version is preserved at `public/index-monolithic.html` (5714 lines). Use it as reference for:
## Questions?
- Line numbers for each feature section
- Complete implementation details
- Original styling decisions
Feel free to:
- Open a GitHub Discussion
- Email chris@cjhlighting.com
- Reach out to maintainers
### Key Sections in Monolithic Version
---
| Lines | Section |
|-------|---------|
| 30-335 | CSS styles & themes |
| 340-640 | Config & map providers |
| 438-636 | Utility functions (geo) |
| 641-691 | useSpaceWeather |
| 721-810 | useBandConditions |
| 812-837 | usePOTASpots |
| 839-1067 | DX cluster filters & helpers |
| 1069-1696 | useDXCluster with filtering |
| 2290-3022 | WorldMap component |
| 3024-3190 | Header component |
| 3195-3800 | DXFilterManager |
| 3800-4200 | SettingsPanel |
| 5019-5714 | Main App & rendering |
**73 and thanks for contributing to OpenHamClock!**
## 📜 License
*In memory of Elwood Downey, WB0OEW*
By contributing, you agree that your contributions will be licensed under the MIT License.

@ -1,355 +1,199 @@
# 🌐 OpenHamClock
# OpenHamClock - Modular React Architecture
<div align="center">
A modern, modular amateur radio dashboard built with React and Vite. This is the **fully extracted modular version** - all components, hooks, and utilities are already separated into individual files.
![OpenHamClock Banner](https://img.shields.io/badge/OpenHamClock-v3.9.0-orange?style=for-the-badge)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg?style=for-the-badge)](LICENSE)
[![Node.js](https://img.shields.io/badge/Node.js-18+-brightgreen?style=for-the-badge&logo=node.js)](https://nodejs.org/)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge)](CONTRIBUTING.md)
**A modern, open-source amateur radio dashboard with real-time space weather, band conditions, DX cluster, and interactive world maps.**
*In loving memory of Elwood Downey, WB0OEW, creator of the original HamClock*
[**Live Demo**](https://openhamclock.up.railway.app) · [**Download**](#-installation) · [**Documentation**](#-features) · [**Contributing**](#-contributing)
![OpenHamClock Screenshot](screenshot.png)
</div>
---
## 📡 About
OpenHamClock is a spiritual successor to the beloved HamClock application created by Elwood Downey, WB0OEW. After Elwood's passing and the announcement that HamClock will cease functioning in June 2026, the amateur radio community came together to create an open-source alternative that carries forward his vision.
### Why OpenHamClock?
- **Open Source**: MIT licensed, community-driven development
- **Cross-Platform**: Runs on Windows, macOS, Linux, and Raspberry Pi
- **Modern Stack**: Built with web technologies for easy customization
- **Real Maps**: Actual satellite/terrain imagery, not approximations
- **Live Data**: Real-time feeds from NOAA, POTA, SOTA, and DX clusters
- **Self-Hosted**: Run locally or deploy to your own server
---
## ✨ Features
### 🗺️ Interactive World Map
- **8 map styles**: Dark, Satellite, Terrain, Streets, Topo, Ocean, NatGeo, Gray
- **Real-time day/night terminator** (gray line)
- **Great circle paths** between DE and DX
- **Click anywhere** to set DX location
- **POTA activators** displayed on map with callsigns
- **DX cluster paths** - Lines connecting spotters to DX stations with band colors
- **Moon tracking** - Real-time sublunar point with phase display
- **Zoom and pan** with full interactivity
### 📡 Propagation Prediction
- **Hybrid ITU-R P.533-14** - Combines professional model with real-time data
- ITURHFProp engine provides base P.533-14 predictions
- KC2G/GIRO ionosonde network provides real-time corrections
- Automatic fallback when services unavailable
- **Real-time ionosonde data** from KC2G/GIRO network (~100 stations)
- **Visual heat map** showing band conditions to DX
- **24-hour propagation chart** with hourly predictions
- **Solar flux, K-index, and sunspot** integration
### 📊 Live Data Integration
| Source | Data | Update Rate |
|--------|------|-------------|
| NOAA SWPC | Solar Flux, K-Index, Sunspots | 5 min |
| KC2G/GIRO | Ionosonde foF2, MUF data | 10 min |
| POTA | Parks on the Air spots | 1 min |
| SOTA | Summits on the Air spots | 1 min |
| DX Cluster | Real-time DX spots | 30 sec |
| HamQSL | Band conditions | 5 min |
### 🔍 DX Cluster
- **Real-time spots** from DX Spider network
- **Visual paths on map** with band-specific colors
- **Hover highlighting** - Mouse over spots to highlight on map
- **Grid square display** - Parsed from spot comments
- **Filtering** by band, mode, continent, and search
- **Spotter locations** shown on map
### 🕐 Station Information
- **UTC and Local time** with date
- **Maidenhead grid square** (6 character)
- **Sunrise/Sunset times** for DE and DX
- **Short path/Long path bearings**
- **Great circle distance** calculation
- **Space weather conditions** assessment
### 📻 Band Conditions
- Visual display for 160m through 70cm
- Color-coded: Good (green), Fair (amber), Poor (red)
- Based on real propagation data
---
## 🚀 Installation
### Quick Start (Any Platform)
## 🚀 Quick Start
```bash
# Clone the repository
git clone https://github.com/accius/openhamclock.git
cd openhamclock
# Install dependencies
npm install
# Start the server
npm start
# Start development servers (need two terminals)
# Terminal 1: Backend API server
node server.js
# Open http://localhost:3000 in your browser
```
### One-Line Install
**Linux/macOS:**
```bash
curl -fsSL https://raw.githubusercontent.com/accius/openhamclock/main/scripts/setup-linux.sh | bash
```
# Terminal 2: Frontend dev server with hot reload
npm run dev
**Windows (PowerShell as Admin):**
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force; iex ((New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/accius/openhamclock/main/scripts/setup-windows.ps1'))
# Open http://localhost:3000
```
### 🍓 Raspberry Pi
For production:
```bash
# Download and run the Pi setup script
curl -fsSL https://raw.githubusercontent.com/accius/openhamclock/main/scripts/setup-pi.sh -o setup-pi.sh
chmod +x setup-pi.sh
# Standard installation
./setup-pi.sh
# Or with kiosk mode (fullscreen, auto-start on boot)
./setup-pi.sh --kiosk
```
**Supported Pi Models:**
- Raspberry Pi 3B / 3B+ ✓
- Raspberry Pi 4 (2GB+) ✓✓ (Recommended)
- Raspberry Pi 5 ✓✓✓ (Best performance)
### 🖥️ Desktop App (Electron)
npm run build
npm start # Serves from dist/ on port 3001
```
## 📁 Project Structure
```
openhamclock-modular/
├── src/
│ ├── main.jsx # React entry point
│ ├── App.jsx # Main application component
│ ├── components/ # All UI components (fully extracted)
│ │ ├── index.js # Component exports
│ │ ├── Header.jsx # Top bar with clocks/controls
│ │ ├── WorldMap.jsx # Leaflet map with DX paths
│ │ ├── SpaceWeatherPanel.jsx
│ │ ├── BandConditionsPanel.jsx
│ │ ├── DXClusterPanel.jsx
│ │ ├── POTAPanel.jsx
│ │ ├── ContestPanel.jsx
│ │ ├── LocationPanel.jsx
│ │ ├── SettingsPanel.jsx
│ │ └── DXFilterManager.jsx
│ ├── hooks/ # All data fetching hooks (fully extracted)
│ │ ├── index.js # Hook exports
│ │ ├── useSpaceWeather.js
│ │ ├── useBandConditions.js
│ │ ├── useDXCluster.js
│ │ ├── useDXPaths.js
│ │ ├── usePOTASpots.js
│ │ ├── useContests.js
│ │ ├── useLocalWeather.js
│ │ ├── usePropagation.js
│ │ ├── useMySpots.js
│ │ ├── useDXpeditions.js
│ │ ├── useSatellites.js
│ │ └── useSolarIndices.js
│ ├── utils/ # Utility functions (fully extracted)
│ │ ├── index.js # Utility exports
│ │ ├── config.js # App config & localStorage
│ │ ├── geo.js # Grid squares, bearings, distances
│ │ └── callsign.js # Band detection, filtering
│ └── styles/
│ └── main.css # All CSS with theme variables
├── public/
│ └── index-monolithic.html # Original 5714-line reference
├── server.js # Backend API server
├── config.js # Server configuration
├── package.json
├── vite.config.js
└── index.html # Vite entry HTML
```
## 🎨 Themes
Three themes available via Settings:
- **Dark** (default) - Modern dark theme with amber accents
- **Light** - Light theme for daytime use
- **Legacy** - Classic HamClock green-on-black terminal style
Themes use CSS custom properties defined in `src/styles/main.css`.
## 🔌 Components
All components are fully extracted and ready to modify:
| Component | Description | File |
|-----------|-------------|------|
| Header | Top bar with clocks, weather, controls | `Header.jsx` |
| WorldMap | Leaflet map with markers & paths | `WorldMap.jsx` |
| SpaceWeatherPanel | SFI, K-index, SSN display | `SpaceWeatherPanel.jsx` |
| BandConditionsPanel | HF band condition indicators | `BandConditionsPanel.jsx` |
| DXClusterPanel | Live DX spots list | `DXClusterPanel.jsx` |
| POTAPanel | Parks on the Air activations | `POTAPanel.jsx` |
| ContestPanel | Upcoming contests | `ContestPanel.jsx` |
| LocationPanel | DE/DX info with grid squares | `LocationPanel.jsx` |
| SettingsPanel | Configuration modal | `SettingsPanel.jsx` |
| DXFilterManager | DX cluster filtering modal | `DXFilterManager.jsx` |
## 🪝 Hooks
All data fetching is handled by custom hooks:
| Hook | Purpose | Interval |
|------|---------|----------|
| `useSpaceWeather` | SFI, K-index, SSN from NOAA | 5 min |
| `useBandConditions` | Calculate band conditions | On SFI change |
| `useDXCluster` | DX spots with filtering | 5 sec |
| `useDXPaths` | DX paths for map | 10 sec |
| `usePOTASpots` | POTA activations | 1 min |
| `useContests` | Contest calendar | 30 min |
| `useLocalWeather` | Weather from Open-Meteo | 15 min |
| `usePropagation` | ITURHFProp predictions | 10 min |
| `useMySpots` | Your callsign spots | 30 sec |
| `useSatellites` | Satellite tracking | 5 sec |
| `useSolarIndices` | Extended solar data | 15 min |
## 🛠️ Utilities
| Module | Functions |
|--------|-----------|
| `config.js` | `loadConfig`, `saveConfig`, `applyTheme`, `MAP_STYLES` |
| `geo.js` | `calculateGridSquare`, `calculateBearing`, `calculateDistance`, `getSunPosition`, `getMoonPosition`, `getGreatCirclePoints` |
| `callsign.js` | `getBandFromFreq`, `getBandColor`, `detectMode`, `getCallsignInfo`, `filterDXPaths` |
## 🌐 API Endpoints
The backend server provides:
| Endpoint | Description |
|----------|-------------|
| `/api/dxcluster/spots` | DX cluster spots |
| `/api/dxcluster/paths` | DX paths with coordinates |
| `/api/solar-indices` | Extended solar data |
| `/api/propagation` | HF propagation predictions |
| `/api/contests` | Contest calendar |
| `/api/myspots/:callsign` | Spots for your callsign |
| `/api/satellites/tle` | Satellite TLE data |
| `/api/dxpeditions` | Active DXpeditions |
## 🚀 Deployment
### Railway
```bash
# Development
npm run electron
# Build for your platform
npm run electron:build
# Build for specific platform
npm run electron:build:win # Windows
npm run electron:build:mac # macOS
npm run electron:build:linux # Linux
# railway.toml and railway.json are included
railway up
```
### 🐳 Docker
### Docker
```bash
# Build the image
docker build -t openhamclock .
# Run the container
docker run -p 3000:3000 openhamclock
# Or use Docker Compose
docker compose up -d
docker-compose up -d
```
### ☁️ Deploy to Railway
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/openhamclock)
#### Full Deployment (3 Services)
For the complete hybrid propagation system, deploy all three services:
**1. Deploy ITURHFProp Service First** (enables hybrid propagation)
```
├── Go to railway.app → New Project → Deploy from GitHub repo
├── Select your forked repository
├── Click "Add Service" → "GitHub Repo" (same repo)
├── In service settings, set "Root Directory" to: iturhfprop-service
├── If Root Directory option not visible:
│ - Go to Service → Settings → Build
│ - Add "Root Directory" and enter: iturhfprop-service
├── Deploy and wait for build to complete (~2-3 min)
└── Copy the public URL (Settings → Networking → Generate Domain)
```
**2. Deploy DX Spider Proxy** (optional - for live DX cluster paths)
```
├── In same project, click "Add Service" → "GitHub Repo"
├── Set "Root Directory" to: dxspider-proxy
└── Deploy
```
**3. Deploy Main OpenHamClock**
```
├── In same project, click "Add Service" → "GitHub Repo"
├── Leave Root Directory empty (uses repo root)
├── Go to Variables tab, add:
│ ITURHFPROP_URL = https://[your-iturhfprop-service].up.railway.app
└── Deploy
```
#### Alternative: Separate Projects
If Root Directory doesn't work, create separate Railway projects:
1. Fork the repo 3 times (or use branches)
2. Move each service to its own repo root
3. Deploy each as separate Railway project
4. Link via environment variables
---
## ⚙️ Configuration
Edit your callsign and location in `public/index.html`:
```javascript
const CONFIG = {
callsign: 'YOUR_CALL',
location: { lat: YOUR_LAT, lon: YOUR_LON },
defaultDX: { lat: 35.6762, lon: 139.6503 },
// ...
};
```
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `3000` | Server port |
| `NODE_ENV` | `development` | Environment mode |
| `ITURHFPROP_URL` | `null` | ITURHFProp service URL (enables hybrid mode) |
---
## 🗺️ Map Styles
| Style | Provider | Best For |
|-------|----------|----------|
| **Dark** | CartoDB | Night use, low-light shacks |
| **Satellite** | ESRI | Terrain visualization |
| **Terrain** | OpenTopoMap | SOTA operations |
| **Streets** | OpenStreetMap | Urban navigation |
| **Topo** | ESRI | Detailed terrain |
| **Ocean** | ESRI | Maritime operations |
| **NatGeo** | ESRI | Classic cartography |
| **Gray** | ESRI | Minimal, distraction-free |
---
## 🛠️ Development
### Manual
```bash
# Clone and setup
git clone https://github.com/accius/openhamclock.git
cd openhamclock
npm install
# Start development server
npm run dev
# Run Electron in dev mode
npm run electron
npm run build
NODE_ENV=production node server.js
```
### Project Structure
```
openhamclock/
├── public/ # Static web files
│ ├── index.html # Main application
│ └── icons/ # App icons
├── electron/ # Electron main process
│ └── main.js # Desktop app entry
├── dxspider-proxy/ # DX Cluster proxy service
│ ├── server.js # Telnet-to-WebSocket proxy
│ ├── package.json # Proxy dependencies
│ └── README.md # Proxy documentation
├── iturhfprop-service/ # HF Propagation prediction service
│ ├── server.js # ITU-R P.533 API wrapper
│ ├── Dockerfile # Builds ITURHFProp engine
│ └── README.md # Service documentation
├── scripts/ # Setup scripts
│ ├── setup-pi.sh # Raspberry Pi setup
│ ├── setup-linux.sh
│ └── setup-windows.ps1
├── server.js # Express server & API proxy
├── Dockerfile # Container build
├── railway.toml # Railway config
└── package.json
```
---
## 🤝 Contributing
We welcome contributions from the amateur radio community! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
### Priority Areas
1. **Satellite Tracking** - TLE parsing and pass predictions
2. **Rotator Control** - Hamlib integration
3. **Additional APIs** - QRZ, LoTW, ClubLog
4. **Accessibility** - Screen reader support, high contrast modes
5. **Translations** - Internationalization
6. **WebSocket DX Cluster** - Direct connection to DX Spider nodes
### How to Contribute
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
---
## 📜 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
2. Pick a component/hook to improve
3. Make changes in the appropriate file
4. Test with all three themes
5. Submit a PR
---
### Code Style
## 🙏 Acknowledgments
- Functional components with hooks
- CSS-in-JS for component-specific styles
- CSS variables for theme colors
- JSDoc comments for functions
- Descriptive variable names
- **Elwood Downey, WB0OEW** - Creator of the original HamClock. Your work inspired thousands of amateur radio operators worldwide. Rest in peace, OM. 🕊️
- **Leaflet.js** - Outstanding open-source mapping library
- **OpenStreetMap** - Community-driven map data
- **ESRI** - Satellite and specialty map tiles
- **NOAA Space Weather Prediction Center** - Space weather data
- **Parks on the Air (POTA)** - Activator spot API
- **Summits on the Air (SOTA)** - Summit spot API
- **The Amateur Radio Community** - For keeping the spirit of experimentation alive
### Testing Changes
---
## 📞 Contact
- **Email**: chris@cjhlighting.com
- **GitHub Issues**: [Report bugs or request features](https://github.com/accius/openhamclock/issues)
- **Discussions**: [Join the conversation](https://github.com/accius/openhamclock/discussions)
```bash
# Run dev server
npm run dev
---
# Check all themes work
# Test on different screen sizes
# Verify data fetching works
```
<div align="center">
## 📄 License
**73 de K0CJH and the OpenHamClock contributors!**
MIT License - See LICENSE file
*"The original HamClock will cease to function in June 2026. OpenHamClock carries forward Elwood's legacy with modern technology and open-source community development."*
## 🙏 Credits
</div>
- K0CJH - Original OpenHamClock
- NOAA SWPC - Space weather data
- POTA - Parks on the Air API
- Open-Meteo - Weather data
- Leaflet - Mapping library

@ -0,0 +1,26 @@
<!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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

@ -1,96 +1,39 @@
{
"name": "openhamclock",
"version": "3.9.0",
"description": "Open-source amateur radio dashboard with real-time space weather, band conditions, DX cluster, and interactive world map",
"version": "3.7.0",
"description": "Amateur Radio Dashboard - A modern web-based HamClock alternative",
"main": "server.js",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"start": "node server.js",
"dev": "node server.js",
"electron": "electron electron/main.js",
"electron:build": "electron-builder",
"electron:build:win": "electron-builder --win",
"electron:build:mac": "electron-builder --mac",
"electron:build:linux": "electron-builder --linux",
"electron:build:pi": "electron-builder --linux --armv7l",
"docker:build": "docker build -t openhamclock .",
"docker:run": "docker run -p 3000:3000 openhamclock",
"setup:pi": "bash scripts/setup-pi.sh",
"setup:linux": "bash scripts/setup-linux.sh",
"test": "echo \"No tests yet\" && exit 0"
"server": "node server.js"
},
"dependencies": {
"axios": "^1.6.2",
"cors": "^2.8.5",
"express": "^4.18.2",
"node-fetch": "^3.3.2",
"satellite.js": "^5.0.0",
"ws": "^8.14.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"vite": "^5.0.10"
},
"keywords": [
"ham-radio",
"amateur-radio",
"ham-radio",
"hamclock",
"dx-cluster",
"space-weather",
"pota",
"sota",
"propagation",
"raspberry-pi",
"electron"
"pota",
"satellite-tracking"
],
"author": "OpenHamClock Contributors",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/accius/openhamclock.git"
},
"bugs": {
"url": "https://github.com/accius/openhamclock/issues"
},
"homepage": "https://github.com/accius/openhamclock#readme",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"node-fetch": "^2.7.0"
},
"devDependencies": {
"electron": "^28.0.0",
"electron-builder": "^24.9.1"
},
"engines": {
"node": ">=18.0.0"
},
"build": {
"appId": "com.openhamclock.app",
"productName": "OpenHamClock",
"directories": {
"output": "dist"
},
"files": [
"public/**/*",
"electron/**/*",
"server.js",
"package.json"
],
"mac": {
"category": "public.app-category.utilities",
"icon": "public/icons/icon.icns",
"target": [
"dmg",
"zip"
]
},
"win": {
"icon": "public/icons/icon.ico",
"target": [
"nsis",
"portable"
]
},
"linux": {
"icon": "public/icons",
"target": [
"AppImage",
"deb",
"rpm"
],
"category": "Utility"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
"author": "K0CJH",
"license": "MIT"
}

@ -1,125 +0,0 @@
#!/usr/bin/env python3
"""
OpenHamClock Development Server
A simple HTTP server for OpenHamClock with API proxy capabilities.
This allows the application to fetch live data from external sources
without CORS issues.
Usage:
python3 server.py [port]
Default port: 8080
Open http://localhost:8080 in your browser
Requirements:
Python 3.7+
requests library (optional, for API proxy)
"""
import http.server
import socketserver
import json
import urllib.request
import urllib.error
import sys
import os
from datetime import datetime
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
# API endpoints for live data
API_ENDPOINTS = {
'solarflux': 'https://services.swpc.noaa.gov/json/solar-cycle/observed-solar-flux.json',
'kindex': 'https://services.swpc.noaa.gov/json/planetary_k_index_1m.json',
'xray': 'https://services.swpc.noaa.gov/json/goes/primary/xrays-7-day.json',
'sunspots': 'https://services.swpc.noaa.gov/json/solar-cycle/sunspots.json',
'pota': 'https://api.pota.app/spot/activator',
'bands': 'https://www.hamqsl.com/solarxml.php', # HamQSL solar data
}
class OpenHamClockHandler(http.server.SimpleHTTPRequestHandler):
"""Custom HTTP handler with API proxy support."""
def do_GET(self):
# Handle API proxy requests
if self.path.startswith('/api/'):
self.handle_api()
else:
# Serve static files
super().do_GET()
def handle_api(self):
"""Proxy API requests to avoid CORS issues."""
endpoint = self.path.replace('/api/', '').split('?')[0]
if endpoint not in API_ENDPOINTS:
self.send_error(404, f"Unknown API endpoint: {endpoint}")
return
try:
url = API_ENDPOINTS[endpoint]
print(f"[{datetime.now().strftime('%H:%M:%S')}] Fetching: {url}")
# Make the request
req = urllib.request.Request(
url,
headers={'User-Agent': 'OpenHamClock/1.0'}
)
with urllib.request.urlopen(req, timeout=10) as response:
data = response.read()
content_type = response.headers.get('Content-Type', 'application/json')
# Send response
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Cache-Control', 'max-age=60')
self.end_headers()
self.wfile.write(data)
except urllib.error.URLError as e:
print(f"[ERROR] Failed to fetch {endpoint}: {e}")
self.send_error(502, f"Failed to fetch data: {e}")
except Exception as e:
print(f"[ERROR] {e}")
self.send_error(500, str(e))
def log_message(self, format, *args):
"""Custom logging format."""
if args[0].startswith('GET /api/'):
return # Already logged in handle_api
print(f"[{datetime.now().strftime('%H:%M:%S')}] {args[0]}")
def main():
# Change to the directory containing this script
script_dir = os.path.dirname(os.path.abspath(__file__))
os.chdir(script_dir)
print("=" * 50)
print(" OpenHamClock Development Server")
print("=" * 50)
print()
print(f" Serving from: {script_dir}")
print(f" URL: http://localhost:{PORT}")
print(f" Press Ctrl+C to stop")
print()
print(" Available API endpoints:")
for name, url in API_ENDPOINTS.items():
print(f" /api/{name}")
print()
print("=" * 50)
print()
with socketserver.TCPServer(("", PORT), OpenHamClockHandler) as httpd:
httpd.allow_reuse_address = True
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nServer stopped.")
if __name__ == "__main__":
main()

@ -0,0 +1,364 @@
/**
* OpenHamClock - Main Application Component
* Amateur Radio Dashboard v3.7.0
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
// Components
import {
Header,
WorldMap,
SpaceWeatherPanel,
BandConditionsPanel,
DXClusterPanel,
POTAPanel,
ContestPanel,
LocationPanel,
SettingsPanel,
DXFilterManager
} from './components';
// Hooks
import {
useSpaceWeather,
useBandConditions,
useDXCluster,
useDXPaths,
usePOTASpots,
useContests,
useLocalWeather,
usePropagation,
useMySpots,
useDXpeditions,
useSatellites,
useSolarIndices
} from './hooks';
// Utils
import {
loadConfig,
saveConfig,
applyTheme,
calculateGridSquare,
calculateSunTimes
} from './utils';
const App = () => {
// Configuration state
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]);
// UI state
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
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
useEffect(() => {
const saved = localStorage.getItem('openhamclock_config');
if (!saved) {
setShowSettings(true);
}
}, []);
const handleSaveConfig = (newConfig) => {
setConfig(newConfig);
saveConfig(newConfig);
applyTheme(newConfig.theme || 'dark');
};
// Data hooks
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);
// Computed values
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]);
// Time and uptime update
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 });
}, []);
// Format times
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' });
// Scale factor for modern layout
const [scale, setScale] = useState(1);
useEffect(() => {
const calculateScale = () => {
const minWidth = 1200;
const minHeight = 800;
const scaleX = window.innerWidth / minWidth;
const scaleY = window.innerHeight / minHeight;
setScale(Math.min(scaleX, scaleY, 1));
};
calculateScale();
window.addEventListener('resize', calculateScale);
return () => window.removeEventListener('resize', calculateScale);
}, []);
// Modern Layout
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 */}
<Header
config={config}
utcTime={utcTime}
utcDate={utcDate}
localTime={localTime}
localDate={localDate}
localWeather={localWeather}
spaceWeather={spaceWeather}
use12Hour={use12Hour}
onTimeFormatToggle={handleTimeFormatToggle}
onSettingsClick={() => setShowSettings(true)}
onFullscreenToggle={handleFullscreenToggle}
isFullscreen={isFullscreen}
/>
{/* LEFT COLUMN */}
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
overflow: 'hidden'
}}>
<LocationPanel
config={config}
dxLocation={dxLocation}
deSunTimes={deSunTimes}
dxSunTimes={dxSunTimes}
currentTime={currentTime}
/>
<SpaceWeatherPanel data={spaceWeather.data} loading={spaceWeather.loading} />
<BandConditionsPanel data={bandConditions.data} loading={bandConditions.loading} />
</div>
{/* CENTER - MAP */}
<div style={{
background: 'var(--bg-panel)',
border: '1px solid var(--border-color)',
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.data}
showDXPaths={mapLayers.showDXPaths}
showDXLabels={mapLayers.showDXLabels}
onToggleDXLabels={toggleDXLabels}
showPOTA={mapLayers.showPOTA}
showSatellites={mapLayers.showSatellites}
onToggleSatellites={toggleSatellites}
hoveredSpot={hoveredSpot}
/>
</div>
{/* RIGHT COLUMN */}
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
overflow: 'hidden'
}}>
<DXClusterPanel
data={dxCluster.data}
loading={dxCluster.loading}
totalSpots={dxCluster.totalSpots}
filters={dxFilters}
onOpenFilters={() => setShowDXFilters(true)}
onHoverSpot={setHoveredSpot}
hoveredSpot={hoveredSpot}
/>
<POTAPanel data={potaSpots.data} loading={potaSpots.loading} />
<ContestPanel data={contests.data} loading={contests.loading} />
</div>
</div>
{/* Modals */}
<SettingsPanel
isOpen={showSettings}
onClose={() => setShowSettings(false)}
config={config}
onSave={handleSaveConfig}
/>
<DXFilterManager
filters={dxFilters}
onFilterChange={setDxFilters}
isOpen={showDXFilters}
onClose={() => setShowDXFilters(false)}
/>
</div>
);
};
export default App;

@ -0,0 +1,72 @@
/**
* BandConditionsPanel Component
* Displays HF band conditions (GOOD/FAIR/POOR)
*/
import React from 'react';
export const BandConditionsPanel = ({ data, loading }) => {
const getConditionStyle = (condition) => {
switch (condition) {
case 'GOOD':
return { color: 'var(--accent-green)', bg: 'rgba(0, 255, 136, 0.15)' };
case 'FAIR':
return { color: 'var(--accent-amber)', bg: 'rgba(255, 180, 50, 0.15)' };
case 'POOR':
return { color: 'var(--accent-red)', bg: 'rgba(255, 68, 102, 0.15)' };
default:
return { color: 'var(--text-muted)', bg: 'transparent' };
}
};
return (
<div className="panel" style={{ padding: '12px' }}>
<div className="panel-header">📡 BAND CONDITIONS</div>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px' }}>
<div className="loading-spinner" />
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(50px, 1fr))',
gap: '6px'
}}>
{data.map(({ band, condition }) => {
const style = getConditionStyle(condition);
return (
<div
key={band}
style={{
textAlign: 'center',
padding: '6px 4px',
background: style.bg,
borderRadius: '4px',
border: `1px solid ${style.color}33`
}}
>
<div style={{
fontSize: '12px',
fontWeight: '700',
color: 'var(--text-primary)',
fontFamily: 'JetBrains Mono, monospace'
}}>
{band}
</div>
<div style={{
fontSize: '9px',
fontWeight: '600',
color: style.color,
marginTop: '2px'
}}>
{condition}
</div>
</div>
);
})}
</div>
)}
</div>
);
};
export default BandConditionsPanel;

@ -0,0 +1,70 @@
/**
* ContestPanel Component
* Displays upcoming amateur radio contests
*/
import React from 'react';
export const ContestPanel = ({ data, loading }) => {
return (
<div className="panel" style={{ padding: '12px' }}>
<div className="panel-header">🏆 CONTESTS</div>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px' }}>
<div className="loading-spinner" />
</div>
) : data.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '20px',
color: 'var(--text-muted)',
fontSize: '12px'
}}>
No upcoming contests
</div>
) : (
<div style={{
fontSize: '11px',
fontFamily: 'JetBrains Mono, monospace'
}}>
{data.slice(0, 5).map((contest, i) => (
<div
key={`${contest.name}-${i}`}
style={{
padding: '8px',
borderRadius: '4px',
marginBottom: '4px',
background: i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent'
}}
>
<div style={{
color: 'var(--accent-cyan)',
fontWeight: '600',
marginBottom: '4px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{contest.name}
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
color: 'var(--text-muted)',
fontSize: '10px'
}}>
<span>{contest.startDate}</span>
<span style={{
color: contest.isActive ? 'var(--accent-green)' : 'var(--text-muted)'
}}>
{contest.isActive ? '● ACTIVE' : contest.timeUntil || ''}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default ContestPanel;

@ -0,0 +1,144 @@
/**
* DXClusterPanel Component
* Displays DX cluster spots with filtering controls
*/
import React from 'react';
import { getBandColor } from '../utils/callsign.js';
export const DXClusterPanel = ({
data,
loading,
totalSpots,
filters,
onOpenFilters,
onHoverSpot,
hoveredSpot
}) => {
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;
};
const filterCount = getActiveFilterCount();
return (
<div className="panel" style={{
padding: '12px',
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden'
}}>
{/* Header with filter button */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px'
}}>
<div className="panel-header" style={{ margin: 0 }}>
📻 DX CLUSTER
<span style={{
fontSize: '10px',
color: 'var(--text-muted)',
fontWeight: '400',
marginLeft: '8px'
}}>
{data.length}/{totalSpots || 0}
</span>
</div>
<button
onClick={onOpenFilters}
style={{
background: filterCount > 0 ? 'rgba(0, 221, 255, 0.15)' : 'var(--bg-tertiary)',
border: `1px solid ${filterCount > 0 ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
color: filterCount > 0 ? 'var(--accent-cyan)' : 'var(--text-secondary)',
padding: '4px 10px',
borderRadius: '4px',
fontSize: '11px',
cursor: 'pointer',
fontFamily: 'JetBrains Mono, monospace'
}}
>
🔍 {filterCount > 0 ? `Filters (${filterCount})` : 'Filters'}
</button>
</div>
{/* Spots list */}
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px' }}>
<div className="loading-spinner" />
</div>
) : data.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '20px',
color: 'var(--text-muted)',
fontSize: '12px'
}}>
{filterCount > 0 ? 'No spots match filters' : 'No spots available'}
</div>
) : (
<div style={{
flex: 1,
overflow: 'auto',
fontSize: '11px',
fontFamily: 'JetBrains Mono, monospace'
}}>
{data.slice(0, 15).map((spot, i) => {
const freq = parseFloat(spot.freq);
const color = getBandColor(freq / 1000); // Convert kHz to MHz for color
const isHovered = hoveredSpot?.call === spot.call &&
Math.abs(parseFloat(hoveredSpot?.freq) - freq) < 1;
return (
<div
key={`${spot.call}-${spot.freq}-${i}`}
onMouseEnter={() => onHoverSpot?.(spot)}
onMouseLeave={() => onHoverSpot?.(null)}
style={{
display: 'grid',
gridTemplateColumns: '70px 1fr auto',
gap: '8px',
padding: '6px 8px',
borderRadius: '4px',
marginBottom: '2px',
background: isHovered ? 'rgba(68, 136, 255, 0.2)' : (i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent'),
cursor: 'pointer',
transition: 'background 0.15s'
}}
>
<div style={{ color, fontWeight: '600' }}>
{(freq / 1000).toFixed(3)}
</div>
<div style={{
color: 'var(--text-primary)',
fontWeight: '600',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{spot.call}
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '10px' }}>
{spot.time || ''}
</div>
</div>
);
})}
</div>
)}
</div>
);
};
export default DXClusterPanel;

@ -0,0 +1,364 @@
/**
* DXFilterManager Component
* Modal for DX cluster filtering (zones, bands, modes, watchlist)
*/
import React, { useState } from 'react';
import { HF_BANDS, CONTINENTS, MODES } from '../utils/callsign.js';
export 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);
// 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',
fontWeight: '600',
fontSize: '12px'
}}>
Done
</button>
</div>
</div>
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color)', padding: '0 20px' }}>
{['zones', 'bands', 'modes', 'watchlist'].map(tab => (
<button key={tab} onClick={() => setActiveTab(tab)} style={tabStyle(activeTab === tab)}>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
{/* Tab Content */}
<div style={{ padding: '20px', overflow: 'auto', flex: 1 }}>
{activeTab === 'zones' && (
<div>
{/* CQ Zones */}
<div style={{ marginBottom: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '10px' }}>
<span style={{ fontWeight: '600' }}>CQ Zones</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => selectAllZones('cqZones', cqZones)} style={{ ...pillStyle(false), fontSize: '9px' }}>Select All</button>
<button onClick={() => clearZones('cqZones')} style={{ ...pillStyle(false), fontSize: '9px' }}>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', textAlign: 'center' }}>
{zone}
</button>
))}
</div>
</div>
{/* Continents */}
<div>
<div style={{ fontWeight: '600', marginBottom: '10px' }}>Continents</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{CONTINENTS.map(({ code, name }) => (
<button key={code} onClick={() => toggleArrayItem('continents', code)} style={{ ...pillStyle(filters?.continents?.includes(code)), padding: '8px 16px' }}>
{code} - {name}
</button>
))}
</div>
</div>
</div>
)}
{activeTab === 'bands' && (
<div>
<div style={{ fontWeight: '600', marginBottom: '10px' }}>Select bands to show</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{HF_BANDS.map(band => (
<button key={band} onClick={() => toggleArrayItem('bands', band)} style={{ ...pillStyle(filters?.bands?.includes(band)), padding: '10px 20px', fontSize: '12px' }}>
{band}
</button>
))}
</div>
</div>
)}
{activeTab === 'modes' && (
<div>
<div style={{ fontWeight: '600', marginBottom: '10px' }}>Select modes to show</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{MODES.map(mode => (
<button key={mode} onClick={() => toggleArrayItem('modes', mode)} style={{ ...pillStyle(filters?.modes?.includes(mode)), padding: '10px 20px', fontSize: '12px' }}>
{mode}
</button>
))}
</div>
</div>
)}
{activeTab === 'watchlist' && (
<div>
{/* Watchlist Only Toggle */}
<div style={{ marginBottom: '20px' }}>
<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 })}
style={{ width: '18px', height: '18px' }}
/>
<span style={{ fontWeight: '600' }}>Show ONLY watchlist callsigns</span>
</label>
</div>
{/* Add to Watchlist */}
<div style={{ marginBottom: '20px' }}>
<div style={{ fontWeight: '600', marginBottom: '10px' }}>Watchlist (highlight these calls)</div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
<input
type="text"
value={newWatchlistCall}
onChange={e => setNewWatchlistCall(e.target.value)}
onKeyPress={e => e.key === 'Enter' && addToWatchlist()}
placeholder="Add callsign..."
style={{
flex: 1,
padding: '8px 12px',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: '4px',
color: 'var(--text-primary)',
fontFamily: 'JetBrains Mono'
}}
/>
<button onClick={addToWatchlist} style={{ ...pillStyle(true), padding: '8px 16px' }}>Add</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{(filters?.watchlist || []).map(call => (
<span key={call} style={{
padding: '4px 10px',
background: 'rgba(0, 255, 136, 0.2)',
border: '1px solid var(--accent-green)',
borderRadius: '4px',
color: 'var(--accent-green)',
fontSize: '11px',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}>
{call}
<button onClick={() => removeFromWatchlist(call)} style={{
background: 'none',
border: 'none',
color: 'var(--accent-red)',
cursor: 'pointer',
padding: 0,
fontSize: '14px'
}}>×</button>
</span>
))}
</div>
</div>
{/* Exclude List */}
<div>
<div style={{ fontWeight: '600', marginBottom: '10px' }}>Exclude List (hide these calls)</div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
<input
type="text"
value={newExcludeCall}
onChange={e => setNewExcludeCall(e.target.value)}
onKeyPress={e => e.key === 'Enter' && addToExclude()}
placeholder="Add callsign..."
style={{
flex: 1,
padding: '8px 12px',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: '4px',
color: 'var(--text-primary)',
fontFamily: 'JetBrains Mono'
}}
/>
<button onClick={addToExclude} style={{ ...pillStyle(false), padding: '8px 16px', borderColor: '#ff6666', color: '#ff6666' }}>Add</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{(filters?.excludeList || []).map(call => (
<span key={call} style={{
padding: '4px 10px',
background: 'rgba(255, 100, 100, 0.2)',
border: '1px solid var(--accent-red)',
borderRadius: '4px',
color: 'var(--accent-red)',
fontSize: '11px',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}>
{call}
<button onClick={() => removeFromExclude(call)} style={{
background: 'none',
border: 'none',
color: 'var(--accent-red)',
cursor: 'pointer',
padding: 0,
fontSize: '14px'
}}>×</button>
</span>
))}
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default DXFilterManager;

@ -0,0 +1,147 @@
/**
* Header Component
* Top bar with callsign, clocks, weather, and controls
*/
import React from 'react';
export const Header = ({
config,
utcTime,
utcDate,
localTime,
localDate,
localWeather,
spaceWeather,
use12Hour,
onTimeFormatToggle,
onSettingsClick,
onFullscreenToggle,
isFullscreen
}) => {
return (
<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={onSettingsClick}
title="Click for settings"
>
{config.callsign}
</span>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>v3.7.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={onTimeFormatToggle}
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: parseInt(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={onSettingsClick}
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={onFullscreenToggle}
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>
);
};
export default Header;

@ -0,0 +1,163 @@
/**
* LocationPanel Component
* Displays DE and DX location info with grid squares and sun times
*/
import React from 'react';
import { calculateGridSquare, calculateBearing, calculateDistance, getMoonPhase, getMoonPhaseEmoji } from '../utils/geo.js';
export const LocationPanel = ({
config,
dxLocation,
deSunTimes,
dxSunTimes,
currentTime
}) => {
const deGrid = calculateGridSquare(config.location.lat, config.location.lon);
const dxGrid = calculateGridSquare(dxLocation.lat, dxLocation.lon);
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 moonPhase = getMoonPhase(currentTime);
const moonEmoji = getMoonPhaseEmoji(moonPhase);
return (
<div className="panel" style={{ padding: '12px' }}>
<div className="panel-header">📍 LOCATIONS</div>
{/* DE Location */}
<div style={{ marginBottom: '12px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '4px'
}}>
<span style={{
color: 'var(--accent-amber)',
fontWeight: '700',
fontSize: '14px'
}}>
DE: {config.callsign}
</span>
<span style={{
color: 'var(--accent-green)',
fontFamily: 'JetBrains Mono, monospace',
fontSize: '12px'
}}>
{deGrid}
</span>
</div>
<div style={{
fontSize: '11px',
color: 'var(--text-muted)',
fontFamily: 'JetBrains Mono, monospace'
}}>
{config.location.lat.toFixed(4)}°, {config.location.lon.toFixed(4)}°
</div>
<div style={{
fontSize: '11px',
color: 'var(--text-secondary)',
marginTop: '4px'
}}>
{deSunTimes.sunrise} / {deSunTimes.sunset} UTC
</div>
</div>
{/* DX Location */}
<div style={{ marginBottom: '12px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '4px'
}}>
<span style={{
color: 'var(--accent-blue)',
fontWeight: '700',
fontSize: '14px'
}}>
DX Target
</span>
<span style={{
color: 'var(--accent-green)',
fontFamily: 'JetBrains Mono, monospace',
fontSize: '12px'
}}>
{dxGrid}
</span>
</div>
<div style={{
fontSize: '11px',
color: 'var(--text-muted)',
fontFamily: 'JetBrains Mono, monospace'
}}>
{dxLocation.lat.toFixed(4)}°, {dxLocation.lon.toFixed(4)}°
</div>
<div style={{
fontSize: '11px',
color: 'var(--text-secondary)',
marginTop: '4px'
}}>
{dxSunTimes.sunrise} / {dxSunTimes.sunset} UTC
</div>
</div>
{/* Path Info */}
<div style={{
padding: '10px',
background: 'var(--bg-tertiary)',
borderRadius: '6px',
marginBottom: '12px'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '12px',
textAlign: 'center'
}}>
<div>
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>BEARING</div>
<div style={{
fontSize: '18px',
fontWeight: '700',
color: 'var(--accent-cyan)',
fontFamily: 'Orbitron, monospace'
}}>
{bearing.toFixed(0)}°
</div>
</div>
<div>
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>DISTANCE</div>
<div style={{
fontSize: '18px',
fontWeight: '700',
color: 'var(--accent-cyan)',
fontFamily: 'Orbitron, monospace'
}}>
{distance.toFixed(0)} km
</div>
</div>
</div>
</div>
{/* Moon Phase */}
<div style={{
textAlign: 'center',
padding: '8px',
background: 'var(--bg-tertiary)',
borderRadius: '6px'
}}>
<span style={{ fontSize: '20px', marginRight: '8px' }}>{moonEmoji}</span>
<span style={{
fontSize: '11px',
color: 'var(--text-secondary)'
}}>
{moonPhase < 0.25 ? 'Waxing' : moonPhase < 0.5 ? 'Waxing' : moonPhase < 0.75 ? 'Waning' : 'Waning'}
{' '}
{Math.round(moonPhase * 100)}%
</span>
</div>
</div>
);
};
export default LocationPanel;

@ -0,0 +1,75 @@
/**
* POTAPanel Component
* Displays Parks on the Air activations
*/
import React from 'react';
export const POTAPanel = ({ data, loading }) => {
return (
<div className="panel" style={{ padding: '12px' }}>
<div className="panel-header">🌲 POTA ACTIVATIONS</div>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px' }}>
<div className="loading-spinner" />
</div>
) : data.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '20px',
color: 'var(--text-muted)',
fontSize: '12px'
}}>
No active POTA spots
</div>
) : (
<div style={{
fontSize: '11px',
fontFamily: 'JetBrains Mono, monospace'
}}>
{data.slice(0, 5).map((spot, i) => (
<div
key={`${spot.call}-${spot.ref}-${i}`}
style={{
padding: '8px',
borderRadius: '4px',
marginBottom: '4px',
background: i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent'
}}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '4px'
}}>
<span style={{ color: 'var(--accent-green)', fontWeight: '600' }}>
{spot.call}
</span>
<span style={{ color: 'var(--accent-amber)' }}>
{spot.freq} {spot.mode}
</span>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
color: 'var(--text-muted)',
fontSize: '10px'
}}>
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '70%'
}}>
{spot.ref} - {spot.name}
</span>
<span>{spot.time}</span>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default POTAPanel;

@ -0,0 +1,263 @@
/**
* SettingsPanel Component
* Modal for app configuration (callsign, location, theme, layout)
*/
import React, { useState, useEffect } from 'react';
export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
const [formData, setFormData] = useState({
callsign: '',
lat: '',
lon: '',
theme: 'dark',
layout: 'modern'
});
useEffect(() => {
if (config) {
setFormData({
callsign: config.callsign || 'N0CALL',
lat: config.location?.lat?.toString() || '40.0150',
lon: config.location?.lon?.toString() || '-105.2705',
theme: config.theme || 'dark',
layout: config.layout || 'modern'
});
}
}, [config, isOpen]);
const handleSubmit = (e) => {
e.preventDefault();
const newConfig = {
...config,
callsign: formData.callsign.toUpperCase().trim() || 'N0CALL',
location: {
lat: parseFloat(formData.lat) || 40.0150,
lon: parseFloat(formData.lon) || -105.2705
},
theme: formData.theme,
layout: formData.layout
};
onSave(newConfig);
onClose();
};
const handleGeolocate = () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
setFormData(prev => ({
...prev,
lat: position.coords.latitude.toFixed(4),
lon: position.coords.longitude.toFixed(4)
}));
},
(error) => {
console.error('Geolocation error:', error);
alert('Unable to get location. Please enter manually.');
}
);
} else {
alert('Geolocation is not supported by your browser.');
}
};
if (!isOpen) return null;
const inputStyle = {
width: '100%',
padding: '10px 12px',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
color: 'var(--text-primary)',
fontSize: '14px',
fontFamily: 'JetBrains Mono, monospace'
};
const labelStyle = {
display: 'block',
fontSize: '12px',
color: 'var(--text-secondary)',
marginBottom: '6px',
fontWeight: '500'
};
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: '500px',
maxHeight: '90vh',
overflow: 'auto'
}} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={{
padding: '20px',
borderBottom: '1px solid var(--border-color)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<h2 style={{ margin: 0, fontSize: '20px', color: 'var(--accent-cyan)' }}> Settings</h2>
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: 'var(--text-muted)',
fontSize: '24px',
cursor: 'pointer',
padding: '4px 8px'
}}
>
×
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} style={{ padding: '20px' }}>
{/* Callsign */}
<div style={{ marginBottom: '20px' }}>
<label style={labelStyle}>Callsign</label>
<input
type="text"
value={formData.callsign}
onChange={e => setFormData(prev => ({ ...prev, callsign: e.target.value }))}
style={inputStyle}
placeholder="W1ABC"
/>
</div>
{/* Location */}
<div style={{ marginBottom: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
<label style={{ ...labelStyle, margin: 0 }}>Location</label>
<button
type="button"
onClick={handleGeolocate}
style={{
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
color: 'var(--accent-cyan)',
padding: '4px 12px',
borderRadius: '4px',
fontSize: '11px',
cursor: 'pointer'
}}
>
📍 Use My Location
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<input
type="text"
value={formData.lat}
onChange={e => setFormData(prev => ({ ...prev, lat: e.target.value }))}
style={inputStyle}
placeholder="Latitude"
/>
</div>
<div>
<input
type="text"
value={formData.lon}
onChange={e => setFormData(prev => ({ ...prev, lon: e.target.value }))}
style={inputStyle}
placeholder="Longitude"
/>
</div>
</div>
</div>
{/* Theme */}
<div style={{ marginBottom: '20px' }}>
<label style={labelStyle}>Theme</label>
<div style={{ display: 'flex', gap: '8px' }}>
{['dark', 'light', 'legacy'].map(theme => (
<button
key={theme}
type="button"
onClick={() => setFormData(prev => ({ ...prev, theme }))}
style={{
flex: 1,
padding: '10px',
background: formData.theme === theme ? 'var(--accent-amber)' : 'var(--bg-tertiary)',
border: `1px solid ${formData.theme === theme ? 'var(--accent-amber)' : 'var(--border-color)'}`,
borderRadius: '6px',
color: formData.theme === theme ? '#000' : 'var(--text-secondary)',
fontSize: '13px',
fontWeight: formData.theme === theme ? '600' : '400',
cursor: 'pointer',
textTransform: 'capitalize'
}}
>
{theme === 'legacy' ? '🖥 Legacy' : theme === 'dark' ? '🌙 Dark' : '☀ Light'}
</button>
))}
</div>
</div>
{/* Layout */}
<div style={{ marginBottom: '24px' }}>
<label style={labelStyle}>Layout</label>
<div style={{ display: 'flex', gap: '8px' }}>
{['modern', 'legacy'].map(layout => (
<button
key={layout}
type="button"
onClick={() => setFormData(prev => ({ ...prev, layout }))}
style={{
flex: 1,
padding: '10px',
background: formData.layout === layout ? 'var(--accent-cyan)' : 'var(--bg-tertiary)',
border: `1px solid ${formData.layout === layout ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
borderRadius: '6px',
color: formData.layout === layout ? '#000' : 'var(--text-secondary)',
fontSize: '13px',
fontWeight: formData.layout === layout ? '600' : '400',
cursor: 'pointer',
textTransform: 'capitalize'
}}
>
{layout === 'modern' ? '✨ Modern' : '📺 Classic'}
</button>
))}
</div>
</div>
{/* Submit */}
<button
type="submit"
style={{
width: '100%',
padding: '14px',
background: 'var(--accent-green)',
border: 'none',
borderRadius: '6px',
color: '#000',
fontSize: '14px',
fontWeight: '700',
cursor: 'pointer'
}}
>
Save Settings
</button>
</form>
</div>
</div>
);
};
export default SettingsPanel;

@ -0,0 +1,90 @@
/**
* SpaceWeatherPanel Component
* Displays solar flux, K-index, and sunspot number
*/
import React from 'react';
export const SpaceWeatherPanel = ({ data, loading }) => {
const getKIndexColor = (kIndex) => {
const k = parseInt(kIndex);
if (isNaN(k)) return 'var(--text-muted)';
if (k >= 5) return 'var(--accent-red)';
if (k >= 4) return 'var(--accent-amber)';
return 'var(--accent-green)';
};
return (
<div className="panel" style={{ padding: '12px' }}>
<div className="panel-header"> SPACE WEATHER</div>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px' }}>
<div className="loading-spinner" />
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '12px',
textAlign: 'center'
}}>
<div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: '4px' }}>SFI</div>
<div style={{
fontSize: '24px',
fontWeight: '700',
color: 'var(--accent-amber)',
fontFamily: 'Orbitron, monospace'
}}>
{data?.solarFlux || '--'}
</div>
</div>
<div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: '4px' }}>K-INDEX</div>
<div style={{
fontSize: '24px',
fontWeight: '700',
color: getKIndexColor(data?.kIndex),
fontFamily: 'Orbitron, monospace'
}}>
{data?.kIndex || '--'}
</div>
</div>
<div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: '4px' }}>SSN</div>
<div style={{
fontSize: '24px',
fontWeight: '700',
color: 'var(--accent-cyan)',
fontFamily: 'Orbitron, monospace'
}}>
{data?.sunspotNumber || '--'}
</div>
</div>
</div>
)}
{data?.conditions && (
<div style={{
textAlign: 'center',
marginTop: '12px',
padding: '6px',
background: data.conditions === 'GOOD' ? 'rgba(0, 255, 136, 0.1)' :
data.conditions === 'FAIR' ? 'rgba(255, 180, 50, 0.1)' :
data.conditions === 'POOR' ? 'rgba(255, 68, 102, 0.1)' : 'transparent',
borderRadius: '4px'
}}>
<span style={{
fontSize: '11px',
fontWeight: '600',
color: data.conditions === 'GOOD' ? 'var(--accent-green)' :
data.conditions === 'FAIR' ? 'var(--accent-amber)' :
data.conditions === 'POOR' ? 'var(--accent-red)' : 'var(--text-muted)'
}}>
CONDITIONS: {data.conditions}
</span>
</div>
)}
</div>
);
};
export default SpaceWeatherPanel;

@ -0,0 +1,474 @@
/**
* WorldMap Component
* Leaflet map with DE/DX markers, terminator, DX paths, POTA, satellites
*/
import React, { useRef, useEffect, useState } from 'react';
import { MAP_STYLES } from '../utils/config.js';
import {
calculateGridSquare,
getSunPosition,
getMoonPosition,
getGreatCirclePoints
} from '../utils/geo.js';
import { filterDXPaths, getBandColor } from '../utils/callsign.js';
export 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;
// Make sure Leaflet is available
if (typeof L === 'undefined') {
console.error('Leaflet not loaded');
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
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
terminatorRef.current = L.terminator({
resolution: 2,
fillOpacity: 0.35,
fillColor: '#000020',
color: '#ffaa00',
weight: 2,
dashArray: '5, 5'
}).addTo(map);
// Refresh terminator
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 DE/DX markers and celestial bodies
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(`<b>☀ Subsolar Point</b><br>${sunPos.lat.toFixed(2)}°, ${sunPos.lon.toFixed(2)}°`)
.addTo(map);
// Moon marker
const moonPos = getMoonPosition(new Date());
const moonIcon = L.divIcon({
className: 'custom-marker moon-marker',
html: '🌙',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
moonMarkerRef.current = L.marker([moonPos.lat, moonPos.lon], { icon: moonIcon })
.bindPopup(`<b>🌙 Sublunar Point</b><br>${moonPos.lat.toFixed(2)}°, ${moonPos.lon.toFixed(2)}°`)
.addTo(map);
}, [deLocation, dxLocation]);
// Update DX paths
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) {
const filteredPaths = filterDXPaths(dxPaths, dxFilters);
filteredPaths.forEach((path) => {
try {
if (!path.spotterLat || !path.spotterLon || !path.dxLat || !path.dxLon) return;
if (isNaN(path.spotterLat) || isNaN(path.spotterLon) || isNaN(path.dxLat) || isNaN(path.dxLon)) return;
const pathPoints = getGreatCirclePoints(
path.spotterLat, path.spotterLon,
path.dxLat, path.dxLon
);
if (!pathPoints || !Array.isArray(pathPoints) || pathPoints.length === 0) return;
const freq = parseFloat(path.freq);
const color = getBandColor(freq);
const isHovered = hoveredSpot && hoveredSpot.call === path.dxCall &&
Math.abs(parseFloat(hoveredSpot.freq) - parseFloat(path.freq)) < 0.01;
// Handle segments
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);
}
});
// Add DX marker
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(`<b style="color: ${color}">${path.dxCall}</b><br>${path.freq} MHz<br>by ${path.spotter}`)
.addTo(map);
if (isHovered) dxCircle.bringToFront();
dxPathsMarkersRef.current.push(dxCircle);
// Add label if enabled
if (showDXLabels || isHovered) {
const labelIcon = L.divIcon({
className: '',
html: `<div style="background: ${isHovered ? '#fff' : color}; color: ${isHovered ? color : '#000'}; padding: 2px 6px; border-radius: 3px; font-family: JetBrains Mono; font-size: 11px; font-weight: 600; white-space: nowrap;">${path.dxCall}</div>`,
iconAnchor: [0, -10]
});
const label = L.marker([path.dxLat, path.dxLon], { icon: labelIcon, interactive: false }).addTo(map);
dxPathsMarkersRef.current.push(label);
}
} catch (err) {
console.error('Error rendering DX path:', err);
}
});
}
}, [dxPaths, dxFilters, showDXPaths, showDXLabels, hoveredSpot]);
// Update POTA markers
useEffect(() => {
if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current;
potaMarkersRef.current.forEach(m => map.removeLayer(m));
potaMarkersRef.current = [];
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
useEffect(() => {
if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current;
satMarkersRef.current.forEach(m => map.removeLayer(m));
satMarkersRef.current = [];
satTracksRef.current.forEach(t => map.removeLayer(t));
satTracksRef.current = [];
if (showSatellites && satellites && satellites.length > 0) {
satellites.forEach(sat => {
const icon = L.divIcon({
className: '',
html: `<div style="background: #00ffff; color: #000; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-family: JetBrains Mono; white-space: nowrap; border: 2px solid ${sat.visible ? '#fff' : 'rgba(255,255,255,0.3)'}; font-weight: bold; opacity: ${sat.visible ? 1 : 0.6};">🛰 ${sat.name}</div>`,
iconAnchor: [25, 12]
});
const marker = L.marker([sat.lat, sat.lon], { icon })
.bindPopup(`<b>🛰 ${sat.name}</b><br>Alt: ${sat.alt} km<br>Az: ${sat.azimuth}° El: ${sat.elevation}°`)
.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 */}
{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
}}
>
🛰 SAT {showSatellites ? 'ON' : 'OFF'}
</button>
)}
{/* Labels toggle */}
{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
}}
>
🏷 CALLS {showDXLabels ? 'ON' : 'OFF'}
</button>
)}
{/* Legend */}
<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'
}}>
{showDXPaths && (
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<span style={{ color: '#888' }}>DX:</span>
<span style={{ color: '#ff6666' }}>160m</span>
<span style={{ color: '#ffcc66' }}>40m</span>
<span style={{ color: '#66ff99' }}>20m</span>
<span style={{ color: '#66ccff' }}>15m</span>
<span style={{ color: '#9966ff' }}>10m</span>
</div>
)}
{showPOTA && (
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<span style={{ color: '#aa66ff' }}> POTA</span>
</div>
)}
{showSatellites && (
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<span style={{ color: '#00ffff' }}>🛰 SAT</span>
</div>
)}
</div>
</div>
);
};
export default WorldMap;

@ -0,0 +1,15 @@
/**
* Components Index
* Central export point for all React components
*/
export { Header } from './Header.jsx';
export { WorldMap } from './WorldMap.jsx';
export { SpaceWeatherPanel } from './SpaceWeatherPanel.jsx';
export { BandConditionsPanel } from './BandConditionsPanel.jsx';
export { DXClusterPanel } from './DXClusterPanel.jsx';
export { POTAPanel } from './POTAPanel.jsx';
export { ContestPanel } from './ContestPanel.jsx';
export { LocationPanel } from './LocationPanel.jsx';
export { SettingsPanel } from './SettingsPanel.jsx';
export { DXFilterManager } from './DXFilterManager.jsx';

@ -0,0 +1,17 @@
/**
* Hooks Index
* Central export point for all custom hooks
*/
export { useSpaceWeather } from './useSpaceWeather.js';
export { useBandConditions } from './useBandConditions.js';
export { useDXCluster } from './useDXCluster.js';
export { useDXPaths } from './useDXPaths.js';
export { usePOTASpots } from './usePOTASpots.js';
export { useContests } from './useContests.js';
export { useLocalWeather } from './useLocalWeather.js';
export { usePropagation } from './usePropagation.js';
export { useMySpots } from './useMySpots.js';
export { useDXpeditions } from './useDXpeditions.js';
export { useSatellites } from './useSatellites.js';
export { useSolarIndices } from './useSolarIndices.js';

@ -0,0 +1,98 @@
/**
* useBandConditions Hook
* Calculates HF band conditions based on SFI, K-index, and time of day
*/
import { useState, useEffect } from 'react';
export 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 };
};
export default useBandConditions;

@ -0,0 +1,34 @@
/**
* useContests Hook
* Fetches upcoming amateur radio contests
*/
import { useState, useEffect } from 'react';
export const useContests = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchContests = async () => {
try {
const response = await fetch('/api/contests');
if (response.ok) {
const contests = await response.json();
setData(contests);
}
} catch (err) {
console.error('Contests error:', err);
} finally {
setLoading(false);
}
};
fetchContests();
const interval = setInterval(fetchContests, 30 * 60 * 1000); // 30 minutes
return () => clearInterval(interval);
}, []);
return { data, loading };
};
export default useContests;

@ -0,0 +1,140 @@
/**
* useDXCluster Hook
* Fetches and filters DX cluster spots with 30-minute retention
*/
import { useState, useEffect, useCallback } from 'react';
import { getBandFromFreq, detectMode, getCallsignInfo } from '../utils/callsign.js';
export 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
if (filters.cqZones?.length > 0) {
if (!spotterInfo.cqZone || !filters.cqZones.includes(spotterInfo.cqZone)) {
return false;
}
}
// ITU Zone filter
if (filters.ituZones?.length > 0) {
if (!spotterInfo.ituZone || !filters.ituZones.includes(spotterInfo.ituZone)) {
return false;
}
}
// Continent filter - filter by SPOTTER's continent
if (filters.continents?.length > 0) {
if (!spotterInfo.continent || !filters.continents.includes(spotterInfo.continent)) {
return false;
}
}
// Band filter
if (filters.bands?.length > 0) {
const band = getBandFromFreq(parseFloat(spot.freq) * 1000);
if (!filters.bands.includes(band)) return false;
}
// Mode filter
if (filters.modes?.length > 0) {
const mode = detectMode(spot.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 matchesCall = spot.call?.toUpperCase().includes(search);
const matchesSpotter = spot.spotter?.toUpperCase().includes(search);
if (!matchesCall && !matchesSpotter) return false;
}
return true;
});
}, []);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch('/api/dxcluster/spots');
if (response.ok) {
const newSpots = await response.json();
setAllSpots(prev => {
const now = Date.now();
// Create map of existing spots by unique key
const existingMap = new Map(
prev.map(s => [`${s.call}-${s.freq}-${s.spotter}`, s])
);
// Add or update with new spots
newSpots.forEach(spot => {
const key = `${spot.call}-${spot.freq}-${spot.spotter}`;
existingMap.set(key, { ...spot, timestamp: now });
});
// Filter out spots older than retention time
const validSpots = Array.from(existingMap.values())
.filter(s => (now - (s.timestamp || now)) < spotRetentionMs);
// Sort by timestamp (newest first) and limit
return validSpots
.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
.slice(0, 200);
});
setActiveSource('dxcluster');
}
} catch (err) {
console.error('DX cluster error:', err);
} finally {
setLoading(false);
}
};
fetchData();
const interval = setInterval(fetchData, pollInterval);
return () => clearInterval(interval);
}, [source]);
// Apply filters whenever allSpots or filters change
useEffect(() => {
const filtered = applyFilters(allSpots, filters);
setData(filtered);
}, [allSpots, filters, applyFilters]);
return { data, loading, activeSource, totalSpots: allSpots.length };
};
export default useDXCluster;

@ -0,0 +1,34 @@
/**
* useDXPaths Hook
* Fetches DX spots with coordinates for map visualization
*/
import { useState, useEffect } from 'react';
export const useDXPaths = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch('/api/dxcluster/paths');
if (response.ok) {
const paths = await response.json();
setData(paths);
}
} catch (err) {
console.error('DX paths error:', err);
} finally {
setLoading(false);
}
};
fetchData();
const interval = setInterval(fetchData, 10000); // 10 seconds
return () => clearInterval(interval);
}, []);
return { data, loading };
};
export default useDXPaths;

@ -0,0 +1,34 @@
/**
* useDXpeditions Hook
* Fetches active and upcoming DXpeditions
*/
import { useState, useEffect } from 'react';
export const useDXpeditions = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchDXpeditions = async () => {
try {
const response = await fetch('/api/dxpeditions');
if (response.ok) {
const dxpeditions = await response.json();
setData(dxpeditions);
}
} catch (err) {
console.error('DXpeditions error:', err);
} finally {
setLoading(false);
}
};
fetchDXpeditions();
const interval = setInterval(fetchDXpeditions, 60 * 60 * 1000); // 1 hour
return () => clearInterval(interval);
}, []);
return { data, loading };
};
export default useDXpeditions;

@ -0,0 +1,74 @@
/**
* useLocalWeather Hook
* Fetches weather data from Open-Meteo API
*/
import { useState, useEffect } from 'react';
// Weather code to description and icon mapping
const WEATHER_CODES = {
0: { desc: 'Clear sky', icon: '☀️' },
1: { desc: 'Mainly clear', icon: '🌤️' },
2: { desc: 'Partly cloudy', icon: '⛅' },
3: { desc: 'Overcast', icon: '☁️' },
45: { desc: 'Fog', icon: '🌫️' },
48: { desc: 'Depositing rime fog', icon: '🌫️' },
51: { desc: 'Light drizzle', icon: '🌧️' },
53: { desc: 'Moderate drizzle', icon: '🌧️' },
55: { desc: 'Dense drizzle', icon: '🌧️' },
61: { desc: 'Slight rain', icon: '🌧️' },
63: { desc: 'Moderate rain', icon: '🌧️' },
65: { desc: 'Heavy rain', icon: '🌧️' },
71: { desc: 'Slight snow', icon: '🌨️' },
73: { desc: 'Moderate snow', icon: '🌨️' },
75: { desc: 'Heavy snow', icon: '❄️' },
77: { desc: 'Snow grains', icon: '🌨️' },
80: { desc: 'Slight rain showers', icon: '🌦️' },
81: { desc: 'Moderate rain showers', icon: '🌦️' },
82: { desc: 'Violent rain showers', icon: '⛈️' },
85: { desc: 'Slight snow showers', icon: '🌨️' },
86: { desc: 'Heavy snow showers', icon: '❄️' },
95: { desc: 'Thunderstorm', icon: '⛈️' },
96: { desc: 'Thunderstorm with slight hail', icon: '⛈️' },
99: { desc: 'Thunderstorm with heavy hail', icon: '⛈️' }
};
export const useLocalWeather = (location) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!location?.lat || !location?.lon) return;
const fetchWeather = async () => {
try {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${location.lat}&longitude=${location.lon}&current=temperature_2m,weather_code,wind_speed_10m&temperature_unit=fahrenheit&wind_speed_unit=mph`;
const response = await fetch(url);
if (response.ok) {
const result = await response.json();
const code = result.current?.weather_code;
const weather = WEATHER_CODES[code] || { desc: 'Unknown', icon: '🌡️' };
setData({
temp: Math.round(result.current?.temperature_2m || 0),
description: weather.desc,
icon: weather.icon,
windSpeed: Math.round(result.current?.wind_speed_10m || 0),
weatherCode: code
});
}
} catch (err) {
console.error('Weather error:', err);
} finally {
setLoading(false);
}
};
fetchWeather();
const interval = setInterval(fetchWeather, 15 * 60 * 1000); // 15 minutes
return () => clearInterval(interval);
}, [location?.lat, location?.lon]);
return { data, loading };
};
export default useLocalWeather;

@ -0,0 +1,40 @@
/**
* useMySpots Hook
* Fetches spots where user's callsign appears (spotted or was spotted)
*/
import { useState, useEffect } from 'react';
export 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);
}
} catch (err) {
console.error('My spots error:', err);
} finally {
setLoading(false);
}
};
fetchMySpots();
const interval = setInterval(fetchMySpots, 30000); // 30 seconds
return () => clearInterval(interval);
}, [callsign]);
return { data, loading };
};
export default useMySpots;

@ -0,0 +1,44 @@
/**
* usePOTASpots Hook
* Fetches Parks on the Air activations
*/
import { useState, useEffect } from 'react';
import { DEFAULT_CONFIG } from '../utils/config.js';
export 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 };
};
export default usePOTASpots;

@ -0,0 +1,43 @@
/**
* usePropagation Hook
* Fetches propagation predictions between DE and DX locations
*/
import { useState, useEffect } from 'react';
export const usePropagation = (deLocation, dxLocation) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!deLocation || !dxLocation) return;
const fetchPropagation = async () => {
try {
const params = new URLSearchParams({
deLat: deLocation.lat,
deLon: deLocation.lon,
dxLat: dxLocation.lat,
dxLon: dxLocation.lon
});
const response = await fetch(`/api/propagation?${params}`);
if (response.ok) {
const result = await response.json();
setData(result);
}
} catch (err) {
console.error('Propagation error:', err);
} finally {
setLoading(false);
}
};
fetchPropagation();
const interval = setInterval(fetchPropagation, 10 * 60 * 1000); // 10 minutes
return () => clearInterval(interval);
}, [deLocation?.lat, deLocation?.lon, dxLocation?.lat, dxLocation?.lon]);
return { data, loading };
};
export default usePropagation;

@ -0,0 +1,131 @@
/**
* useSatellites Hook
* Tracks amateur radio satellites using TLE data and satellite.js
*/
import { useState, useEffect, useCallback } from 'react';
import * as satellite from 'satellite.js';
// List of popular amateur radio satellites
const AMATEUR_SATS = [
'ISS (ZARYA)',
'SO-50',
'AO-91',
'AO-92',
'CAS-4A',
'CAS-4B',
'XW-2A',
'XW-2B',
'JO-97',
'RS-44'
];
export const useSatellites = (observerLocation) => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [tleData, setTleData] = useState({});
// Fetch TLE data
useEffect(() => {
const fetchTLE = async () => {
try {
const response = await fetch('/api/satellites/tle');
if (response.ok) {
const tle = await response.json();
setTleData(tle);
}
} catch (err) {
console.error('TLE fetch error:', err);
}
};
fetchTLE();
const interval = setInterval(fetchTLE, 6 * 60 * 60 * 1000); // 6 hours
return () => clearInterval(interval);
}, []);
// Calculate satellite positions
const calculatePositions = useCallback(() => {
if (!observerLocation || Object.keys(tleData).length === 0) {
setLoading(false);
return;
}
try {
const now = new Date();
const positions = [];
// Observer position in radians
const observerGd = {
longitude: satellite.degreesToRadians(observerLocation.lon),
latitude: satellite.degreesToRadians(observerLocation.lat),
height: 0.1 // km above sea level
};
Object.entries(tleData).forEach(([name, tle]) => {
if (!tle.line1 || !tle.line2) return;
try {
const satrec = satellite.twoline2satrec(tle.line1, tle.line2);
const positionAndVelocity = satellite.propagate(satrec, now);
if (!positionAndVelocity.position) return;
const gmst = satellite.gstime(now);
const positionGd = satellite.eciToGeodetic(positionAndVelocity.position, gmst);
// Convert to degrees
const lat = satellite.degreesLat(positionGd.latitude);
const lon = satellite.degreesLong(positionGd.longitude);
const alt = positionGd.height;
// Calculate look angles
const lookAngles = satellite.ecfToLookAngles(
observerGd,
satellite.eciToEcf(positionAndVelocity.position, gmst)
);
const azimuth = satellite.radiansToDegrees(lookAngles.azimuth);
const elevation = satellite.radiansToDegrees(lookAngles.elevation);
const rangeSat = lookAngles.rangeSat;
// Only include if above horizon or popular sat
const isPopular = AMATEUR_SATS.some(s => name.includes(s));
if (elevation > -5 || isPopular) {
positions.push({
name,
lat,
lon,
alt: Math.round(alt),
azimuth: Math.round(azimuth),
elevation: Math.round(elevation),
range: Math.round(rangeSat),
visible: elevation > 0,
isPopular
});
}
} catch (e) {
// Skip satellites with invalid TLE
}
});
// Sort by elevation (highest first) and limit
positions.sort((a, b) => b.elevation - a.elevation);
setData(positions.slice(0, 20));
setLoading(false);
} catch (err) {
console.error('Satellite calculation error:', err);
setLoading(false);
}
}, [observerLocation, tleData]);
// Update positions every 5 seconds
useEffect(() => {
calculatePositions();
const interval = setInterval(calculatePositions, 5000);
return () => clearInterval(interval);
}, [calculatePositions]);
return { data, loading };
};
export default useSatellites;

@ -0,0 +1,35 @@
/**
* useSolarIndices Hook
* Fetches solar indices with history and Kp forecast
*/
import { useState, useEffect } from 'react';
export 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 };
};
export default useSolarIndices;

@ -0,0 +1,68 @@
/**
* useSpaceWeather Hook
* Fetches solar flux, K-index, and sunspot number from NOAA
*/
import { useState, useEffect } from 'react';
import { DEFAULT_CONFIG } from '../utils/config.js';
export 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 };
};
export default useSpaceWeather;

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/main.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

@ -0,0 +1,350 @@
/* ============================================
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;
}
/* ============================================
ANIMATIONS
============================================ */
@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 MAP 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;
margin: 10px 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;
}
/* ============================================
SCROLLBAR STYLING
============================================ */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent-amber-dim);
}
/* ============================================
UTILITY CLASSES
============================================ */
.text-amber { color: var(--accent-amber); }
.text-green { color: var(--accent-green); }
.text-red { color: var(--accent-red); }
.text-blue { color: var(--accent-blue); }
.text-cyan { color: var(--accent-cyan); }
.text-muted { color: var(--text-muted); }
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.font-mono { font-family: 'JetBrains Mono', monospace; }
.font-display { font-family: 'Orbitron', monospace; }
.bg-panel { background: var(--bg-panel); }
.bg-primary { background: var(--bg-primary); }
.bg-secondary { background: var(--bg-secondary); }
.bg-tertiary { background: var(--bg-tertiary); }

@ -0,0 +1,307 @@
/**
* Callsign and Band Utilities
* Band detection, mode detection, callsign parsing, DX filtering
*/
/**
* HF Amateur Bands
*/
export const HF_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '11m', '10m', '6m', '2m', '70cm'];
/**
* Continents for DX filtering
*/
export 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' }
];
/**
* Digital/Voice Modes
*/
export const MODES = ['CW', 'SSB', 'FT8', 'FT4', 'RTTY', 'PSK', 'AM', 'FM'];
/**
* Get band from frequency (in kHz)
*/
export 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 >= 26000 && freqKhz <= 28000) return '11m'; // CB band
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';
};
/**
* Get band color for map visualization
*/
export const getBandColor = (freq) => {
const f = parseFloat(freq);
if (f >= 1.8 && f < 2) return '#ff6666'; // 160m - red
if (f >= 3.5 && f < 4) return '#ff9966'; // 80m - orange
if (f >= 7 && f < 7.5) return '#ffcc66'; // 40m - yellow
if (f >= 10 && f < 10.5) return '#99ff66'; // 30m - lime
if (f >= 14 && f < 14.5) return '#66ff99'; // 20m - green
if (f >= 18 && f < 18.5) return '#66ffcc'; // 17m - teal
if (f >= 21 && f < 21.5) return '#66ccff'; // 15m - cyan
if (f >= 24 && f < 25) return '#6699ff'; // 12m - blue
if (f >= 26 && f < 28) return '#8866ff'; // 11m - violet (CB band)
if (f >= 28 && f < 30) return '#9966ff'; // 10m - purple
if (f >= 50 && f < 54) return '#ff66ff'; // 6m - magenta
return '#4488ff'; // default blue
};
/**
* Detect mode from comment text
*/
export 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
*/
export const PREFIX_MAP = {
// 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' },
'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' },
'F': { cq: 14, itu: 27, cont: 'EU' }, 'DL': { cq: 14, itu: 28, cont: 'EU' },
'DJ': { cq: 14, itu: 28, cont: 'EU' }, 'DK': { cq: 14, itu: 28, cont: 'EU' },
'PA': { cq: 14, itu: 27, cont: 'EU' }, 'ON': { cq: 14, itu: 27, cont: 'EU' },
'EA': { cq: 14, itu: 37, cont: 'EU' }, 'I': { cq: 15, itu: 28, 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' },
'OE': { cq: 15, itu: 28, cont: 'EU' }, 'HB': { cq: 14, itu: 28, cont: 'EU' },
'SM': { cq: 14, itu: 18, cont: 'EU' }, 'LA': { cq: 14, itu: 18, cont: 'EU' },
'OH': { cq: 15, itu: 18, cont: 'EU' }, 'OZ': { cq: 14, itu: 18, cont: 'EU' },
'UA': { cq: 16, itu: 29, cont: 'EU' }, 'RA': { cq: 16, itu: 29, cont: 'EU' },
'RU': { cq: 16, itu: 29, cont: 'EU' }, 'RW': { cq: 16, itu: 29, cont: 'EU' },
'UR': { cq: 16, itu: 29, cont: 'EU' }, 'UT': { cq: 16, itu: 29, cont: 'EU' },
'YU': { cq: 15, itu: 28, cont: 'EU' }, 'YT': { cq: 15, itu: 28, cont: 'EU' },
'LY': { cq: 15, itu: 29, cont: 'EU' }, 'ES': { cq: 15, itu: 29, cont: 'EU' },
'YL': { cq: 15, itu: 29, cont: 'EU' }, 'EI': { cq: 14, itu: 27, cont: 'EU' },
'GI': { cq: 14, itu: 27, cont: 'EU' }, 'GW': { cq: 14, itu: 27, cont: 'EU' },
'GM': { cq: 14, itu: 27, cont: 'EU' }, 'CT': { cq: 14, itu: 37, cont: 'EU' },
'SV': { cq: 20, itu: 28, cont: 'EU' }, '9A': { cq: 15, itu: 28, cont: 'EU' },
'S5': { cq: 15, itu: 28, cont: 'EU' }, 'LZ': { cq: 20, itu: 28, cont: 'EU' },
'YO': { cq: 20, 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' }, 'JE': { cq: 25, itu: 45, cont: 'AS' },
'JF': { cq: 25, itu: 45, cont: 'AS' }, 'JG': { cq: 25, itu: 45, cont: 'AS' },
'JI': { cq: 25, itu: 45, cont: 'AS' }, 'JJ': { cq: 25, itu: 45, cont: 'AS' },
'JK': { cq: 25, itu: 45, cont: 'AS' }, 'JL': { cq: 25, itu: 45, cont: 'AS' },
'JM': { cq: 25, itu: 45, cont: 'AS' }, 'JN': { cq: 25, itu: 45, cont: 'AS' },
'JO': { cq: 25, itu: 45, cont: 'AS' }, 'JP': { cq: 25, itu: 45, cont: 'AS' },
'JQ': { cq: 25, itu: 45, cont: 'AS' }, 'JS': { 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' },
'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' },
// Oceania
'VK': { cq: 30, itu: 59, cont: 'OC' },
'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' },
// 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' },
// 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' }
};
/**
* Fallback mapping based on first character
*/
const FALLBACK_MAP = {
'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' }
};
/**
* Get CQ zone, ITU zone, and continent from callsign
*/
export const getCallsignInfo = (call) => {
if (!call) return { cqZone: null, ituZone: null, continent: null };
const upper = call.toUpperCase();
// Try to match prefix (longest match first)
for (let len = 4; len >= 1; len--) {
const prefix = upper.substring(0, len);
if (PREFIX_MAP[prefix]) {
return {
cqZone: PREFIX_MAP[prefix].cq,
ituZone: PREFIX_MAP[prefix].itu,
continent: PREFIX_MAP[prefix].cont
};
}
}
// Fallback based on first character
const firstChar = upper[0];
if (FALLBACK_MAP[firstChar]) {
return {
cqZone: FALLBACK_MAP[firstChar].cq,
ituZone: FALLBACK_MAP[firstChar].itu,
continent: FALLBACK_MAP[firstChar].cont
};
}
return { cqZone: null, ituZone: null, continent: null };
};
/**
* Filter DX paths based on filters (filter by SPOTTER origin)
*/
export 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;
});
};
export default {
HF_BANDS,
CONTINENTS,
MODES,
getBandFromFreq,
getBandColor,
detectMode,
PREFIX_MAP,
getCallsignInfo,
filterDXPaths
};

@ -0,0 +1,107 @@
/**
* Configuration Utilities
* Handles app configuration, localStorage persistence, and theme management
*/
export 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
*/
export 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
*/
export const saveConfig = (config) => {
try {
localStorage.setItem('openhamclock_config', JSON.stringify(config));
} catch (e) {
console.error('Error saving config:', e);
}
};
/**
* Apply theme to document
*/
export const applyTheme = (theme) => {
document.documentElement.setAttribute('data-theme', theme);
};
/**
* Map Tile Providers
*/
export 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'
}
};
export default {
DEFAULT_CONFIG,
loadConfig,
saveConfig,
applyTheme,
MAP_STYLES
};

@ -0,0 +1,240 @@
/**
* Geographic Calculation Utilities
* Grid squares, bearings, distances, sun/moon positions
*/
/**
* Calculate Maidenhead grid square from coordinates
*/
export 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
*/
export 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;
};
/**
* Calculate distance between two points in km
*/
export 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));
};
/**
* Get subsolar point (position where sun is directly overhead)
*/
export 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 sublunar point (position where moon is directly overhead)
*/
export 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)
*/
export 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
*/
export 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
};
/**
* Calculate sunrise and sunset times
*/
export 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) };
};
/**
* Calculate great circle path points for Leaflet
* Handles antimeridian crossing by returning multiple segments
*/
export 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;
};
export default {
calculateGridSquare,
calculateBearing,
calculateDistance,
getSunPosition,
getMoonPosition,
getMoonPhase,
getMoonPhaseEmoji,
calculateSunTimes,
getGreatCirclePoints
};

@ -0,0 +1,39 @@
/**
* Utilities Index
* Central export point for all utility functions
*/
// Configuration utilities
export {
DEFAULT_CONFIG,
loadConfig,
saveConfig,
applyTheme,
MAP_STYLES
} from './config.js';
// Geographic calculations
export {
calculateGridSquare,
calculateBearing,
calculateDistance,
getSunPosition,
getMoonPosition,
getMoonPhase,
getMoonPhaseEmoji,
calculateSunTimes,
getGreatCirclePoints
} from './geo.js';
// Callsign and band utilities
export {
HF_BANDS,
CONTINENTS,
MODES,
getBandFromFreq,
getBandColor,
detectMode,
PREFIX_MAP,
getCallsignInfo,
filterDXPaths
} from './callsign.js';

@ -0,0 +1,37 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@hooks': path.resolve(__dirname, './src/hooks'),
'@utils': path.resolve(__dirname, './src/utils'),
'@styles': path.resolve(__dirname, './src/styles')
}
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
satellite: ['satellite.js']
}
}
}
}
});
Loading…
Cancel
Save

Powered by TurnKey Linux.