diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1d813d6..47912f6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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 (
+
+ {/* Use CSS variables for colors */}
+
+ {prop1}
+
+
+ );
+};
```
-### 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.
diff --git a/Dockerfile b/Dockerfile
index 30f179c..ee30b65 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,7 +2,7 @@
# Multi-stage build for optimized production image
# ============================================
-# Stage 1: Build
+# Stage 1: Build Frontend
# ============================================
FROM node:20-alpine AS builder
@@ -11,8 +11,14 @@ WORKDIR /app
# Copy package files
COPY package*.json ./
-# Install dependencies
-RUN npm install --omit=dev
+# Install ALL dependencies (including devDependencies for Vite)
+RUN npm install
+
+# Copy source files
+COPY . .
+
+# Build the React app with Vite
+RUN npm run build
# ============================================
# Stage 2: Production
@@ -29,12 +35,18 @@ RUN addgroup -g 1001 -S nodejs && \
WORKDIR /app
-# Copy node_modules from builder
-COPY --from=builder /app/node_modules ./node_modules
-
-# Copy application files
+# Copy package files and install production deps only
COPY package*.json ./
+RUN npm install --omit=dev
+
+# Copy server files
COPY server.js ./
+COPY config.js ./
+
+# Copy built frontend from builder stage
+COPY --from=builder /app/dist ./dist
+
+# Copy public folder (for monolithic fallback reference)
COPY public ./public
# Set ownership
diff --git a/README.md b/README.md
index f3156e8..c94bc63 100644
--- a/README.md
+++ b/README.md
@@ -1,355 +1,199 @@
-# π OpenHamClock
+# OpenHamClock - Modular React Architecture
-
+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)
-
-
-
-
-
----
-
-## π‘ 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
+```
-
+## π 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
-
+- K0CJH - Original OpenHamClock
+- NOAA SWPC - Space weather data
+- POTA - Parks on the Air API
+- Open-Meteo - Weather data
+- Leaflet - Mapping library
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..127a284
--- /dev/null
+++ b/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ OpenHamClock - Amateur Radio Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index a820088..191fced 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
diff --git a/public/index.html b/public/index-monolithic.html
similarity index 100%
rename from public/index.html
rename to public/index-monolithic.html
diff --git a/server.js b/server.js
index 39bac10..f151355 100644
--- a/server.js
+++ b/server.js
@@ -41,8 +41,16 @@ if (ITURHFPROP_URL) {
app.use(cors());
app.use(express.json());
-// Serve static files from public directory
-app.use(express.static(path.join(__dirname, 'public')));
+// Serve static files - use 'dist' in production (Vite build), 'public' in development
+const staticDir = process.env.NODE_ENV === 'production'
+ ? path.join(__dirname, 'dist')
+ : path.join(__dirname, 'public');
+app.use(express.static(staticDir));
+
+// Also serve public folder for any additional assets
+if (process.env.NODE_ENV === 'production') {
+ app.use(express.static(path.join(__dirname, 'public')));
+}
// ============================================
// API PROXY ENDPOINTS
@@ -1501,18 +1509,67 @@ app.get('/api/myspots/:callsign', async (req, res) => {
// SATELLITE TRACKING API
// ============================================
-// Ham radio satellites - NORAD IDs
+// Comprehensive ham radio satellites - NORAD IDs
+// Updated list of active amateur radio satellites
const HAM_SATELLITES = {
- 'ISS': { norad: 25544, name: 'ISS (ZARYA)', color: '#00ffff', priority: 1 },
- 'AO-91': { norad: 43017, name: 'AO-91 (Fox-1B)', color: '#ff6600', priority: 2 },
- 'AO-92': { norad: 43137, name: 'AO-92 (Fox-1D)', color: '#ff9900', priority: 2 },
- 'SO-50': { norad: 27607, name: 'SO-50 (SaudiSat)', color: '#00ff00', priority: 2 },
- 'RS-44': { norad: 44909, name: 'RS-44 (DOSAAF)', color: '#ff0066', priority: 2 },
- 'IO-117': { norad: 53106, name: 'IO-117 (GreenCube)', color: '#00ff99', priority: 3 },
- 'CAS-4A': { norad: 42761, name: 'CAS-4A (ZHUHAI-1 01)', color: '#9966ff', priority: 3 },
- 'CAS-4B': { norad: 42759, name: 'CAS-4B (ZHUHAI-1 02)', color: '#9933ff', priority: 3 },
- 'PO-101': { norad: 43678, name: 'PO-101 (Diwata-2)', color: '#ff3399', priority: 3 },
- 'TEVEL': { norad: 50988, name: 'TEVEL-1', color: '#66ccff', priority: 4 }
+ // High Priority - Popular FM satellites
+ 'ISS': { norad: 25544, name: 'ISS (ZARYA)', color: '#00ffff', priority: 1, mode: 'FM/APRS/SSTV' },
+ 'SO-50': { norad: 27607, name: 'SO-50', color: '#00ff00', priority: 1, mode: 'FM' },
+ 'AO-91': { norad: 43017, name: 'AO-91 (Fox-1B)', color: '#ff6600', priority: 1, mode: 'FM' },
+ 'AO-92': { norad: 43137, name: 'AO-92 (Fox-1D)', color: '#ff9900', priority: 1, mode: 'FM/L-band' },
+ 'PO-101': { norad: 43678, name: 'PO-101 (Diwata-2)', color: '#ff3399', priority: 1, mode: 'FM' },
+
+ // Linear Transponder Satellites
+ 'RS-44': { norad: 44909, name: 'RS-44 (DOSAAF)', color: '#ff0066', priority: 1, mode: 'Linear' },
+ 'AO-7': { norad: 7530, name: 'AO-7', color: '#ffcc00', priority: 2, mode: 'Linear (daylight)' },
+ 'FO-29': { norad: 24278, name: 'FO-29 (JAS-2)', color: '#ff6699', priority: 2, mode: 'Linear' },
+ 'FO-99': { norad: 43937, name: 'FO-99 (NEXUS)', color: '#ff99cc', priority: 2, mode: 'Linear' },
+ 'JO-97': { norad: 43803, name: 'JO-97 (JY1Sat)', color: '#cc99ff', priority: 2, mode: 'Linear/FM' },
+ 'XW-2A': { norad: 40903, name: 'XW-2A (CAS-3A)', color: '#66ff99', priority: 2, mode: 'Linear' },
+ 'XW-2B': { norad: 40911, name: 'XW-2B (CAS-3B)', color: '#66ffcc', priority: 2, mode: 'Linear' },
+ 'XW-2C': { norad: 40906, name: 'XW-2C (CAS-3C)', color: '#99ffcc', priority: 2, mode: 'Linear' },
+ 'XW-2D': { norad: 40907, name: 'XW-2D (CAS-3D)', color: '#99ff99', priority: 2, mode: 'Linear' },
+ 'XW-2E': { norad: 40909, name: 'XW-2E (CAS-3E)', color: '#ccff99', priority: 2, mode: 'Linear' },
+ 'XW-2F': { norad: 40910, name: 'XW-2F (CAS-3F)', color: '#ccffcc', priority: 2, mode: 'Linear' },
+
+ // CAS (Chinese Amateur Satellites)
+ 'CAS-4A': { norad: 42761, name: 'CAS-4A', color: '#9966ff', priority: 2, mode: 'Linear' },
+ 'CAS-4B': { norad: 42759, name: 'CAS-4B', color: '#9933ff', priority: 2, mode: 'Linear' },
+ 'CAS-6': { norad: 44881, name: 'CAS-6 (TO-108)', color: '#cc66ff', priority: 2, mode: 'Linear' },
+
+ // GreenCube / IO satellites
+ 'IO-117': { norad: 53106, name: 'IO-117 (GreenCube)', color: '#00ff99', priority: 2, mode: 'Digipeater' },
+
+ // TEVEL constellation
+ 'TEVEL-1': { norad: 50988, name: 'TEVEL-1', color: '#66ccff', priority: 3, mode: 'FM' },
+ 'TEVEL-2': { norad: 50989, name: 'TEVEL-2', color: '#66ddff', priority: 3, mode: 'FM' },
+ 'TEVEL-3': { norad: 50994, name: 'TEVEL-3', color: '#66eeff', priority: 3, mode: 'FM' },
+ 'TEVEL-4': { norad: 50998, name: 'TEVEL-4', color: '#77ccff', priority: 3, mode: 'FM' },
+ 'TEVEL-5': { norad: 51062, name: 'TEVEL-5', color: '#77ddff', priority: 3, mode: 'FM' },
+ 'TEVEL-6': { norad: 51063, name: 'TEVEL-6', color: '#77eeff', priority: 3, mode: 'FM' },
+ 'TEVEL-7': { norad: 51069, name: 'TEVEL-7', color: '#88ccff', priority: 3, mode: 'FM' },
+ 'TEVEL-8': { norad: 51084, name: 'TEVEL-8', color: '#88ddff', priority: 3, mode: 'FM' },
+
+ // OSCAR satellites
+ 'AO-27': { norad: 22825, name: 'AO-27', color: '#ff9966', priority: 3, mode: 'FM' },
+ 'AO-73': { norad: 39444, name: 'AO-73 (FUNcube-1)', color: '#ffcc66', priority: 3, mode: 'Linear/Telemetry' },
+ 'EO-88': { norad: 42017, name: 'EO-88 (Nayif-1)', color: '#ffaa66', priority: 3, mode: 'Linear/Telemetry' },
+
+ // Russian satellites
+ 'RS-15': { norad: 23439, name: 'RS-15', color: '#ff6666', priority: 3, mode: 'Linear' },
+
+ // QO-100 (Geostationary - special)
+ 'QO-100': { norad: 43700, name: 'QO-100 (Es\'hail-2)', color: '#ffff00', priority: 1, mode: 'Linear (GEO)' },
+
+ // APRS Digipeaters
+ 'ARISS': { norad: 25544, name: 'ARISS (ISS)', color: '#00ffff', priority: 1, mode: 'APRS' },
+
+ // Cubesats with amateur payloads
+ 'UVSQ-SAT': { norad: 47438, name: 'UVSQ-SAT', color: '#ff66ff', priority: 4, mode: 'Telemetry' },
+ 'MEZNSAT': { norad: 46489, name: 'MeznSat', color: '#66ff66', priority: 4, mode: 'Telemetry' },
+
+ // SSTV/Slow Scan
+ 'SSTV-ISS': { norad: 25544, name: 'ISS SSTV', color: '#00ffff', priority: 2, mode: 'SSTV' }
};
// Cache for TLE data (refresh every 6 hours)
@@ -2793,14 +2850,17 @@ app.get('/api/config', (req, res) => {
// ============================================
app.get('*', (req, res) => {
- res.sendFile(path.join(__dirname, 'public', 'index.html'));
+ const indexPath = process.env.NODE_ENV === 'production'
+ ? path.join(__dirname, 'dist', 'index.html')
+ : path.join(__dirname, 'public', 'index.html');
+ res.sendFile(indexPath);
});
// ============================================
// START SERVER
// ============================================
-app.listen(PORT, () => {
+app.listen(PORT, '0.0.0.0', () => {
console.log('');
console.log('βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ');
console.log('β β');
diff --git a/server.py b/server.py
deleted file mode 100644
index b50ca36..0000000
--- a/server.py
+++ /dev/null
@@ -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()
diff --git a/src/App.jsx b/src/App.jsx
new file mode 100644
index 0000000..4cc8f87
--- /dev/null
+++ b/src/App.jsx
@@ -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 (
+
+ {config.layout === 'classic' ? (
+ /* CLASSIC HAMCLOCK-STYLE LAYOUT */
+
+ {/* TOP BAR - HamClock style */}
+
+ {/* Callsign & Time */}
+
+
setShowSettings(true)}
+ title="Click for settings"
+ >
+ {config.callsign}
+
+
+ Up 35d 18h β’ v4.20
+
+
+
+ {utcTime}:{String(new Date().getUTCSeconds()).padStart(2, '0')}
+
+
+ {utcDate} UTC
+
+
+
+
+ {/* Solar Indices - SSN & SFI */}
+
+ {/* SSN */}
+
+
Sunspot Number
+
+
+ {solarIndices?.data?.ssn?.history?.length > 0 && (
+
+ {(() => {
+ 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 ;
+ })()}
+
+ )}
+
+
+ {solarIndices?.data?.ssn?.current || '--'}
+
+
+
-30 Days
+
+
+ {/* SFI */}
+
+
10.7 cm Solar flux
+
+
+ {solarIndices?.data?.sfi?.history?.length > 0 && (
+
+ {(() => {
+ 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 ;
+ })()}
+
+ )}
+
+
+ {solarIndices?.data?.sfi?.current || '--'}
+
+
+
-30 Days +7
+
+
+
+ {/* Live Spots & Indices */}
+
+ {/* Live Spots by Band */}
+
+
Live Spots
+
of {deGrid} - 15 mins
+
+ {[
+ { 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 => (
+
+ {b.band}
+
+ {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}
+
+
+ ))}
+
+
+
+ {/* Space Weather Indices */}
+
+
+
+
Kp
+
{spaceWeather?.data?.kIndex ?? '--'}
+
+
+
+
+
+
+
+ {/* MAIN AREA */}
+
+ {/* DX Cluster List */}
+
+
+ Cluster
+ dxspider.co.uk:7300
+
+
+ {dxCluster.data?.slice(0, 25).map((spot, i) => (
+
setHoveredSpot(spot)}
+ onMouseLeave={() => setHoveredSpot(null)}
+ >
+ {parseFloat(spot.freq).toFixed(1)}
+ {spot.call}
+ {spot.time || '--'}
+
+ ))}
+
+
+
+ {/* Map */}
+
+
+
+ {/* Settings button overlay */}
+ 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
+
+
+
+
+ {/* BOTTOM - Frequency Scale */}
+
+ MHz
+ 5
+ 10
+ 15
+ 20
+ 25
+ 30
+ 35
+
+
+ ) : (
+ /* MODERN LAYOUT */
+
+ {/* TOP BAR */}
+
setShowSettings(true)}
+ onFullscreenToggle={handleFullscreenToggle}
+ isFullscreen={isFullscreen}
+ />
+
+ {/* LEFT SIDEBAR */}
+
+ {/* DE Location */}
+
+
π DE - YOUR LOCATION
+
+
{deGrid}
+
{config.location.lat.toFixed(4)}Β°, {config.location.lon.toFixed(4)}Β°
+
+ β
+ {deSunTimes.sunrise}
+ β
+ {deSunTimes.sunset}
+
+
+
+
+ {/* DX Location */}
+
+
π― DX - TARGET
+
+
{dxGrid}
+
{dxLocation.lat.toFixed(4)}Β°, {dxLocation.lon.toFixed(4)}Β°
+
+ β
+ {dxSunTimes.sunrise}
+ β
+ {dxSunTimes.sunset}
+
+
+
+
+ {/* Solar Panel */}
+
+
+ {/* VOACAP/Propagation Panel */}
+
+
+
+ {/* CENTER - MAP */}
+
+
+
+ Click map to set DX β’ 73 de {config.callsign}
+
+
+
+ {/* RIGHT SIDEBAR */}
+
+ {/* DX Cluster - takes most space */}
+
+ setShowDXFilters(true)}
+ onHoverSpot={setHoveredSpot}
+ hoveredSpot={hoveredSpot}
+ showOnMap={mapLayers.showDXPaths}
+ onToggleMap={toggleDXPaths}
+ />
+
+
+ {/* DXpeditions - smaller */}
+
+
+
+
+ {/* POTA - smaller */}
+
+
+ {/* Contests - smaller */}
+
+
+
+
+
+ )}
+
+ {/* Modals */}
+
setShowSettings(false)}
+ config={config}
+ onSave={handleSaveConfig}
+ />
+ setShowDXFilters(false)}
+ />
+
+ );
+};
+
+export default App;
diff --git a/src/components/BandConditionsPanel.jsx b/src/components/BandConditionsPanel.jsx
new file mode 100644
index 0000000..732964d
--- /dev/null
+++ b/src/components/BandConditionsPanel.jsx
@@ -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 (
+
+
π‘ BAND CONDITIONS
+ {loading ? (
+
+ ) : (
+
+ {data.map(({ band, condition }) => {
+ const style = getConditionStyle(condition);
+ return (
+
+
+ {band}
+
+
+ {condition}
+
+
+ );
+ })}
+
+ )}
+
+ );
+};
+
+export default BandConditionsPanel;
diff --git a/src/components/ContestPanel.jsx b/src/components/ContestPanel.jsx
new file mode 100644
index 0000000..3991a7d
--- /dev/null
+++ b/src/components/ContestPanel.jsx
@@ -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 (
+
+
+ π CONTESTS
+
+
+
+ {loading ? (
+
+ ) : data && data.length > 0 ? (
+
+ {data.slice(0, 6).map((contest, i) => (
+
+
+ {contest.name}
+
+
+ {contest.mode}
+ {formatDate(contest.start)}
+
+
+ ))}
+
+ ) : (
+
+ No upcoming contests
+
+ )}
+
+
+ {/* Contest Calendar Credit */}
+
+
+ );
+};
+
+export default ContestPanel;
diff --git a/src/components/DXClusterPanel.jsx b/src/components/DXClusterPanel.jsx
new file mode 100644
index 0000000..3cd50c7
--- /dev/null
+++ b/src/components/DXClusterPanel.jsx
@@ -0,0 +1,195 @@
+/**
+ * DXClusterPanel Component
+ * Displays DX cluster spots with filtering controls and ON/OFF toggle
+ */
+import React from 'react';
+import { getBandColor } from '../utils/callsign.js';
+
+export const DXClusterPanel = ({
+ data,
+ loading,
+ totalSpots,
+ filters,
+ onFilterChange,
+ onOpenFilters,
+ onHoverSpot,
+ hoveredSpot,
+ showOnMap,
+ onToggleMap
+}) => {
+ const getActiveFilterCount = () => {
+ let count = 0;
+ if (filters?.cqZones?.length) count++;
+ if (filters?.ituZones?.length) count++;
+ if (filters?.continents?.length) count++;
+ if (filters?.bands?.length) count++;
+ if (filters?.modes?.length) count++;
+ if (filters?.watchlist?.length) count++;
+ if (filters?.excludeList?.length) count++;
+ if (filters?.callsign) count++;
+ if (filters?.watchlistOnly) count++;
+ return count;
+ };
+
+ const filterCount = getActiveFilterCount();
+ const spots = data || [];
+
+ return (
+
+ {/* Header */}
+
+
π DX CLUSTER β LIVE
+
+ {spots.length}/{totalSpots || spots.length}
+ 0 ? 'rgba(255, 170, 0, 0.3)' : 'rgba(100, 100, 100, 0.3)',
+ border: `1px solid ${filterCount > 0 ? '#ffaa00' : '#666'}`,
+ color: filterCount > 0 ? '#ffaa00' : '#888',
+ padding: '2px 8px',
+ borderRadius: '4px',
+ fontSize: '10px',
+ fontFamily: 'JetBrains Mono',
+ cursor: 'pointer'
+ }}
+ >
+ π Filters
+
+
+ πΊοΈ {showOnMap ? 'ON' : 'OFF'}
+
+
+
+
+ {/* Quick search */}
+
+ onFilterChange?.({ ...filters, callsign: e.target.value || undefined })}
+ style={{
+ flex: 1,
+ padding: '4px 8px',
+ background: 'var(--bg-secondary)',
+ border: '1px solid var(--border-color)',
+ borderRadius: '3px',
+ color: 'var(--text-primary)',
+ fontSize: '11px',
+ fontFamily: 'JetBrains Mono'
+ }}
+ />
+
+
+ {/* Spots list */}
+ {loading ? (
+
+ ) : spots.length === 0 ? (
+
+ {filterCount > 0 ? 'No spots match filters' : 'No spots available'}
+
+ ) : (
+
+ {spots.slice(0, 25).map((spot, i) => {
+ // Frequency can be in MHz (string like "14.070") or kHz (number like 14070)
+ let freqDisplay = '?';
+ let freqMHz = 0;
+
+ if (spot.freq) {
+ const freqVal = parseFloat(spot.freq);
+ if (freqVal > 1000) {
+ // It's in kHz, convert to MHz
+ freqMHz = freqVal / 1000;
+ freqDisplay = freqMHz.toFixed(3);
+ } else {
+ // Already in MHz
+ freqMHz = freqVal;
+ freqDisplay = freqVal.toFixed(3);
+ }
+ }
+
+ const color = getBandColor(freqMHz);
+ const isHovered = hoveredSpot?.call === spot.call;
+
+ return (
+
onHoverSpot?.(spot)}
+ onMouseLeave={() => onHoverSpot?.(null)}
+ style={{
+ display: 'grid',
+ gridTemplateColumns: '60px 1fr auto',
+ gap: '8px',
+ padding: '5px 6px',
+ borderRadius: '3px',
+ marginBottom: '2px',
+ background: isHovered ? 'rgba(68, 136, 255, 0.25)' : (i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent'),
+ cursor: 'pointer',
+ transition: 'background 0.15s',
+ borderLeft: isHovered ? '2px solid #4488ff' : '2px solid transparent'
+ }}
+ >
+
+ {freqDisplay}
+
+
+ {spot.call}
+
+
+ {spot.time || ''}
+
+
+ );
+ })}
+
+ )}
+
+ );
+};
+
+export default DXClusterPanel;
diff --git a/src/components/DXFilterManager.jsx b/src/components/DXFilterManager.jsx
new file mode 100644
index 0000000..bd3f218
--- /dev/null
+++ b/src/components/DXFilterManager.jsx
@@ -0,0 +1,482 @@
+/**
+ * DXFilterManager Component
+ * Filter modal with tabs for Zones, Bands, Modes, Watchlist, Exclude, Settings
+ */
+import React, { useState } from 'react';
+
+export const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose }) => {
+ const [activeTab, setActiveTab] = useState('zones');
+ const [newWatchlistCall, setNewWatchlistCall] = useState('');
+ const [newExcludeCall, setNewExcludeCall] = useState('');
+
+ if (!isOpen) return null;
+
+ 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' }
+ ];
+
+ const bands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '11m', '10m', '6m', '2m', '70cm'];
+ const modes = ['CW', 'SSB', 'FT8', 'FT4', 'RTTY', 'PSK', 'JT65', 'JS8', 'SSTV', 'AM', 'FM'];
+
+ const toggleArrayItem = (key, item) => {
+ const current = filters[key] || [];
+ const newArray = current.includes(item)
+ ? current.filter(x => x !== item)
+ : [...current, item];
+ onFilterChange({ ...filters, [key]: newArray.length ? newArray : undefined });
+ };
+
+ const selectAll = (key, items) => {
+ onFilterChange({ ...filters, [key]: [...items] });
+ };
+
+ const clearFilter = (key) => {
+ const newFilters = { ...filters };
+ delete newFilters[key];
+ onFilterChange(newFilters);
+ };
+
+ const clearAllFilters = () => {
+ onFilterChange({});
+ };
+
+ const getActiveFilterCount = () => {
+ let count = 0;
+ if (filters?.continents?.length) count += filters.continents.length;
+ if (filters?.cqZones?.length) count += filters.cqZones.length;
+ if (filters?.ituZones?.length) count += filters.ituZones.length;
+ if (filters?.bands?.length) count += filters.bands.length;
+ if (filters?.modes?.length) count += filters.modes.length;
+ if (filters?.watchlist?.length) count += filters.watchlist.length;
+ if (filters?.excludeList?.length) count += filters.excludeList.length;
+ return count;
+ };
+
+ const tabStyle = (active) => ({
+ padding: '8px 16px',
+ background: active ? 'var(--bg-tertiary)' : 'transparent',
+ border: 'none',
+ borderBottom: active ? '2px solid var(--accent-cyan)' : '2px solid transparent',
+ color: active ? 'var(--accent-cyan)' : 'var(--text-muted)',
+ fontSize: '13px',
+ cursor: 'pointer',
+ fontFamily: 'inherit'
+ });
+
+ const chipStyle = (selected) => ({
+ padding: '6px 12px',
+ background: selected ? 'rgba(0, 221, 255, 0.2)' : 'var(--bg-tertiary)',
+ border: `1px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
+ borderRadius: '4px',
+ color: selected ? 'var(--accent-cyan)' : 'var(--text-secondary)',
+ fontSize: '12px',
+ cursor: 'pointer',
+ fontFamily: 'JetBrains Mono, monospace'
+ });
+
+ const zoneButtonStyle = (selected) => ({
+ width: '36px',
+ height: '32px',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ background: selected ? 'rgba(0, 221, 255, 0.2)' : 'var(--bg-tertiary)',
+ border: `1px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
+ borderRadius: '4px',
+ color: selected ? 'var(--accent-cyan)' : 'var(--text-secondary)',
+ fontSize: '12px',
+ cursor: 'pointer',
+ fontFamily: 'JetBrains Mono, monospace'
+ });
+
+ const addToWatchlist = () => {
+ if (newWatchlistCall.trim()) {
+ const current = filters?.watchlist || [];
+ if (!current.includes(newWatchlistCall.toUpperCase())) {
+ onFilterChange({ ...filters, watchlist: [...current, newWatchlistCall.toUpperCase()] });
+ }
+ setNewWatchlistCall('');
+ }
+ };
+
+ const addToExclude = () => {
+ if (newExcludeCall.trim()) {
+ const current = filters?.excludeList || [];
+ if (!current.includes(newExcludeCall.toUpperCase())) {
+ onFilterChange({ ...filters, excludeList: [...current, newExcludeCall.toUpperCase()] });
+ }
+ setNewExcludeCall('');
+ }
+ };
+
+ const renderZonesTab = () => (
+
+ {/* Continents */}
+
+
+ Continents
+
+
+ {continents.map(c => (
+ toggleArrayItem('continents', c.code)}
+ style={chipStyle(filters?.continents?.includes(c.code))}
+ >
+ {c.code} - {c.name}
+
+ ))}
+
+
+
+ {/* CQ Zones */}
+
+
+
CQ Zones
+
+ selectAll('cqZones', Array.from({length: 40}, (_, i) => i + 1))} style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', fontSize: '12px', cursor: 'pointer' }}>Select All
+ clearFilter('cqZones')} style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}>Clear
+
+
+
+ {Array.from({ length: 40 }, (_, i) => i + 1).map(zone => (
+ toggleArrayItem('cqZones', zone)}
+ style={zoneButtonStyle(filters?.cqZones?.includes(zone))}
+ >
+ {zone}
+
+ ))}
+
+
+
+ {/* ITU Zones */}
+
+
+
ITU Zones
+
+ selectAll('ituZones', Array.from({length: 90}, (_, i) => i + 1))} style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', fontSize: '12px', cursor: 'pointer' }}>Select All
+ clearFilter('ituZones')} style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}>Clear
+
+
+
+ {Array.from({ length: 90 }, (_, i) => i + 1).map(zone => (
+ toggleArrayItem('ituZones', zone)}
+ style={zoneButtonStyle(filters?.ituZones?.includes(zone))}
+ >
+ {zone}
+
+ ))}
+
+
+
+ );
+
+ const renderBandsTab = () => (
+
+
+
HF/VHF/UHF Bands
+
+ selectAll('bands', bands)} style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', fontSize: '12px', cursor: 'pointer' }}>Select All
+ clearFilter('bands')} style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}>Clear
+
+
+
+ {bands.map(band => (
+ toggleArrayItem('bands', band)}
+ style={chipStyle(filters?.bands?.includes(band))}
+ >
+ {band}
+
+ ))}
+
+
+ );
+
+ const renderModesTab = () => (
+
+
+
Operating Modes
+
+ selectAll('modes', modes)} style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', fontSize: '12px', cursor: 'pointer' }}>Select All
+ clearFilter('modes')} style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}>Clear
+
+
+
+ {modes.map(mode => (
+ toggleArrayItem('modes', mode)}
+ style={chipStyle(filters?.modes?.includes(mode))}
+ >
+ {mode}
+
+ ))}
+
+
+ );
+
+ const renderWatchlistTab = () => (
+
+
+
+ Watchlist - Highlight these callsigns
+
+
+ setNewWatchlistCall(e.target.value.toUpperCase())}
+ onKeyPress={(e) => e.key === 'Enter' && addToWatchlist()}
+ placeholder="Enter callsign..."
+ style={{
+ flex: 1,
+ padding: '8px 12px',
+ background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-color)',
+ borderRadius: '4px',
+ color: 'var(--text-primary)',
+ fontSize: '13px',
+ fontFamily: 'JetBrains Mono'
+ }}
+ />
+ Add
+
+
+
+ {(filters?.watchlist || []).map(call => (
+
+ {call}
+ toggleArrayItem('watchlist', call)} style={{ background: 'none', border: 'none', color: 'var(--accent-red)', cursor: 'pointer', padding: 0, fontSize: '14px' }}>Γ
+
+ ))}
+
+
+
+ onFilterChange({ ...filters, watchlistOnly: e.target.checked || undefined })}
+ />
+ Show only watchlist callsigns
+
+
+
+ );
+
+ const renderExcludeTab = () => (
+
+
+
+ Exclude List - Hide these callsigns
+
+
+ setNewExcludeCall(e.target.value.toUpperCase())}
+ onKeyPress={(e) => e.key === 'Enter' && addToExclude()}
+ placeholder="Enter callsign..."
+ style={{
+ flex: 1,
+ padding: '8px 12px',
+ background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-color)',
+ borderRadius: '4px',
+ color: 'var(--text-primary)',
+ fontSize: '13px',
+ fontFamily: 'JetBrains Mono'
+ }}
+ />
+ Add
+
+
+
+ {(filters?.excludeList || []).map(call => (
+
+ {call}
+ toggleArrayItem('excludeList', call)} style={{ background: 'none', border: 'none', color: 'var(--accent-red)', cursor: 'pointer', padding: 0, fontSize: '14px' }}>Γ
+
+ ))}
+
+
+ );
+
+ const renderSettingsTab = () => {
+ const retentionMinutes = filters?.spotRetentionMinutes || 30;
+
+ return (
+
+
+
+ Spot Retention Time
+
+
+ How long to keep DX spots on the map before they expire. Shorter times show only the most recent activity.
+
+
+
onFilterChange({ ...filters, spotRetentionMinutes: parseInt(e.target.value) })}
+ style={{ flex: 1, cursor: 'pointer' }}
+ />
+
+ {retentionMinutes} min
+
+
+
+ 5 min (freshest)
+ 30 min (default)
+
+
+
+
+
+ Quick Presets
+
+
+ {[5, 10, 15, 20, 30].map(mins => (
+ onFilterChange({ ...filters, spotRetentionMinutes: mins })}
+ style={{
+ padding: '8px 16px',
+ background: retentionMinutes === mins ? 'rgba(0, 221, 255, 0.2)' : 'var(--bg-tertiary)',
+ border: `1px solid ${retentionMinutes === mins ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
+ borderRadius: '4px',
+ color: retentionMinutes === mins ? 'var(--accent-cyan)' : 'var(--text-secondary)',
+ fontSize: '13px',
+ cursor: 'pointer',
+ fontFamily: 'JetBrains Mono'
+ }}
+ >
+ {mins} min
+
+ ))}
+
+
+
+ );
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ π DX Cluster Filters
+
+
+ {getActiveFilterCount()} filters active
+
+
+
+
+ Clear All
+
+
+ Done
+
+
+
+
+ {/* Tabs */}
+
+ setActiveTab('zones')} style={tabStyle(activeTab === 'zones')}>Zones
+ setActiveTab('bands')} style={tabStyle(activeTab === 'bands')}>Bands
+ setActiveTab('modes')} style={tabStyle(activeTab === 'modes')}>Modes
+ setActiveTab('watchlist')} style={tabStyle(activeTab === 'watchlist')}>Watchlist
+ setActiveTab('exclude')} style={tabStyle(activeTab === 'exclude')}>Exclude
+ setActiveTab('settings')} style={tabStyle(activeTab === 'settings')}>β Settings
+
+
+ {/* Tab Content */}
+
+ {activeTab === 'zones' && renderZonesTab()}
+ {activeTab === 'bands' && renderBandsTab()}
+ {activeTab === 'modes' && renderModesTab()}
+ {activeTab === 'watchlist' && renderWatchlistTab()}
+ {activeTab === 'exclude' && renderExcludeTab()}
+ {activeTab === 'settings' && renderSettingsTab()}
+
+
+
+ );
+};
+
+export default DXFilterManager;
diff --git a/src/components/DXpeditionPanel.jsx b/src/components/DXpeditionPanel.jsx
new file mode 100644
index 0000000..a8806a9
--- /dev/null
+++ b/src/components/DXpeditionPanel.jsx
@@ -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 (
+
+
+ π DXPEDITIONS
+ {data && (
+
+ {data.active > 0 && {data.active} active }
+
+ )}
+
+
+
+ {loading ? (
+
+ ) : data?.dxpeditions?.length > 0 ? (
+ data.dxpeditions.slice(0, 4).map((exp, idx) => {
+ const style = getStatusStyle(exp);
+ return (
+
+
+ {exp.callsign}
+
+ {exp.isActive ? 'β NOW' : 'SOON'}
+
+
+
+ {exp.entity}
+
+
+ );
+ })
+ ) : (
+
+ No DXpeditions
+
+ )}
+
+
+ );
+};
+
+export default DXpeditionPanel;
diff --git a/src/components/Header.jsx b/src/components/Header.jsx
new file mode 100644
index 0000000..7084b7e
--- /dev/null
+++ b/src/components/Header.jsx
@@ -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 (
+
+ {/* Callsign & Settings */}
+
+
+ {config.callsign}
+
+ v3.7.0
+
+
+ {/* UTC Clock */}
+
+ UTC
+ {utcTime}
+ {utcDate}
+
+
+ {/* Local Clock - Clickable to toggle 12/24 hour format */}
+
+ LOCAL
+ {localTime}
+ {localDate}
+
+
+ {/* Weather & Solar Stats */}
+
+ {localWeather?.data && (
+
+ {localWeather.data.icon}
+
+ {localWeather.data.temp}Β°F / {Math.round((localWeather.data.temp - 32) * 5/9)}Β°C
+
+
+ )}
+
+ SFI
+ {spaceWeather?.data?.solarFlux || '--'}
+
+
+ K
+ = 4 ? 'var(--accent-red)' : 'var(--accent-green)', fontWeight: '700', fontSize: '16px' }}>
+ {spaceWeather?.data?.kIndex ?? '--'}
+
+
+
+ SSN
+ {spaceWeather?.data?.sunspotNumber || '--'}
+
+
+
+ {/* Settings & Fullscreen Buttons */}
+
+
+ β Donate
+
+
+ β Settings
+
+
+ {isFullscreen ? 'βΆ Exit' : 'βΆ Full'}
+
+
+
+ );
+};
+
+export default Header;
diff --git a/src/components/LocationPanel.jsx b/src/components/LocationPanel.jsx
new file mode 100644
index 0000000..26c2a5f
--- /dev/null
+++ b/src/components/LocationPanel.jsx
@@ -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 (
+
+
π LOCATIONS
+
+ {/* DE Location */}
+
+
+
+ DE: {config.callsign}
+
+
+ {deGrid}
+
+
+
+ {config.location.lat.toFixed(4)}Β°, {config.location.lon.toFixed(4)}Β°
+
+
+ β {deSunTimes.sunrise} / {deSunTimes.sunset} UTC
+
+
+
+ {/* DX Location */}
+
+
+
+ DX Target
+
+
+ {dxGrid}
+
+
+
+ {dxLocation.lat.toFixed(4)}Β°, {dxLocation.lon.toFixed(4)}Β°
+
+
+ β {dxSunTimes.sunrise} / {dxSunTimes.sunset} UTC
+
+
+
+ {/* Path Info */}
+
+
+
+
BEARING
+
+ {bearing.toFixed(0)}Β°
+
+
+
+
DISTANCE
+
+ {distance.toFixed(0)} km
+
+
+
+
+
+ {/* Moon Phase */}
+
+ {moonEmoji}
+
+ {moonPhase < 0.25 ? 'Waxing' : moonPhase < 0.5 ? 'Waxing' : moonPhase < 0.75 ? 'Waning' : 'Waning'}
+ {' '}
+ {Math.round(moonPhase * 100)}%
+
+
+
+ );
+};
+
+export default LocationPanel;
diff --git a/src/components/POTAPanel.jsx b/src/components/POTAPanel.jsx
new file mode 100644
index 0000000..8e466bc
--- /dev/null
+++ b/src/components/POTAPanel.jsx
@@ -0,0 +1,75 @@
+/**
+ * POTAPanel Component
+ * Displays Parks on the Air activations with ON/OFF toggle (compact version)
+ */
+import React from 'react';
+
+export const POTAPanel = ({ data, loading, showOnMap, onToggleMap }) => {
+ return (
+
+
+ ποΈ POTA ACTIVATORS
+
+ πΊοΈ {showOnMap ? 'ON' : 'OFF'}
+
+
+
+
+ {loading ? (
+
+ ) : data && data.length > 0 ? (
+
+ {data.slice(0, 5).map((spot, i) => (
+
+
+ {spot.call}
+
+
+ {spot.ref}
+
+
+ {spot.freq}
+
+
+ ))}
+
+ ) : (
+
+ No POTA spots
+
+ )}
+
+
+ );
+};
+
+export default POTAPanel;
diff --git a/src/components/PropagationPanel.jsx b/src/components/PropagationPanel.jsx
new file mode 100644
index 0000000..71e6396
--- /dev/null
+++ b/src/components/PropagationPanel.jsx
@@ -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 (
+
+
π‘ VOACAP
+
+ Loading predictions...
+
+
+ );
+ }
+
+ 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 (
+
+
+
+ {viewMode === 'bands' ? 'π BAND CONDITIONS' : 'π‘ VOACAP'}
+ {hasRealData && viewMode !== 'bands' && β }
+
+
+ {viewModeLabels[viewMode]} β’ click to toggle
+
+
+
+ {viewMode === 'bands' ? (
+ /* Band Conditions Grid View */
+
+
+ {(bandConditions?.data || []).slice(0, 13).map((band, idx) => {
+ const style = getBandStyle(band.condition);
+ return (
+
+
+ {band.band}
+
+
+ {band.condition}
+
+
+ );
+ })}
+
+
+ SFI {solarData?.sfi} β’ K {solarData?.kIndex} β’ General conditions for all paths
+
+
+ ) : (
+ <>
+ {/* MUF/LUF and Data Source Info */}
+
+
+
+ MUF
+ {muf || '?'}
+ MHz
+
+
+ LUF
+ {luf || '?'}
+ MHz
+
+
+
+ {hasRealData
+ ? `π‘ ${ionospheric?.source || 'ionosonde'}${ionospheric?.distance ? ` (${ionospheric.distance}km)` : ''}`
+ : 'β‘ estimated'
+ }
+
+ {dataSource && dataSource.includes('ITU') && (
+
+ π¬ ITU-R P.533
+
+ )}
+
+
+ {viewMode === 'chart' ? (
+ /* VOACAP Heat Map Chart View */
+
+
+ {bands.map((band) => (
+
+
+ {band.replace('m', '')}
+
+ {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 (
+
+ );
+ })}
+
+ ))}
+
+
+ {/* Hour labels */}
+
+
UTC
+ {[0, '', '', 3, '', '', 6, '', '', 9, '', '', 12, '', '', 15, '', '', 18, '', '', 21, '', ''].map((h, i) => (
+
{h}
+ ))}
+
+
+ {/* Legend */}
+
+
+
REL:
+ {['#004400', '#00aa00', '#88cc00', '#ffcc00', '#ff6600', '#ff0000'].map((c, i) => (
+
+ ))}
+
+
+ {Math.round(distance || 0)}km β’ {ionospheric?.foF2 ? `foF2=${ionospheric.foF2}` : `SSN=${solarData?.ssn}`}
+
+
+
+ ) : (
+ /* Bar Chart View */
+
+
+ SFI {solarData?.sfi}
+ {ionospheric?.foF2 ? (
+ foF2 {ionospheric.foF2}
+ ) : (
+ SSN {solarData?.ssn}
+ )}
+ K = 4 ? '#ff4444' : '#00ff88' }}>{solarData?.kIndex}
+
+
+ {(currentBands || []).slice(0, 11).map((band) => (
+
+
= 50 ? 'var(--accent-green)' : 'var(--text-muted)'
+ }}>
+ {band.band}
+
+
+
+ {band.reliability}%
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ );
+};
+
+export default PropagationPanel;
diff --git a/src/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx
new file mode 100644
index 0000000..de51447
--- /dev/null
+++ b/src/components/SettingsPanel.jsx
@@ -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 (
+
+
+
+ β Station Settings
+
+
+ {/* Callsign */}
+
+
+ Your Callsign
+
+ 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'
+ }}
+ />
+
+
+ {/* Grid Square */}
+
+
+ Grid Square (or enter Lat/Lon below)
+
+ 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'
+ }}
+ />
+
+
+ {/* Lat/Lon */}
+
+
+
+ Latitude
+
+ 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'
+ }}
+ />
+
+
+
+ Longitude
+
+ 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'
+ }}
+ />
+
+
+
+ {/* Use My Location button */}
+
+ π Use My Current Location
+
+
+ {/* Theme */}
+
+
+ Theme
+
+
+ {['dark', 'light', 'legacy', 'retro'].map((t) => (
+ 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)}
+
+ ))}
+
+
+ {themeDescriptions[theme]}
+
+
+
+ {/* Layout */}
+
+
+ Layout
+
+
+ {['modern', 'classic'].map((l) => (
+ 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)}
+
+ ))}
+
+
+ {layoutDescriptions[layout]}
+
+
+
+ {/* DX Cluster Source */}
+
+
+ DX Cluster Source
+
+
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'
+ }}
+ >
+ β DX Spider Proxy (Recommended)
+ HamQTH Cluster
+ DXWatch
+ Auto (try all sources)
+
+
+ β Real-time DX Spider feed via our dedicated proxy service
+
+
+
+ {/* Buttons */}
+
+
+ Cancel
+
+
+ Save Settings
+
+
+
+
+ Settings are saved in your browser
+
+
+
+ );
+};
+
+export default SettingsPanel;
diff --git a/src/components/SolarPanel.jsx b/src/components/SolarPanel.jsx
new file mode 100644
index 0000000..762d360
--- /dev/null
+++ b/src/components/SolarPanel.jsx
@@ -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 (
+
+ {/* Header with toggle */}
+
+
+ β {showIndices ? 'SOLAR INDICES' : 'SOLAR'}
+
+
+ {!showIndices && (
+ 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]) => (
+ {val.desc}
+ ))}
+
+ )}
+
+ {showIndices ? 'πΌοΈ' : 'π'}
+
+
+
+
+ {showIndices ? (
+ /* Solar Indices View */
+
+ {solarIndices?.data ? (
+
+ {/* SFI Row */}
+
+
+
SFI
+
+ {solarIndices.data.sfi?.current || '--'}
+
+
+
+ {solarIndices.data.sfi?.history?.length > 0 && (
+
+ {(() => {
+ 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 ;
+ })()}
+
+ )}
+
+
+
+ {/* K-Index Row */}
+
+
+
K-Index
+
+ {kpData?.current ?? '--'}
+
+
+
+ {kpData?.forecast?.length > 0 ? (
+
+ {kpData.forecast.slice(0, 8).map((item, i) => {
+ const val = typeof item === 'object' ? item.value : item;
+ return (
+
+ );
+ })}
+
+ ) : kpData?.history?.length > 0 ? (
+
+ {kpData.history.slice(-8).map((item, i) => {
+ const val = typeof item === 'object' ? item.value : item;
+ return (
+
+ );
+ })}
+
+ ) : (
+
No forecast data
+ )}
+
+
+
+ {/* SSN Row */}
+
+
+
SSN
+
+ {solarIndices.data.ssn?.current || '--'}
+
+
+
+ {solarIndices.data.ssn?.history?.length > 0 && (
+
+ {(() => {
+ 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 ;
+ })()}
+
+ )}
+
+
+
+ ) : (
+
+ Loading solar data...
+
+ )}
+
+ ) : (
+ /* Solar Image View */
+
+
{
+ e.target.style.display = 'none';
+ }}
+ />
+
+ SDO/AIA β’ Live from NASA
+
+
+ )}
+
+ );
+};
+
+export default SolarPanel;
diff --git a/src/components/SpaceWeatherPanel.jsx b/src/components/SpaceWeatherPanel.jsx
new file mode 100644
index 0000000..7fa71fd
--- /dev/null
+++ b/src/components/SpaceWeatherPanel.jsx
@@ -0,0 +1,90 @@
+/**
+ * SpaceWeatherPanel Component
+ * Displays solar flux, K-index, and sunspot number
+ */
+import React from 'react';
+
+export const SpaceWeatherPanel = ({ data, loading }) => {
+ const getKIndexColor = (kIndex) => {
+ const k = parseInt(kIndex);
+ if (isNaN(k)) return 'var(--text-muted)';
+ if (k >= 5) return 'var(--accent-red)';
+ if (k >= 4) return 'var(--accent-amber)';
+ return 'var(--accent-green)';
+ };
+
+ return (
+
+
βοΈ SPACE WEATHER
+ {loading ? (
+
+ ) : (
+
+
+
SFI
+
+ {data?.solarFlux || '--'}
+
+
+
+
K-INDEX
+
+ {data?.kIndex || '--'}
+
+
+
+
SSN
+
+ {data?.sunspotNumber || '--'}
+
+
+
+ )}
+ {data?.conditions && (
+
+
+ CONDITIONS: {data.conditions}
+
+
+ )}
+
+ );
+};
+
+export default SpaceWeatherPanel;
diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx
new file mode 100644
index 0000000..6ad138d
--- /dev/null
+++ b/src/components/WorldMap.jsx
@@ -0,0 +1,549 @@
+/**
+ * WorldMap Component
+ * Leaflet map with DE/DX markers, terminator, DX paths, POTA, satellites
+ */
+import React, { useRef, useEffect, useState } from 'react';
+import { MAP_STYLES } from '../utils/config.js';
+import {
+ calculateGridSquare,
+ getSunPosition,
+ getMoonPosition,
+ getGreatCirclePoints
+} from '../utils/geo.js';
+import { filterDXPaths, getBandColor } from '../utils/callsign.js';
+
+export const WorldMap = ({
+ deLocation,
+ dxLocation,
+ onDXChange,
+ potaSpots,
+ mySpots,
+ dxPaths,
+ dxFilters,
+ satellites,
+ showDXPaths,
+ showDXLabels,
+ onToggleDXLabels,
+ showPOTA,
+ showSatellites,
+ onToggleSatellites,
+ hoveredSpot
+}) => {
+ const mapRef = useRef(null);
+ const mapInstanceRef = useRef(null);
+ const tileLayerRef = useRef(null);
+ const terminatorRef = useRef(null);
+ const deMarkerRef = useRef(null);
+ const dxMarkerRef = useRef(null);
+ const sunMarkerRef = useRef(null);
+ const moonMarkerRef = useRef(null);
+ const potaMarkersRef = useRef([]);
+ const mySpotsMarkersRef = useRef([]);
+ const mySpotsLinesRef = useRef([]);
+ const dxPathsLinesRef = useRef([]);
+ const dxPathsMarkersRef = useRef([]);
+ const satMarkersRef = useRef([]);
+ const satTracksRef = useRef([]);
+
+ // Load map style from localStorage
+ const getStoredMapSettings = () => {
+ try {
+ const stored = localStorage.getItem('openhamclock_mapSettings');
+ return stored ? JSON.parse(stored) : {};
+ } catch (e) { return {}; }
+ };
+ const storedSettings = getStoredMapSettings();
+
+ const [mapStyle, setMapStyle] = useState(storedSettings.mapStyle || 'dark');
+ const [mapView, setMapView] = useState({
+ center: storedSettings.center || [20, 0],
+ zoom: storedSettings.zoom || 2.5
+ });
+
+ // Save map settings to localStorage when changed
+ useEffect(() => {
+ try {
+ localStorage.setItem('openhamclock_mapSettings', JSON.stringify({
+ mapStyle,
+ center: mapView.center,
+ zoom: mapView.zoom
+ }));
+ } catch (e) { console.error('Failed to save map settings:', e); }
+ }, [mapStyle, mapView]);
+
+ // Initialize map
+ useEffect(() => {
+ if (!mapRef.current || mapInstanceRef.current) return;
+
+ // Make sure Leaflet is available
+ if (typeof L === 'undefined') {
+ console.error('Leaflet not loaded');
+ return;
+ }
+
+ const map = L.map(mapRef.current, {
+ center: mapView.center,
+ zoom: mapView.zoom,
+ minZoom: 1,
+ maxZoom: 18,
+ worldCopyJump: true,
+ zoomControl: true,
+ maxBounds: [[-90, -Infinity], [90, Infinity]],
+ maxBoundsViscosity: 0.8
+ });
+
+ // Initial tile layer
+ tileLayerRef.current = L.tileLayer(MAP_STYLES[mapStyle].url, {
+ attribution: MAP_STYLES[mapStyle].attribution,
+ noWrap: false,
+ crossOrigin: 'anonymous',
+ bounds: [[-85, -180], [85, 180]]
+ }).addTo(map);
+
+ // Day/night terminator
+ terminatorRef.current = L.terminator({
+ resolution: 2,
+ fillOpacity: 0.35,
+ fillColor: '#000020',
+ color: '#ffaa00',
+ weight: 2,
+ dashArray: '5, 5'
+ }).addTo(map);
+
+ // Refresh terminator
+ setTimeout(() => {
+ if (terminatorRef.current) {
+ terminatorRef.current.setTime();
+ }
+ }, 100);
+
+ // Update terminator every minute
+ const terminatorInterval = setInterval(() => {
+ if (terminatorRef.current) {
+ terminatorRef.current.setTime();
+ }
+ }, 60000);
+
+ // Click handler for setting DX
+ map.on('click', (e) => {
+ if (onDXChange) {
+ onDXChange({ lat: e.latlng.lat, lon: e.latlng.lng });
+ }
+ });
+
+ // Save map view when user pans or zooms
+ map.on('moveend', () => {
+ const center = map.getCenter();
+ const zoom = map.getZoom();
+ setMapView({ center: [center.lat, center.lng], zoom });
+ });
+
+ mapInstanceRef.current = map;
+
+ return () => {
+ clearInterval(terminatorInterval);
+ map.remove();
+ mapInstanceRef.current = null;
+ };
+ }, []);
+
+ // Update tile layer when style changes
+ useEffect(() => {
+ if (!mapInstanceRef.current || !tileLayerRef.current) return;
+
+ mapInstanceRef.current.removeLayer(tileLayerRef.current);
+ tileLayerRef.current = L.tileLayer(MAP_STYLES[mapStyle].url, {
+ attribution: MAP_STYLES[mapStyle].attribution,
+ noWrap: false,
+ crossOrigin: 'anonymous',
+ bounds: [[-85, -180], [85, 180]]
+ }).addTo(mapInstanceRef.current);
+
+ // Ensure terminator is on top
+ if (terminatorRef.current) {
+ terminatorRef.current.bringToFront();
+ }
+ }, [mapStyle]);
+
+ // Update DE/DX markers and celestial bodies
+ useEffect(() => {
+ if (!mapInstanceRef.current) return;
+ const map = mapInstanceRef.current;
+
+ // Remove old markers
+ if (deMarkerRef.current) map.removeLayer(deMarkerRef.current);
+ if (dxMarkerRef.current) map.removeLayer(dxMarkerRef.current);
+ if (sunMarkerRef.current) map.removeLayer(sunMarkerRef.current);
+ if (moonMarkerRef.current) map.removeLayer(moonMarkerRef.current);
+
+ // DE Marker
+ const deIcon = L.divIcon({
+ className: 'custom-marker de-marker',
+ html: 'DE',
+ iconSize: [32, 32],
+ iconAnchor: [16, 16]
+ });
+ deMarkerRef.current = L.marker([deLocation.lat, deLocation.lon], { icon: deIcon })
+ .bindPopup(`DE - Your Location ${calculateGridSquare(deLocation.lat, deLocation.lon)} ${deLocation.lat.toFixed(4)}Β°, ${deLocation.lon.toFixed(4)}Β°`)
+ .addTo(map);
+
+ // DX Marker
+ const dxIcon = L.divIcon({
+ className: 'custom-marker dx-marker',
+ html: 'DX',
+ iconSize: [32, 32],
+ iconAnchor: [16, 16]
+ });
+ dxMarkerRef.current = L.marker([dxLocation.lat, dxLocation.lon], { icon: dxIcon })
+ .bindPopup(`DX - Target ${calculateGridSquare(dxLocation.lat, dxLocation.lon)} ${dxLocation.lat.toFixed(4)}Β°, ${dxLocation.lon.toFixed(4)}Β°`)
+ .addTo(map);
+
+ // Sun marker
+ const sunPos = getSunPosition(new Date());
+ const sunIcon = L.divIcon({
+ className: 'custom-marker sun-marker',
+ html: 'β',
+ iconSize: [24, 24],
+ iconAnchor: [12, 12]
+ });
+ sunMarkerRef.current = L.marker([sunPos.lat, sunPos.lon], { icon: sunIcon })
+ .bindPopup(`β Subsolar Point ${sunPos.lat.toFixed(2)}Β°, ${sunPos.lon.toFixed(2)}Β°`)
+ .addTo(map);
+
+ // Moon marker
+ const moonPos = getMoonPosition(new Date());
+ const moonIcon = L.divIcon({
+ className: 'custom-marker moon-marker',
+ html: 'π',
+ iconSize: [24, 24],
+ iconAnchor: [12, 12]
+ });
+ moonMarkerRef.current = L.marker([moonPos.lat, moonPos.lon], { icon: moonIcon })
+ .bindPopup(`π Sublunar Point ${moonPos.lat.toFixed(2)}Β°, ${moonPos.lon.toFixed(2)}Β°`)
+ .addTo(map);
+ }, [deLocation, dxLocation]);
+
+ // Update DX paths
+ useEffect(() => {
+ if (!mapInstanceRef.current) return;
+ const map = mapInstanceRef.current;
+
+ // Remove old DX paths
+ dxPathsLinesRef.current.forEach(l => map.removeLayer(l));
+ dxPathsLinesRef.current = [];
+ dxPathsMarkersRef.current.forEach(m => map.removeLayer(m));
+ dxPathsMarkersRef.current = [];
+
+ // Add new DX paths if enabled
+ if (showDXPaths && dxPaths && dxPaths.length > 0) {
+ const filteredPaths = filterDXPaths(dxPaths, dxFilters);
+
+ filteredPaths.forEach((path) => {
+ try {
+ if (!path.spotterLat || !path.spotterLon || !path.dxLat || !path.dxLon) return;
+ if (isNaN(path.spotterLat) || isNaN(path.spotterLon) || isNaN(path.dxLat) || isNaN(path.dxLon)) return;
+
+ const pathPoints = getGreatCirclePoints(
+ path.spotterLat, path.spotterLon,
+ path.dxLat, path.dxLon
+ );
+
+ if (!pathPoints || !Array.isArray(pathPoints) || pathPoints.length === 0) return;
+
+ const freq = parseFloat(path.freq);
+ const color = getBandColor(freq);
+
+ const isHovered = hoveredSpot && hoveredSpot.call === path.dxCall &&
+ Math.abs(parseFloat(hoveredSpot.freq) - parseFloat(path.freq)) < 0.01;
+
+ // Handle segments
+ const isSegmented = Array.isArray(pathPoints[0]) && pathPoints[0].length > 0 && Array.isArray(pathPoints[0][0]);
+ const segments = isSegmented ? pathPoints : [pathPoints];
+
+ segments.forEach(segment => {
+ if (segment && Array.isArray(segment) && segment.length > 1) {
+ const line = L.polyline(segment, {
+ color: isHovered ? '#ffffff' : color,
+ weight: isHovered ? 4 : 1.5,
+ opacity: isHovered ? 1 : 0.5
+ }).addTo(map);
+ if (isHovered) line.bringToFront();
+ dxPathsLinesRef.current.push(line);
+ }
+ });
+
+ // Add DX marker
+ const dxCircle = L.circleMarker([path.dxLat, path.dxLon], {
+ radius: isHovered ? 10 : 6,
+ fillColor: isHovered ? '#ffffff' : color,
+ color: isHovered ? color : '#fff',
+ weight: isHovered ? 3 : 1.5,
+ opacity: 1,
+ fillOpacity: isHovered ? 1 : 0.9
+ })
+ .bindPopup(`${path.dxCall} ${path.freq} MHz by ${path.spotter}`)
+ .addTo(map);
+ if (isHovered) dxCircle.bringToFront();
+ dxPathsMarkersRef.current.push(dxCircle);
+
+ // Add label if enabled
+ if (showDXLabels || isHovered) {
+ const labelIcon = L.divIcon({
+ className: '',
+ html: `${path.dxCall} `,
+ iconSize: null,
+ iconAnchor: [0, 0]
+ });
+ const label = L.marker([path.dxLat, path.dxLon], { icon: labelIcon, interactive: false }).addTo(map);
+ dxPathsMarkersRef.current.push(label);
+ }
+ } catch (err) {
+ console.error('Error rendering DX path:', err);
+ }
+ });
+ }
+ }, [dxPaths, dxFilters, showDXPaths, showDXLabels, hoveredSpot]);
+
+ // Update POTA markers
+ useEffect(() => {
+ if (!mapInstanceRef.current) return;
+ const map = mapInstanceRef.current;
+
+ potaMarkersRef.current.forEach(m => map.removeLayer(m));
+ potaMarkersRef.current = [];
+
+ if (showPOTA && potaSpots) {
+ potaSpots.forEach(spot => {
+ if (spot.lat && spot.lon) {
+ const icon = L.divIcon({
+ className: '',
+ html: `${spot.call} `,
+ iconSize: null,
+ iconAnchor: [0, 0]
+ });
+ const marker = L.marker([spot.lat, spot.lon], { icon })
+ .bindPopup(`${spot.call} ${spot.ref} ${spot.freq} ${spot.mode}`)
+ .addTo(map);
+ potaMarkersRef.current.push(marker);
+ }
+ });
+ }
+ }, [potaSpots, showPOTA]);
+
+ // Update satellite markers with orbit tracks
+ useEffect(() => {
+ if (!mapInstanceRef.current) return;
+ const map = mapInstanceRef.current;
+
+ satMarkersRef.current.forEach(m => map.removeLayer(m));
+ satMarkersRef.current = [];
+ satTracksRef.current.forEach(t => map.removeLayer(t));
+ satTracksRef.current = [];
+
+ if (showSatellites && satellites && satellites.length > 0) {
+ satellites.forEach(sat => {
+ const satColor = sat.color || '#00ffff';
+ const satColorDark = sat.visible ? satColor : '#446666';
+
+ // Draw orbit track if available
+ if (sat.track && sat.track.length > 1) {
+ // Split track into segments to handle date line crossing
+ let segments = [];
+ let currentSegment = [sat.track[0]];
+
+ for (let i = 1; i < sat.track.length; i++) {
+ const prevLon = sat.track[i-1][1];
+ const currLon = sat.track[i][1];
+ // If longitude jumps more than 180 degrees, start new segment
+ if (Math.abs(currLon - prevLon) > 180) {
+ segments.push(currentSegment);
+ currentSegment = [];
+ }
+ currentSegment.push(sat.track[i]);
+ }
+ segments.push(currentSegment);
+
+ // Draw each segment
+ segments.forEach(segment => {
+ if (segment.length > 1) {
+ const trackLine = L.polyline(segment, {
+ color: sat.visible ? satColor : satColorDark,
+ weight: 2,
+ opacity: sat.visible ? 0.8 : 0.4,
+ dashArray: sat.visible ? null : '5, 5'
+ }).addTo(map);
+ satTracksRef.current.push(trackLine);
+ }
+ });
+ }
+
+ // Draw footprint circle if available and satellite is visible
+ if (sat.footprintRadius && sat.lat && sat.lon && sat.visible) {
+ const footprint = L.circle([sat.lat, sat.lon], {
+ radius: sat.footprintRadius * 1000, // Convert km to meters
+ color: satColor,
+ weight: 1,
+ opacity: 0.5,
+ fillColor: satColor,
+ fillOpacity: 0.1
+ }).addTo(map);
+ satTracksRef.current.push(footprint);
+ }
+
+ // Add satellite marker icon
+ const icon = L.divIcon({
+ className: '',
+ html: `π° ${sat.name} `,
+ iconSize: null,
+ iconAnchor: [0, 0]
+ });
+
+ const marker = L.marker([sat.lat, sat.lon], { icon })
+ .bindPopup(`
+ π° ${sat.name}
+
+ Mode: ${sat.mode || 'Unknown'}
+ Alt: ${sat.alt} km
+ Az: ${sat.azimuth}Β°
+ El: ${sat.elevation}Β°
+ Range: ${sat.range} km
+ Status: ${sat.visible ? 'β Visible ' : 'Below horizon '}
+
+ `)
+ .addTo(map);
+ satMarkersRef.current.push(marker);
+ });
+ }
+ }, [satellites, showSatellites]);
+
+ return (
+
+
+
+ {/* Map style dropdown */}
+
setMapStyle(e.target.value)}
+ style={{
+ position: 'absolute',
+ top: '10px',
+ right: '10px',
+ background: 'rgba(0, 0, 0, 0.8)',
+ border: '1px solid #444',
+ color: '#00ffcc',
+ padding: '6px 10px',
+ borderRadius: '4px',
+ fontSize: '11px',
+ fontFamily: 'JetBrains Mono',
+ cursor: 'pointer',
+ zIndex: 1000,
+ outline: 'none'
+ }}
+ >
+ {Object.entries(MAP_STYLES).map(([key, style]) => (
+ {style.name}
+ ))}
+
+
+ {/* Satellite toggle */}
+ {onToggleSatellites && (
+
+ π° SAT {showSatellites ? 'ON' : 'OFF'}
+
+ )}
+
+ {/* Labels toggle */}
+ {onToggleDXLabels && showDXPaths && (
+
+ π·οΈ CALLS {showDXLabels ? 'ON' : 'OFF'}
+
+ )}
+
+ {/* Legend */}
+
+ {showDXPaths && (
+
+ DX:
+ 160m
+ 80m
+ 40m
+ 30m
+ 20m
+ 17m
+ 15m
+ 12m
+ 10m
+ 6m
+
+ )}
+
+ β DE
+ β DX
+
+ {showPOTA && (
+
+ β POTA
+
+ )}
+ {showSatellites && (
+
+ π° SAT
+
+ )}
+
+ β Sun
+ π Moon
+
+
+
+ );
+};
+
+export default WorldMap;
diff --git a/src/components/index.js b/src/components/index.js
new file mode 100644
index 0000000..27279cc
--- /dev/null
+++ b/src/components/index.js
@@ -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';
diff --git a/src/hooks/index.js b/src/hooks/index.js
new file mode 100644
index 0000000..2f981dc
--- /dev/null
+++ b/src/hooks/index.js
@@ -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';
diff --git a/src/hooks/useBandConditions.js b/src/hooks/useBandConditions.js
new file mode 100644
index 0000000..d3e3be7
--- /dev/null
+++ b/src/hooks/useBandConditions.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;
diff --git a/src/hooks/useContests.js b/src/hooks/useContests.js
new file mode 100644
index 0000000..b61d9d7
--- /dev/null
+++ b/src/hooks/useContests.js
@@ -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;
diff --git a/src/hooks/useDXCluster.js b/src/hooks/useDXCluster.js
new file mode 100644
index 0000000..4c4e4c4
--- /dev/null
+++ b/src/hooks/useDXCluster.js
@@ -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;
diff --git a/src/hooks/useDXPaths.js b/src/hooks/useDXPaths.js
new file mode 100644
index 0000000..ffee685
--- /dev/null
+++ b/src/hooks/useDXPaths.js
@@ -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;
diff --git a/src/hooks/useDXpeditions.js b/src/hooks/useDXpeditions.js
new file mode 100644
index 0000000..c0e172b
--- /dev/null
+++ b/src/hooks/useDXpeditions.js
@@ -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;
diff --git a/src/hooks/useLocalWeather.js b/src/hooks/useLocalWeather.js
new file mode 100644
index 0000000..9fee906
--- /dev/null
+++ b/src/hooks/useLocalWeather.js
@@ -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;
diff --git a/src/hooks/useMySpots.js b/src/hooks/useMySpots.js
new file mode 100644
index 0000000..5134ff4
--- /dev/null
+++ b/src/hooks/useMySpots.js
@@ -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;
diff --git a/src/hooks/usePOTASpots.js b/src/hooks/usePOTASpots.js
new file mode 100644
index 0000000..7fef2e4
--- /dev/null
+++ b/src/hooks/usePOTASpots.js
@@ -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;
diff --git a/src/hooks/usePropagation.js b/src/hooks/usePropagation.js
new file mode 100644
index 0000000..fa76f5d
--- /dev/null
+++ b/src/hooks/usePropagation.js
@@ -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;
diff --git a/src/hooks/useSatellites.js b/src/hooks/useSatellites.js
new file mode 100644
index 0000000..22bdcc1
--- /dev/null
+++ b/src/hooks/useSatellites.js
@@ -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;
diff --git a/src/hooks/useSolarIndices.js b/src/hooks/useSolarIndices.js
new file mode 100644
index 0000000..ace97e0
--- /dev/null
+++ b/src/hooks/useSolarIndices.js
@@ -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;
diff --git a/src/hooks/useSpaceWeather.js b/src/hooks/useSpaceWeather.js
new file mode 100644
index 0000000..b983454
--- /dev/null
+++ b/src/hooks/useSpaceWeather.js
@@ -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;
diff --git a/src/main.jsx b/src/main.jsx
new file mode 100644
index 0000000..4302c8f
--- /dev/null
+++ b/src/main.jsx
@@ -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(
+
+
+
+);
diff --git a/src/styles/main.css b/src/styles/main.css
new file mode 100644
index 0000000..ca46343
--- /dev/null
+++ b/src/styles/main.css
@@ -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); }
diff --git a/src/utils/callsign.js b/src/utils/callsign.js
new file mode 100644
index 0000000..2207fa2
--- /dev/null
+++ b/src/utils/callsign.js
@@ -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
+};
diff --git a/src/utils/config.js b/src/utils/config.js
new file mode 100644
index 0000000..259f4c5
--- /dev/null
+++ b/src/utils/config.js
@@ -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: '© OpenTopoMap '
+ },
+ streets: {
+ name: 'Streets',
+ url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ attribution: '© OpenStreetMap '
+ },
+ 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
+};
diff --git a/src/utils/geo.js b/src/utils/geo.js
new file mode 100644
index 0000000..86e4f01
--- /dev/null
+++ b/src/utils/geo.js
@@ -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
+};
diff --git a/src/utils/index.js b/src/utils/index.js
new file mode 100644
index 0000000..d33a0d3
--- /dev/null
+++ b/src/utils/index.js
@@ -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';
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..a7bf8e7
--- /dev/null
+++ b/vite.config.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']
+ }
+ }
+ }
+ }
+});
diff --git a/vite.config.mjs b/vite.config.mjs
new file mode 100644
index 0000000..a7bf8e7
--- /dev/null
+++ b/vite.config.mjs
@@ -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']
+ }
+ }
+ }
+ }
+});