parent
c60c318c00
commit
0eef840530
@ -1,219 +1,222 @@
|
|||||||
# Contributing to OpenHamClock
|
# 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)
|
OpenHamClock uses a clean separation of concerns:
|
||||||
- [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)
|
|
||||||
|
|
||||||
## Code of Conduct
|
```
|
||||||
|
src/
|
||||||
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.
|
├── components/ # React UI components
|
||||||
|
├── hooks/ # Data fetching & state management
|
||||||
## Getting Started
|
├── utils/ # Pure utility functions
|
||||||
|
└── styles/ # CSS with theme variables
|
||||||
### 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
|
|
||||||
|
|
||||||
# Install dependencies
|
## 🔧 Working on Components
|
||||||
npm install
|
|
||||||
|
|
||||||
# Start development server
|
Each component is self-contained in its own file. To modify a component:
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# In another terminal, run Electron (optional)
|
1. Open the component file in `src/components/`
|
||||||
npm run electron
|
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
|
## 🪝 Working on Hooks
|
||||||
|
|
||||||
```
|
Hooks handle data fetching and state. Each hook:
|
||||||
openhamclock/
|
- Fetches from a specific API endpoint
|
||||||
├── public/index.html # Main application (React + Leaflet)
|
- Manages loading state
|
||||||
├── server.js # Express API proxy server
|
- Handles errors gracefully
|
||||||
├── electron/main.js # Desktop app wrapper
|
- Returns consistent shape: `{ data, loading, error? }`
|
||||||
├── scripts/ # Platform setup scripts
|
|
||||||
└── package.json # Dependencies and scripts
|
### 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`:
|
Utilities are pure functions with no side effects:
|
||||||
```bash
|
|
||||||
git checkout -b feature/your-feature-name
|
|
||||||
```
|
|
||||||
|
|
||||||
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:
|
## 🎨 CSS & Theming
|
||||||
- Test in multiple browsers (Chrome, Firefox, Safari)
|
|
||||||
- Test on desktop and mobile viewports
|
|
||||||
- Test the Electron app if applicable
|
|
||||||
- Verify API proxy endpoints work
|
|
||||||
|
|
||||||
4. Commit with clear messages:
|
Use CSS variables for all colors:
|
||||||
```bash
|
|
||||||
git commit -m "Add satellite tracking panel with TLE parser"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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**:
|
### New Component
|
||||||
- Use a clear, descriptive title
|
|
||||||
- Reference any related issues (`Fixes #123`)
|
|
||||||
- Describe what changes you made and why
|
|
||||||
- Include screenshots for UI changes
|
|
||||||
|
|
||||||
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:
|
### New Utility
|
||||||
- `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`
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
- Prefer `const` over `let`, avoid `var`
|
# Start dev servers
|
||||||
- Use meaningful variable and function names
|
node server.js # Terminal 1
|
||||||
- Add comments for complex logic
|
npm run dev # Terminal 2
|
||||||
- Keep functions focused and small
|
|
||||||
|
# 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
|
- [ ] Code follows existing patterns
|
||||||
- Follow the existing naming conventions
|
- [ ] All themes work correctly
|
||||||
- Prefer flexbox/grid over floats
|
- [ ] No console errors/warnings
|
||||||
- Test responsive breakpoints
|
- [ ] 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
|
1. Check existing issues first
|
||||||
- Keep components focused on single responsibilities
|
2. Include browser and screen size
|
||||||
- Extract reusable logic into custom hooks
|
3. Include console errors if any
|
||||||
- Use meaningful prop names
|
4. Include steps to reproduce
|
||||||
|
|
||||||
### Git Commits
|
## 💡 Feature Requests
|
||||||
|
|
||||||
- Write clear, concise commit messages
|
1. Describe the feature
|
||||||
- Use present tense ("Add feature" not "Added feature")
|
2. Explain the use case
|
||||||
- Reference issues when applicable
|
3. Show how it would work (mockups welcome)
|
||||||
|
|
||||||
## Recognition
|
## 🏗️ Reference Implementation
|
||||||
|
|
||||||
Contributors will be recognized in:
|
The original monolithic version is preserved at `public/index-monolithic.html` (5714 lines). Use it as reference for:
|
||||||
- The README contributors section
|
|
||||||
- Release notes for significant contributions
|
|
||||||
- The project's GitHub contributors page
|
|
||||||
|
|
||||||
## Questions?
|
- Line numbers for each feature section
|
||||||
|
- Complete implementation details
|
||||||
|
- Original styling decisions
|
||||||
|
|
||||||
Feel free to:
|
### Key Sections in Monolithic Version
|
||||||
- Open a GitHub Discussion
|
|
||||||
- Email chris@cjhlighting.com
|
|
||||||
- Reach out to maintainers
|
|
||||||
|
|
||||||
---
|
| 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.
|
||||||
|
|
||||||

|
## 🚀 Quick Start
|
||||||
[](LICENSE)
|
|
||||||
[](https://nodejs.org/)
|
|
||||||
[](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)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
</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)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
|
||||||
git clone https://github.com/accius/openhamclock.git
|
|
||||||
cd openhamclock
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Start the server
|
# Start development servers (need two terminals)
|
||||||
npm start
|
# Terminal 1: Backend API server
|
||||||
|
node server.js
|
||||||
|
|
||||||
# Open http://localhost:3000 in your browser
|
# Terminal 2: Frontend dev server with hot reload
|
||||||
```
|
npm run dev
|
||||||
|
|
||||||
### One-Line Install
|
|
||||||
|
|
||||||
**Linux/macOS:**
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/accius/openhamclock/main/scripts/setup-linux.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windows (PowerShell as Admin):**
|
# Open http://localhost:3000
|
||||||
```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'))
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🍓 Raspberry Pi
|
For production:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download and run the Pi setup script
|
npm run build
|
||||||
curl -fsSL https://raw.githubusercontent.com/accius/openhamclock/main/scripts/setup-pi.sh -o setup-pi.sh
|
npm start # Serves from dist/ on port 3001
|
||||||
chmod +x setup-pi.sh
|
```
|
||||||
|
|
||||||
# Standard installation
|
## 📁 Project Structure
|
||||||
./setup-pi.sh
|
|
||||||
|
```
|
||||||
# Or with kiosk mode (fullscreen, auto-start on boot)
|
openhamclock-modular/
|
||||||
./setup-pi.sh --kiosk
|
├── src/
|
||||||
```
|
│ ├── main.jsx # React entry point
|
||||||
|
│ ├── App.jsx # Main application component
|
||||||
**Supported Pi Models:**
|
│ ├── components/ # All UI components (fully extracted)
|
||||||
- Raspberry Pi 3B / 3B+ ✓
|
│ │ ├── index.js # Component exports
|
||||||
- Raspberry Pi 4 (2GB+) ✓✓ (Recommended)
|
│ │ ├── Header.jsx # Top bar with clocks/controls
|
||||||
- Raspberry Pi 5 ✓✓✓ (Best performance)
|
│ │ ├── WorldMap.jsx # Leaflet map with DX paths
|
||||||
|
│ │ ├── SpaceWeatherPanel.jsx
|
||||||
### 🖥️ Desktop App (Electron)
|
│ │ ├── 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
|
```bash
|
||||||
# Development
|
# railway.toml and railway.json are included
|
||||||
npm run electron
|
railway up
|
||||||
|
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🐳 Docker
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the image
|
docker-compose up -d
|
||||||
docker build -t openhamclock .
|
|
||||||
|
|
||||||
# Run the container
|
|
||||||
docker run -p 3000:3000 openhamclock
|
|
||||||
|
|
||||||
# Or use Docker Compose
|
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### ☁️ Deploy to Railway
|
### Manual
|
||||||
|
|
||||||
[](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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone and setup
|
npm run build
|
||||||
git clone https://github.com/accius/openhamclock.git
|
NODE_ENV=production node server.js
|
||||||
cd openhamclock
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Start development server
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Run Electron in dev mode
|
|
||||||
npm run electron
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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
|
## 🤝 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
|
1. Fork the repository
|
||||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
2. Pick a component/hook to improve
|
||||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
3. Make changes in the appropriate file
|
||||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
4. Test with all three themes
|
||||||
5. Open a Pull Request
|
5. Submit a PR
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📜 License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
||||||
|
|
||||||
---
|
### 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. 🕊️
|
### Testing Changes
|
||||||
- **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
|
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
# Run dev server
|
||||||
## 📞 Contact
|
npm run dev
|
||||||
|
|
||||||
- **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)
|
|
||||||
|
|
||||||
---
|
# 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",
|
"name": "openhamclock",
|
||||||
"version": "3.9.0",
|
"version": "3.7.0",
|
||||||
"description": "Open-source amateur radio dashboard with real-time space weather, band conditions, DX cluster, and interactive world map",
|
"description": "Amateur Radio Dashboard - A modern web-based HamClock alternative",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "node server.js",
|
"server": "node server.js"
|
||||||
"electron": "electron electron/main.js",
|
},
|
||||||
"electron:build": "electron-builder",
|
"dependencies": {
|
||||||
"electron:build:win": "electron-builder --win",
|
"axios": "^1.6.2",
|
||||||
"electron:build:mac": "electron-builder --mac",
|
"cors": "^2.8.5",
|
||||||
"electron:build:linux": "electron-builder --linux",
|
"express": "^4.18.2",
|
||||||
"electron:build:pi": "electron-builder --linux --armv7l",
|
"node-fetch": "^3.3.2",
|
||||||
"docker:build": "docker build -t openhamclock .",
|
"satellite.js": "^5.0.0",
|
||||||
"docker:run": "docker run -p 3000:3000 openhamclock",
|
"ws": "^8.14.2"
|
||||||
"setup:pi": "bash scripts/setup-pi.sh",
|
},
|
||||||
"setup:linux": "bash scripts/setup-linux.sh",
|
"devDependencies": {
|
||||||
"test": "echo \"No tests yet\" && exit 0"
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"vite": "^5.0.10"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ham-radio",
|
|
||||||
"amateur-radio",
|
"amateur-radio",
|
||||||
|
"ham-radio",
|
||||||
"hamclock",
|
"hamclock",
|
||||||
"dx-cluster",
|
"dx-cluster",
|
||||||
"space-weather",
|
|
||||||
"pota",
|
|
||||||
"sota",
|
|
||||||
"propagation",
|
"propagation",
|
||||||
"raspberry-pi",
|
"pota",
|
||||||
"electron"
|
"satellite-tracking"
|
||||||
],
|
],
|
||||||
"author": "OpenHamClock Contributors",
|
"author": "K0CJH",
|
||||||
"license": "MIT",
|
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,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,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}¤t=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: '© Esri'
|
||||||
|
},
|
||||||
|
satellite: {
|
||||||
|
name: 'Satellite',
|
||||||
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
attribution: '© Esri'
|
||||||
|
},
|
||||||
|
terrain: {
|
||||||
|
name: 'Terrain',
|
||||||
|
url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
|
||||||
|
attribution: '© <a href="https://opentopomap.org">OpenTopoMap</a>'
|
||||||
|
},
|
||||||
|
streets: {
|
||||||
|
name: 'Streets',
|
||||||
|
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
attribution: '© <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: '© Esri'
|
||||||
|
},
|
||||||
|
watercolor: {
|
||||||
|
name: 'Ocean',
|
||||||
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
attribution: '© Esri'
|
||||||
|
},
|
||||||
|
hybrid: {
|
||||||
|
name: 'Hybrid',
|
||||||
|
url: 'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}',
|
||||||
|
attribution: '© Google'
|
||||||
|
},
|
||||||
|
gray: {
|
||||||
|
name: 'Gray',
|
||||||
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
attribution: '© 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…
Reference in new issue