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

|
||||
[](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)
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/accius/openhamclock.git
|
||||
cd openhamclock
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start the server
|
||||
npm start
|
||||
# Start development servers (need two terminals)
|
||||
# Terminal 1: Backend API server
|
||||
node server.js
|
||||
|
||||
# Open http://localhost:3000 in your browser
|
||||
```
|
||||
|
||||
### One-Line Install
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/accius/openhamclock/main/scripts/setup-linux.sh | bash
|
||||
```
|
||||
# Terminal 2: Frontend dev server with hot reload
|
||||
npm run dev
|
||||
|
||||
**Windows (PowerShell as Admin):**
|
||||
```powershell
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force; iex ((New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/accius/openhamclock/main/scripts/setup-windows.ps1'))
|
||||
# Open http://localhost:3000
|
||||
```
|
||||
|
||||
### 🍓 Raspberry Pi
|
||||
|
||||
For production:
|
||||
```bash
|
||||
# Download and run the Pi setup script
|
||||
curl -fsSL https://raw.githubusercontent.com/accius/openhamclock/main/scripts/setup-pi.sh -o setup-pi.sh
|
||||
chmod +x setup-pi.sh
|
||||
|
||||
# Standard installation
|
||||
./setup-pi.sh
|
||||
|
||||
# Or with kiosk mode (fullscreen, auto-start on boot)
|
||||
./setup-pi.sh --kiosk
|
||||
```
|
||||
|
||||
**Supported Pi Models:**
|
||||
- Raspberry Pi 3B / 3B+ ✓
|
||||
- Raspberry Pi 4 (2GB+) ✓✓ (Recommended)
|
||||
- Raspberry Pi 5 ✓✓✓ (Best performance)
|
||||
|
||||
### 🖥️ Desktop App (Electron)
|
||||
|
||||
npm run build
|
||||
npm start # Serves from dist/ on port 3001
|
||||
```
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
openhamclock-modular/
|
||||
├── src/
|
||||
│ ├── main.jsx # React entry point
|
||||
│ ├── App.jsx # Main application component
|
||||
│ ├── components/ # All UI components (fully extracted)
|
||||
│ │ ├── index.js # Component exports
|
||||
│ │ ├── Header.jsx # Top bar with clocks/controls
|
||||
│ │ ├── WorldMap.jsx # Leaflet map with DX paths
|
||||
│ │ ├── SpaceWeatherPanel.jsx
|
||||
│ │ ├── BandConditionsPanel.jsx
|
||||
│ │ ├── DXClusterPanel.jsx
|
||||
│ │ ├── POTAPanel.jsx
|
||||
│ │ ├── ContestPanel.jsx
|
||||
│ │ ├── LocationPanel.jsx
|
||||
│ │ ├── SettingsPanel.jsx
|
||||
│ │ └── DXFilterManager.jsx
|
||||
│ ├── hooks/ # All data fetching hooks (fully extracted)
|
||||
│ │ ├── index.js # Hook exports
|
||||
│ │ ├── useSpaceWeather.js
|
||||
│ │ ├── useBandConditions.js
|
||||
│ │ ├── useDXCluster.js
|
||||
│ │ ├── useDXPaths.js
|
||||
│ │ ├── usePOTASpots.js
|
||||
│ │ ├── useContests.js
|
||||
│ │ ├── useLocalWeather.js
|
||||
│ │ ├── usePropagation.js
|
||||
│ │ ├── useMySpots.js
|
||||
│ │ ├── useDXpeditions.js
|
||||
│ │ ├── useSatellites.js
|
||||
│ │ └── useSolarIndices.js
|
||||
│ ├── utils/ # Utility functions (fully extracted)
|
||||
│ │ ├── index.js # Utility exports
|
||||
│ │ ├── config.js # App config & localStorage
|
||||
│ │ ├── geo.js # Grid squares, bearings, distances
|
||||
│ │ └── callsign.js # Band detection, filtering
|
||||
│ └── styles/
|
||||
│ └── main.css # All CSS with theme variables
|
||||
├── public/
|
||||
│ └── index-monolithic.html # Original 5714-line reference
|
||||
├── server.js # Backend API server
|
||||
├── config.js # Server configuration
|
||||
├── package.json
|
||||
├── vite.config.js
|
||||
└── index.html # Vite entry HTML
|
||||
```
|
||||
|
||||
## 🎨 Themes
|
||||
|
||||
Three themes available via Settings:
|
||||
- **Dark** (default) - Modern dark theme with amber accents
|
||||
- **Light** - Light theme for daytime use
|
||||
- **Legacy** - Classic HamClock green-on-black terminal style
|
||||
|
||||
Themes use CSS custom properties defined in `src/styles/main.css`.
|
||||
|
||||
## 🔌 Components
|
||||
|
||||
All components are fully extracted and ready to modify:
|
||||
|
||||
| Component | Description | File |
|
||||
|-----------|-------------|------|
|
||||
| Header | Top bar with clocks, weather, controls | `Header.jsx` |
|
||||
| WorldMap | Leaflet map with markers & paths | `WorldMap.jsx` |
|
||||
| SpaceWeatherPanel | SFI, K-index, SSN display | `SpaceWeatherPanel.jsx` |
|
||||
| BandConditionsPanel | HF band condition indicators | `BandConditionsPanel.jsx` |
|
||||
| DXClusterPanel | Live DX spots list | `DXClusterPanel.jsx` |
|
||||
| POTAPanel | Parks on the Air activations | `POTAPanel.jsx` |
|
||||
| ContestPanel | Upcoming contests | `ContestPanel.jsx` |
|
||||
| LocationPanel | DE/DX info with grid squares | `LocationPanel.jsx` |
|
||||
| SettingsPanel | Configuration modal | `SettingsPanel.jsx` |
|
||||
| DXFilterManager | DX cluster filtering modal | `DXFilterManager.jsx` |
|
||||
|
||||
## 🪝 Hooks
|
||||
|
||||
All data fetching is handled by custom hooks:
|
||||
|
||||
| Hook | Purpose | Interval |
|
||||
|------|---------|----------|
|
||||
| `useSpaceWeather` | SFI, K-index, SSN from NOAA | 5 min |
|
||||
| `useBandConditions` | Calculate band conditions | On SFI change |
|
||||
| `useDXCluster` | DX spots with filtering | 5 sec |
|
||||
| `useDXPaths` | DX paths for map | 10 sec |
|
||||
| `usePOTASpots` | POTA activations | 1 min |
|
||||
| `useContests` | Contest calendar | 30 min |
|
||||
| `useLocalWeather` | Weather from Open-Meteo | 15 min |
|
||||
| `usePropagation` | ITURHFProp predictions | 10 min |
|
||||
| `useMySpots` | Your callsign spots | 30 sec |
|
||||
| `useSatellites` | Satellite tracking | 5 sec |
|
||||
| `useSolarIndices` | Extended solar data | 15 min |
|
||||
|
||||
## 🛠️ Utilities
|
||||
|
||||
| Module | Functions |
|
||||
|--------|-----------|
|
||||
| `config.js` | `loadConfig`, `saveConfig`, `applyTheme`, `MAP_STYLES` |
|
||||
| `geo.js` | `calculateGridSquare`, `calculateBearing`, `calculateDistance`, `getSunPosition`, `getMoonPosition`, `getGreatCirclePoints` |
|
||||
| `callsign.js` | `getBandFromFreq`, `getBandColor`, `detectMode`, `getCallsignInfo`, `filterDXPaths` |
|
||||
|
||||
## 🌐 API Endpoints
|
||||
|
||||
The backend server provides:
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `/api/dxcluster/spots` | DX cluster spots |
|
||||
| `/api/dxcluster/paths` | DX paths with coordinates |
|
||||
| `/api/solar-indices` | Extended solar data |
|
||||
| `/api/propagation` | HF propagation predictions |
|
||||
| `/api/contests` | Contest calendar |
|
||||
| `/api/myspots/:callsign` | Spots for your callsign |
|
||||
| `/api/satellites/tle` | Satellite TLE data |
|
||||
| `/api/dxpeditions` | Active DXpeditions |
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Railway
|
||||
```bash
|
||||
# Development
|
||||
npm run electron
|
||||
|
||||
# Build for your platform
|
||||
npm run electron:build
|
||||
|
||||
# Build for specific platform
|
||||
npm run electron:build:win # Windows
|
||||
npm run electron:build:mac # macOS
|
||||
npm run electron:build:linux # Linux
|
||||
# railway.toml and railway.json are included
|
||||
railway up
|
||||
```
|
||||
|
||||
### 🐳 Docker
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -t openhamclock .
|
||||
|
||||
# Run the container
|
||||
docker run -p 3000:3000 openhamclock
|
||||
|
||||
# Or use Docker Compose
|
||||
docker compose up -d
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### ☁️ Deploy to Railway
|
||||
|
||||
[](https://railway.app/template/openhamclock)
|
||||
|
||||
#### Full Deployment (3 Services)
|
||||
|
||||
For the complete hybrid propagation system, deploy all three services:
|
||||
|
||||
**1. Deploy ITURHFProp Service First** (enables hybrid propagation)
|
||||
```
|
||||
├── Go to railway.app → New Project → Deploy from GitHub repo
|
||||
├── Select your forked repository
|
||||
├── Click "Add Service" → "GitHub Repo" (same repo)
|
||||
├── In service settings, set "Root Directory" to: iturhfprop-service
|
||||
├── If Root Directory option not visible:
|
||||
│ - Go to Service → Settings → Build
|
||||
│ - Add "Root Directory" and enter: iturhfprop-service
|
||||
├── Deploy and wait for build to complete (~2-3 min)
|
||||
└── Copy the public URL (Settings → Networking → Generate Domain)
|
||||
```
|
||||
|
||||
**2. Deploy DX Spider Proxy** (optional - for live DX cluster paths)
|
||||
```
|
||||
├── In same project, click "Add Service" → "GitHub Repo"
|
||||
├── Set "Root Directory" to: dxspider-proxy
|
||||
└── Deploy
|
||||
```
|
||||
|
||||
**3. Deploy Main OpenHamClock**
|
||||
```
|
||||
├── In same project, click "Add Service" → "GitHub Repo"
|
||||
├── Leave Root Directory empty (uses repo root)
|
||||
├── Go to Variables tab, add:
|
||||
│ ITURHFPROP_URL = https://[your-iturhfprop-service].up.railway.app
|
||||
└── Deploy
|
||||
```
|
||||
|
||||
#### Alternative: Separate Projects
|
||||
|
||||
If Root Directory doesn't work, create separate Railway projects:
|
||||
1. Fork the repo 3 times (or use branches)
|
||||
2. Move each service to its own repo root
|
||||
3. Deploy each as separate Railway project
|
||||
4. Link via environment variables
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Edit your callsign and location in `public/index.html`:
|
||||
|
||||
```javascript
|
||||
const CONFIG = {
|
||||
callsign: 'YOUR_CALL',
|
||||
location: { lat: YOUR_LAT, lon: YOUR_LON },
|
||||
defaultDX: { lat: 35.6762, lon: 139.6503 },
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `3000` | Server port |
|
||||
| `NODE_ENV` | `development` | Environment mode |
|
||||
| `ITURHFPROP_URL` | `null` | ITURHFProp service URL (enables hybrid mode) |
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Map Styles
|
||||
|
||||
| Style | Provider | Best For |
|
||||
|-------|----------|----------|
|
||||
| **Dark** | CartoDB | Night use, low-light shacks |
|
||||
| **Satellite** | ESRI | Terrain visualization |
|
||||
| **Terrain** | OpenTopoMap | SOTA operations |
|
||||
| **Streets** | OpenStreetMap | Urban navigation |
|
||||
| **Topo** | ESRI | Detailed terrain |
|
||||
| **Ocean** | ESRI | Maritime operations |
|
||||
| **NatGeo** | ESRI | Classic cartography |
|
||||
| **Gray** | ESRI | Minimal, distraction-free |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Manual
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone https://github.com/accius/openhamclock.git
|
||||
cd openhamclock
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Run Electron in dev mode
|
||||
npm run electron
|
||||
npm run build
|
||||
NODE_ENV=production node server.js
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
openhamclock/
|
||||
├── public/ # Static web files
|
||||
│ ├── index.html # Main application
|
||||
│ └── icons/ # App icons
|
||||
├── electron/ # Electron main process
|
||||
│ └── main.js # Desktop app entry
|
||||
├── dxspider-proxy/ # DX Cluster proxy service
|
||||
│ ├── server.js # Telnet-to-WebSocket proxy
|
||||
│ ├── package.json # Proxy dependencies
|
||||
│ └── README.md # Proxy documentation
|
||||
├── iturhfprop-service/ # HF Propagation prediction service
|
||||
│ ├── server.js # ITU-R P.533 API wrapper
|
||||
│ ├── Dockerfile # Builds ITURHFProp engine
|
||||
│ └── README.md # Service documentation
|
||||
├── scripts/ # Setup scripts
|
||||
│ ├── setup-pi.sh # Raspberry Pi setup
|
||||
│ ├── setup-linux.sh
|
||||
│ └── setup-windows.ps1
|
||||
├── server.js # Express server & API proxy
|
||||
├── Dockerfile # Container build
|
||||
├── railway.toml # Railway config
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions from the amateur radio community! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
### Priority Areas
|
||||
|
||||
1. **Satellite Tracking** - TLE parsing and pass predictions
|
||||
2. **Rotator Control** - Hamlib integration
|
||||
3. **Additional APIs** - QRZ, LoTW, ClubLog
|
||||
4. **Accessibility** - Screen reader support, high contrast modes
|
||||
5. **Translations** - Internationalization
|
||||
6. **WebSocket DX Cluster** - Direct connection to DX Spider nodes
|
||||
|
||||
### How to Contribute
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
2. Pick a component/hook to improve
|
||||
3. Make changes in the appropriate file
|
||||
4. Test with all three themes
|
||||
5. Submit a PR
|
||||
|
||||
---
|
||||
### Code Style
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
- Functional components with hooks
|
||||
- CSS-in-JS for component-specific styles
|
||||
- CSS variables for theme colors
|
||||
- JSDoc comments for functions
|
||||
- Descriptive variable names
|
||||
|
||||
- **Elwood Downey, WB0OEW** - Creator of the original HamClock. Your work inspired thousands of amateur radio operators worldwide. Rest in peace, OM. 🕊️
|
||||
- **Leaflet.js** - Outstanding open-source mapping library
|
||||
- **OpenStreetMap** - Community-driven map data
|
||||
- **ESRI** - Satellite and specialty map tiles
|
||||
- **NOAA Space Weather Prediction Center** - Space weather data
|
||||
- **Parks on the Air (POTA)** - Activator spot API
|
||||
- **Summits on the Air (SOTA)** - Summit spot API
|
||||
- **The Amateur Radio Community** - For keeping the spirit of experimentation alive
|
||||
### Testing Changes
|
||||
|
||||
---
|
||||
|
||||
## 📞 Contact
|
||||
|
||||
- **Email**: chris@cjhlighting.com
|
||||
- **GitHub Issues**: [Report bugs or request features](https://github.com/accius/openhamclock/issues)
|
||||
- **Discussions**: [Join the conversation](https://github.com/accius/openhamclock/discussions)
|
||||
```bash
|
||||
# Run dev server
|
||||
npm run dev
|
||||
|
||||
---
|
||||
# Check all themes work
|
||||
# Test on different screen sizes
|
||||
# Verify data fetching works
|
||||
```
|
||||
|
||||
<div align="center">
|
||||
## 📄 License
|
||||
|
||||
**73 de K0CJH and the OpenHamClock contributors!**
|
||||
MIT License - See LICENSE file
|
||||
|
||||
*"The original HamClock will cease to function in June 2026. OpenHamClock carries forward Elwood's legacy with modern technology and open-source community development."*
|
||||
## 🙏 Credits
|
||||
|
||||
</div>
|
||||
- K0CJH - Original OpenHamClock
|
||||
- NOAA SWPC - Space weather data
|
||||
- POTA - Parks on the Air API
|
||||
- Open-Meteo - Weather data
|
||||
- Leaflet - Mapping library
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OpenHamClock - Amateur Radio Dashboard</title>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
|
||||
<!-- Leaflet JS -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
<!-- Leaflet Terminator (day/night) -->
|
||||
<script src="https://unpkg.com/@joergdietrich/leaflet.terminator@1.0.0/L.Terminator.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,96 +1,38 @@
|
||||
{
|
||||
"name": "openhamclock",
|
||||
"version": "3.9.0",
|
||||
"description": "Open-source amateur radio dashboard with real-time space weather, band conditions, DX cluster, and interactive world map",
|
||||
"version": "3.7.0",
|
||||
"description": "Amateur Radio Dashboard - A modern web-based HamClock alternative",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node server.js",
|
||||
"dev": "node server.js",
|
||||
"electron": "electron electron/main.js",
|
||||
"electron:build": "electron-builder",
|
||||
"electron:build:win": "electron-builder --win",
|
||||
"electron:build:mac": "electron-builder --mac",
|
||||
"electron:build:linux": "electron-builder --linux",
|
||||
"electron:build:pi": "electron-builder --linux --armv7l",
|
||||
"docker:build": "docker build -t openhamclock .",
|
||||
"docker:run": "docker run -p 3000:3000 openhamclock",
|
||||
"setup:pi": "bash scripts/setup-pi.sh",
|
||||
"setup:linux": "bash scripts/setup-linux.sh",
|
||||
"test": "echo \"No tests yet\" && exit 0"
|
||||
"server": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"node-fetch": "^2.7.0",
|
||||
"satellite.js": "^5.0.0",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"vite": "^5.0.10"
|
||||
},
|
||||
"keywords": [
|
||||
"ham-radio",
|
||||
"amateur-radio",
|
||||
"ham-radio",
|
||||
"hamclock",
|
||||
"dx-cluster",
|
||||
"space-weather",
|
||||
"pota",
|
||||
"sota",
|
||||
"propagation",
|
||||
"raspberry-pi",
|
||||
"electron"
|
||||
"pota",
|
||||
"satellite-tracking"
|
||||
],
|
||||
"author": "OpenHamClock Contributors",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/accius/openhamclock.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/accius/openhamclock/issues"
|
||||
},
|
||||
"homepage": "https://github.com/accius/openhamclock#readme",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"node-fetch": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.openhamclock.app",
|
||||
"productName": "OpenHamClock",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"public/**/*",
|
||||
"electron/**/*",
|
||||
"server.js",
|
||||
"package.json"
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.utilities",
|
||||
"icon": "public/icons/icon.icns",
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"icon": "public/icons/icon.ico",
|
||||
"target": [
|
||||
"nsis",
|
||||
"portable"
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"icon": "public/icons",
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb",
|
||||
"rpm"
|
||||
],
|
||||
"category": "Utility"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
}
|
||||
}
|
||||
"author": "K0CJH",
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@ -1,125 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
OpenHamClock Development Server
|
||||
|
||||
A simple HTTP server for OpenHamClock with API proxy capabilities.
|
||||
This allows the application to fetch live data from external sources
|
||||
without CORS issues.
|
||||
|
||||
Usage:
|
||||
python3 server.py [port]
|
||||
|
||||
Default port: 8080
|
||||
Open http://localhost:8080 in your browser
|
||||
|
||||
Requirements:
|
||||
Python 3.7+
|
||||
requests library (optional, for API proxy)
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import socketserver
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
|
||||
|
||||
# API endpoints for live data
|
||||
API_ENDPOINTS = {
|
||||
'solarflux': 'https://services.swpc.noaa.gov/json/solar-cycle/observed-solar-flux.json',
|
||||
'kindex': 'https://services.swpc.noaa.gov/json/planetary_k_index_1m.json',
|
||||
'xray': 'https://services.swpc.noaa.gov/json/goes/primary/xrays-7-day.json',
|
||||
'sunspots': 'https://services.swpc.noaa.gov/json/solar-cycle/sunspots.json',
|
||||
'pota': 'https://api.pota.app/spot/activator',
|
||||
'bands': 'https://www.hamqsl.com/solarxml.php', # HamQSL solar data
|
||||
}
|
||||
|
||||
class OpenHamClockHandler(http.server.SimpleHTTPRequestHandler):
|
||||
"""Custom HTTP handler with API proxy support."""
|
||||
|
||||
def do_GET(self):
|
||||
# Handle API proxy requests
|
||||
if self.path.startswith('/api/'):
|
||||
self.handle_api()
|
||||
else:
|
||||
# Serve static files
|
||||
super().do_GET()
|
||||
|
||||
def handle_api(self):
|
||||
"""Proxy API requests to avoid CORS issues."""
|
||||
endpoint = self.path.replace('/api/', '').split('?')[0]
|
||||
|
||||
if endpoint not in API_ENDPOINTS:
|
||||
self.send_error(404, f"Unknown API endpoint: {endpoint}")
|
||||
return
|
||||
|
||||
try:
|
||||
url = API_ENDPOINTS[endpoint]
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] Fetching: {url}")
|
||||
|
||||
# Make the request
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'User-Agent': 'OpenHamClock/1.0'}
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
data = response.read()
|
||||
content_type = response.headers.get('Content-Type', 'application/json')
|
||||
|
||||
# Send response
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', content_type)
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Cache-Control', 'max-age=60')
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
print(f"[ERROR] Failed to fetch {endpoint}: {e}")
|
||||
self.send_error(502, f"Failed to fetch data: {e}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] {e}")
|
||||
self.send_error(500, str(e))
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Custom logging format."""
|
||||
if args[0].startswith('GET /api/'):
|
||||
return # Already logged in handle_api
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] {args[0]}")
|
||||
|
||||
|
||||
def main():
|
||||
# Change to the directory containing this script
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
os.chdir(script_dir)
|
||||
|
||||
print("=" * 50)
|
||||
print(" OpenHamClock Development Server")
|
||||
print("=" * 50)
|
||||
print()
|
||||
print(f" Serving from: {script_dir}")
|
||||
print(f" URL: http://localhost:{PORT}")
|
||||
print(f" Press Ctrl+C to stop")
|
||||
print()
|
||||
print(" Available API endpoints:")
|
||||
for name, url in API_ENDPOINTS.items():
|
||||
print(f" /api/{name}")
|
||||
print()
|
||||
print("=" * 50)
|
||||
print()
|
||||
|
||||
with socketserver.TCPServer(("", PORT), OpenHamClockHandler) as httpd:
|
||||
httpd.allow_reuse_address = True
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nServer stopped.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -0,0 +1,657 @@
|
||||
/**
|
||||
* OpenHamClock - Main Application Component
|
||||
* Amateur Radio Dashboard v3.7.0
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
|
||||
// Components
|
||||
import {
|
||||
Header,
|
||||
WorldMap,
|
||||
DXClusterPanel,
|
||||
POTAPanel,
|
||||
ContestPanel,
|
||||
SettingsPanel,
|
||||
DXFilterManager,
|
||||
SolarPanel,
|
||||
PropagationPanel,
|
||||
DXpeditionPanel
|
||||
} 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;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem('openhamclock_dxLocation', JSON.stringify(dxLocation));
|
||||
} catch (e) {}
|
||||
}, [dxLocation]);
|
||||
|
||||
// UI state
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showDXFilters, setShowDXFilters] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// Map layer visibility
|
||||
const [mapLayers, setMapLayers] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('openhamclock_mapLayers');
|
||||
const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false };
|
||||
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
|
||||
} catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false }; }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem('openhamclock_mapLayers', JSON.stringify(mapLayers));
|
||||
} catch (e) {}
|
||||
}, [mapLayers]);
|
||||
|
||||
const [hoveredSpot, setHoveredSpot] = useState(null);
|
||||
|
||||
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
|
||||
const [use12Hour, setUse12Hour] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('openhamclock_use12Hour') === 'true';
|
||||
} catch (e) { return false; }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem('openhamclock_use12Hour', use12Hour.toString());
|
||||
} catch (e) {}
|
||||
}, [use12Hour]);
|
||||
|
||||
const handleTimeFormatToggle = useCallback(() => setUse12Hour(prev => !prev), []);
|
||||
|
||||
// Fullscreen
|
||||
const handleFullscreenToggle = useCallback(() => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().then(() => setIsFullscreen(true)).catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen().then(() => setIsFullscreen(false)).catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', handler);
|
||||
return () => document.removeEventListener('fullscreenchange', handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(config.theme || 'dark');
|
||||
}, []);
|
||||
|
||||
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 Filters
|
||||
const [dxFilters, setDxFilters] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('openhamclock_dxFilters');
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch (e) { return {}; }
|
||||
});
|
||||
|
||||
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 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 for small screens
|
||||
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);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: 'var(--bg-primary)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{config.layout === 'classic' ? (
|
||||
/* CLASSIC HAMCLOCK-STYLE LAYOUT */
|
||||
<div style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#000000',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* TOP BAR - HamClock style */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '280px 1fr 300px',
|
||||
height: '130px',
|
||||
borderBottom: '2px solid #333',
|
||||
background: '#000'
|
||||
}}>
|
||||
{/* Callsign & Time */}
|
||||
<div style={{ padding: '8px 12px', borderRight: '1px solid #333' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '42px',
|
||||
fontWeight: '900',
|
||||
color: '#ff4444',
|
||||
fontFamily: 'Orbitron, monospace',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 1
|
||||
}}
|
||||
onClick={() => setShowSettings(true)}
|
||||
title="Click for settings"
|
||||
>
|
||||
{config.callsign}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#888', marginTop: '2px' }}>
|
||||
Up 35d 18h • v4.20
|
||||
</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<div style={{ fontSize: '36px', fontWeight: '700', color: '#00ff00', fontFamily: 'Orbitron, monospace', lineHeight: 1 }}>
|
||||
{utcTime}<span style={{ fontSize: '20px', color: '#00cc00' }}>:{String(new Date().getUTCSeconds()).padStart(2, '0')}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#00cc00', marginTop: '2px' }}>
|
||||
{utcDate} <span style={{ color: '#666', marginLeft: '8px' }}>UTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Solar Indices - SSN & SFI */}
|
||||
<div style={{ display: 'flex', borderRight: '1px solid #333' }}>
|
||||
{/* SSN */}
|
||||
<div style={{ flex: 1, padding: '8px', borderRight: '1px solid #333' }}>
|
||||
<div style={{ fontSize: '10px', color: '#888', textAlign: 'center' }}>Sunspot Number</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ flex: 1, height: '70px', background: '#001100', border: '1px solid #333', borderRadius: '2px', padding: '4px' }}>
|
||||
{solarIndices?.data?.ssn?.history?.length > 0 && (
|
||||
<svg width="100%" height="100%" viewBox="0 0 100 60" preserveAspectRatio="none">
|
||||
{(() => {
|
||||
const data = solarIndices.data.ssn.history.slice(-30);
|
||||
const values = data.map(d => d.value);
|
||||
const max = Math.max(...values, 1);
|
||||
const min = Math.min(...values, 0);
|
||||
const range = max - min || 1;
|
||||
const points = data.map((d, i) => {
|
||||
const x = (i / (data.length - 1)) * 100;
|
||||
const y = 60 - ((d.value - min) / range) * 55;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
return <polyline points={points} fill="none" stroke="#00ff00" strokeWidth="1.5" />;
|
||||
})()}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '48px', fontWeight: '700', color: '#00ffff', fontFamily: 'Orbitron, monospace' }}>
|
||||
{solarIndices?.data?.ssn?.current || '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: '#666', textAlign: 'center', marginTop: '2px' }}>-30 Days</div>
|
||||
</div>
|
||||
|
||||
{/* SFI */}
|
||||
<div style={{ flex: 1, padding: '8px' }}>
|
||||
<div style={{ fontSize: '10px', color: '#888', textAlign: 'center' }}>10.7 cm Solar flux</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ flex: 1, height: '70px', background: '#001100', border: '1px solid #333', borderRadius: '2px', padding: '4px' }}>
|
||||
{solarIndices?.data?.sfi?.history?.length > 0 && (
|
||||
<svg width="100%" height="100%" viewBox="0 0 100 60" preserveAspectRatio="none">
|
||||
{(() => {
|
||||
const data = solarIndices.data.sfi.history.slice(-30);
|
||||
const values = data.map(d => d.value);
|
||||
const max = Math.max(...values, 1);
|
||||
const min = Math.min(...values);
|
||||
const range = max - min || 1;
|
||||
const points = data.map((d, i) => {
|
||||
const x = (i / (data.length - 1)) * 100;
|
||||
const y = 60 - ((d.value - min) / range) * 55;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
return <polyline points={points} fill="none" stroke="#00ff00" strokeWidth="1.5" />;
|
||||
})()}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '48px', fontWeight: '700', color: '#ff66ff', fontFamily: 'Orbitron, monospace' }}>
|
||||
{solarIndices?.data?.sfi?.current || '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: '#666', textAlign: 'center', marginTop: '2px' }}>-30 Days +7</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Spots & Indices */}
|
||||
<div style={{ display: 'flex' }}>
|
||||
{/* Live Spots by Band */}
|
||||
<div style={{ flex: 1, padding: '8px', borderRight: '1px solid #333' }}>
|
||||
<div style={{ fontSize: '12px', color: '#ff6666', fontWeight: '700' }}>Live Spots</div>
|
||||
<div style={{ fontSize: '9px', color: '#888', marginBottom: '4px' }}>of {deGrid} - 15 mins</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px', fontSize: '10px' }}>
|
||||
{[
|
||||
{ band: '160m', color: '#ff6666' },
|
||||
{ band: '80m', color: '#ff9966' },
|
||||
{ band: '60m', color: '#ffcc66' },
|
||||
{ band: '40m', color: '#ccff66' },
|
||||
{ band: '30m', color: '#66ff99' },
|
||||
{ band: '20m', color: '#66ffcc' },
|
||||
{ band: '17m', color: '#66ccff' },
|
||||
{ band: '15m', color: '#6699ff' },
|
||||
{ band: '12m', color: '#9966ff' },
|
||||
{ band: '10m', color: '#cc66ff' },
|
||||
].map(b => (
|
||||
<div key={b.band} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: b.color }}>{b.band}</span>
|
||||
<span style={{ color: '#fff' }}>
|
||||
{dxCluster.data?.filter(s => {
|
||||
const freq = parseFloat(s.freq);
|
||||
const bands = {
|
||||
'160m': [1.8, 2], '80m': [3.5, 4], '60m': [5.3, 5.4], '40m': [7, 7.3],
|
||||
'30m': [10.1, 10.15], '20m': [14, 14.35], '17m': [18.068, 18.168],
|
||||
'15m': [21, 21.45], '12m': [24.89, 24.99], '10m': [28, 29.7]
|
||||
};
|
||||
const r = bands[b.band];
|
||||
return r && freq >= r[0] && freq <= r[1];
|
||||
}).length || 0}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Space Weather Indices */}
|
||||
<div style={{ width: '70px', padding: '8px', fontSize: '11px' }}>
|
||||
<div style={{ marginBottom: '6px' }}>
|
||||
<div style={{ color: '#888' }}>X-Ray</div>
|
||||
<div style={{ color: '#ffff00', fontSize: '16px', fontWeight: '700' }}>M3.0</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: '6px' }}>
|
||||
<div style={{ color: '#888' }}>Kp</div>
|
||||
<div style={{ color: '#00ff00', fontSize: '16px', fontWeight: '700' }}>{spaceWeather?.data?.kIndex ?? '--'}</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: '6px' }}>
|
||||
<div style={{ color: '#888' }}>Bz</div>
|
||||
<div style={{ color: '#00ffff', fontSize: '16px', fontWeight: '700' }}>-0</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#888' }}>Aurora</div>
|
||||
<div style={{ color: '#ff00ff', fontSize: '16px', fontWeight: '700' }}>18</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MAIN AREA */}
|
||||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
{/* DX Cluster List */}
|
||||
<div style={{ width: '220px', borderRight: '1px solid #333', display: 'flex', flexDirection: 'column', background: '#000' }}>
|
||||
<div style={{ padding: '4px 8px', borderBottom: '1px solid #333', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ color: '#ff6666', fontSize: '14px', fontWeight: '700' }}>Cluster</span>
|
||||
<span style={{ color: '#00ff00', fontSize: '10px' }}>dxspider.co.uk:7300</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', fontSize: '11px' }}>
|
||||
{dxCluster.data?.slice(0, 25).map((spot, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '65px 1fr 35px',
|
||||
gap: '4px',
|
||||
borderBottom: '1px solid #111',
|
||||
cursor: 'pointer',
|
||||
background: hoveredSpot?.call === spot.call ? '#333' : 'transparent'
|
||||
}}
|
||||
onMouseEnter={() => setHoveredSpot(spot)}
|
||||
onMouseLeave={() => setHoveredSpot(null)}
|
||||
>
|
||||
<span style={{ color: '#ffff00' }}>{parseFloat(spot.freq).toFixed(1)}</span>
|
||||
<span style={{ color: '#00ffff' }}>{spot.call}</span>
|
||||
<span style={{ color: '#888' }}>{spot.time || '--'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* Settings button overlay */}
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
border: '1px solid #444',
|
||||
color: '#fff',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
⚙ Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOTTOM - Frequency Scale */}
|
||||
<div style={{
|
||||
height: '24px',
|
||||
background: 'linear-gradient(90deg, #ff0000 0%, #ff8800 15%, #ffff00 30%, #00ff00 45%, #00ffff 60%, #0088ff 75%, #8800ff 90%, #ff00ff 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
fontSize: '10px',
|
||||
color: '#000',
|
||||
fontWeight: '700'
|
||||
}}>
|
||||
<span>MHz</span>
|
||||
<span>5</span>
|
||||
<span>10</span>
|
||||
<span>15</span>
|
||||
<span>20</span>
|
||||
<span>25</span>
|
||||
<span>30</span>
|
||||
<span>35</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* MODERN LAYOUT */
|
||||
<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: '270px 1fr 300px',
|
||||
gridTemplateRows: '65px 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 SIDEBAR */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
{/* DE Location */}
|
||||
<div className="panel" style={{ padding: '14px', flex: '0 0 auto' }}>
|
||||
<div style={{ fontSize: '14px', color: 'var(--accent-cyan)', fontWeight: '700', marginBottom: '10px' }}>📍 DE - YOUR LOCATION</div>
|
||||
<div style={{ fontFamily: 'JetBrains Mono', fontSize: '14px' }}>
|
||||
<div style={{ color: 'var(--accent-amber)', fontSize: '22px', fontWeight: '700', letterSpacing: '1px' }}>{deGrid}</div>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', marginTop: '4px' }}>{config.location.lat.toFixed(4)}°, {config.location.lon.toFixed(4)}°</div>
|
||||
<div style={{ marginTop: '8px', fontSize: '13px' }}>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>☀ </span>
|
||||
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{deSunTimes.sunrise}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}> → </span>
|
||||
<span style={{ color: 'var(--accent-purple)', fontWeight: '600' }}>{deSunTimes.sunset}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DX Location */}
|
||||
<div className="panel" style={{ padding: '14px', flex: '0 0 auto' }}>
|
||||
<div style={{ fontSize: '14px', color: 'var(--accent-green)', fontWeight: '700', marginBottom: '10px' }}>🎯 DX - TARGET</div>
|
||||
<div style={{ fontFamily: 'JetBrains Mono', fontSize: '14px' }}>
|
||||
<div style={{ color: 'var(--accent-amber)', fontSize: '22px', fontWeight: '700', letterSpacing: '1px' }}>{dxGrid}</div>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', marginTop: '4px' }}>{dxLocation.lat.toFixed(4)}°, {dxLocation.lon.toFixed(4)}°</div>
|
||||
<div style={{ marginTop: '8px', fontSize: '13px' }}>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>☀ </span>
|
||||
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{dxSunTimes.sunrise}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}> → </span>
|
||||
<span style={{ color: 'var(--accent-purple)', fontWeight: '600' }}>{dxSunTimes.sunset}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Solar Panel */}
|
||||
<SolarPanel solarIndices={solarIndices} />
|
||||
|
||||
{/* VOACAP/Propagation Panel */}
|
||||
<PropagationPanel
|
||||
propagation={propagation.data}
|
||||
loading={propagation.loading}
|
||||
bandConditions={bandConditions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CENTER - MAP */}
|
||||
<div style={{ position: 'relative', borderRadius: '6px', overflow: 'hidden' }}>
|
||||
<WorldMap
|
||||
deLocation={config.location}
|
||||
dxLocation={dxLocation}
|
||||
onDXChange={handleDXChange}
|
||||
potaSpots={potaSpots.data}
|
||||
mySpots={mySpots.data}
|
||||
dxPaths={dxPaths.data}
|
||||
dxFilters={dxFilters}
|
||||
satellites={satellites.data}
|
||||
showDXPaths={mapLayers.showDXPaths}
|
||||
showDXLabels={mapLayers.showDXLabels}
|
||||
onToggleDXLabels={toggleDXLabels}
|
||||
showPOTA={mapLayers.showPOTA}
|
||||
showSatellites={mapLayers.showSatellites}
|
||||
onToggleSatellites={toggleSatellites}
|
||||
hoveredSpot={hoveredSpot}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '8px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
fontSize: '13px',
|
||||
color: 'var(--text-muted)',
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
Click map to set DX • 73 de {config.callsign}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT SIDEBAR */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', overflow: 'hidden' }}>
|
||||
{/* DX Cluster - takes most space */}
|
||||
<div style={{ flex: '2 1 0', minHeight: '250px', overflow: 'hidden' }}>
|
||||
<DXClusterPanel
|
||||
data={dxCluster.data}
|
||||
loading={dxCluster.loading}
|
||||
totalSpots={dxCluster.totalSpots}
|
||||
filters={dxFilters}
|
||||
onFilterChange={setDxFilters}
|
||||
onOpenFilters={() => setShowDXFilters(true)}
|
||||
onHoverSpot={setHoveredSpot}
|
||||
hoveredSpot={hoveredSpot}
|
||||
showOnMap={mapLayers.showDXPaths}
|
||||
onToggleMap={toggleDXPaths}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* DXpeditions - smaller */}
|
||||
<div style={{ flex: '0 0 auto', maxHeight: '140px', overflow: 'hidden' }}>
|
||||
<DXpeditionPanel data={dxpeditions.data} loading={dxpeditions.loading} />
|
||||
</div>
|
||||
|
||||
{/* POTA - smaller */}
|
||||
<div style={{ flex: '0 0 auto', maxHeight: '120px', overflow: 'hidden' }}>
|
||||
<POTAPanel
|
||||
data={potaSpots.data}
|
||||
loading={potaSpots.loading}
|
||||
showOnMap={mapLayers.showPOTA}
|
||||
onToggleMap={togglePOTA}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Contests - smaller */}
|
||||
<div style={{ flex: '0 0 auto', maxHeight: '150px', overflow: 'hidden' }}>
|
||||
<ContestPanel data={contests.data} loading={contests.loading} />
|
||||
</div>
|
||||
</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,96 @@
|
||||
/**
|
||||
* ContestPanel Component
|
||||
* Displays upcoming contests with contestcalendar.com credit
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
export const ContestPanel = ({ data, loading }) => {
|
||||
const getModeColor = (mode) => {
|
||||
switch(mode) {
|
||||
case 'CW': return 'var(--accent-cyan)';
|
||||
case 'SSB': return 'var(--accent-amber)';
|
||||
case 'RTTY': return 'var(--accent-purple)';
|
||||
case 'FT8': case 'FT4': return 'var(--accent-green)';
|
||||
case 'Mixed': return 'var(--text-secondary)';
|
||||
default: return 'var(--text-secondary)';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '8px', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="panel-header" style={{
|
||||
marginBottom: '6px',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
🏆 CONTESTS
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '10px' }}>
|
||||
<div className="loading-spinner" />
|
||||
</div>
|
||||
) : data && data.length > 0 ? (
|
||||
<div style={{ fontSize: '10px', fontFamily: 'JetBrains Mono, monospace' }}>
|
||||
{data.slice(0, 6).map((contest, i) => (
|
||||
<div
|
||||
key={`${contest.name}-${i}`}
|
||||
style={{
|
||||
padding: '4px 0',
|
||||
borderBottom: i < Math.min(data.length, 6) - 1 ? '1px solid var(--border-color)' : 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
color: 'var(--text-primary)',
|
||||
fontWeight: '600',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{contest.name}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '2px' }}>
|
||||
<span style={{ color: getModeColor(contest.mode) }}>{contest.mode}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{formatDate(contest.start)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '10px', fontSize: '11px' }}>
|
||||
No upcoming contests
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contest Calendar Credit */}
|
||||
<div style={{
|
||||
marginTop: '6px',
|
||||
paddingTop: '6px',
|
||||
borderTop: '1px solid var(--border-color)',
|
||||
textAlign: 'right'
|
||||
}}>
|
||||
<a
|
||||
href="https://www.contestcalendar.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: '9px',
|
||||
color: 'var(--text-muted)',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
>
|
||||
WA7BNM Contest Calendar
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContestPanel;
|
||||
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* DXpeditionPanel Component
|
||||
* Shows active and upcoming DXpeditions (compact version)
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
export const DXpeditionPanel = ({ data, loading }) => {
|
||||
const getStatusStyle = (expedition) => {
|
||||
if (expedition.isActive) {
|
||||
return { bg: 'rgba(0, 255, 136, 0.15)', border: 'var(--accent-green)', color: 'var(--accent-green)' };
|
||||
}
|
||||
if (expedition.isUpcoming) {
|
||||
return { bg: 'rgba(0, 170, 255, 0.15)', border: 'var(--accent-cyan)', color: 'var(--accent-cyan)' };
|
||||
}
|
||||
return { bg: 'var(--bg-tertiary)', border: 'var(--border-color)', color: 'var(--text-muted)' };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '8px', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="panel-header" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '6px',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
<span>🌍 DXPEDITIONS</span>
|
||||
{data && (
|
||||
<span style={{ fontSize: '9px', color: 'var(--text-muted)' }}>
|
||||
{data.active > 0 && <span style={{ color: 'var(--accent-green)' }}>{data.active} active</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '10px' }}>
|
||||
<div className="loading-spinner" />
|
||||
</div>
|
||||
) : data?.dxpeditions?.length > 0 ? (
|
||||
data.dxpeditions.slice(0, 4).map((exp, idx) => {
|
||||
const style = getStatusStyle(exp);
|
||||
return (
|
||||
<div key={idx} style={{
|
||||
padding: '4px 6px',
|
||||
marginBottom: '3px',
|
||||
background: style.bg,
|
||||
borderLeft: `2px solid ${style.border}`,
|
||||
borderRadius: '3px',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'JetBrains Mono, monospace'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ color: 'var(--accent-amber)', fontWeight: '700' }}>{exp.callsign}</span>
|
||||
<span style={{ color: style.color, fontSize: '9px' }}>
|
||||
{exp.isActive ? '● NOW' : 'SOON'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '10px' }}>
|
||||
{exp.entity}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '10px', fontSize: '11px' }}>
|
||||
No DXpeditions
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DXpeditionPanel;
|
||||
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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: '8px 20px',
|
||||
minHeight: '60px',
|
||||
fontFamily: 'JetBrains Mono, monospace'
|
||||
}}>
|
||||
{/* Callsign & Settings */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<span
|
||||
style={{ fontSize: '24px', 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: '10px' }}>
|
||||
<span style={{ fontSize: '14px', color: 'var(--accent-cyan)', fontWeight: '600' }}>UTC</span>
|
||||
<span style={{ fontSize: '28px', fontWeight: '700', color: 'var(--accent-cyan)', fontFamily: 'Orbitron, monospace' }}>{utcTime}</span>
|
||||
<span style={{ fontSize: '13px', color: 'var(--text-muted)' }}>{utcDate}</span>
|
||||
</div>
|
||||
|
||||
{/* Local Clock - Clickable to toggle 12/24 hour format */}
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}
|
||||
onClick={onTimeFormatToggle}
|
||||
title={`Click to switch to ${use12Hour ? '24-hour' : '12-hour'} format`}
|
||||
>
|
||||
<span style={{ fontSize: '14px', color: 'var(--accent-amber)', fontWeight: '600' }}>LOCAL</span>
|
||||
<span style={{ fontSize: '28px', fontWeight: '700', color: 'var(--accent-amber)', fontFamily: 'Orbitron, monospace' }}>{localTime}</span>
|
||||
<span style={{ fontSize: '13px', color: 'var(--text-muted)' }}>{localDate}</span>
|
||||
</div>
|
||||
|
||||
{/* Weather & Solar Stats */}
|
||||
<div style={{ display: 'flex', gap: '20px', fontSize: '14px' }}>
|
||||
{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: '700', fontSize: '16px' }}>{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: '700', fontSize: '16px' }}>
|
||||
{spaceWeather?.data?.kIndex ?? '--'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'var(--text-muted)' }}>SSN </span>
|
||||
<span style={{ color: 'var(--accent-cyan)', fontWeight: '700', fontSize: '16px' }}>{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: '8px 14px',
|
||||
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: '8px 14px',
|
||||
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: '8px 14px',
|
||||
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,312 @@
|
||||
/**
|
||||
* PropagationPanel Component (VOACAP)
|
||||
* Toggleable between heatmap chart, bar chart, and band conditions view
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export const PropagationPanel = ({ propagation, loading, bandConditions }) => {
|
||||
// Load view mode preference from localStorage
|
||||
const [viewMode, setViewMode] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('openhamclock_voacapViewMode');
|
||||
if (saved === 'bars' || saved === 'bands') return saved;
|
||||
return 'chart';
|
||||
} catch (e) { return 'chart'; }
|
||||
});
|
||||
|
||||
// Cycle through view modes
|
||||
const cycleViewMode = () => {
|
||||
const modes = ['chart', 'bars', 'bands'];
|
||||
const currentIdx = modes.indexOf(viewMode);
|
||||
const newMode = modes[(currentIdx + 1) % modes.length];
|
||||
setViewMode(newMode);
|
||||
try {
|
||||
localStorage.setItem('openhamclock_voacapViewMode', newMode);
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const getBandStyle = (condition) => ({
|
||||
GOOD: { bg: 'rgba(0,255,136,0.2)', color: '#00ff88', border: 'rgba(0,255,136,0.4)' },
|
||||
FAIR: { bg: 'rgba(255,180,50,0.2)', color: '#ffb432', border: 'rgba(255,180,50,0.4)' },
|
||||
POOR: { bg: 'rgba(255,68,102,0.2)', color: '#ff4466', border: 'rgba(255,68,102,0.4)' }
|
||||
}[condition] || { bg: 'rgba(255,180,50,0.2)', color: '#ffb432', border: 'rgba(255,180,50,0.4)' });
|
||||
|
||||
if (loading || !propagation) {
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="panel-header">📡 VOACAP</div>
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
Loading predictions...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { solarData, distance, currentBands, currentHour, hourlyPredictions, muf, luf, ionospheric, dataSource } = propagation;
|
||||
const hasRealData = ionospheric?.method === 'direct' || ionospheric?.method === 'interpolated';
|
||||
|
||||
// Heat map colors (VOACAP style - red=good, green=poor)
|
||||
const getHeatColor = (rel) => {
|
||||
if (rel >= 80) return '#ff0000';
|
||||
if (rel >= 60) return '#ff6600';
|
||||
if (rel >= 40) return '#ffcc00';
|
||||
if (rel >= 20) return '#88cc00';
|
||||
if (rel >= 10) return '#00aa00';
|
||||
return '#004400';
|
||||
};
|
||||
|
||||
const getReliabilityColor = (rel) => {
|
||||
if (rel >= 70) return '#00ff88';
|
||||
if (rel >= 50) return '#88ff00';
|
||||
if (rel >= 30) return '#ffcc00';
|
||||
if (rel >= 15) return '#ff8800';
|
||||
return '#ff4444';
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'EXCELLENT': return '#00ff88';
|
||||
case 'GOOD': return '#88ff00';
|
||||
case 'FAIR': return '#ffcc00';
|
||||
case 'POOR': return '#ff8800';
|
||||
case 'CLOSED': return '#ff4444';
|
||||
default: return 'var(--text-muted)';
|
||||
}
|
||||
};
|
||||
|
||||
const bands = ['80m', '40m', '30m', '20m', '17m', '15m', '12m', '11m', '10m'];
|
||||
const viewModeLabels = { chart: '▤ chart', bars: '▦ bars', bands: '◫ bands' };
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ cursor: 'pointer' }} onClick={cycleViewMode}>
|
||||
<div className="panel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>
|
||||
{viewMode === 'bands' ? '📊 BAND CONDITIONS' : '📡 VOACAP'}
|
||||
{hasRealData && viewMode !== 'bands' && <span style={{ color: '#00ff88', fontSize: '10px', marginLeft: '4px' }}>●</span>}
|
||||
</span>
|
||||
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>
|
||||
{viewModeLabels[viewMode]} • click to toggle
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{viewMode === 'bands' ? (
|
||||
/* Band Conditions Grid View */
|
||||
<div style={{ padding: '4px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '4px' }}>
|
||||
{(bandConditions?.data || []).slice(0, 13).map((band, idx) => {
|
||||
const style = getBandStyle(band.condition);
|
||||
return (
|
||||
<div key={idx} style={{
|
||||
background: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
borderRadius: '4px',
|
||||
padding: '6px 2px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontFamily: 'Orbitron, monospace', fontSize: '13px', fontWeight: '700', color: style.color }}>
|
||||
{band.band}
|
||||
</div>
|
||||
<div style={{ fontSize: '9px', fontWeight: '600', color: style.color, marginTop: '2px', opacity: 0.8 }}>
|
||||
{band.condition}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ marginTop: '6px', fontSize: '10px', color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
SFI {solarData?.sfi} • K {solarData?.kIndex} • General conditions for all paths
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* MUF/LUF and Data Source Info */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '4px 8px',
|
||||
background: hasRealData ? 'rgba(0, 255, 136, 0.1)' : 'var(--bg-tertiary)',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '4px',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>MUF </span>
|
||||
<span style={{ color: '#ff8800', fontWeight: '600' }}>{muf || '?'}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}> MHz</span>
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>LUF </span>
|
||||
<span style={{ color: '#00aaff', fontWeight: '600' }}>{luf || '?'}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}> MHz</span>
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ color: hasRealData ? '#00ff88' : 'var(--text-muted)', fontSize: '10px' }}>
|
||||
{hasRealData
|
||||
? `📡 ${ionospheric?.source || 'ionosonde'}${ionospheric?.distance ? ` (${ionospheric.distance}km)` : ''}`
|
||||
: '⚡ estimated'
|
||||
}
|
||||
</span>
|
||||
{dataSource && dataSource.includes('ITU') && (
|
||||
<span style={{
|
||||
color: '#ff6b35',
|
||||
fontSize: '9px',
|
||||
marginLeft: '8px',
|
||||
padding: '1px 4px',
|
||||
background: 'rgba(255,107,53,0.15)',
|
||||
borderRadius: '3px'
|
||||
}}>
|
||||
🔬 ITU-R P.533
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{viewMode === 'chart' ? (
|
||||
/* VOACAP Heat Map Chart View */
|
||||
<div style={{ padding: '4px' }}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '28px repeat(24, 1fr)',
|
||||
gridTemplateRows: `repeat(${bands.length}, 12px)`,
|
||||
gap: '1px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'JetBrains Mono, monospace'
|
||||
}}>
|
||||
{bands.map((band) => (
|
||||
<React.Fragment key={band}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
paddingRight: '4px',
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
{band.replace('m', '')}
|
||||
</div>
|
||||
{Array.from({ length: 24 }, (_, hour) => {
|
||||
let rel = 0;
|
||||
if (hour === currentHour && currentBands?.length > 0) {
|
||||
const currentBandData = currentBands.find(b => b.band === band);
|
||||
if (currentBandData) {
|
||||
rel = currentBandData.reliability || 0;
|
||||
}
|
||||
} else {
|
||||
const bandData = hourlyPredictions?.[band];
|
||||
const hourData = bandData?.find(h => h.hour === hour);
|
||||
rel = hourData?.reliability || 0;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={hour}
|
||||
style={{
|
||||
background: getHeatColor(rel),
|
||||
borderRadius: '1px',
|
||||
border: hour === currentHour ? '1px solid white' : 'none'
|
||||
}}
|
||||
title={`${band} @ ${hour}:00 UTC: ${rel}%`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Hour labels */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '28px repeat(24, 1fr)',
|
||||
marginTop: '2px',
|
||||
fontSize: '9px',
|
||||
color: 'var(--text-muted)'
|
||||
}}>
|
||||
<div>UTC</div>
|
||||
{[0, '', '', 3, '', '', 6, '', '', 9, '', '', 12, '', '', 15, '', '', 18, '', '', 21, '', ''].map((h, i) => (
|
||||
<div key={i} style={{ textAlign: 'center' }}>{h}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{
|
||||
marginTop: '6px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '2px', alignItems: 'center' }}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>REL:</span>
|
||||
{['#004400', '#00aa00', '#88cc00', '#ffcc00', '#ff6600', '#ff0000'].map((c, i) => (
|
||||
<div key={i} style={{ width: '8px', height: '8px', background: c, borderRadius: '1px' }} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-muted)' }}>
|
||||
{Math.round(distance || 0)}km • {ionospheric?.foF2 ? `foF2=${ionospheric.foF2}` : `SSN=${solarData?.ssn}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Bar Chart View */
|
||||
<div style={{ fontSize: '13px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
padding: '4px',
|
||||
marginBottom: '4px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
<span><span style={{ color: 'var(--text-muted)' }}>SFI </span><span style={{ color: 'var(--accent-amber)' }}>{solarData?.sfi}</span></span>
|
||||
{ionospheric?.foF2 ? (
|
||||
<span><span style={{ color: 'var(--text-muted)' }}>foF2 </span><span style={{ color: '#00ff88' }}>{ionospheric.foF2}</span></span>
|
||||
) : (
|
||||
<span><span style={{ color: 'var(--text-muted)' }}>SSN </span><span style={{ color: 'var(--accent-cyan)' }}>{solarData?.ssn}</span></span>
|
||||
)}
|
||||
<span><span style={{ color: 'var(--text-muted)' }}>K </span><span style={{ color: solarData?.kIndex >= 4 ? '#ff4444' : '#00ff88' }}>{solarData?.kIndex}</span></span>
|
||||
</div>
|
||||
|
||||
{(currentBands || []).slice(0, 11).map((band) => (
|
||||
<div key={band.band} style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '32px 1fr 40px',
|
||||
gap: '4px',
|
||||
padding: '2px 0',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontSize: '12px',
|
||||
color: band.reliability >= 50 ? 'var(--accent-green)' : 'var(--text-muted)'
|
||||
}}>
|
||||
{band.band}
|
||||
</span>
|
||||
<div style={{ position: 'relative', height: '10px', background: 'var(--bg-tertiary)', borderRadius: '2px' }}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
width: `${band.reliability}%`,
|
||||
background: getReliabilityColor(band.reliability),
|
||||
borderRadius: '2px'
|
||||
}} />
|
||||
</div>
|
||||
<span style={{
|
||||
textAlign: 'right',
|
||||
fontSize: '12px',
|
||||
color: getStatusColor(band.status)
|
||||
}}>
|
||||
{band.reliability}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropagationPanel;
|
||||
@ -0,0 +1,398 @@
|
||||
/**
|
||||
* SettingsPanel Component
|
||||
* Full settings modal matching production version
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { calculateGridSquare } from '../utils/geo.js';
|
||||
|
||||
export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
|
||||
const [callsign, setCallsign] = useState(config?.callsign || '');
|
||||
const [gridSquare, setGridSquare] = useState('');
|
||||
const [lat, setLat] = useState(config?.location?.lat || 0);
|
||||
const [lon, setLon] = useState(config?.location?.lon || 0);
|
||||
const [theme, setTheme] = useState(config?.theme || 'dark');
|
||||
const [layout, setLayout] = useState(config?.layout || 'modern');
|
||||
const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy');
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setCallsign(config.callsign || '');
|
||||
setLat(config.location?.lat || 0);
|
||||
setLon(config.location?.lon || 0);
|
||||
setTheme(config.theme || 'dark');
|
||||
setLayout(config.layout || 'modern');
|
||||
setDxClusterSource(config.dxClusterSource || 'dxspider-proxy');
|
||||
// Calculate grid from coordinates
|
||||
if (config.location?.lat && config.location?.lon) {
|
||||
setGridSquare(calculateGridSquare(config.location.lat, config.location.lon));
|
||||
}
|
||||
}
|
||||
}, [config, isOpen]);
|
||||
|
||||
// Update lat/lon when grid square changes
|
||||
const handleGridChange = (grid) => {
|
||||
setGridSquare(grid.toUpperCase());
|
||||
// Parse grid square to lat/lon if valid (6 char)
|
||||
if (grid.length >= 4) {
|
||||
const parsed = parseGridSquare(grid);
|
||||
if (parsed) {
|
||||
setLat(parsed.lat);
|
||||
setLon(parsed.lon);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Parse grid square to coordinates
|
||||
const parseGridSquare = (grid) => {
|
||||
grid = grid.toUpperCase();
|
||||
if (grid.length < 4) return null;
|
||||
|
||||
const lon1 = (grid.charCodeAt(0) - 65) * 20 - 180;
|
||||
const lat1 = (grid.charCodeAt(1) - 65) * 10 - 90;
|
||||
const lon2 = parseInt(grid[2]) * 2;
|
||||
const lat2 = parseInt(grid[3]) * 1;
|
||||
|
||||
let lon = lon1 + lon2 + 1;
|
||||
let lat = lat1 + lat2 + 0.5;
|
||||
|
||||
if (grid.length >= 6) {
|
||||
const lon3 = (grid.charCodeAt(4) - 65) * (2/24);
|
||||
const lat3 = (grid.charCodeAt(5) - 65) * (1/24);
|
||||
lon = lon1 + lon2 + lon3 + (1/24);
|
||||
lat = lat1 + lat2 + lat3 + (1/48);
|
||||
}
|
||||
|
||||
return { lat, lon };
|
||||
};
|
||||
|
||||
// Update grid when lat/lon changes
|
||||
useEffect(() => {
|
||||
if (lat && lon) {
|
||||
setGridSquare(calculateGridSquare(lat, lon));
|
||||
}
|
||||
}, [lat, lon]);
|
||||
|
||||
const handleUseLocation = () => {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setLat(position.coords.latitude);
|
||||
setLon(position.coords.longitude);
|
||||
},
|
||||
(error) => {
|
||||
console.error('Geolocation error:', error);
|
||||
alert('Unable to get location. Please enter manually.');
|
||||
}
|
||||
);
|
||||
} else {
|
||||
alert('Geolocation not supported by your browser.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
...config,
|
||||
callsign: callsign.toUpperCase(),
|
||||
location: { lat: parseFloat(lat), lon: parseFloat(lon) },
|
||||
theme,
|
||||
layout,
|
||||
dxClusterSource
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const themeDescriptions = {
|
||||
dark: '→ Modern dark theme (default)',
|
||||
light: '→ Light theme for daytime use',
|
||||
legacy: '→ Green terminal CRT style',
|
||||
retro: '→ 90s Windows retro style'
|
||||
};
|
||||
|
||||
const layoutDescriptions = {
|
||||
modern: '→ Modern responsive grid layout',
|
||||
classic: '→ Original HamClock-style layout'
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10000
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '2px solid var(--accent-amber)',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
width: '420px',
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<h2 style={{
|
||||
color: 'var(--accent-cyan)',
|
||||
marginTop: 0,
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'Orbitron, monospace',
|
||||
fontSize: '20px'
|
||||
}}>
|
||||
⚙ Station Settings
|
||||
</h2>
|
||||
|
||||
{/* Callsign */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
||||
Your Callsign
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={callsign}
|
||||
onChange={(e) => setCallsign(e.target.value.toUpperCase())}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--accent-amber)',
|
||||
fontSize: '18px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontWeight: '700',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grid Square */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
||||
Grid Square (or enter Lat/Lon below)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={gridSquare}
|
||||
onChange={(e) => handleGridChange(e.target.value)}
|
||||
placeholder="FN20nc"
|
||||
maxLength={6}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--accent-amber)',
|
||||
fontSize: '18px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontWeight: '700',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Lat/Lon */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase' }}>
|
||||
Latitude
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
value={lat}
|
||||
onChange={(e) => setLat(parseFloat(e.target.value))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase' }}>
|
||||
Longitude
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
value={lon}
|
||||
onChange={(e) => setLon(parseFloat(e.target.value))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Use My Location button */}
|
||||
<button
|
||||
onClick={handleUseLocation}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '13px',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '20px'
|
||||
}}
|
||||
>
|
||||
📍 Use My Current Location
|
||||
</button>
|
||||
|
||||
{/* Theme */}
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
||||
Theme
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px' }}>
|
||||
{['dark', 'light', 'legacy', 'retro'].map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTheme(t)}
|
||||
style={{
|
||||
padding: '10px',
|
||||
background: theme === t ? 'var(--accent-amber)' : 'var(--bg-tertiary)',
|
||||
border: `1px solid ${theme === t ? 'var(--accent-amber)' : 'var(--border-color)'}`,
|
||||
borderRadius: '6px',
|
||||
color: theme === t ? '#000' : 'var(--text-secondary)',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: theme === t ? '600' : '400'
|
||||
}}
|
||||
>
|
||||
{t === 'dark' ? '🌙' : t === 'light' ? '☀️' : t === 'legacy' ? '💻' : '🪟'} {t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}>
|
||||
{themeDescriptions[theme]}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layout */}
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
||||
Layout
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }}>
|
||||
{['modern', 'classic'].map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setLayout(l)}
|
||||
style={{
|
||||
padding: '10px',
|
||||
background: layout === l ? 'var(--accent-amber)' : 'var(--bg-tertiary)',
|
||||
border: `1px solid ${layout === l ? 'var(--accent-amber)' : 'var(--border-color)'}`,
|
||||
borderRadius: '6px',
|
||||
color: layout === l ? '#000' : 'var(--text-secondary)',
|
||||
fontSize: '13px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: layout === l ? '600' : '400'
|
||||
}}
|
||||
>
|
||||
{l === 'modern' ? '🖥️' : '📺'} {l.charAt(0).toUpperCase() + l.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}>
|
||||
{layoutDescriptions[layout]}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DX Cluster Source */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
||||
DX Cluster Source
|
||||
</label>
|
||||
<select
|
||||
value={dxClusterSource}
|
||||
onChange={(e) => setDxClusterSource(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--accent-green)',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<option value="dxspider-proxy">⭐ DX Spider Proxy (Recommended)</option>
|
||||
<option value="hamqth">HamQTH Cluster</option>
|
||||
<option value="dxwatch">DXWatch</option>
|
||||
<option value="auto">Auto (try all sources)</option>
|
||||
</select>
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}>
|
||||
→ Real-time DX Spider feed via our dedicated proxy service
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginTop: '24px' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '14px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
padding: '14px',
|
||||
background: 'linear-gradient(135deg, #00ff88 0%, #00ddff 100%)',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
color: '#000',
|
||||
fontSize: '14px',
|
||||
fontWeight: '700',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: '16px', fontSize: '11px', color: 'var(--text-muted)' }}>
|
||||
Settings are saved in your browser
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPanel;
|
||||
@ -0,0 +1,236 @@
|
||||
/**
|
||||
* SolarPanel Component
|
||||
* Toggleable between live sun image from NASA SDO and solar indices display
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export const SolarPanel = ({ solarIndices }) => {
|
||||
const [showIndices, setShowIndices] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('openhamclock_solarPanelMode');
|
||||
return saved === 'indices';
|
||||
} catch (e) { return false; }
|
||||
});
|
||||
const [imageType, setImageType] = useState('0193');
|
||||
|
||||
const toggleMode = () => {
|
||||
const newMode = !showIndices;
|
||||
setShowIndices(newMode);
|
||||
try {
|
||||
localStorage.setItem('openhamclock_solarPanelMode', newMode ? 'indices' : 'image');
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const imageTypes = {
|
||||
'0193': { name: 'AIA 193Å', desc: 'Corona' },
|
||||
'0304': { name: 'AIA 304Å', desc: 'Chromosphere' },
|
||||
'0171': { name: 'AIA 171Å', desc: 'Quiet Corona' },
|
||||
'0094': { name: 'AIA 94Å', desc: 'Flaring' },
|
||||
'HMIIC': { name: 'HMI Int', desc: 'Visible' }
|
||||
};
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 900000) * 900000;
|
||||
const imageUrl = `https://sdo.gsfc.nasa.gov/assets/img/latest/latest_256_${imageType}.jpg?t=${timestamp}`;
|
||||
|
||||
const getKpColor = (value) => {
|
||||
if (value >= 7) return '#ff0000';
|
||||
if (value >= 5) return '#ff6600';
|
||||
if (value >= 4) return '#ffcc00';
|
||||
if (value >= 3) return '#88cc00';
|
||||
return '#00ff88';
|
||||
};
|
||||
|
||||
// Get K-Index data - server returns 'kp' not 'kIndex'
|
||||
const kpData = solarIndices?.data?.kp || solarIndices?.data?.kIndex;
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '8px' }}>
|
||||
{/* Header with toggle */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
<span style={{ fontSize: '12px', color: 'var(--accent-amber)', fontWeight: '700' }}>
|
||||
☀ {showIndices ? 'SOLAR INDICES' : 'SOLAR'}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!showIndices && (
|
||||
<select
|
||||
value={imageType}
|
||||
onChange={(e) => setImageType(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '10px',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{Object.entries(imageTypes).map(([key, val]) => (
|
||||
<option key={key} value={key}>{val.desc}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleMode}
|
||||
style={{
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
title={showIndices ? 'Show solar image' : 'Show solar indices'}
|
||||
>
|
||||
{showIndices ? '🖼️' : '📊'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showIndices ? (
|
||||
/* Solar Indices View */
|
||||
<div>
|
||||
{solarIndices?.data ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{/* SFI Row */}
|
||||
<div style={{ background: 'var(--bg-tertiary)', borderRadius: '6px', padding: '8px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ minWidth: '60px' }}>
|
||||
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>SFI</div>
|
||||
<div style={{ fontSize: '22px', fontWeight: '700', color: '#ff8800', fontFamily: 'Orbitron, monospace' }}>
|
||||
{solarIndices.data.sfi?.current || '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
{solarIndices.data.sfi?.history?.length > 0 && (
|
||||
<svg width="100%" height="30" viewBox="0 0 100 30" preserveAspectRatio="none">
|
||||
{(() => {
|
||||
const data = solarIndices.data.sfi.history.slice(-20);
|
||||
const values = data.map(d => d.value);
|
||||
const max = Math.max(...values, 1);
|
||||
const min = Math.min(...values);
|
||||
const range = max - min || 1;
|
||||
const points = data.map((d, i) => {
|
||||
const x = (i / (data.length - 1)) * 100;
|
||||
const y = 30 - ((d.value - min) / range) * 25;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
return <polyline points={points} fill="none" stroke="#ff8800" strokeWidth="1.5" />;
|
||||
})()}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* K-Index Row */}
|
||||
<div style={{ background: 'var(--bg-tertiary)', borderRadius: '6px', padding: '8px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ minWidth: '60px' }}>
|
||||
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>K-Index</div>
|
||||
<div style={{ fontSize: '22px', fontWeight: '700', color: getKpColor(kpData?.current), fontFamily: 'Orbitron, monospace' }}>
|
||||
{kpData?.current ?? '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
{kpData?.forecast?.length > 0 ? (
|
||||
<div style={{ display: 'flex', gap: '2px', alignItems: 'flex-end', height: '30px' }}>
|
||||
{kpData.forecast.slice(0, 8).map((item, i) => {
|
||||
const val = typeof item === 'object' ? item.value : item;
|
||||
return (
|
||||
<div key={i} style={{
|
||||
flex: 1,
|
||||
height: `${Math.max(10, (val / 9) * 100)}%`,
|
||||
background: getKpColor(val),
|
||||
borderRadius: '2px',
|
||||
opacity: 0.8
|
||||
}} title={`Kp ${val}`} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : kpData?.history?.length > 0 ? (
|
||||
<div style={{ display: 'flex', gap: '2px', alignItems: 'flex-end', height: '30px' }}>
|
||||
{kpData.history.slice(-8).map((item, i) => {
|
||||
const val = typeof item === 'object' ? item.value : item;
|
||||
return (
|
||||
<div key={i} style={{
|
||||
flex: 1,
|
||||
height: `${Math.max(10, (val / 9) * 100)}%`,
|
||||
background: getKpColor(val),
|
||||
borderRadius: '2px',
|
||||
opacity: 0.8
|
||||
}} title={`Kp ${val}`} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '10px' }}>No forecast data</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SSN Row */}
|
||||
<div style={{ background: 'var(--bg-tertiary)', borderRadius: '6px', padding: '8px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ minWidth: '60px' }}>
|
||||
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>SSN</div>
|
||||
<div style={{ fontSize: '22px', fontWeight: '700', color: '#aa88ff', fontFamily: 'Orbitron, monospace' }}>
|
||||
{solarIndices.data.ssn?.current || '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
{solarIndices.data.ssn?.history?.length > 0 && (
|
||||
<svg width="100%" height="30" viewBox="0 0 100 30" preserveAspectRatio="none">
|
||||
{(() => {
|
||||
const data = solarIndices.data.ssn.history.slice(-20);
|
||||
const values = data.map(d => d.value);
|
||||
const max = Math.max(...values, 1);
|
||||
const min = Math.min(...values, 0);
|
||||
const range = max - min || 1;
|
||||
const points = data.map((d, i) => {
|
||||
const x = (i / (data.length - 1)) * 100;
|
||||
const y = 30 - ((d.value - min) / range) * 25;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
return <polyline points={points} fill="none" stroke="#aa88ff" strokeWidth="1.5" />;
|
||||
})()}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '20px' }}>
|
||||
Loading solar data...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Solar Image View */
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="SDO Solar Image"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '200px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid var(--border-color)'
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginTop: '4px' }}>
|
||||
SDO/AIA • Live from NASA
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SolarPanel;
|
||||
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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';
|
||||
export { SolarPanel } from './SolarPanel.jsx';
|
||||
export { PropagationPanel } from './PropagationPanel.jsx';
|
||||
export { DXpeditionPanel } from './DXpeditionPanel.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,150 @@
|
||||
/**
|
||||
* 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('');
|
||||
|
||||
// Get retention time from filters, default to 30 minutes
|
||||
const spotRetentionMs = (filters?.spotRetentionMinutes || 30) * 60 * 1000;
|
||||
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, spotRetentionMs]);
|
||||
|
||||
// Clean up spots immediately when retention time changes
|
||||
useEffect(() => {
|
||||
setAllSpots(prev => {
|
||||
const now = Date.now();
|
||||
return prev.filter(s => (now - (s.timestamp || now)) < spotRetentionMs);
|
||||
});
|
||||
}, [spotRetentionMs]);
|
||||
|
||||
// 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,149 @@
|
||||
/**
|
||||
* useSatellites Hook
|
||||
* Tracks amateur radio satellites using TLE data and satellite.js
|
||||
* Includes orbit track prediction
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import * as satellite from 'satellite.js';
|
||||
|
||||
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 and orbits
|
||||
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]) => {
|
||||
// Handle both line1/line2 and tle1/tle2 formats
|
||||
const line1 = tle.line1 || tle.tle1;
|
||||
const line2 = tle.line2 || tle.tle2;
|
||||
if (!line1 || !line2) return;
|
||||
|
||||
try {
|
||||
const satrec = satellite.twoline2satrec(line1, 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;
|
||||
|
||||
// Include all satellites we get TLE for (they're all ham sats)
|
||||
// Calculate orbit track (past 45 min and future 45 min = 90 min total)
|
||||
const track = [];
|
||||
const trackMinutes = 90;
|
||||
const stepMinutes = 1;
|
||||
|
||||
for (let m = -trackMinutes/2; m <= trackMinutes/2; m += stepMinutes) {
|
||||
const trackTime = new Date(now.getTime() + m * 60 * 1000);
|
||||
const trackPV = satellite.propagate(satrec, trackTime);
|
||||
|
||||
if (trackPV.position) {
|
||||
const trackGmst = satellite.gstime(trackTime);
|
||||
const trackGd = satellite.eciToGeodetic(trackPV.position, trackGmst);
|
||||
const trackLat = satellite.degreesLat(trackGd.latitude);
|
||||
const trackLon = satellite.degreesLong(trackGd.longitude);
|
||||
track.push([trackLat, trackLon]);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate footprint radius (visibility circle)
|
||||
// Formula: radius = Earth_radius * arccos(Earth_radius / (Earth_radius + altitude))
|
||||
const earthRadius = 6371; // km
|
||||
const footprintRadius = earthRadius * Math.acos(earthRadius / (earthRadius + alt));
|
||||
|
||||
positions.push({
|
||||
name: tle.name || name,
|
||||
lat,
|
||||
lon,
|
||||
alt: Math.round(alt),
|
||||
azimuth: Math.round(azimuth),
|
||||
elevation: Math.round(elevation),
|
||||
range: Math.round(rangeSat),
|
||||
visible: elevation > 0,
|
||||
isPopular: tle.priority <= 2,
|
||||
track,
|
||||
footprintRadius: Math.round(footprintRadius),
|
||||
mode: tle.mode || 'Unknown',
|
||||
color: tle.color || '#00ffff'
|
||||
});
|
||||
} catch (e) {
|
||||
// Skip satellites with invalid TLE
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by visibility first (visible on top), then by elevation
|
||||
positions.sort((a, b) => {
|
||||
if (a.visible !== b.visible) return b.visible - a.visible;
|
||||
return b.elevation - a.elevation;
|
||||
});
|
||||
// Show all satellites (no limit for ham sats)
|
||||
setData(positions);
|
||||
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,592 @@
|
||||
/* ============================================
|
||||
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 (Green Terminal 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;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
THEME: RETRO (90s Windows Style)
|
||||
============================================ */
|
||||
[data-theme="retro"] {
|
||||
--bg-primary: #008080;
|
||||
--bg-secondary: #c0c0c0;
|
||||
--bg-tertiary: #dfdfdf;
|
||||
--bg-panel: #c0c0c0;
|
||||
--border-color: #808080;
|
||||
--border-light: #ffffff;
|
||||
--border-dark: #404040;
|
||||
--text-primary: #000000;
|
||||
--text-secondary: #000000;
|
||||
--text-muted: #404040;
|
||||
--accent-amber: #ffff00;
|
||||
--accent-amber-dim: #c0c000;
|
||||
--accent-green: #00ff00;
|
||||
--accent-green-dim: #00c000;
|
||||
--accent-red: #ff0000;
|
||||
--accent-blue: #000080;
|
||||
--accent-cyan: #008080;
|
||||
--accent-purple: #800080;
|
||||
--title-bar: linear-gradient(90deg, #000080, #1084d0);
|
||||
--title-bar-text: #ffffff;
|
||||
--map-ocean: #000080;
|
||||
--scanline-opacity: 0;
|
||||
}
|
||||
|
||||
* { 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;
|
||||
}
|
||||
|
||||
/* Classic theme uses system fonts */
|
||||
[data-theme="retro"] body,
|
||||
[data-theme="retro"] * {
|
||||
font-family: 'Tahoma', 'MS Sans Serif', 'Arial', sans-serif !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;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
CLASSIC THEME - 90s WINDOWS STYLING
|
||||
============================================ */
|
||||
|
||||
/* 3D Beveled border effect for classic theme */
|
||||
[data-theme="retro"] .panel {
|
||||
background: #c0c0c0 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow:
|
||||
inset -1px -1px 0 #404040,
|
||||
inset 1px 1px 0 #ffffff,
|
||||
inset -2px -2px 0 #808080,
|
||||
inset 2px 2px 0 #dfdfdf !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
/* Classic window title bar style */
|
||||
[data-theme="retro"] .panel-header,
|
||||
[data-theme="retro"] .panel > div:first-child {
|
||||
background: linear-gradient(90deg, #000080, #1084d0) !important;
|
||||
color: #ffffff !important;
|
||||
font-weight: bold !important;
|
||||
padding: 2px 4px !important;
|
||||
margin: -10px -10px 8px -10px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
/* Classic buttons */
|
||||
[data-theme="retro"] button,
|
||||
[data-theme="retro"] .map-style-btn {
|
||||
background: #c0c0c0 !important;
|
||||
color: #000000 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow:
|
||||
inset -1px -1px 0 #404040,
|
||||
inset 1px 1px 0 #ffffff,
|
||||
inset -2px -2px 0 #808080,
|
||||
inset 2px 2px 0 #dfdfdf !important;
|
||||
font-family: 'Tahoma', 'MS Sans Serif', sans-serif !important;
|
||||
font-size: 11px !important;
|
||||
padding: 4px 12px !important;
|
||||
}
|
||||
|
||||
[data-theme="retro"] button:hover,
|
||||
[data-theme="retro"] .map-style-btn:hover {
|
||||
background: #dfdfdf !important;
|
||||
}
|
||||
|
||||
[data-theme="retro"] button:active,
|
||||
[data-theme="retro"] .map-style-btn:active,
|
||||
[data-theme="retro"] .map-style-btn.active {
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #404040,
|
||||
inset -1px -1px 0 #ffffff,
|
||||
inset 2px 2px 0 #808080,
|
||||
inset -2px -2px 0 #dfdfdf !important;
|
||||
}
|
||||
|
||||
/* Classic inputs */
|
||||
[data-theme="retro"] input,
|
||||
[data-theme="retro"] select {
|
||||
background: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #404040,
|
||||
inset -1px -1px 0 #ffffff,
|
||||
inset 2px 2px 0 #808080,
|
||||
inset -2px -2px 0 #dfdfdf !important;
|
||||
font-family: 'Tahoma', 'MS Sans Serif', sans-serif !important;
|
||||
font-size: 11px !important;
|
||||
padding: 2px 4px !important;
|
||||
}
|
||||
|
||||
/* Classic scrollbars */
|
||||
[data-theme="retro"] ::-webkit-scrollbar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
[data-theme="retro"] ::-webkit-scrollbar-track {
|
||||
background: #c0c0c0;
|
||||
background-image:
|
||||
linear-gradient(45deg, #808080 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #808080 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #808080 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #808080 75%);
|
||||
background-size: 4px 4px;
|
||||
background-position: 0 0, 0 2px, 2px -2px, -2px 0px;
|
||||
}
|
||||
|
||||
[data-theme="retro"] ::-webkit-scrollbar-thumb {
|
||||
background: #c0c0c0;
|
||||
border-radius: 0;
|
||||
box-shadow:
|
||||
inset -1px -1px 0 #404040,
|
||||
inset 1px 1px 0 #ffffff,
|
||||
inset -2px -2px 0 #808080,
|
||||
inset 2px 2px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
[data-theme="retro"] ::-webkit-scrollbar-button {
|
||||
background: #c0c0c0;
|
||||
box-shadow:
|
||||
inset -1px -1px 0 #404040,
|
||||
inset 1px 1px 0 #ffffff;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Classic links */
|
||||
[data-theme="retro"] a {
|
||||
color: #0000ff !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
[data-theme="retro"] a:visited {
|
||||
color: #800080 !important;
|
||||
}
|
||||
|
||||
/* Classic header styling */
|
||||
[data-theme="retro"] [style*="gridColumn: '1 / -1'"] {
|
||||
background: #c0c0c0 !important;
|
||||
}
|
||||
|
||||
/* Classic text colors override */
|
||||
[data-theme="retro"] [style*="color: var(--accent-cyan)"],
|
||||
[data-theme="retro"] [style*="color: var(--accent-amber)"],
|
||||
[data-theme="retro"] [style*="color: var(--accent-green)"] {
|
||||
color: #000080 !important;
|
||||
}
|
||||
|
||||
/* Classic modal/dialog styling */
|
||||
[data-theme="retro"] [style*="position: fixed"][style*="background: rgba"] {
|
||||
background: rgba(0, 128, 128, 0.9) !important;
|
||||
}
|
||||
|
||||
/* Classic table styling for DX cluster */
|
||||
[data-theme="retro"] table,
|
||||
[data-theme="retro"] [style*="display: grid"] {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
[data-theme="retro"] tr:nth-child(even) {
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
[data-theme="retro"] tr:nth-child(odd) {
|
||||
background: #c0c0c0;
|
||||
}
|
||||
|
||||
/* Classic Leaflet overrides */
|
||||
[data-theme="retro"] .leaflet-container {
|
||||
background: #000080 !important;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .leaflet-control-zoom {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .leaflet-control-zoom a {
|
||||
background: #c0c0c0 !important;
|
||||
color: #000000 !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow:
|
||||
inset -1px -1px 0 #404040,
|
||||
inset 1px 1px 0 #ffffff !important;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .leaflet-popup-content-wrapper {
|
||||
background: #c0c0c0 !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow:
|
||||
inset -1px -1px 0 #404040,
|
||||
inset 1px 1px 0 #ffffff,
|
||||
2px 2px 4px rgba(0,0,0,0.5) !important;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .leaflet-popup-tip {
|
||||
background: #c0c0c0 !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
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);
|
||||
}
|
||||
|
||||
/* Classic theme uses hourglass cursor for loading */
|
||||
[data-theme="retro"] .loading-spinner {
|
||||
border-radius: 0;
|
||||
border-color: #000000;
|
||||
border-top-color: #808080;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
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;
|
||||
}
|
||||
|
||||
/* Classic theme markers - square */
|
||||
[data-theme="retro"] .custom-marker,
|
||||
[data-theme="retro"] .de-marker,
|
||||
[data-theme="retro"] .dx-marker {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
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;
|
||||
}
|
||||
|
||||
/* Classic tooltips */
|
||||
[data-theme="retro"] .dx-tooltip {
|
||||
background: #ffffcc !important;
|
||||
border: 1px solid #000000 !important;
|
||||
border-radius: 0 !important;
|
||||
color: #000000 !important;
|
||||
box-shadow: 2px 2px 0 #000000 !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', 'legacy', or 'retro'
|
||||
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']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -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