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/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. -![OpenHamClock Banner](https://img.shields.io/badge/OpenHamClock-v3.9.0-orange?style=for-the-badge) -[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg?style=for-the-badge)](LICENSE) -[![Node.js](https://img.shields.io/badge/Node.js-18+-brightgreen?style=for-the-badge&logo=node.js)](https://nodejs.org/) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge)](CONTRIBUTING.md) - -**A modern, open-source amateur radio dashboard with real-time space weather, band conditions, DX cluster, and interactive world maps.** - -*In loving memory of Elwood Downey, WB0OEW, creator of the original HamClock* - -[**Live Demo**](https://openhamclock.up.railway.app) Β· [**Download**](#-installation) Β· [**Documentation**](#-features) Β· [**Contributing**](#-contributing) - -![OpenHamClock Screenshot](screenshot.png) - -
- ---- - -## πŸ“‘ About - -OpenHamClock is a spiritual successor to the beloved HamClock application created by Elwood Downey, WB0OEW. After Elwood's passing and the announcement that HamClock will cease functioning in June 2026, the amateur radio community came together to create an open-source alternative that carries forward his vision. - -### Why OpenHamClock? - -- **Open Source**: MIT licensed, community-driven development -- **Cross-Platform**: Runs on Windows, macOS, Linux, and Raspberry Pi -- **Modern Stack**: Built with web technologies for easy customization -- **Real Maps**: Actual satellite/terrain imagery, not approximations -- **Live Data**: Real-time feeds from NOAA, POTA, SOTA, and DX clusters -- **Self-Hosted**: Run locally or deploy to your own server - ---- - -## ✨ Features - -### πŸ—ΊοΈ Interactive World Map -- **8 map styles**: Dark, Satellite, Terrain, Streets, Topo, Ocean, NatGeo, Gray -- **Real-time day/night terminator** (gray line) -- **Great circle paths** between DE and DX -- **Click anywhere** to set DX location -- **POTA activators** displayed on map with callsigns -- **DX cluster paths** - Lines connecting spotters to DX stations with band colors -- **Moon tracking** - Real-time sublunar point with phase display -- **Zoom and pan** with full interactivity - -### πŸ“‘ Propagation Prediction -- **Hybrid ITU-R P.533-14** - Combines professional model with real-time data - - ITURHFProp engine provides base P.533-14 predictions - - KC2G/GIRO ionosonde network provides real-time corrections - - Automatic fallback when services unavailable -- **Real-time ionosonde data** from KC2G/GIRO network (~100 stations) -- **Visual heat map** showing band conditions to DX -- **24-hour propagation chart** with hourly predictions -- **Solar flux, K-index, and sunspot** integration - -### πŸ“Š Live Data Integration - -| Source | Data | Update Rate | -|--------|------|-------------| -| NOAA SWPC | Solar Flux, K-Index, Sunspots | 5 min | -| KC2G/GIRO | Ionosonde foF2, MUF data | 10 min | -| POTA | Parks on the Air spots | 1 min | -| SOTA | Summits on the Air spots | 1 min | -| DX Cluster | Real-time DX spots | 30 sec | -| HamQSL | Band conditions | 5 min | - -### πŸ” DX Cluster -- **Real-time spots** from DX Spider network -- **Visual paths on map** with band-specific colors -- **Hover highlighting** - Mouse over spots to highlight on map -- **Grid square display** - Parsed from spot comments -- **Filtering** by band, mode, continent, and search -- **Spotter locations** shown on map - -### πŸ• Station Information -- **UTC and Local time** with date -- **Maidenhead grid square** (6 character) -- **Sunrise/Sunset times** for DE and DX -- **Short path/Long path bearings** -- **Great circle distance** calculation -- **Space weather conditions** assessment - -### πŸ“» Band Conditions -- Visual display for 160m through 70cm -- Color-coded: Good (green), Fair (amber), Poor (red) -- Based on real propagation data - ---- - -## πŸš€ Installation - -### Quick Start (Any Platform) +## πŸš€ Quick Start ```bash -# Clone the repository -git clone https://github.com/accius/openhamclock.git -cd openhamclock - # Install dependencies npm install -# Start the server -npm start +# Start development servers (need two terminals) +# Terminal 1: Backend API server +node server.js -# Open http://localhost:3000 in your browser -``` - -### One-Line Install - -**Linux/macOS:** -```bash -curl -fsSL https://raw.githubusercontent.com/accius/openhamclock/main/scripts/setup-linux.sh | bash -``` +# Terminal 2: Frontend dev server with hot reload +npm run dev -**Windows (PowerShell as Admin):** -```powershell -Set-ExecutionPolicy Bypass -Scope Process -Force; iex ((New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/accius/openhamclock/main/scripts/setup-windows.ps1')) +# Open http://localhost:3000 ``` -### πŸ“ Raspberry Pi - +For production: ```bash -# Download and run the Pi setup script -curl -fsSL https://raw.githubusercontent.com/accius/openhamclock/main/scripts/setup-pi.sh -o setup-pi.sh -chmod +x setup-pi.sh - -# Standard installation -./setup-pi.sh - -# Or with kiosk mode (fullscreen, auto-start on boot) -./setup-pi.sh --kiosk -``` - -**Supported Pi Models:** -- Raspberry Pi 3B / 3B+ βœ“ -- Raspberry Pi 4 (2GB+) βœ“βœ“ (Recommended) -- Raspberry Pi 5 βœ“βœ“βœ“ (Best performance) - -### πŸ–₯️ Desktop App (Electron) - +npm run build +npm start # Serves from dist/ on port 3001 +``` + +## πŸ“ Project Structure + +``` +openhamclock-modular/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ main.jsx # React entry point +β”‚ β”œβ”€β”€ App.jsx # Main application component +β”‚ β”œβ”€β”€ components/ # All UI components (fully extracted) +β”‚ β”‚ β”œβ”€β”€ index.js # Component exports +β”‚ β”‚ β”œβ”€β”€ Header.jsx # Top bar with clocks/controls +β”‚ β”‚ β”œβ”€β”€ WorldMap.jsx # Leaflet map with DX paths +β”‚ β”‚ β”œβ”€β”€ SpaceWeatherPanel.jsx +β”‚ β”‚ β”œβ”€β”€ BandConditionsPanel.jsx +β”‚ β”‚ β”œβ”€β”€ DXClusterPanel.jsx +β”‚ β”‚ β”œβ”€β”€ POTAPanel.jsx +β”‚ β”‚ β”œβ”€β”€ ContestPanel.jsx +β”‚ β”‚ β”œβ”€β”€ LocationPanel.jsx +β”‚ β”‚ β”œβ”€β”€ SettingsPanel.jsx +β”‚ β”‚ └── DXFilterManager.jsx +β”‚ β”œβ”€β”€ hooks/ # All data fetching hooks (fully extracted) +β”‚ β”‚ β”œβ”€β”€ index.js # Hook exports +β”‚ β”‚ β”œβ”€β”€ useSpaceWeather.js +β”‚ β”‚ β”œβ”€β”€ useBandConditions.js +β”‚ β”‚ β”œβ”€β”€ useDXCluster.js +β”‚ β”‚ β”œβ”€β”€ useDXPaths.js +β”‚ β”‚ β”œβ”€β”€ usePOTASpots.js +β”‚ β”‚ β”œβ”€β”€ useContests.js +β”‚ β”‚ β”œβ”€β”€ useLocalWeather.js +β”‚ β”‚ β”œβ”€β”€ usePropagation.js +β”‚ β”‚ β”œβ”€β”€ useMySpots.js +β”‚ β”‚ β”œβ”€β”€ useDXpeditions.js +β”‚ β”‚ β”œβ”€β”€ useSatellites.js +β”‚ β”‚ └── useSolarIndices.js +β”‚ β”œβ”€β”€ utils/ # Utility functions (fully extracted) +β”‚ β”‚ β”œβ”€β”€ index.js # Utility exports +β”‚ β”‚ β”œβ”€β”€ config.js # App config & localStorage +β”‚ β”‚ β”œβ”€β”€ geo.js # Grid squares, bearings, distances +β”‚ β”‚ └── callsign.js # Band detection, filtering +β”‚ └── styles/ +β”‚ └── main.css # All CSS with theme variables +β”œβ”€β”€ public/ +β”‚ └── index-monolithic.html # Original 5714-line reference +β”œβ”€β”€ server.js # Backend API server +β”œβ”€β”€ config.js # Server configuration +β”œβ”€β”€ package.json +β”œβ”€β”€ vite.config.js +└── index.html # Vite entry HTML +``` + +## 🎨 Themes + +Three themes available via Settings: +- **Dark** (default) - Modern dark theme with amber accents +- **Light** - Light theme for daytime use +- **Legacy** - Classic HamClock green-on-black terminal style + +Themes use CSS custom properties defined in `src/styles/main.css`. + +## πŸ”Œ Components + +All components are fully extracted and ready to modify: + +| Component | Description | File | +|-----------|-------------|------| +| Header | Top bar with clocks, weather, controls | `Header.jsx` | +| WorldMap | Leaflet map with markers & paths | `WorldMap.jsx` | +| SpaceWeatherPanel | SFI, K-index, SSN display | `SpaceWeatherPanel.jsx` | +| BandConditionsPanel | HF band condition indicators | `BandConditionsPanel.jsx` | +| DXClusterPanel | Live DX spots list | `DXClusterPanel.jsx` | +| POTAPanel | Parks on the Air activations | `POTAPanel.jsx` | +| ContestPanel | Upcoming contests | `ContestPanel.jsx` | +| LocationPanel | DE/DX info with grid squares | `LocationPanel.jsx` | +| SettingsPanel | Configuration modal | `SettingsPanel.jsx` | +| DXFilterManager | DX cluster filtering modal | `DXFilterManager.jsx` | + +## πŸͺ Hooks + +All data fetching is handled by custom hooks: + +| Hook | Purpose | Interval | +|------|---------|----------| +| `useSpaceWeather` | SFI, K-index, SSN from NOAA | 5 min | +| `useBandConditions` | Calculate band conditions | On SFI change | +| `useDXCluster` | DX spots with filtering | 5 sec | +| `useDXPaths` | DX paths for map | 10 sec | +| `usePOTASpots` | POTA activations | 1 min | +| `useContests` | Contest calendar | 30 min | +| `useLocalWeather` | Weather from Open-Meteo | 15 min | +| `usePropagation` | ITURHFProp predictions | 10 min | +| `useMySpots` | Your callsign spots | 30 sec | +| `useSatellites` | Satellite tracking | 5 sec | +| `useSolarIndices` | Extended solar data | 15 min | + +## πŸ› οΈ Utilities + +| Module | Functions | +|--------|-----------| +| `config.js` | `loadConfig`, `saveConfig`, `applyTheme`, `MAP_STYLES` | +| `geo.js` | `calculateGridSquare`, `calculateBearing`, `calculateDistance`, `getSunPosition`, `getMoonPosition`, `getGreatCirclePoints` | +| `callsign.js` | `getBandFromFreq`, `getBandColor`, `detectMode`, `getCallsignInfo`, `filterDXPaths` | + +## 🌐 API Endpoints + +The backend server provides: + +| Endpoint | Description | +|----------|-------------| +| `/api/dxcluster/spots` | DX cluster spots | +| `/api/dxcluster/paths` | DX paths with coordinates | +| `/api/solar-indices` | Extended solar data | +| `/api/propagation` | HF propagation predictions | +| `/api/contests` | Contest calendar | +| `/api/myspots/:callsign` | Spots for your callsign | +| `/api/satellites/tle` | Satellite TLE data | +| `/api/dxpeditions` | Active DXpeditions | + +## πŸš€ Deployment + +### Railway ```bash -# Development -npm run electron - -# Build for your platform -npm run electron:build - -# Build for specific platform -npm run electron:build:win # Windows -npm run electron:build:mac # macOS -npm run electron:build:linux # Linux +# railway.toml and railway.json are included +railway up ``` -### 🐳 Docker - +### Docker ```bash -# Build the image -docker build -t openhamclock . - -# Run the container -docker run -p 3000:3000 openhamclock - -# Or use Docker Compose -docker compose up -d +docker-compose up -d ``` -### ☁️ Deploy to Railway - -[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/openhamclock) - -#### Full Deployment (3 Services) - -For the complete hybrid propagation system, deploy all three services: - -**1. Deploy ITURHFProp Service First** (enables hybrid propagation) -``` -β”œβ”€β”€ Go to railway.app β†’ New Project β†’ Deploy from GitHub repo -β”œβ”€β”€ Select your forked repository -β”œβ”€β”€ Click "Add Service" β†’ "GitHub Repo" (same repo) -β”œβ”€β”€ In service settings, set "Root Directory" to: iturhfprop-service -β”œβ”€β”€ If Root Directory option not visible: -β”‚ - Go to Service β†’ Settings β†’ Build -β”‚ - Add "Root Directory" and enter: iturhfprop-service -β”œβ”€β”€ Deploy and wait for build to complete (~2-3 min) -└── Copy the public URL (Settings β†’ Networking β†’ Generate Domain) -``` - -**2. Deploy DX Spider Proxy** (optional - for live DX cluster paths) -``` -β”œβ”€β”€ In same project, click "Add Service" β†’ "GitHub Repo" -β”œβ”€β”€ Set "Root Directory" to: dxspider-proxy -└── Deploy -``` - -**3. Deploy Main OpenHamClock** -``` -β”œβ”€β”€ In same project, click "Add Service" β†’ "GitHub Repo" -β”œβ”€β”€ Leave Root Directory empty (uses repo root) -β”œβ”€β”€ Go to Variables tab, add: -β”‚ ITURHFPROP_URL = https://[your-iturhfprop-service].up.railway.app -└── Deploy -``` - -#### Alternative: Separate Projects - -If Root Directory doesn't work, create separate Railway projects: -1. Fork the repo 3 times (or use branches) -2. Move each service to its own repo root -3. Deploy each as separate Railway project -4. Link via environment variables - ---- - -## βš™οΈ Configuration - -Edit your callsign and location in `public/index.html`: - -```javascript -const CONFIG = { - callsign: 'YOUR_CALL', - location: { lat: YOUR_LAT, lon: YOUR_LON }, - defaultDX: { lat: 35.6762, lon: 139.6503 }, - // ... -}; -``` - -### Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `PORT` | `3000` | Server port | -| `NODE_ENV` | `development` | Environment mode | -| `ITURHFPROP_URL` | `null` | ITURHFProp service URL (enables hybrid mode) | - ---- - -## πŸ—ΊοΈ Map Styles - -| Style | Provider | Best For | -|-------|----------|----------| -| **Dark** | CartoDB | Night use, low-light shacks | -| **Satellite** | ESRI | Terrain visualization | -| **Terrain** | OpenTopoMap | SOTA operations | -| **Streets** | OpenStreetMap | Urban navigation | -| **Topo** | ESRI | Detailed terrain | -| **Ocean** | ESRI | Maritime operations | -| **NatGeo** | ESRI | Classic cartography | -| **Gray** | ESRI | Minimal, distraction-free | - ---- - -## πŸ› οΈ Development - +### Manual ```bash -# Clone and setup -git clone https://github.com/accius/openhamclock.git -cd openhamclock -npm install - -# Start development server -npm run dev - -# Run Electron in dev mode -npm run electron +npm run build +NODE_ENV=production node server.js ``` -### Project Structure - -``` -openhamclock/ -β”œβ”€β”€ public/ # Static web files -β”‚ β”œβ”€β”€ index.html # Main application -β”‚ └── icons/ # App icons -β”œβ”€β”€ electron/ # Electron main process -β”‚ └── main.js # Desktop app entry -β”œβ”€β”€ dxspider-proxy/ # DX Cluster proxy service -β”‚ β”œβ”€β”€ server.js # Telnet-to-WebSocket proxy -β”‚ β”œβ”€β”€ package.json # Proxy dependencies -β”‚ └── README.md # Proxy documentation -β”œβ”€β”€ iturhfprop-service/ # HF Propagation prediction service -β”‚ β”œβ”€β”€ server.js # ITU-R P.533 API wrapper -β”‚ β”œβ”€β”€ Dockerfile # Builds ITURHFProp engine -β”‚ └── README.md # Service documentation -β”œβ”€β”€ scripts/ # Setup scripts -β”‚ β”œβ”€β”€ setup-pi.sh # Raspberry Pi setup -β”‚ β”œβ”€β”€ setup-linux.sh -β”‚ └── setup-windows.ps1 -β”œβ”€β”€ server.js # Express server & API proxy -β”œβ”€β”€ Dockerfile # Container build -β”œβ”€β”€ railway.toml # Railway config -└── package.json -``` - ---- - ## 🀝 Contributing -We welcome contributions from the amateur radio community! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. - -### Priority Areas - -1. **Satellite Tracking** - TLE parsing and pass predictions -2. **Rotator Control** - Hamlib integration -3. **Additional APIs** - QRZ, LoTW, ClubLog -4. **Accessibility** - Screen reader support, high contrast modes -5. **Translations** - Internationalization -6. **WebSocket DX Cluster** - Direct connection to DX Spider nodes - -### How to Contribute - 1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request - ---- - -## πŸ“œ License - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +2. Pick a component/hook to improve +3. Make changes in the appropriate file +4. Test with all three themes +5. Submit a PR ---- +### Code Style -## πŸ™ Acknowledgments +- Functional components with hooks +- CSS-in-JS for component-specific styles +- CSS variables for theme colors +- JSDoc comments for functions +- Descriptive variable names -- **Elwood Downey, WB0OEW** - Creator of the original HamClock. Your work inspired thousands of amateur radio operators worldwide. Rest in peace, OM. πŸ•ŠοΈ -- **Leaflet.js** - Outstanding open-source mapping library -- **OpenStreetMap** - Community-driven map data -- **ESRI** - Satellite and specialty map tiles -- **NOAA Space Weather Prediction Center** - Space weather data -- **Parks on the Air (POTA)** - Activator spot API -- **Summits on the Air (SOTA)** - Summit spot API -- **The Amateur Radio Community** - For keeping the spirit of experimentation alive +### Testing Changes ---- - -## πŸ“ž Contact - -- **Email**: chris@cjhlighting.com -- **GitHub Issues**: [Report bugs or request features](https://github.com/accius/openhamclock/issues) -- **Discussions**: [Join the conversation](https://github.com/accius/openhamclock/discussions) +```bash +# Run dev server +npm run dev ---- +# Check all themes work +# Test on different screen sizes +# Verify data fetching works +``` -
+## πŸ“„ 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..1f58ad0 100644 --- a/package.json +++ b/package.json @@ -1,96 +1,39 @@ { "name": "openhamclock", - "version": "3.9.0", - "description": "Open-source amateur radio dashboard with real-time space weather, band conditions, DX cluster, and interactive world map", + "version": "3.7.0", + "description": "Amateur Radio Dashboard - A modern web-based HamClock alternative", "main": "server.js", + "type": "module", "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", "start": "node server.js", - "dev": "node server.js", - "electron": "electron electron/main.js", - "electron:build": "electron-builder", - "electron:build:win": "electron-builder --win", - "electron:build:mac": "electron-builder --mac", - "electron:build:linux": "electron-builder --linux", - "electron:build:pi": "electron-builder --linux --armv7l", - "docker:build": "docker build -t openhamclock .", - "docker:run": "docker run -p 3000:3000 openhamclock", - "setup:pi": "bash scripts/setup-pi.sh", - "setup:linux": "bash scripts/setup-linux.sh", - "test": "echo \"No tests yet\" && exit 0" + "server": "node server.js" + }, + "dependencies": { + "axios": "^1.6.2", + "cors": "^2.8.5", + "express": "^4.18.2", + "node-fetch": "^3.3.2", + "satellite.js": "^5.0.0", + "ws": "^8.14.2" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "vite": "^5.0.10" }, "keywords": [ - "ham-radio", "amateur-radio", + "ham-radio", "hamclock", "dx-cluster", - "space-weather", - "pota", - "sota", "propagation", - "raspberry-pi", - "electron" + "pota", + "satellite-tracking" ], - "author": "OpenHamClock Contributors", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/accius/openhamclock.git" - }, - "bugs": { - "url": "https://github.com/accius/openhamclock/issues" - }, - "homepage": "https://github.com/accius/openhamclock#readme", - "dependencies": { - "express": "^4.18.2", - "cors": "^2.8.5", - "node-fetch": "^2.7.0" - }, - "devDependencies": { - "electron": "^28.0.0", - "electron-builder": "^24.9.1" - }, - "engines": { - "node": ">=18.0.0" - }, - "build": { - "appId": "com.openhamclock.app", - "productName": "OpenHamClock", - "directories": { - "output": "dist" - }, - "files": [ - "public/**/*", - "electron/**/*", - "server.js", - "package.json" - ], - "mac": { - "category": "public.app-category.utilities", - "icon": "public/icons/icon.icns", - "target": [ - "dmg", - "zip" - ] - }, - "win": { - "icon": "public/icons/icon.ico", - "target": [ - "nsis", - "portable" - ] - }, - "linux": { - "icon": "public/icons", - "target": [ - "AppImage", - "deb", - "rpm" - ], - "category": "Utility" - }, - "nsis": { - "oneClick": false, - "allowToChangeInstallationDirectory": true - } - } + "author": "K0CJH", + "license": "MIT" } 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.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..ea86005 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,364 @@ +/** + * OpenHamClock - Main Application Component + * Amateur Radio Dashboard v3.7.0 + */ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; + +// Components +import { + Header, + WorldMap, + SpaceWeatherPanel, + BandConditionsPanel, + DXClusterPanel, + POTAPanel, + ContestPanel, + LocationPanel, + SettingsPanel, + DXFilterManager +} from './components'; + +// Hooks +import { + useSpaceWeather, + useBandConditions, + useDXCluster, + useDXPaths, + usePOTASpots, + useContests, + useLocalWeather, + usePropagation, + useMySpots, + useDXpeditions, + useSatellites, + useSolarIndices +} from './hooks'; + +// Utils +import { + loadConfig, + saveConfig, + applyTheme, + calculateGridSquare, + calculateSunTimes +} from './utils'; + +const App = () => { + // Configuration state + const [config, setConfig] = useState(loadConfig); + const [currentTime, setCurrentTime] = useState(new Date()); + const [startTime] = useState(Date.now()); + const [uptime, setUptime] = useState('0d 0h 0m'); + + // DX Location with localStorage persistence + const [dxLocation, setDxLocation] = useState(() => { + try { + const stored = localStorage.getItem('openhamclock_dxLocation'); + if (stored) { + const parsed = JSON.parse(stored); + if (parsed.lat && parsed.lon) return parsed; + } + } catch (e) {} + return config.defaultDX; + }); + + // Save DX location when changed + useEffect(() => { + try { + localStorage.setItem('openhamclock_dxLocation', JSON.stringify(dxLocation)); + } catch (e) { console.error('Failed to save DX location:', e); } + }, [dxLocation]); + + // UI state + const [showSettings, setShowSettings] = useState(false); + const [showDXFilters, setShowDXFilters] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + + // Map layer visibility state with localStorage persistence + const [mapLayers, setMapLayers] = useState(() => { + try { + const stored = localStorage.getItem('openhamclock_mapLayers'); + const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: true }; + return stored ? { ...defaults, ...JSON.parse(stored) } : defaults; + } catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: true }; } + }); + + // Save map layer preferences when changed + useEffect(() => { + try { + localStorage.setItem('openhamclock_mapLayers', JSON.stringify(mapLayers)); + } catch (e) { console.error('Failed to save map layers:', e); } + }, [mapLayers]); + + // Hovered spot state for highlighting paths on map + const [hoveredSpot, setHoveredSpot] = useState(null); + + // Toggle handlers for map layers + const toggleDXPaths = useCallback(() => setMapLayers(prev => ({ ...prev, showDXPaths: !prev.showDXPaths })), []); + const toggleDXLabels = useCallback(() => setMapLayers(prev => ({ ...prev, showDXLabels: !prev.showDXLabels })), []); + const togglePOTA = useCallback(() => setMapLayers(prev => ({ ...prev, showPOTA: !prev.showPOTA })), []); + const toggleSatellites = useCallback(() => setMapLayers(prev => ({ ...prev, showSatellites: !prev.showSatellites })), []); + + // 12/24 hour format preference with localStorage persistence + const [use12Hour, setUse12Hour] = useState(() => { + try { + const saved = localStorage.getItem('openhamclock_use12Hour'); + return saved === 'true'; + } catch (e) { return false; } + }); + + // Save 12/24 hour preference when changed + useEffect(() => { + try { + localStorage.setItem('openhamclock_use12Hour', use12Hour.toString()); + } catch (e) { console.error('Failed to save time format:', e); } + }, [use12Hour]); + + // Toggle time format handler + const handleTimeFormatToggle = useCallback(() => { + setUse12Hour(prev => !prev); + }, []); + + // Fullscreen toggle handler + const handleFullscreenToggle = useCallback(() => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen().then(() => { + setIsFullscreen(true); + }).catch(err => { + console.error('Fullscreen error:', err); + }); + } else { + document.exitFullscreen().then(() => { + setIsFullscreen(false); + }).catch(err => { + console.error('Exit fullscreen error:', err); + }); + } + }, []); + + // Listen for fullscreen changes + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement); + }; + document.addEventListener('fullscreenchange', handleFullscreenChange); + return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); + }, []); + + // Apply theme on initial load + useEffect(() => { + applyTheme(config.theme || 'dark'); + }, []); + + // Check if this is first run + useEffect(() => { + const saved = localStorage.getItem('openhamclock_config'); + if (!saved) { + setShowSettings(true); + } + }, []); + + const handleSaveConfig = (newConfig) => { + setConfig(newConfig); + saveConfig(newConfig); + applyTheme(newConfig.theme || 'dark'); + }; + + // Data hooks + const spaceWeather = useSpaceWeather(); + const bandConditions = useBandConditions(spaceWeather.data); + const solarIndices = useSolarIndices(); + const potaSpots = usePOTASpots(); + + // DX Cluster filters with localStorage persistence + const [dxFilters, setDxFilters] = useState(() => { + try { + const stored = localStorage.getItem('openhamclock_dxFilters'); + return stored ? JSON.parse(stored) : {}; + } catch (e) { return {}; } + }); + + // Save DX filters when changed + useEffect(() => { + try { + localStorage.setItem('openhamclock_dxFilters', JSON.stringify(dxFilters)); + } catch (e) {} + }, [dxFilters]); + + const dxCluster = useDXCluster(config.dxClusterSource || 'auto', dxFilters); + const dxPaths = useDXPaths(); + const dxpeditions = useDXpeditions(); + const contests = useContests(); + const propagation = usePropagation(config.location, dxLocation); + const mySpots = useMySpots(config.callsign); + const satellites = useSatellites(config.location); + const localWeather = useLocalWeather(config.location); + + // Computed values + const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]); + const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]); + const deSunTimes = useMemo(() => calculateSunTimes(config.location.lat, config.location.lon, currentTime), [config.location, currentTime]); + const dxSunTimes = useMemo(() => calculateSunTimes(dxLocation.lat, dxLocation.lon, currentTime), [dxLocation, currentTime]); + + // Time and uptime update + useEffect(() => { + const timer = setInterval(() => { + setCurrentTime(new Date()); + const elapsed = Date.now() - startTime; + const d = Math.floor(elapsed / 86400000); + const h = Math.floor((elapsed % 86400000) / 3600000); + const m = Math.floor((elapsed % 3600000) / 60000); + setUptime(`${d}d ${h}h ${m}m`); + }, 1000); + return () => clearInterval(timer); + }, [startTime]); + + const handleDXChange = useCallback((coords) => { + setDxLocation({ lat: coords.lat, lon: coords.lon }); + }, []); + + // Format times + const utcTime = currentTime.toISOString().substr(11, 8); + const localTime = currentTime.toLocaleTimeString('en-US', { hour12: use12Hour }); + const utcDate = currentTime.toISOString().substr(0, 10); + const localDate = currentTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); + + // Scale factor for modern layout + const [scale, setScale] = useState(1); + + useEffect(() => { + const calculateScale = () => { + const minWidth = 1200; + const minHeight = 800; + const scaleX = window.innerWidth / minWidth; + const scaleY = window.innerHeight / minHeight; + setScale(Math.min(scaleX, scaleY, 1)); + }; + calculateScale(); + window.addEventListener('resize', calculateScale); + return () => window.removeEventListener('resize', calculateScale); + }, []); + + // Modern Layout + return ( +
+
+ {/* TOP BAR */} +
setShowSettings(true)} + onFullscreenToggle={handleFullscreenToggle} + isFullscreen={isFullscreen} + /> + + {/* LEFT COLUMN */} +
+ + + +
+ + {/* CENTER - MAP */} +
+ +
+ + {/* RIGHT COLUMN */} +
+ setShowDXFilters(true)} + onHoverSpot={setHoveredSpot} + hoveredSpot={hoveredSpot} + /> + + +
+
+ + {/* 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..cfd4f91 --- /dev/null +++ b/src/components/ContestPanel.jsx @@ -0,0 +1,70 @@ +/** + * ContestPanel Component + * Displays upcoming amateur radio contests + */ +import React from 'react'; + +export const ContestPanel = ({ data, loading }) => { + return ( +
+
πŸ† CONTESTS
+ {loading ? ( +
+
+
+ ) : data.length === 0 ? ( +
+ No upcoming contests +
+ ) : ( +
+ {data.slice(0, 5).map((contest, i) => ( +
+
+ {contest.name} +
+
+ {contest.startDate} + + {contest.isActive ? '● ACTIVE' : contest.timeUntil || ''} + +
+
+ ))} +
+ )} +
+ ); +}; + +export default ContestPanel; diff --git a/src/components/DXClusterPanel.jsx b/src/components/DXClusterPanel.jsx new file mode 100644 index 0000000..a2c88bd --- /dev/null +++ b/src/components/DXClusterPanel.jsx @@ -0,0 +1,144 @@ +/** + * DXClusterPanel Component + * Displays DX cluster spots with filtering controls + */ +import React from 'react'; +import { getBandColor } from '../utils/callsign.js'; + +export const DXClusterPanel = ({ + data, + loading, + totalSpots, + filters, + onOpenFilters, + onHoverSpot, + hoveredSpot +}) => { + const getActiveFilterCount = () => { + let count = 0; + if (filters?.cqZones?.length) count++; + if (filters?.ituZones?.length) count++; + if (filters?.continents?.length) count++; + if (filters?.bands?.length) count++; + if (filters?.modes?.length) count++; + if (filters?.watchlist?.length) count++; + if (filters?.excludeList?.length) count++; + if (filters?.callsign) count++; + if (filters?.watchlistOnly) count++; + return count; + }; + + const filterCount = getActiveFilterCount(); + + return ( +
+ {/* Header with filter button */} +
+
+ πŸ“» DX CLUSTER + + {data.length}/{totalSpots || 0} + +
+ +
+ + {/* Spots list */} + {loading ? ( +
+
+
+ ) : data.length === 0 ? ( +
+ {filterCount > 0 ? 'No spots match filters' : 'No spots available'} +
+ ) : ( +
+ {data.slice(0, 15).map((spot, i) => { + const freq = parseFloat(spot.freq); + const color = getBandColor(freq / 1000); // Convert kHz to MHz for color + const isHovered = hoveredSpot?.call === spot.call && + Math.abs(parseFloat(hoveredSpot?.freq) - freq) < 1; + + return ( +
onHoverSpot?.(spot)} + onMouseLeave={() => onHoverSpot?.(null)} + style={{ + display: 'grid', + gridTemplateColumns: '70px 1fr auto', + gap: '8px', + padding: '6px 8px', + borderRadius: '4px', + marginBottom: '2px', + background: isHovered ? 'rgba(68, 136, 255, 0.2)' : (i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent'), + cursor: 'pointer', + transition: 'background 0.15s' + }} + > +
+ {(freq / 1000).toFixed(3)} +
+
+ {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..9dac74c --- /dev/null +++ b/src/components/DXFilterManager.jsx @@ -0,0 +1,364 @@ +/** + * DXFilterManager Component + * Modal for DX cluster filtering (zones, bands, modes, watchlist) + */ +import React, { useState } from 'react'; +import { HF_BANDS, CONTINENTS, MODES } from '../utils/callsign.js'; + +export const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose }) => { + const [activeTab, setActiveTab] = useState('zones'); + const [newWatchlistCall, setNewWatchlistCall] = useState(''); + const [newExcludeCall, setNewExcludeCall] = useState(''); + + // CQ Zones (1-40) + const cqZones = Array.from({ length: 40 }, (_, i) => i + 1); + + // ITU Zones (1-90) + const ituZones = Array.from({ length: 90 }, (_, i) => i + 1); + + // Toggle functions + const toggleArrayItem = (key, item) => { + const current = filters?.[key] || []; + const newArray = current.includes(item) + ? current.filter(i => i !== item) + : [...current, item]; + onFilterChange({ ...filters, [key]: newArray.length > 0 ? newArray : undefined }); + }; + + const selectAllZones = (type, zones) => { + onFilterChange({ ...filters, [type]: [...zones] }); + }; + + const clearZones = (type) => { + onFilterChange({ ...filters, [type]: undefined }); + }; + + const addToWatchlist = () => { + if (!newWatchlistCall.trim()) return; + const current = filters?.watchlist || []; + const call = newWatchlistCall.toUpperCase().trim(); + if (!current.includes(call)) { + onFilterChange({ ...filters, watchlist: [...current, call] }); + } + setNewWatchlistCall(''); + }; + + const removeFromWatchlist = (call) => { + const current = filters?.watchlist || []; + onFilterChange({ ...filters, watchlist: current.filter(c => c !== call) }); + }; + + const addToExclude = () => { + if (!newExcludeCall.trim()) return; + const current = filters?.excludeList || []; + const call = newExcludeCall.toUpperCase().trim(); + if (!current.includes(call)) { + onFilterChange({ ...filters, excludeList: [...current, call] }); + } + setNewExcludeCall(''); + }; + + const removeFromExclude = (call) => { + const current = filters?.excludeList || []; + onFilterChange({ ...filters, excludeList: current.filter(c => c !== call) }); + }; + + const clearAllFilters = () => { + onFilterChange({}); + }; + + const getActiveFilterCount = () => { + let count = 0; + if (filters?.cqZones?.length) count++; + if (filters?.ituZones?.length) count++; + if (filters?.continents?.length) count++; + if (filters?.bands?.length) count++; + if (filters?.modes?.length) count++; + if (filters?.watchlist?.length) count++; + if (filters?.excludeList?.length) count++; + if (filters?.callsign) count++; + if (filters?.watchlistOnly) count++; + return count; + }; + + if (!isOpen) return null; + + const tabStyle = (active) => ({ + padding: '8px 16px', + background: active ? 'var(--accent-cyan)' : 'transparent', + color: active ? '#000' : 'var(--text-secondary)', + border: 'none', + borderRadius: '4px 4px 0 0', + cursor: 'pointer', + fontFamily: 'JetBrains Mono', + fontSize: '11px', + fontWeight: active ? '700' : '400' + }); + + const pillStyle = (active) => ({ + padding: '4px 10px', + background: active ? 'rgba(0, 255, 136, 0.3)' : 'rgba(60,60,60,0.5)', + border: `1px solid ${active ? '#00ff88' : '#444'}`, + color: active ? '#00ff88' : '#888', + borderRadius: '4px', + fontSize: '10px', + cursor: 'pointer', + fontFamily: 'JetBrains Mono', + transition: 'all 0.15s' + }); + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+
+

πŸ” DX Cluster Filters

+
+ {getActiveFilterCount()} filter{getActiveFilterCount() !== 1 ? 's' : ''} active +
+
+
+ + +
+
+ + {/* Tabs */} +
+ {['zones', 'bands', 'modes', 'watchlist'].map(tab => ( + + ))} +
+ + {/* Tab Content */} +
+ {activeTab === 'zones' && ( +
+ {/* CQ Zones */} +
+
+ CQ Zones +
+ + +
+
+
+ {cqZones.map(zone => ( + + ))} +
+
+ + {/* Continents */} +
+
Continents
+
+ {CONTINENTS.map(({ code, name }) => ( + + ))} +
+
+
+ )} + + {activeTab === 'bands' && ( +
+
Select bands to show
+
+ {HF_BANDS.map(band => ( + + ))} +
+
+ )} + + {activeTab === 'modes' && ( +
+
Select modes to show
+
+ {MODES.map(mode => ( + + ))} +
+
+ )} + + {activeTab === 'watchlist' && ( +
+ {/* Watchlist Only Toggle */} +
+ +
+ + {/* Add to Watchlist */} +
+
Watchlist (highlight these calls)
+
+ setNewWatchlistCall(e.target.value)} + onKeyPress={e => e.key === 'Enter' && addToWatchlist()} + placeholder="Add callsign..." + style={{ + flex: 1, + padding: '8px 12px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '4px', + color: 'var(--text-primary)', + fontFamily: 'JetBrains Mono' + }} + /> + +
+
+ {(filters?.watchlist || []).map(call => ( + + {call} + + + ))} +
+
+ + {/* Exclude List */} +
+
Exclude List (hide these calls)
+
+ setNewExcludeCall(e.target.value)} + onKeyPress={e => e.key === 'Enter' && addToExclude()} + placeholder="Add callsign..." + style={{ + flex: 1, + padding: '8px 12px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '4px', + color: 'var(--text-primary)', + fontFamily: 'JetBrains Mono' + }} + /> + +
+
+ {(filters?.excludeList || []).map(call => ( + + {call} + + + ))} +
+
+
+ )} +
+
+
+ ); +}; + +export default DXFilterManager; diff --git a/src/components/Header.jsx b/src/components/Header.jsx new file mode 100644 index 0000000..219421e --- /dev/null +++ b/src/components/Header.jsx @@ -0,0 +1,147 @@ +/** + * Header Component + * Top bar with callsign, clocks, weather, and controls + */ +import React from 'react'; + +export const Header = ({ + config, + utcTime, + utcDate, + localTime, + localDate, + localWeather, + spaceWeather, + use12Hour, + onTimeFormatToggle, + onSettingsClick, + onFullscreenToggle, + isFullscreen +}) => { + return ( +
+ {/* 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: '600' }}> + {spaceWeather?.data?.kIndex ?? '--'} + +
+
+ SSN + {spaceWeather?.data?.sunspotNumber || '--'} +
+
+ + {/* Settings & Fullscreen Buttons */} +
+ + β˜• Donate + + + +
+
+ ); +}; + +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..0327edf --- /dev/null +++ b/src/components/POTAPanel.jsx @@ -0,0 +1,75 @@ +/** + * POTAPanel Component + * Displays Parks on the Air activations + */ +import React from 'react'; + +export const POTAPanel = ({ data, loading }) => { + return ( +
+
🌲 POTA ACTIVATIONS
+ {loading ? ( +
+
+
+ ) : data.length === 0 ? ( +
+ No active POTA spots +
+ ) : ( +
+ {data.slice(0, 5).map((spot, i) => ( +
+
+ + {spot.call} + + + {spot.freq} {spot.mode} + +
+
+ + {spot.ref} - {spot.name} + + {spot.time} +
+
+ ))} +
+ )} +
+ ); +}; + +export default POTAPanel; diff --git a/src/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx new file mode 100644 index 0000000..4069ebc --- /dev/null +++ b/src/components/SettingsPanel.jsx @@ -0,0 +1,263 @@ +/** + * SettingsPanel Component + * Modal for app configuration (callsign, location, theme, layout) + */ +import React, { useState, useEffect } from 'react'; + +export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { + const [formData, setFormData] = useState({ + callsign: '', + lat: '', + lon: '', + theme: 'dark', + layout: 'modern' + }); + + useEffect(() => { + if (config) { + setFormData({ + callsign: config.callsign || 'N0CALL', + lat: config.location?.lat?.toString() || '40.0150', + lon: config.location?.lon?.toString() || '-105.2705', + theme: config.theme || 'dark', + layout: config.layout || 'modern' + }); + } + }, [config, isOpen]); + + const handleSubmit = (e) => { + e.preventDefault(); + const newConfig = { + ...config, + callsign: formData.callsign.toUpperCase().trim() || 'N0CALL', + location: { + lat: parseFloat(formData.lat) || 40.0150, + lon: parseFloat(formData.lon) || -105.2705 + }, + theme: formData.theme, + layout: formData.layout + }; + onSave(newConfig); + onClose(); + }; + + const handleGeolocate = () => { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position) => { + setFormData(prev => ({ + ...prev, + lat: position.coords.latitude.toFixed(4), + lon: position.coords.longitude.toFixed(4) + })); + }, + (error) => { + console.error('Geolocation error:', error); + alert('Unable to get location. Please enter manually.'); + } + ); + } else { + alert('Geolocation is not supported by your browser.'); + } + }; + + if (!isOpen) return null; + + const inputStyle = { + width: '100%', + padding: '10px 12px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '6px', + color: 'var(--text-primary)', + fontSize: '14px', + fontFamily: 'JetBrains Mono, monospace' + }; + + const labelStyle = { + display: 'block', + fontSize: '12px', + color: 'var(--text-secondary)', + marginBottom: '6px', + fontWeight: '500' + }; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+

βš™ Settings

+ +
+ + {/* Form */} +
+ {/* Callsign */} +
+ + setFormData(prev => ({ ...prev, callsign: e.target.value }))} + style={inputStyle} + placeholder="W1ABC" + /> +
+ + {/* Location */} +
+
+ + +
+
+
+ setFormData(prev => ({ ...prev, lat: e.target.value }))} + style={inputStyle} + placeholder="Latitude" + /> +
+
+ setFormData(prev => ({ ...prev, lon: e.target.value }))} + style={inputStyle} + placeholder="Longitude" + /> +
+
+
+ + {/* Theme */} +
+ +
+ {['dark', 'light', 'legacy'].map(theme => ( + + ))} +
+
+ + {/* Layout */} +
+ +
+ {['modern', 'legacy'].map(layout => ( + + ))} +
+
+ + {/* Submit */} + +
+
+
+ ); +}; + +export default SettingsPanel; 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..f792238 --- /dev/null +++ b/src/components/WorldMap.jsx @@ -0,0 +1,474 @@ +/** + * WorldMap Component + * Leaflet map with DE/DX markers, terminator, DX paths, POTA, satellites + */ +import React, { useRef, useEffect, useState } from 'react'; +import { MAP_STYLES } from '../utils/config.js'; +import { + calculateGridSquare, + getSunPosition, + getMoonPosition, + getGreatCirclePoints +} from '../utils/geo.js'; +import { filterDXPaths, getBandColor } from '../utils/callsign.js'; + +export const WorldMap = ({ + deLocation, + dxLocation, + onDXChange, + potaSpots, + mySpots, + dxPaths, + dxFilters, + satellites, + showDXPaths, + showDXLabels, + onToggleDXLabels, + showPOTA, + showSatellites, + onToggleSatellites, + hoveredSpot +}) => { + const mapRef = useRef(null); + const mapInstanceRef = useRef(null); + const tileLayerRef = useRef(null); + const terminatorRef = useRef(null); + const deMarkerRef = useRef(null); + const dxMarkerRef = useRef(null); + const sunMarkerRef = useRef(null); + const moonMarkerRef = useRef(null); + const potaMarkersRef = useRef([]); + const mySpotsMarkersRef = useRef([]); + const mySpotsLinesRef = useRef([]); + const dxPathsLinesRef = useRef([]); + const dxPathsMarkersRef = useRef([]); + const satMarkersRef = useRef([]); + const satTracksRef = useRef([]); + + // Load map style from localStorage + const getStoredMapSettings = () => { + try { + const stored = localStorage.getItem('openhamclock_mapSettings'); + return stored ? JSON.parse(stored) : {}; + } catch (e) { return {}; } + }; + const storedSettings = getStoredMapSettings(); + + const [mapStyle, setMapStyle] = useState(storedSettings.mapStyle || 'dark'); + const [mapView, setMapView] = useState({ + center: storedSettings.center || [20, 0], + zoom: storedSettings.zoom || 2.5 + }); + + // Save map settings to localStorage when changed + useEffect(() => { + try { + localStorage.setItem('openhamclock_mapSettings', JSON.stringify({ + mapStyle, + center: mapView.center, + zoom: mapView.zoom + })); + } catch (e) { console.error('Failed to save map settings:', e); } + }, [mapStyle, mapView]); + + // Initialize map + useEffect(() => { + if (!mapRef.current || mapInstanceRef.current) return; + + // Make sure Leaflet is available + if (typeof L === 'undefined') { + console.error('Leaflet not loaded'); + return; + } + + const map = L.map(mapRef.current, { + center: mapView.center, + zoom: mapView.zoom, + minZoom: 1, + maxZoom: 18, + worldCopyJump: true, + zoomControl: true, + maxBounds: [[-90, -Infinity], [90, Infinity]], + maxBoundsViscosity: 0.8 + }); + + // Initial tile layer + tileLayerRef.current = L.tileLayer(MAP_STYLES[mapStyle].url, { + attribution: MAP_STYLES[mapStyle].attribution, + noWrap: false, + crossOrigin: 'anonymous', + bounds: [[-85, -180], [85, 180]] + }).addTo(map); + + // Day/night terminator + terminatorRef.current = L.terminator({ + resolution: 2, + fillOpacity: 0.35, + fillColor: '#000020', + color: '#ffaa00', + weight: 2, + dashArray: '5, 5' + }).addTo(map); + + // Refresh terminator + setTimeout(() => { + if (terminatorRef.current) { + terminatorRef.current.setTime(); + } + }, 100); + + // Update terminator every minute + const terminatorInterval = setInterval(() => { + if (terminatorRef.current) { + terminatorRef.current.setTime(); + } + }, 60000); + + // Click handler for setting DX + map.on('click', (e) => { + if (onDXChange) { + onDXChange({ lat: e.latlng.lat, lon: e.latlng.lng }); + } + }); + + // Save map view when user pans or zooms + map.on('moveend', () => { + const center = map.getCenter(); + const zoom = map.getZoom(); + setMapView({ center: [center.lat, center.lng], zoom }); + }); + + mapInstanceRef.current = map; + + return () => { + clearInterval(terminatorInterval); + map.remove(); + mapInstanceRef.current = null; + }; + }, []); + + // Update tile layer when style changes + useEffect(() => { + if (!mapInstanceRef.current || !tileLayerRef.current) return; + + mapInstanceRef.current.removeLayer(tileLayerRef.current); + tileLayerRef.current = L.tileLayer(MAP_STYLES[mapStyle].url, { + attribution: MAP_STYLES[mapStyle].attribution, + noWrap: false, + crossOrigin: 'anonymous', + bounds: [[-85, -180], [85, 180]] + }).addTo(mapInstanceRef.current); + + // Ensure terminator is on top + if (terminatorRef.current) { + terminatorRef.current.bringToFront(); + } + }, [mapStyle]); + + // Update DE/DX markers and celestial bodies + useEffect(() => { + if (!mapInstanceRef.current) return; + const map = mapInstanceRef.current; + + // Remove old markers + if (deMarkerRef.current) map.removeLayer(deMarkerRef.current); + if (dxMarkerRef.current) map.removeLayer(dxMarkerRef.current); + if (sunMarkerRef.current) map.removeLayer(sunMarkerRef.current); + if (moonMarkerRef.current) map.removeLayer(moonMarkerRef.current); + + // DE Marker + const deIcon = L.divIcon({ + className: 'custom-marker de-marker', + html: 'DE', + iconSize: [32, 32], + iconAnchor: [16, 16] + }); + deMarkerRef.current = L.marker([deLocation.lat, deLocation.lon], { icon: deIcon }) + .bindPopup(`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}
`, + iconAnchor: [0, -10] + }); + const label = L.marker([path.dxLat, path.dxLon], { icon: labelIcon, interactive: false }).addTo(map); + dxPathsMarkersRef.current.push(label); + } + } catch (err) { + console.error('Error rendering DX path:', err); + } + }); + } + }, [dxPaths, dxFilters, showDXPaths, showDXLabels, hoveredSpot]); + + // Update POTA markers + useEffect(() => { + if (!mapInstanceRef.current) return; + const map = mapInstanceRef.current; + + potaMarkersRef.current.forEach(m => map.removeLayer(m)); + potaMarkersRef.current = []; + + if (showPOTA && potaSpots) { + potaSpots.forEach(spot => { + if (spot.lat && spot.lon) { + const icon = L.divIcon({ + className: '', + html: `
${spot.call}
`, + iconAnchor: [20, 10] + }); + 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 + useEffect(() => { + if (!mapInstanceRef.current) return; + const map = mapInstanceRef.current; + + satMarkersRef.current.forEach(m => map.removeLayer(m)); + satMarkersRef.current = []; + satTracksRef.current.forEach(t => map.removeLayer(t)); + satTracksRef.current = []; + + if (showSatellites && satellites && satellites.length > 0) { + satellites.forEach(sat => { + const icon = L.divIcon({ + className: '', + html: `
πŸ›° ${sat.name}
`, + iconAnchor: [25, 12] + }); + + const marker = L.marker([sat.lat, sat.lon], { icon }) + .bindPopup(`πŸ›° ${sat.name}
Alt: ${sat.alt} km
Az: ${sat.azimuth}Β° El: ${sat.elevation}Β°`) + .addTo(map); + satMarkersRef.current.push(marker); + }); + } + }, [satellites, showSatellites]); + + return ( +
+
+ + {/* Map style dropdown */} + + + {/* Satellite toggle */} + {onToggleSatellites && ( + + )} + + {/* Labels toggle */} + {onToggleDXLabels && showDXPaths && ( + + )} + + {/* Legend */} +
+ {showDXPaths && ( +
+ DX: + 160m + 40m + 20m + 15m + 10m +
+ )} + {showPOTA && ( +
+ ● POTA +
+ )} + {showSatellites && ( +
+ πŸ›° SAT +
+ )} +
+
+ ); +}; + +export default WorldMap; diff --git a/src/components/index.js b/src/components/index.js new file mode 100644 index 0000000..4ae269b --- /dev/null +++ b/src/components/index.js @@ -0,0 +1,15 @@ +/** + * Components Index + * Central export point for all React components + */ + +export { Header } from './Header.jsx'; +export { WorldMap } from './WorldMap.jsx'; +export { SpaceWeatherPanel } from './SpaceWeatherPanel.jsx'; +export { BandConditionsPanel } from './BandConditionsPanel.jsx'; +export { DXClusterPanel } from './DXClusterPanel.jsx'; +export { POTAPanel } from './POTAPanel.jsx'; +export { ContestPanel } from './ContestPanel.jsx'; +export { LocationPanel } from './LocationPanel.jsx'; +export { SettingsPanel } from './SettingsPanel.jsx'; +export { DXFilterManager } from './DXFilterManager.jsx'; 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..0579879 --- /dev/null +++ b/src/hooks/useDXCluster.js @@ -0,0 +1,140 @@ +/** + * useDXCluster Hook + * Fetches and filters DX cluster spots with 30-minute retention + */ +import { useState, useEffect, useCallback } from 'react'; +import { getBandFromFreq, detectMode, getCallsignInfo } from '../utils/callsign.js'; + +export const useDXCluster = (source = 'auto', filters = {}) => { + const [allSpots, setAllSpots] = useState([]); // All accumulated spots + const [data, setData] = useState([]); // Filtered spots for display + const [loading, setLoading] = useState(true); + const [activeSource, setActiveSource] = useState(''); + const spotRetentionMs = 30 * 60 * 1000; // 30 minutes + const pollInterval = 5000; // 5 seconds + + // Apply filters to spots + const applyFilters = useCallback((spots, filters) => { + if (!filters || Object.keys(filters).length === 0) return spots; + + return spots.filter(spot => { + // Get spotter info for origin-based filtering + const spotterInfo = getCallsignInfo(spot.spotter); + + // Watchlist only mode - must match watchlist + if (filters.watchlistOnly && filters.watchlist?.length > 0) { + const matchesWatchlist = filters.watchlist.some(w => + spot.call?.toUpperCase().includes(w.toUpperCase()) || + spot.spotter?.toUpperCase().includes(w.toUpperCase()) + ); + if (!matchesWatchlist) return false; + } + + // Exclude list - hide matching calls + if (filters.excludeList?.length > 0) { + const isExcluded = filters.excludeList.some(exc => + spot.call?.toUpperCase().includes(exc.toUpperCase()) || + spot.spotter?.toUpperCase().includes(exc.toUpperCase()) + ); + if (isExcluded) return false; + } + + // CQ Zone filter - filter by SPOTTER's zone + if (filters.cqZones?.length > 0) { + if (!spotterInfo.cqZone || !filters.cqZones.includes(spotterInfo.cqZone)) { + return false; + } + } + + // ITU Zone filter + if (filters.ituZones?.length > 0) { + if (!spotterInfo.ituZone || !filters.ituZones.includes(spotterInfo.ituZone)) { + return false; + } + } + + // Continent filter - filter by SPOTTER's continent + if (filters.continents?.length > 0) { + if (!spotterInfo.continent || !filters.continents.includes(spotterInfo.continent)) { + return false; + } + } + + // Band filter + if (filters.bands?.length > 0) { + const band = getBandFromFreq(parseFloat(spot.freq) * 1000); + if (!filters.bands.includes(band)) return false; + } + + // Mode filter + if (filters.modes?.length > 0) { + const mode = detectMode(spot.comment); + if (!mode || !filters.modes.includes(mode)) return false; + } + + // Callsign search filter + if (filters.callsign && filters.callsign.trim()) { + const search = filters.callsign.trim().toUpperCase(); + const matchesCall = spot.call?.toUpperCase().includes(search); + const matchesSpotter = spot.spotter?.toUpperCase().includes(search); + if (!matchesCall && !matchesSpotter) return false; + } + + return true; + }); + }, []); + + useEffect(() => { + const fetchData = async () => { + try { + const response = await fetch('/api/dxcluster/spots'); + if (response.ok) { + const newSpots = await response.json(); + + setAllSpots(prev => { + const now = Date.now(); + // Create map of existing spots by unique key + const existingMap = new Map( + prev.map(s => [`${s.call}-${s.freq}-${s.spotter}`, s]) + ); + + // Add or update with new spots + newSpots.forEach(spot => { + const key = `${spot.call}-${spot.freq}-${spot.spotter}`; + existingMap.set(key, { ...spot, timestamp: now }); + }); + + // Filter out spots older than retention time + const validSpots = Array.from(existingMap.values()) + .filter(s => (now - (s.timestamp || now)) < spotRetentionMs); + + // Sort by timestamp (newest first) and limit + return validSpots + .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)) + .slice(0, 200); + }); + + setActiveSource('dxcluster'); + } + } catch (err) { + console.error('DX cluster error:', err); + } finally { + setLoading(false); + } + }; + + fetchData(); + const interval = setInterval(fetchData, pollInterval); + return () => clearInterval(interval); + }, [source]); + + // Apply filters whenever allSpots or filters change + useEffect(() => { + const filtered = applyFilters(allSpots, filters); + setData(filtered); + }, [allSpots, filters, applyFilters]); + + return { data, loading, activeSource, totalSpots: allSpots.length }; +}; + +export default useDXCluster; 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..d553669 --- /dev/null +++ b/src/hooks/useSatellites.js @@ -0,0 +1,131 @@ +/** + * useSatellites Hook + * Tracks amateur radio satellites using TLE data and satellite.js + */ +import { useState, useEffect, useCallback } from 'react'; +import * as satellite from 'satellite.js'; + +// List of popular amateur radio satellites +const AMATEUR_SATS = [ + 'ISS (ZARYA)', + 'SO-50', + 'AO-91', + 'AO-92', + 'CAS-4A', + 'CAS-4B', + 'XW-2A', + 'XW-2B', + 'JO-97', + 'RS-44' +]; + +export const useSatellites = (observerLocation) => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [tleData, setTleData] = useState({}); + + // Fetch TLE data + useEffect(() => { + const fetchTLE = async () => { + try { + const response = await fetch('/api/satellites/tle'); + if (response.ok) { + const tle = await response.json(); + setTleData(tle); + } + } catch (err) { + console.error('TLE fetch error:', err); + } + }; + + fetchTLE(); + const interval = setInterval(fetchTLE, 6 * 60 * 60 * 1000); // 6 hours + return () => clearInterval(interval); + }, []); + + // Calculate satellite positions + const calculatePositions = useCallback(() => { + if (!observerLocation || Object.keys(tleData).length === 0) { + setLoading(false); + return; + } + + try { + const now = new Date(); + const positions = []; + + // Observer position in radians + const observerGd = { + longitude: satellite.degreesToRadians(observerLocation.lon), + latitude: satellite.degreesToRadians(observerLocation.lat), + height: 0.1 // km above sea level + }; + + Object.entries(tleData).forEach(([name, tle]) => { + if (!tle.line1 || !tle.line2) return; + + try { + const satrec = satellite.twoline2satrec(tle.line1, tle.line2); + const positionAndVelocity = satellite.propagate(satrec, now); + + if (!positionAndVelocity.position) return; + + const gmst = satellite.gstime(now); + const positionGd = satellite.eciToGeodetic(positionAndVelocity.position, gmst); + + // Convert to degrees + const lat = satellite.degreesLat(positionGd.latitude); + const lon = satellite.degreesLong(positionGd.longitude); + const alt = positionGd.height; + + // Calculate look angles + const lookAngles = satellite.ecfToLookAngles( + observerGd, + satellite.eciToEcf(positionAndVelocity.position, gmst) + ); + + const azimuth = satellite.radiansToDegrees(lookAngles.azimuth); + const elevation = satellite.radiansToDegrees(lookAngles.elevation); + const rangeSat = lookAngles.rangeSat; + + // Only include if above horizon or popular sat + const isPopular = AMATEUR_SATS.some(s => name.includes(s)); + if (elevation > -5 || isPopular) { + positions.push({ + name, + lat, + lon, + alt: Math.round(alt), + azimuth: Math.round(azimuth), + elevation: Math.round(elevation), + range: Math.round(rangeSat), + visible: elevation > 0, + isPopular + }); + } + } catch (e) { + // Skip satellites with invalid TLE + } + }); + + // Sort by elevation (highest first) and limit + positions.sort((a, b) => b.elevation - a.elevation); + setData(positions.slice(0, 20)); + setLoading(false); + } catch (err) { + console.error('Satellite calculation error:', err); + setLoading(false); + } + }, [observerLocation, tleData]); + + // Update positions every 5 seconds + useEffect(() => { + calculatePositions(); + const interval = setInterval(calculatePositions, 5000); + return () => clearInterval(interval); + }, [calculatePositions]); + + return { data, loading }; +}; + +export default useSatellites; 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..235dbb7 --- /dev/null +++ b/src/styles/main.css @@ -0,0 +1,350 @@ +/* ============================================ + THEME: DARK (Default) + ============================================ */ +:root, [data-theme="dark"] { + --bg-primary: #0a0e14; + --bg-secondary: #111820; + --bg-tertiary: #1a2332; + --bg-panel: rgba(17, 24, 32, 0.92); + --border-color: rgba(255, 180, 50, 0.25); + --text-primary: #f0f4f8; + --text-secondary: #a0b0c0; + --text-muted: #8899aa; + --accent-amber: #ffb432; + --accent-amber-dim: rgba(255, 180, 50, 0.6); + --accent-green: #00ff88; + --accent-green-dim: rgba(0, 255, 136, 0.6); + --accent-red: #ff4466; + --accent-blue: #4488ff; + --accent-cyan: #00ddff; + --accent-purple: #aa66ff; + --map-ocean: #0a0e14; + --scanline-opacity: 0.02; +} + +/* ============================================ + THEME: LIGHT + ============================================ */ +[data-theme="light"] { + --bg-primary: #f5f7fa; + --bg-secondary: #ffffff; + --bg-tertiary: #e8ecf0; + --bg-panel: rgba(255, 255, 255, 0.95); + --border-color: rgba(0, 100, 200, 0.2); + --text-primary: #1a2332; + --text-secondary: #4a5a6a; + --text-muted: #7a8a9a; + --accent-amber: #d4940a; + --accent-amber-dim: rgba(212, 148, 10, 0.4); + --accent-green: #00aa55; + --accent-green-dim: rgba(0, 170, 85, 0.4); + --accent-red: #cc3344; + --accent-blue: #2266cc; + --accent-cyan: #0099bb; + --accent-purple: #7744cc; + --map-ocean: #f0f4f8; + --scanline-opacity: 0; +} + +/* ============================================ + THEME: LEGACY (Classic HamClock Style) + ============================================ */ +[data-theme="legacy"] { + --bg-primary: #000000; + --bg-secondary: #0a0a0a; + --bg-tertiary: #151515; + --bg-panel: rgba(0, 0, 0, 0.95); + --border-color: rgba(0, 255, 0, 0.3); + --text-primary: #00ff00; + --text-secondary: #00dd00; + --text-muted: #00bb00; + --accent-amber: #ffaa00; + --accent-amber-dim: rgba(255, 170, 0, 0.5); + --accent-green: #00ff00; + --accent-green-dim: rgba(0, 255, 0, 0.5); + --accent-red: #ff0000; + --accent-blue: #00aaff; + --accent-cyan: #00ffff; + --accent-purple: #ff00ff; + --map-ocean: #000008; + --scanline-opacity: 0.05; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: 'Space Grotesk', sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; + transition: background 0.3s, color 0.3s; +} + +/* Legacy theme uses monospace font */ +[data-theme="legacy"] body, +[data-theme="legacy"] * { + font-family: 'JetBrains Mono', monospace !important; +} + +/* Subtle scanline effect */ +body::before { + content: ''; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,var(--scanline-opacity)) 2px, rgba(0,0,0,var(--scanline-opacity)) 4px); + pointer-events: none; + z-index: 9999; +} + +/* ============================================ + ANIMATIONS + ============================================ */ +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } } +@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } +@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } +@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } + +.loading-spinner { + width: 14px; height: 14px; + border: 2px solid var(--border-color); + border-top-color: var(--accent-amber); + border-radius: 50%; + animation: spin 1s linear infinite; + display: inline-block; +} + +/* Legacy theme specific styles */ +[data-theme="legacy"] .loading-spinner { + border-color: var(--accent-green); + border-top-color: var(--accent-amber); +} + +/* ============================================ + LEAFLET MAP CUSTOMIZATIONS + ============================================ */ +.leaflet-container { + background: var(--bg-primary); + font-family: 'Space Grotesk', sans-serif; +} + +.leaflet-control-zoom { + border: 1px solid var(--border-color) !important; + border-radius: 6px !important; + overflow: hidden; +} + +.leaflet-control-zoom a { + background: var(--bg-secondary) !important; + color: var(--text-primary) !important; + border-bottom: 1px solid var(--border-color) !important; +} + +.leaflet-control-zoom a:hover { + background: var(--bg-tertiary) !important; + color: var(--accent-amber) !important; +} + +.leaflet-control-attribution { + background: rgba(10, 14, 20, 0.8) !important; + color: var(--text-muted) !important; + font-size: 11px !important; +} + +.leaflet-control-attribution a { + color: var(--text-secondary) !important; +} + +/* ============================================ + CUSTOM MARKER STYLES + ============================================ */ +.custom-marker { + display: flex; + align-items: center; + justify-content: center; + font-family: 'JetBrains Mono', monospace; + font-weight: 700; + font-size: 12px; + border-radius: 50%; + border: 2px solid white; + box-shadow: 0 0 10px rgba(0,0,0,0.5); +} + +.de-marker { + background: var(--accent-amber); + color: #000; + width: 32px; + height: 32px; +} + +.dx-marker { + background: var(--accent-blue); + color: #fff; + width: 32px; + height: 32px; +} + +.sun-marker { + background: radial-gradient(circle, #ffdd00 0%, #ff8800 100%); + width: 24px; + height: 24px; + border: 2px solid #ffaa00; +} + +.moon-marker { + background: radial-gradient(circle, #e8e8f0 0%, #8888aa 100%); + width: 24px; + height: 24px; + border: 2px solid #aaaacc; +} + +/* ============================================ + MAP STYLE SELECTOR + ============================================ */ +.map-style-control { + position: absolute; + top: 10px; + right: 10px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 4px; +} + +.map-style-btn { + background: var(--bg-panel); + border: 1px solid var(--border-color); + color: var(--text-secondary); + padding: 8px 12px; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.map-style-btn:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + border-color: var(--accent-amber); +} + +.map-style-btn.active { + background: var(--accent-amber); + color: #000; + border-color: var(--accent-amber); +} + +/* ============================================ + POPUP STYLING + ============================================ */ +.leaflet-popup-content-wrapper { + background: var(--bg-panel) !important; + border: 1px solid var(--border-color) !important; + border-radius: 8px !important; + color: var(--text-primary) !important; +} + +.leaflet-popup-tip { + background: var(--bg-panel) !important; + border: 1px solid var(--border-color) !important; +} + +.leaflet-popup-content { + font-family: 'JetBrains Mono', monospace !important; + font-size: 12px !important; + margin: 10px 12px !important; +} + +/* ============================================ + PANEL STYLING + ============================================ */ +.panel { + background: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 10px; + backdrop-filter: blur(10px); +} + +.panel-header { + font-size: 12px; + font-weight: 700; + color: var(--accent-cyan); + margin-bottom: 8px; + letter-spacing: 0.5px; +} + +/* ============================================ + DX CLUSTER MAP TOOLTIPS + ============================================ */ +.dx-tooltip { + background: rgba(20, 20, 30, 0.95) !important; + border: 1px solid rgba(0, 170, 255, 0.5) !important; + border-radius: 4px !important; + padding: 4px 8px !important; + font-family: 'JetBrains Mono', monospace !important; + font-size: 11px !important; + color: #00aaff !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5) !important; +} + +.dx-tooltip::before { + border-top-color: rgba(0, 170, 255, 0.5) !important; +} + +.dx-tooltip-highlighted { + background: rgba(68, 136, 255, 0.95) !important; + border: 2px solid #ffffff !important; + color: #ffffff !important; + font-weight: bold !important; + font-size: 13px !important; + box-shadow: 0 4px 16px rgba(68, 136, 255, 0.8) !important; +} + +.dx-tooltip-highlighted::before { + border-top-color: #ffffff !important; +} + +/* ============================================ + SCROLLBAR STYLING + ============================================ */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--accent-amber-dim); +} + +/* ============================================ + UTILITY CLASSES + ============================================ */ +.text-amber { color: var(--accent-amber); } +.text-green { color: var(--accent-green); } +.text-red { color: var(--accent-red); } +.text-blue { color: var(--accent-blue); } +.text-cyan { color: var(--accent-cyan); } +.text-muted { color: var(--text-muted); } +.text-primary { color: var(--text-primary); } +.text-secondary { color: var(--text-secondary); } + +.font-mono { font-family: 'JetBrains Mono', monospace; } +.font-display { font-family: 'Orbitron', monospace; } + +.bg-panel { background: var(--bg-panel); } +.bg-primary { background: var(--bg-primary); } +.bg-secondary { background: var(--bg-secondary); } +.bg-tertiary { background: var(--bg-tertiary); } 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..8afc553 --- /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', or 'legacy' + layout: 'modern', // 'modern' or 'legacy' + refreshIntervals: { + spaceWeather: 300000, + bandConditions: 300000, + pota: 60000, + dxCluster: 30000, + terminator: 60000 + } +}; + +/** + * Load config from localStorage or use defaults + */ +export const loadConfig = () => { + try { + const saved = localStorage.getItem('openhamclock_config'); + if (saved) { + const parsed = JSON.parse(saved); + return { ...DEFAULT_CONFIG, ...parsed }; + } + } catch (e) { + console.error('Error loading config:', e); + } + return DEFAULT_CONFIG; +}; + +/** + * Save config to localStorage + */ +export const saveConfig = (config) => { + try { + localStorage.setItem('openhamclock_config', JSON.stringify(config)); + } catch (e) { + console.error('Error saving config:', e); + } +}; + +/** + * Apply theme to document + */ +export const applyTheme = (theme) => { + document.documentElement.setAttribute('data-theme', theme); +}; + +/** + * Map Tile Providers + */ +export const MAP_STYLES = { + dark: { + name: 'Dark', + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}', + attribution: '© Esri' + }, + satellite: { + name: 'Satellite', + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + attribution: '© Esri' + }, + terrain: { + name: 'Terrain', + url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', + attribution: '© 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'] + } + } + } + } +});