initial commit v3

pull/1/head
accius 6 days ago
parent 9d980e11eb
commit 70f2279554

@ -0,0 +1,48 @@
---
name: Bug Report
about: Create a report to help us improve OpenHamClock
title: '[BUG] '
labels: bug
assignees: ''
---
## Describe the Bug
A clear and concise description of what the bug is.
## To Reproduce
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
## Expected Behavior
A clear and concise description of what you expected to happen.
## Screenshots
If applicable, add screenshots to help explain your problem.
## Environment
- **OS**: [e.g., Windows 11, macOS Sonoma, Ubuntu 22.04, Raspberry Pi OS]
- **Browser**: [e.g., Chrome 120, Firefox 121, Safari 17]
- **Node.js Version**: [e.g., 20.10.0]
- **OpenHamClock Version**: [e.g., 3.0.0]
- **Running As**: [Web browser / Electron app / Docker]
## For Raspberry Pi Users
- **Pi Model**: [e.g., Pi 4 4GB, Pi 3B+]
- **Pi OS Version**: [e.g., Bookworm 64-bit]
- **Display**: [e.g., Official 7" touchscreen, HDMI 1080p monitor]
- **Running in Kiosk Mode**: [Yes/No]
## Console Errors
If there are any errors in the browser console (F12), please paste them here:
```
Paste console errors here
```
## Additional Context
Add any other context about the problem here. Include your callsign if you're comfortable sharing it!
---
73!

@ -0,0 +1,41 @@
---
name: Feature Request
about: Suggest an idea for OpenHamClock
title: '[FEATURE] '
labels: enhancement
assignees: ''
---
## Is your feature request related to a problem?
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
## Describe the Solution You'd Like
A clear and concise description of what you want to happen.
## Describe Alternatives You've Considered
A clear and concise description of any alternative solutions or features you've considered.
## Use Case
How would this feature benefit amateur radio operators? Be specific about the use case:
- [ ] General HF operation
- [ ] VHF/UHF operation
- [ ] Satellite operation
- [ ] Contesting
- [ ] POTA/SOTA
- [ ] DXing
- [ ] Emergency communications
- [ ] Other: ___________
## Would you be willing to help implement this?
- [ ] Yes, I can submit a PR
- [ ] Yes, I can help test
- [ ] No, but I'd use it!
## Additional Context
Add any other context, screenshots, or mockups about the feature request here.
## Similar Features in Other Software
Are there similar features in other amateur radio software? If so, what do you like/dislike about their implementation?
---
73!

@ -0,0 +1,86 @@
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Start server and test health endpoint
run: |
npm start &
sleep 5
curl -f http://localhost:3000/api/health || exit 1
docker:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t openhamclock:test .
- name: Test Docker container
run: |
docker run -d -p 3000:3000 --name ohc-test openhamclock:test
sleep 10
curl -f http://localhost:3000/api/health || exit 1
docker stop ohc-test
build-electron:
runs-on: ${{ matrix.os }}
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Electron app
run: npm run electron:build
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: electron-${{ matrix.os }}
path: dist/
retention-days: 7

81
.gitignore vendored

@ -0,0 +1,81 @@
# Dependencies
node_modules/
package-lock.json
# Build outputs
dist/
build/
out/
# Electron
*.asar
# Logs
logs/
*.log
npm-debug.log*
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Coverage directory
coverage/
.nyc_output/
# Environment files
.env
.env.local
.env.*.local
*.env
# Editor directories and files
.idea/
.vscode/
*.swp
*.swo
*~
.project
.classpath
.settings/
*.sublime-workspace
*.sublime-project
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Desktop.ini
# Temporary files
tmp/
temp/
*.tmp
*.temp
# Config files that might contain secrets
config.local.js
config.local.json
# Test files
*.test.js.snap
# Optional npm cache directory
.npm/
# Optional eslint cache
.eslintcache
# Yarn
.yarn/
.pnp.*
# Raspberry Pi specific
*.img
*.iso

@ -0,0 +1,92 @@
# Changelog
All notable changes to OpenHamClock will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Planned
- Satellite tracking with pass predictions
- SOTA API integration
- Contest calendar
- WebSocket DX cluster connection
- Azimuthal equidistant projection option
## [3.0.0] - 2024-01-30
### Added
- **Real map tiles** via Leaflet.js - no more approximated shapes!
- **8 map styles**: Dark, Satellite, Terrain, Streets, Topo, Ocean, NatGeo, Gray
- **Interactive map** - click anywhere to set DX location
- **Day/night terminator** using Leaflet.Terminator plugin
- **Great circle path** visualization between DE and DX
- **POTA activators** displayed on map with callsigns
- **Express server** with API proxy for CORS-free data fetching
- **Electron desktop app** support for Windows, macOS, Linux
- **Docker support** with multi-stage build
- **Railway deployment** configuration
- **Raspberry Pi setup script** with kiosk mode option
- **Cross-platform install scripts** (Linux, macOS, Windows)
- **GitHub Actions CI/CD** pipeline
### Changed
- Complete rewrite of map rendering using Leaflet.js
- Improved responsive layout for different screen sizes
- Better error handling for API failures
- Cleaner separation of frontend and backend
### Fixed
- CORS issues with external APIs now handled by server proxy
- Map projection accuracy improved
## [2.0.0] - 2024-01-29
### Added
- Live API integrations for NOAA space weather
- POTA API integration for activator spots
- Band conditions from HamQSL (XML parsing)
- DX cluster spot display
- Realistic continent shapes (SVG paths)
- Great circle path calculations
- Interactive map (click to set DX)
### Changed
- Improved space weather display with color coding
- Better visual hierarchy in panels
## [1.0.0] - 2024-01-29
### Added
- Initial release
- World map with day/night terminator
- UTC and local time display
- DE/DX location panels with grid squares
- Short path / Long path bearing calculations
- Distance calculations
- Sunrise/sunset calculations
- Space weather panel (mock data)
- Band conditions panel
- DX cluster panel (mock data)
- POTA activity panel (mock data)
- Responsive grid layout
- Dark theme with amber/green accents
### Acknowledgments
- Created in memory of Elwood Downey, WB0OEW
- Inspired by the original HamClock
---
## Version History Summary
| Version | Date | Highlights |
|---------|------|------------|
| 3.0.0 | 2024-01-30 | Real maps, Electron, Docker, Railway |
| 2.0.0 | 2024-01-29 | Live APIs, improved map |
| 1.0.0 | 2024-01-29 | Initial release |
---
*73 de OpenHamClock contributors*

@ -0,0 +1,44 @@
# Code of Conduct
## Our Pledge
In the spirit of amateur radio's long tradition of courtesy, we as contributors and maintainers pledge to make participation in our project and community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
* Following amateur radio's tradition of courtesy and helpfulness
Examples of unacceptable behavior include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information without explicit permission
* Other conduct which could reasonably be considered inappropriate
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
## Scope
This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team through GitHub issues. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.4.
---
**73 - The amateur radio community is built on mutual respect and helpfulness. Let's keep that tradition alive!**

@ -0,0 +1,219 @@
# 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!
## Table of Contents
- [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)
## 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/k0cjh/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/k0cjh/openhamclock.git
# Install dependencies
npm install
# Start development server
npm run dev
# In another terminal, run Electron (optional)
npm run electron
```
### 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
```
### Making Changes
1. Create a new branch from `main`:
```bash
git checkout -b feature/your-feature-name
```
2. Make your changes
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
4. Commit with clear messages:
```bash
git commit -m "Add satellite tracking panel with TLE parser"
```
## Pull Request Process
1. **Update documentation** if needed (README, inline comments)
2. **Ensure your code follows style guidelines** (see below)
3. **Test your changes** on multiple platforms if possible
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
5. **Respond to feedback** - maintainers may request changes
6. **Once approved**, a maintainer will merge your PR
### PR Title Format
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`
## Style Guidelines
### JavaScript
- 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
### CSS
- Use CSS custom properties (variables) for theming
- Follow the existing naming conventions
- Prefer flexbox/grid over floats
- Test responsive breakpoints
### React Components
- Use functional components with hooks
- Keep components focused on single responsibilities
- Extract reusable logic into custom hooks
- Use meaningful prop names
### Git Commits
- Write clear, concise commit messages
- Use present tense ("Add feature" not "Added feature")
- Reference issues when applicable
## Recognition
Contributors will be recognized in:
- The README contributors section
- Release notes for significant contributions
- The project's GitHub contributors page
## Questions?
Feel free to:
- Open a GitHub Discussion
- Reach out to maintainers
- Join the amateur radio community discussions
---
**73 and thanks for contributing to OpenHamClock!**
*In memory of Elwood Downey, WB0OEW*

@ -0,0 +1,54 @@
# OpenHamClock Dockerfile
# Multi-stage build for optimized production image
# ============================================
# Stage 1: Build
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (including dev for build)
RUN npm ci --only=production
# ============================================
# Stage 2: Production
# ============================================
FROM node:20-alpine AS production
# Set environment
ENV NODE_ENV=production
ENV PORT=3000
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S openhamclock -u 1001
WORKDIR /app
# Copy node_modules from builder
COPY --from=builder /app/node_modules ./node_modules
# Copy application files
COPY package*.json ./
COPY server.js ./
COPY public ./public
# Set ownership
RUN chown -R openhamclock:nodejs /app
# Switch to non-root user
USER openhamclock
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
# Start server
CMD ["node", "server.js"]

@ -0,0 +1,27 @@
MIT License
Copyright (c) 2024-2026 OpenHamClock Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
This project is dedicated to the memory of Elwood Downey, WB0OEW, creator of
the original HamClock. His contributions to the amateur radio community will
never be forgotten. 73, OM.

@ -1,242 +1,289 @@
# OpenHamClock # 🌐 OpenHamClock
**A modern, open-source amateur radio dashboard - spiritual successor to HamClock** <div align="center">
*In memory of Elwood Downey, WB0OEW, creator of the original HamClock* ![OpenHamClock Banner](https://img.shields.io/badge/OpenHamClock-v3.0.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.**
## Overview *In loving memory of Elwood Downey, WB0OEW, creator of the original HamClock*
OpenHamClock is a web-based kiosk-style application that provides real-time space weather, radio propagation information, and other data useful to amateur radio operators. It's designed to run on any platform with a web browser, with special consideration for Raspberry Pi deployments. [**Live Demo**](https://openhamclock.up.railway.app) · [**Download**](#-installation) · [**Documentation**](#-features) · [**Contributing**](#-contributing)
## Features ![OpenHamClock Screenshot](https://via.placeholder.com/800x450/0a0e14/ffb432?text=OpenHamClock+Screenshot)
### Current Features (v1.0.0) </div>
- **World Map with Day/Night Terminator** ---
- Real-time gray line display
- Sun position tracking
- DE and DX location markers with path visualization
- **Time Displays** ## 📡 About
- UTC time (large, prominent display)
- Local time with date
- Uptime counter
- **Location Information** 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.
- DE (your location) with Maidenhead grid square
- DX (target location) with grid square
- Short path and long path bearing
- Distance calculation
- Sunrise/sunset times for both locations
- **Space Weather Panel** ### Why OpenHamClock?
- Solar Flux Index (SFI)
- Sunspot Number
- K-Index and A-Index
- X-Ray flux
- Overall conditions assessment
- **Band Conditions** - **Open Source**: MIT licensed, community-driven development
- Visual display for all HF bands - **Cross-Platform**: Runs on Windows, macOS, Linux, and Raspberry Pi
- Color-coded conditions (Good/Fair/Poor) - **Modern Stack**: Built with web technologies for easy customization
- VHF band status - **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
- **DX Cluster Feed** ---
- Live spot display (placeholder for API integration)
- Frequency, callsign, comment, and time
- **POTA Activity** ## ✨ Features
- Parks on the Air activator tracking
- Reference, frequency, and mode display ### 🗺️ 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
- **Zoom and pan** with full interactivity
### 📊 Live Data Integration
| Source | Data | Update Rate |
|--------|------|-------------|
| NOAA SWPC | Solar Flux, K-Index, Sunspots | 5 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 |
### 🕐 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
### Planned Features (Roadmap) ---
- [ ] Live API integration for space weather (NOAA, hamqsl.com) ## 🚀 Installation
- [ ] Real DX cluster connectivity (Telnet/WebSocket)
- [ ] Live POTA/SOTA API integration
- [ ] Satellite tracking
- [ ] VOACAP propagation predictions
- [ ] Contest calendar integration
- [ ] Hamlib/flrig radio control
- [ ] Rotator control
- [ ] Customizable panel layout
- [ ] Multiple map projections
- [ ] ADIF log file integration
- [ ] RESTful API for external control
- [ ] Touch screen support
- [ ] Alarm/alert system
## Installation ### Quick Start (Any Platform)
### Option 1: Direct Browser Use ```bash
# Clone the repository
git clone https://github.com/k0cjh/openhamclock.git
cd openhamclock
Simply open `index.html` in any modern web browser. No server required! # Install dependencies
npm install
```bash # Start the server
# Clone or download the files npm start
firefox index.html
# or
chromium-browser index.html
```
### Option 2: Raspberry Pi Kiosk Mode # Open http://localhost:3000 in your browser
```
1. **Install Raspberry Pi OS** (Desktop version recommended) ### One-Line Install
2. **Copy OpenHamClock files** **Linux/macOS:**
```bash ```bash
mkdir ~/openhamclock curl -fsSL https://raw.githubusercontent.com/k0cjh/openhamclock/main/scripts/setup-linux.sh | bash
cp index.html ~/openhamclock/ ```
**Windows (PowerShell as Admin):**
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force; iex ((New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/k0cjh/openhamclock/main/scripts/setup-windows.ps1'))
``` ```
3. **Run the setup script** ### 🍓 Raspberry Pi
```bash ```bash
# Download and run the Pi setup script
curl -fsSL https://raw.githubusercontent.com/k0cjh/openhamclock/main/scripts/setup-pi.sh -o setup-pi.sh
chmod +x setup-pi.sh chmod +x setup-pi.sh
# Standard installation
./setup-pi.sh ./setup-pi.sh
```
4. **Manual Kiosk Setup** (alternative) # Or with kiosk mode (fullscreen, auto-start on boot)
```bash ./setup-pi.sh --kiosk
# Install unclutter to hide mouse cursor
sudo apt-get install unclutter
# Create autostart entry
mkdir -p ~/.config/autostart
cat > ~/.config/autostart/openhamclock.desktop << EOF
[Desktop Entry]
Type=Application
Name=OpenHamClock
Exec=chromium-browser --kiosk --noerrdialogs --disable-infobars --incognito file:///home/pi/openhamclock/index.html
EOF
``` ```
### Option 3: Local Web Server **Supported Pi Models:**
- Raspberry Pi 3B / 3B+ ✓
- Raspberry Pi 4 (2GB+) ✓✓ (Recommended)
- Raspberry Pi 5 ✓✓✓ (Best performance)
For advanced features (future API integrations), run with a local server: ### 🖥️ Desktop App (Electron)
```bash ```bash
# Python 3 # Development
cd openhamclock npm run electron
python3 -m http.server 8080
# Then open http://localhost:8080 # 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
``` ```
### Option 4: Electron Desktop App (Future) ### 🐳 Docker
Coming soon: Packaged desktop applications for Windows, macOS, and Linux. ```bash
# Build the image
docker build -t openhamclock .
## Configuration # Run the container
docker run -p 3000:3000 openhamclock
Edit the following values in `index.html` to customize: # Or use Docker Compose
docker compose up -d
```
```javascript ### ☁️ Deploy to Railway
// Your callsign
const [callsign, setCallsign] = useState('YOUR_CALL');
// Your location (lat, lon) [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/openhamclock)
const [deLocation, setDeLocation] = useState({ lat: 39.7392, lon: -104.9903 });
// Default DX location Or manually:
const [dxLocation, setDxLocation] = useState({ lat: 35.6762, lon: 139.6503 }); 1. Fork this repository
``` 2. Create a new project on [Railway](https://railway.app)
3. Connect your GitHub repository
4. Deploy!
### Future Configuration File ---
## ⚙️ Configuration
A separate `config.js` will be provided for easier configuration: Edit your callsign and location in `public/index.html`:
```javascript ```javascript
// config.js (coming soon) const CONFIG = {
export default { callsign: 'YOUR_CALL',
callsign: 'K0CJH', location: { lat: YOUR_LAT, lon: YOUR_LON },
location: { defaultDX: { lat: 35.6762, lon: 139.6503 },
lat: 39.7392, // ...
lon: -104.9903
},
theme: 'dark',
panels: ['clock', 'map', 'weather', 'dx', 'bands'],
// ... more options
}; };
``` ```
## Display Resolutions ### Environment Variables
OpenHamClock is responsive and works at various resolutions: | Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `3000` | Server port |
| `NODE_ENV` | `development` | Environment mode |
| Resolution | Recommended Use | ---
|------------|-----------------|
| 800x480 | Small Pi displays, Inovato Quadra |
| 1024x600 | 7" Pi touchscreens |
| 1280x720 | HD ready monitors |
| 1600x960 | Recommended for full features |
| 1920x1080 | Full HD monitors |
| 2560x1440 | Large displays, high detail |
## API Data Sources (Planned) ## 🗺️ Map Styles
| Data | Source | Status | | Style | Provider | Best For |
|------|--------|--------| |-------|----------|----------|
| Space Weather | NOAA SWPC | Planned | | **Dark** | CartoDB | Night use, low-light shacks |
| Band Conditions | hamqsl.com | Planned | | **Satellite** | ESRI | Terrain visualization |
| DX Cluster | Various Telnet nodes | Planned | | **Terrain** | OpenTopoMap | SOTA operations |
| POTA | pota.app API | Planned | | **Streets** | OpenStreetMap | Urban navigation |
| SOTA | sotawatch.org | Planned | | **Topo** | ESRI | Detailed terrain |
| Satellites | N2YO, CelesTrak | Planned | | **Ocean** | ESRI | Maritime operations |
| **NatGeo** | ESRI | Classic cartography |
| **Gray** | ESRI | Minimal, distraction-free |
## Technical Details ---
### Architecture ## 🛠️ Development
- **Frontend**: React 18 (single-file, no build required) ```bash
- **Styling**: CSS-in-JS with CSS variables for theming # Clone and setup
- **Maps**: SVG-based rendering (no external tiles) git clone https://github.com/k0cjh/openhamclock.git
- **Data**: Currently static, API integration planned cd openhamclock
npm install
### Browser Support # Start development server
npm run dev
- Chrome/Chromium 80+ # Run Electron in dev mode
- Firefox 75+ npm run electron
- Safari 13+ ```
- Edge 80+
### Dependencies ### Project Structure
None! OpenHamClock loads React and Babel from CDN for simplicity. ```
openhamclock/
├── public/ # Static web files
│ ├── index.html # Main application
│ └── icons/ # App icons
├── electron/ # Electron main process
│ └── main.js # Desktop app entry
├── 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
```
For offline/airgapped deployments, download these files: ---
- react.production.min.js
- react-dom.production.min.js
- babel.min.js
## Contributing ## 🤝 Contributing
Contributions are welcome! Areas where help is needed: We welcome contributions from the amateur radio community! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
1. **API Integrations** - Connect to live data sources ### Priority Areas
2. **Satellite Tracking** - SGP4 propagator implementation
3. **Map Improvements** - Better landmass rendering, additional projections
4. **Testing** - Various Pi models and display sizes
5. **Documentation** - User guides, translations
6. **Design** - UI/UX improvements, accessibility
## License 1. **Satellite Tracking** - TLE parsing and pass predictions
2. **Contest Calendar** - Integration with contest databases
3. **Rotator Control** - Hamlib integration
4. **Additional APIs** - QRZ, LoTW, ClubLog
5. **Accessibility** - Screen reader support, high contrast modes
6. **Translations** - Internationalization
MIT License - Free for personal and commercial use. ### How to Contribute
## Acknowledgments 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
- **Elwood Downey, WB0OEW** - Creator of the original HamClock. His work inspired thousands of amateur radio operators worldwide. Rest in peace, OM. ---
- **Amateur Radio Community** - For continued innovation and the spirit of experimentation.
## Contact ## 📜 License
- **GitHub**: [https://github.com/accius/openhamclock.git](https://github.com/accius/openhamclock.git) This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
- **Email**: chris@cjhlighting.com
--- ---
**73 de K0CJH** ## 🙏 Acknowledgments
- **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
---
## 📞 Contact
- **GitHub Issues**: [Report bugs or request features](https://github.com/k0cjh/openhamclock/issues)
- **Discussions**: [Join the conversation](https://github.com/k0cjh/openhamclock/discussions)
---
<div align="center">
**73 de K0CJH and the OpenHamClock contributors!**
*"The original HamClock will cease to function in June 2026. OpenHamClock carries forward Elwood's legacy with modern technology and open-source community development."*
*"The original HamClock will cease to function in June 2026. OpenHamClock aims to carry on Elwood's legacy with a modern, open-source implementation that the community can maintain and improve together."* </div>

@ -0,0 +1,24 @@
version: '3.8'
services:
openhamclock:
build: .
container_name: openhamclock
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# Uncomment to set timezone
# environment:
# - TZ=America/Denver
# For development with hot reload:
# docker compose -f docker-compose.dev.yml up

@ -0,0 +1,287 @@
/**
* OpenHamClock Electron Main Process
*
* Creates a native desktop application wrapper for OpenHamClock
* Supports Windows, macOS, Linux, and Raspberry Pi
*/
const { app, BrowserWindow, Menu, shell, ipcMain } = require('electron');
const path = require('path');
// Keep a global reference to prevent garbage collection
let mainWindow;
// Check if running in development
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
// Start the Express server in production
let server;
if (!isDev) {
// In production, start the built-in server
const express = require('express');
const serverApp = express();
const PORT = 3847; // Use a unique port for embedded server
serverApp.use(express.static(path.join(__dirname, '..', 'public')));
serverApp.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
});
server = serverApp.listen(PORT, () => {
console.log(`Embedded server running on port ${PORT}`);
});
}
function createWindow() {
// Determine the URL to load
const loadURL = isDev
? 'http://localhost:3000'
: `http://localhost:3847`;
// Create the browser window
mainWindow = new BrowserWindow({
width: 1600,
height: 900,
minWidth: 1024,
minHeight: 600,
title: 'OpenHamClock',
icon: path.join(__dirname, '..', 'public', 'icons', 'icon.png'),
backgroundColor: '#0a0e14',
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
enableRemoteModule: false,
// Preload script for any IPC communication
// preload: path.join(__dirname, 'preload.js')
},
// Frame options
frame: true,
autoHideMenuBar: false,
});
// Load the app
mainWindow.loadURL(loadURL);
// Open DevTools in development
if (isDev) {
mainWindow.webContents.openDevTools();
}
// Handle external links
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
// Cleanup on close
mainWindow.on('closed', () => {
mainWindow = null;
});
// Handle fullscreen toggle with F11
mainWindow.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F11') {
mainWindow.setFullScreen(!mainWindow.isFullScreen());
event.preventDefault();
}
});
}
// Create application menu
function createMenu() {
const template = [
{
label: 'File',
submenu: [
{
label: 'Refresh Data',
accelerator: 'F5',
click: () => {
if (mainWindow) {
mainWindow.webContents.reload();
}
}
},
{ type: 'separator' },
{
label: 'Kiosk Mode',
accelerator: 'F11',
click: () => {
if (mainWindow) {
mainWindow.setFullScreen(!mainWindow.isFullScreen());
}
}
},
{ type: 'separator' },
{ role: 'quit' }
]
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' }
]
},
{
label: 'Map',
submenu: [
{
label: 'Dark Theme',
accelerator: '1',
click: () => sendMapStyle('dark')
},
{
label: 'Satellite',
accelerator: '2',
click: () => sendMapStyle('satellite')
},
{
label: 'Terrain',
accelerator: '3',
click: () => sendMapStyle('terrain')
},
{
label: 'Streets',
accelerator: '4',
click: () => sendMapStyle('streets')
},
{
label: 'Topographic',
accelerator: '5',
click: () => sendMapStyle('topo')
},
{
label: 'Ocean',
accelerator: '6',
click: () => sendMapStyle('ocean')
},
{
label: 'National Geographic',
accelerator: '7',
click: () => sendMapStyle('natgeo')
},
{
label: 'Gray',
accelerator: '8',
click: () => sendMapStyle('gray')
}
]
},
{
label: 'Help',
submenu: [
{
label: 'About OpenHamClock',
click: () => {
const { dialog } = require('electron');
dialog.showMessageBox(mainWindow, {
type: 'info',
title: 'About OpenHamClock',
message: 'OpenHamClock v3.0.0',
detail: 'An open-source amateur radio dashboard.\n\nIn memory of Elwood Downey, WB0OEW, creator of the original HamClock.\n\n73 de the OpenHamClock community!'
});
}
},
{
label: 'GitHub Repository',
click: () => {
shell.openExternal('https://github.com/k0cjh/openhamclock');
}
},
{ type: 'separator' },
{
label: 'Report Issue',
click: () => {
shell.openExternal('https://github.com/k0cjh/openhamclock/issues/new');
}
},
{ type: 'separator' },
{
label: 'Toggle Developer Tools',
accelerator: 'F12',
click: () => {
if (mainWindow) {
mainWindow.webContents.toggleDevTools();
}
}
}
]
}
];
// macOS specific menu adjustments
if (process.platform === 'darwin') {
template.unshift({
label: app.getName(),
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' }
]
});
}
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
// Send map style change to renderer
function sendMapStyle(style) {
if (mainWindow) {
mainWindow.webContents.executeJavaScript(`
window.postMessage({ type: 'SET_MAP_STYLE', style: '${style}' }, '*');
`);
}
}
// App ready
app.whenReady().then(() => {
createWindow();
createMenu();
// macOS: recreate window when dock icon is clicked
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
// Quit when all windows are closed
app.on('window-all-closed', () => {
// On macOS, apps typically stay open until Cmd+Q
if (process.platform !== 'darwin') {
app.quit();
}
});
// Cleanup on quit
app.on('before-quit', () => {
if (server) {
server.close();
}
});
// Security: Prevent navigation to external URLs
app.on('web-contents-created', (event, contents) => {
contents.on('will-navigate', (event, navigationUrl) => {
const parsedUrl = new URL(navigationUrl);
if (parsedUrl.origin !== 'http://localhost:3000' && parsedUrl.origin !== 'http://localhost:3847') {
event.preventDefault();
shell.openExternal(navigationUrl);
}
});
});

@ -0,0 +1,96 @@
{
"name": "openhamclock",
"version": "3.0.0",
"description": "Open-source amateur radio dashboard with real-time space weather, band conditions, DX cluster, and interactive world map",
"main": "server.js",
"scripts": {
"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"
},
"keywords": [
"ham-radio",
"amateur-radio",
"hamclock",
"dx-cluster",
"space-weather",
"pota",
"sota",
"propagation",
"raspberry-pi",
"electron"
],
"author": "OpenHamClock Contributors",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/k0cjh/openhamclock.git"
},
"bugs": {
"url": "https://github.com/k0cjh/openhamclock/issues"
},
"homepage": "https://github.com/k0cjh/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
}
}
}

@ -0,0 +1,929 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenHamClock - Amateur Radio Dashboard</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Leaflet Terminator (day/night) -->
<script src="https://unpkg.com/@joergdietrich/leaflet.terminator@1.0.0/L.Terminator.js"></script>
<!-- React -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.23.5/babel.min.js"></script>
<style>
:root {
--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: #8a9aaa;
--text-muted: #5a6a7a;
--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;
}
* { 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;
}
/* 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,0.02) 2px, rgba(0,0,0,0.02) 4px);
pointer-events: none;
z-index: 9999;
}
@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); } }
.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;
}
/* Leaflet 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: 9px !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: 10px;
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;
}
/* 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: 10px;
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;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useCallback, useMemo, useRef } = React;
// ============================================
// CONFIGURATION
// ============================================
const CONFIG = {
callsign: 'K0CJH',
location: { lat: 39.7392, lon: -104.9903 }, // Denver, CO
defaultDX: { lat: 35.6762, lon: 139.6503 }, // Tokyo
refreshIntervals: {
spaceWeather: 300000,
bandConditions: 300000,
pota: 60000,
dxCluster: 30000,
terminator: 60000
}
};
// ============================================
// MAP TILE PROVIDERS
// ============================================
const MAP_STYLES = {
dark: {
name: 'Dark',
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>'
},
satellite: {
name: 'Satellite',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; <a href="https://www.esri.com/">Esri</a>'
},
terrain: {
name: 'Terrain',
url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://opentopomap.org">OpenTopoMap</a>'
},
streets: {
name: 'Streets',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
},
topo: {
name: 'Topo',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; <a href="https://www.esri.com/">Esri</a>'
},
ocean: {
name: 'Ocean',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; <a href="https://www.esri.com/">Esri</a>'
},
natgeo: {
name: 'NatGeo',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; <a href="https://www.esri.com/">Esri</a> &copy; National Geographic'
},
gray: {
name: 'Gray',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; <a href="https://www.esri.com/">Esri</a>'
}
};
// ============================================
// UTILITY FUNCTIONS
// ============================================
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}`;
};
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;
};
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));
};
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 };
};
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) };
};
// Great circle path for Leaflet
const getGreatCirclePoints = (lat1, lon1, lat2, lon2, n = 100) => {
const points = [];
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
));
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);
points.push([toDeg(Math.atan2(z, Math.sqrt(x*x+y*y))), toDeg(Math.atan2(y, x))]);
}
return points;
};
// ============================================
// API HOOKS
// ============================================
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, CONFIG.refreshIntervals.spaceWeather);
return () => clearInterval(interval);
}, []);
return { data, loading };
};
const useBandConditions = () => {
const [data, setData] = useState([
{ band: '160m', condition: 'FAIR' }, { band: '80m', condition: 'GOOD' },
{ band: '40m', condition: 'GOOD' }, { band: '30m', condition: 'GOOD' },
{ band: '20m', condition: 'GOOD' }, { band: '17m', condition: 'GOOD' },
{ band: '15m', condition: 'FAIR' }, { band: '12m', condition: 'FAIR' },
{ band: '10m', condition: 'POOR' }, { band: '6m', condition: 'POOR' },
{ band: '2m', condition: 'GOOD' }, { band: '70cm', condition: 'GOOD' }
]);
const [loading, setLoading] = useState(false);
return { data, loading };
};
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, CONFIG.refreshIntervals.pota);
return () => clearInterval(interval);
}, []);
return { data, loading };
};
const useDXCluster = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchDX = async () => {
try {
// Fallback to sample data since DXWatch may have CORS issues
setData([
{ freq: '14.074', call: 'JA1ABC', comment: 'FT8 -12dB', time: new Date().toISOString().substr(11,5)+'z' },
{ freq: '21.074', call: 'VK2DEF', comment: 'FT8 -08dB', time: new Date().toISOString().substr(11,5)+'z' },
{ freq: '7.040', call: 'DL1XYZ', comment: 'CW 599', time: new Date().toISOString().substr(11,5)+'z' },
{ freq: '14.200', call: 'ZL3QRS', comment: 'SSB 59', time: new Date().toISOString().substr(11,5)+'z' },
{ freq: '28.074', call: 'LU5TUV', comment: 'FT8 -15dB', time: new Date().toISOString().substr(11,5)+'z' }
]);
} catch (err) { console.error('DX error:', err); }
finally { setLoading(false); }
};
fetchDX();
const interval = setInterval(fetchDX, CONFIG.refreshIntervals.dxCluster);
return () => clearInterval(interval);
}, []);
return { data, loading };
};
// ============================================
// LEAFLET MAP COMPONENT
// ============================================
const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots }) => {
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
const tileLayerRef = useRef(null);
const terminatorRef = useRef(null);
const pathRef = useRef(null);
const deMarkerRef = useRef(null);
const dxMarkerRef = useRef(null);
const sunMarkerRef = useRef(null);
const potaMarkersRef = useRef([]);
const [mapStyle, setMapStyle] = useState('dark');
// Initialize map
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
const map = L.map(mapRef.current, {
center: [20, 0],
zoom: 2,
minZoom: 1,
maxZoom: 18,
worldCopyJump: true,
zoomControl: true
});
// Initial tile layer
tileLayerRef.current = L.tileLayer(MAP_STYLES[mapStyle].url, {
attribution: MAP_STYLES[mapStyle].attribution,
noWrap: false
}).addTo(map);
// Day/night terminator
terminatorRef.current = L.terminator({
fillOpacity: 0.4,
fillColor: '#000010',
color: '#ffaa00',
weight: 2,
dashArray: '5, 5'
}).addTo(map);
// Update terminator every minute
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 });
}
});
mapInstanceRef.current = map;
return () => {
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
}).addTo(mapInstanceRef.current);
// Ensure terminator is on top
if (terminatorRef.current) {
terminatorRef.current.bringToFront();
}
}, [mapStyle]);
// Update markers and path
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 (pathRef.current) map.removeLayer(pathRef.current);
// DE Marker
const deIcon = L.divIcon({
className: 'custom-marker de-marker',
html: 'DE',
iconSize: [32, 32],
iconAnchor: [16, 16]
});
deMarkerRef.current = L.marker([deLocation.lat, deLocation.lon], { icon: deIcon })
.bindPopup(`<b>DE - Your Location</b><br>${calculateGridSquare(deLocation.lat, deLocation.lon)}<br>${deLocation.lat.toFixed(4)}°, ${deLocation.lon.toFixed(4)}°`)
.addTo(map);
// DX Marker
const dxIcon = L.divIcon({
className: 'custom-marker dx-marker',
html: 'DX',
iconSize: [32, 32],
iconAnchor: [16, 16]
});
dxMarkerRef.current = L.marker([dxLocation.lat, dxLocation.lon], { icon: dxIcon })
.bindPopup(`<b>DX - Target</b><br>${calculateGridSquare(dxLocation.lat, dxLocation.lon)}<br>${dxLocation.lat.toFixed(4)}°, ${dxLocation.lon.toFixed(4)}°`)
.addTo(map);
// Sun marker
const sunPos = getSunPosition(new Date());
const sunIcon = L.divIcon({
className: 'custom-marker sun-marker',
html: '☀',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
sunMarkerRef.current = L.marker([sunPos.lat, sunPos.lon], { icon: sunIcon })
.bindPopup('Subsolar Point')
.addTo(map);
// Great circle path
const pathPoints = getGreatCirclePoints(deLocation.lat, deLocation.lon, dxLocation.lat, dxLocation.lon);
pathRef.current = L.polyline(pathPoints, {
color: '#00ddff',
weight: 3,
opacity: 0.8,
dashArray: '10, 6'
}).addTo(map);
}, [deLocation, dxLocation]);
// Update POTA markers
useEffect(() => {
if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current;
// Remove old POTA markers
potaMarkersRef.current.forEach(m => map.removeLayer(m));
potaMarkersRef.current = [];
// Add new POTA markers
potaSpots.forEach(spot => {
if (spot.lat && spot.lon) {
const icon = L.divIcon({
className: '',
html: `<div style="background: #aa66ff; color: white; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-family: JetBrains Mono; white-space: nowrap; border: 1px solid white;">${spot.call}</div>`,
iconAnchor: [20, 10]
});
const marker = L.marker([spot.lat, spot.lon], { icon })
.bindPopup(`<b>${spot.call}</b><br>${spot.ref}<br>${spot.freq} ${spot.mode}`)
.addTo(map);
potaMarkersRef.current.push(marker);
}
});
}, [potaSpots]);
return (
<div style={{ position: 'relative', height: '100%', minHeight: '350px' }}>
<div ref={mapRef} style={{ height: '100%', width: '100%', borderRadius: '8px' }} />
{/* Map style selector */}
<div className="map-style-control">
{Object.entries(MAP_STYLES).map(([key, style]) => (
<button
key={key}
className={`map-style-btn ${mapStyle === key ? 'active' : ''}`}
onClick={() => setMapStyle(key)}
>
{style.name}
</button>
))}
</div>
</div>
);
};
// ============================================
// UI COMPONENTS
// ============================================
const Header = ({ callsign, uptime, version }) => (
<header style={{
background: 'linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%)',
borderBottom: '1px solid var(--border-color)',
padding: '12px 24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
<div style={{
fontFamily: 'Orbitron, monospace', fontSize: '28px', fontWeight: '700',
color: 'var(--accent-amber)', textShadow: '0 0 10px var(--accent-amber-dim)', letterSpacing: '2px'
}}>OpenHamClock</div>
<div style={{
background: 'var(--bg-tertiary)', padding: '6px 16px', borderRadius: '4px',
border: '1px solid var(--border-color)', fontFamily: 'JetBrains Mono, monospace',
fontSize: '16px', fontWeight: '600', color: 'var(--accent-green)',
textShadow: '0 0 8px var(--accent-green-dim)'
}}>{callsign}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '24px', fontFamily: 'JetBrains Mono, monospace', fontSize: '12px', color: 'var(--text-secondary)' }}>
<span>UPTIME: {uptime}</span>
<span style={{ color: 'var(--accent-cyan)' }}>v{version}</span>
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: 'var(--accent-green)', boxShadow: '0 0 8px var(--accent-green)', animation: 'pulse 2s infinite' }} />
</div>
</header>
);
const ClockPanel = ({ label, time, date, isUtc }) => (
<div style={{
background: 'var(--bg-panel)', border: '1px solid var(--border-color)',
borderRadius: '8px', padding: '16px 24px', backdropFilter: 'blur(10px)'
}}>
<div style={{ fontSize: '11px', fontWeight: '600', color: isUtc ? 'var(--accent-amber)' : 'var(--text-secondary)', letterSpacing: '2px', marginBottom: '8px', textTransform: 'uppercase' }}>{label}</div>
<div style={{ fontFamily: 'Orbitron, monospace', fontSize: '42px', fontWeight: '700', color: isUtc ? 'var(--accent-amber)' : 'var(--text-primary)', textShadow: isUtc ? '0 0 20px var(--accent-amber-dim)' : 'none', letterSpacing: '3px', lineHeight: 1 }}>{time}</div>
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: '13px', color: 'var(--text-muted)', marginTop: '6px' }}>{date}</div>
</div>
);
const LocationPanel = ({ type, location, gridSquare, sunTimes, otherLocation }) => {
const isDE = type === 'DE';
const bearing = otherLocation ? calculateBearing(location.lat, location.lon, otherLocation.lat, otherLocation.lon).toFixed(0) : null;
const distance = otherLocation ? calculateDistance(location.lat, location.lon, otherLocation.lat, otherLocation.lon).toFixed(0) : null;
return (
<div style={{
background: 'var(--bg-panel)', border: `1px solid ${isDE ? 'var(--border-color)' : 'rgba(68,136,255,0.3)'}`,
borderRadius: '8px', padding: '16px 20px', backdropFilter: 'blur(10px)'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<div style={{ fontSize: '11px', fontWeight: '700', color: isDE ? 'var(--accent-amber)' : 'var(--accent-blue)', letterSpacing: '3px', padding: '4px 12px', background: isDE ? 'rgba(255,180,50,0.15)' : 'rgba(68,136,255,0.15)', borderRadius: '4px' }}>{type}</div>
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: '18px', fontWeight: '700', color: 'var(--accent-green)', textShadow: '0 0 10px var(--accent-green-dim)' }}>{gridSquare}</div>
</div>
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '12px' }}>
{Math.abs(location.lat).toFixed(4)}°{location.lat >= 0 ? 'N' : 'S'}, {Math.abs(location.lon).toFixed(4)}°{location.lon >= 0 ? 'E' : 'W'}
</div>
{sunTimes && (
<div style={{ display: 'flex', gap: '16px', fontSize: '12px' }}>
<div><span style={{ color: 'var(--accent-amber)' }}>☀↑</span><span style={{ marginLeft: '6px', fontFamily: 'JetBrains Mono, monospace' }}>{sunTimes.sunrise}z</span></div>
<div><span style={{ color: 'var(--accent-amber)' }}>☀↓</span><span style={{ marginLeft: '6px', fontFamily: 'JetBrains Mono, monospace' }}>{sunTimes.sunset}z</span></div>
</div>
)}
{bearing && distance && (
<div style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', gap: '20px', fontSize: '12px' }}>
<div><span style={{ color: 'var(--text-muted)' }}>SP:</span><span style={{ marginLeft: '6px', fontFamily: 'JetBrains Mono, monospace', color: 'var(--accent-cyan)' }}>{bearing}°</span></div>
<div><span style={{ color: 'var(--text-muted)' }}>LP:</span><span style={{ marginLeft: '6px', fontFamily: 'JetBrains Mono, monospace', color: 'var(--accent-cyan)' }}>{(parseFloat(bearing) + 180) % 360}°</span></div>
<div><span style={{ color: 'var(--text-muted)' }}>Dist:</span><span style={{ marginLeft: '6px', fontFamily: 'JetBrains Mono, monospace', color: 'var(--text-primary)' }}>{parseInt(distance).toLocaleString()} km</span></div>
</div>
)}
</div>
);
};
const SpaceWeatherPanel = ({ data, loading }) => {
const getColor = (val, th) => {
const v = parseFloat(val);
if (isNaN(v)) return 'var(--text-primary)';
if (v >= th.bad) return 'var(--accent-red)';
if (v >= th.fair) return 'var(--accent-amber)';
return 'var(--accent-green)';
};
const condColors = { EXCELLENT: 'var(--accent-green)', GOOD: 'var(--accent-green)', FAIR: 'var(--accent-amber)', POOR: 'var(--accent-red)', UNKNOWN: 'var(--text-muted)' };
return (
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-color)', borderRadius: '8px', padding: '20px', backdropFilter: 'blur(10px)' }}>
<div style={{ fontSize: '12px', fontWeight: '600', color: 'var(--accent-cyan)', letterSpacing: '2px', marginBottom: '16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>☀ SPACE WEATHER</span>
{loading && <div className="loading-spinner" />}
</div>
{data ? (
<>
{[
{ label: 'Solar Flux Index', value: data.solarFlux, unit: 'sfu', th: { fair: 70, bad: 0 } },
{ label: 'Sunspot Number', value: data.sunspotNumber },
{ label: 'K-Index', value: data.kIndex, th: { fair: 4, bad: 6 } },
{ label: 'A-Index', value: data.aIndex, th: { fair: 15, bad: 30 } }
].map((item, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<span style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>{item.label}</span>
<span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: '16px', fontWeight: '600', color: item.th ? getColor(item.value, item.th) : 'var(--text-primary)' }}>
{item.value}{item.unit && <span style={{ fontSize: '11px', marginLeft: '2px' }}>{item.unit}</span>}
</span>
</div>
))}
<div style={{ marginTop: '12px', padding: '10px', background: `${condColors[data.conditions]}15`, border: `1px solid ${condColors[data.conditions]}40`, borderRadius: '4px', textAlign: 'center', fontFamily: 'JetBrains Mono, monospace', fontSize: '12px', color: condColors[data.conditions] }}>
CONDITIONS: {data.conditions}
</div>
</>
) : <div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '20px' }}>Loading...</div>}
</div>
);
};
const BandConditionsPanel = ({ bands, loading }) => {
const getStyle = (c) => ({ GOOD: { bg: 'rgba(0,255,136,0.15)', color: 'var(--accent-green)', border: 'rgba(0,255,136,0.3)' }, FAIR: { bg: 'rgba(255,180,50,0.15)', color: 'var(--accent-amber)', border: 'rgba(255,180,50,0.3)' }, POOR: { bg: 'rgba(255,68,102,0.15)', color: 'var(--accent-red)', border: 'rgba(255,68,102,0.3)' } }[c] || { bg: 'rgba(255,180,50,0.15)', color: 'var(--accent-amber)', border: 'rgba(255,180,50,0.3)' });
return (
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-color)', borderRadius: '8px', padding: '20px', backdropFilter: 'blur(10px)' }}>
<div style={{ fontSize: '12px', fontWeight: '600', color: 'var(--accent-cyan)', letterSpacing: '2px', marginBottom: '16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>📡 BAND CONDITIONS</span>
{loading && <div className="loading-spinner" />}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px' }}>
{bands.map((b, i) => {
const s = getStyle(b.condition);
return (
<div key={i} style={{ background: s.bg, border: `1px solid ${s.border}`, borderRadius: '6px', padding: '10px', textAlign: 'center' }}>
<div style={{ fontFamily: 'Orbitron, monospace', fontSize: '14px', fontWeight: '700', color: s.color }}>{b.band}</div>
<div style={{ fontSize: '8px', fontWeight: '600', color: s.color, marginTop: '4px', opacity: 0.8 }}>{b.condition}</div>
</div>
);
})}
</div>
</div>
);
};
const DXClusterPanel = ({ spots, loading }) => (
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-color)', borderRadius: '8px', padding: '20px', backdropFilter: 'blur(10px)', maxHeight: '280px', overflow: 'hidden' }}>
<div style={{ fontSize: '12px', fontWeight: '600', color: 'var(--accent-cyan)', letterSpacing: '2px', marginBottom: '16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>🌐 DX CLUSTER</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{loading && <div className="loading-spinner" />}
<span style={{ fontSize: '10px', color: 'var(--accent-green)' }}>● LIVE</span>
</div>
</div>
<div style={{ overflowY: 'auto', maxHeight: '200px' }}>
{spots.map((s, i) => (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '65px 75px 1fr auto', gap: '10px', padding: '8px 0', borderBottom: '1px solid rgba(255,255,255,0.03)', fontFamily: 'JetBrains Mono, monospace', fontSize: '11px', alignItems: 'center' }}>
<span style={{ color: 'var(--accent-green)' }}>{s.freq}</span>
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{s.call}</span>
<span style={{ color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.comment}</span>
<span style={{ color: 'var(--text-muted)' }}>{s.time}</span>
</div>
))}
</div>
</div>
);
const POTAPanel = ({ activities, loading }) => (
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-color)', borderRadius: '8px', padding: '20px', backdropFilter: 'blur(10px)' }}>
<div style={{ fontSize: '12px', fontWeight: '600', color: 'var(--accent-cyan)', letterSpacing: '2px', marginBottom: '16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>🏕 POTA ACTIVITY</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{loading && <div className="loading-spinner" />}
<span style={{ fontSize: '10px', color: 'var(--accent-green)' }}>● LIVE</span>
</div>
</div>
<div style={{ maxHeight: '160px', overflowY: 'auto' }}>
{activities.length > 0 ? activities.map((a, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 0', borderBottom: '1px solid rgba(255,255,255,0.03)', fontFamily: 'JetBrains Mono, monospace', fontSize: '11px' }}>
<div>
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{a.call}</span>
<span style={{ color: 'var(--accent-purple)', marginLeft: '8px' }}>{a.ref}</span>
</div>
<div>
<span style={{ color: 'var(--accent-green)' }}>{a.freq}</span>
<span style={{ color: 'var(--text-secondary)', marginLeft: '8px' }}>{a.mode}</span>
</div>
</div>
)) : <div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '20px' }}>No active POTA spots</div>}
</div>
</div>
);
// ============================================
// MAIN APP
// ============================================
const App = () => {
const [currentTime, setCurrentTime] = useState(new Date());
const [startTime] = useState(Date.now());
const [uptime, setUptime] = useState('0d 0h 0m');
const [deLocation] = useState(CONFIG.location);
const [dxLocation, setDxLocation] = useState(CONFIG.defaultDX);
const spaceWeather = useSpaceWeather();
const bandConditions = useBandConditions();
const potaSpots = usePOTASpots();
const dxCluster = useDXCluster();
const deGrid = useMemo(() => calculateGridSquare(deLocation.lat, deLocation.lon), [deLocation]);
const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]);
const deSunTimes = useMemo(() => calculateSunTimes(deLocation.lat, deLocation.lon, currentTime), [deLocation, currentTime]);
const dxSunTimes = useMemo(() => calculateSunTimes(dxLocation.lat, dxLocation.lon, currentTime), [dxLocation, currentTime]);
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 });
}, []);
const utcTime = currentTime.toISOString().substr(11, 8);
const localTime = currentTime.toLocaleTimeString('en-US', { hour12: false });
const utcDate = currentTime.toISOString().substr(0, 10);
const localDate = currentTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
return (
<div style={{ minHeight: '100vh', background: 'var(--bg-primary)' }}>
<Header callsign={CONFIG.callsign} uptime={uptime} version="3.0.0" />
<main style={{ padding: '20px', display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gridTemplateRows: 'auto 1fr auto', gap: '16px', maxWidth: '1800px', margin: '0 auto', minHeight: 'calc(100vh - 120px)' }}>
{/* Row 1 */}
<ClockPanel label="UTC Time" time={utcTime} date={utcDate} isUtc={true} />
<ClockPanel label="Local Time" time={localTime} date={localDate} isUtc={false} />
<LocationPanel type="DE" location={deLocation} gridSquare={deGrid} sunTimes={deSunTimes} otherLocation={dxLocation} />
{/* Row 2: Map */}
<div style={{ gridColumn: 'span 2', minHeight: '350px' }}>
<WorldMap deLocation={deLocation} dxLocation={dxLocation} onDXChange={handleDXChange} potaSpots={potaSpots.data} />
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginTop: '8px', fontFamily: 'JetBrains Mono', textAlign: 'center' }}>
Click anywhere on map to set DX location • Use buttons to change map style
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<LocationPanel type="DX" location={dxLocation} gridSquare={dxGrid} sunTimes={dxSunTimes} otherLocation={deLocation} />
<SpaceWeatherPanel data={spaceWeather.data} loading={spaceWeather.loading} />
</div>
{/* Row 3 */}
<BandConditionsPanel bands={bandConditions.data} loading={bandConditions.loading} />
<DXClusterPanel spots={dxCluster.data} loading={dxCluster.loading} />
<POTAPanel activities={potaSpots.data} loading={potaSpots.loading} />
</main>
<footer style={{ textAlign: 'center', padding: '20px', color: 'var(--text-muted)', fontSize: '11px', fontFamily: 'JetBrains Mono, monospace' }}>
OpenHamClock v3.0.0 | In memory of Elwood Downey WB0OEW | Map tiles © OpenStreetMap, ESRI, CARTO | 73 de {CONFIG.callsign}
</footer>
</div>
);
};
ReactDOM.render(<App />, document.getElementById('root'));
</script>
</body>
</html>

@ -0,0 +1,13 @@
# Railway Configuration
# https://docs.railway.app/reference/config-as-code
[build]
builder = "DOCKERFILE"
dockerfilePath = "Dockerfile"
[deploy]
startCommand = "node server.js"
healthcheckPath = "/api/health"
healthcheckTimeout = 100
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 3

@ -0,0 +1,125 @@
#!/bin/bash
#
# OpenHamClock - Linux/macOS Setup Script
#
# Quick installation script for Linux and macOS systems
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/k0cjh/openhamclock/main/scripts/setup-linux.sh | bash
#
# Or manually:
# chmod +x setup-linux.sh
# ./setup-linux.sh
#
set -e
# Colors
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
NC='\033[0m'
INSTALL_DIR="$HOME/openhamclock"
echo -e "${BLUE}"
echo "╔═══════════════════════════════════════════════════════════╗"
echo "║ OpenHamClock Installation Script ║"
echo "╚═══════════════════════════════════════════════════════════╝"
echo -e "${NC}"
# Check for Node.js
check_node() {
if ! command -v node &> /dev/null; then
echo -e "${YELLOW}Node.js not found. Please install Node.js 18 or later:${NC}"
echo ""
echo " macOS: brew install node"
echo " Ubuntu: curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && sudo apt-get install -y nodejs"
echo " Fedora: sudo dnf install nodejs"
echo " Arch: sudo pacman -S nodejs npm"
echo ""
exit 1
fi
NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
if [ "$NODE_VERSION" -lt 18 ]; then
echo -e "${YELLOW}Node.js version 18 or later required. Current: $(node -v)${NC}"
exit 1
fi
echo -e "${GREEN}✓ Node.js $(node -v) detected${NC}"
}
# Check for Git
check_git() {
if ! command -v git &> /dev/null; then
echo -e "${YELLOW}Git not found. Please install Git first.${NC}"
exit 1
fi
echo -e "${GREEN}✓ Git detected${NC}"
}
# Clone or update repository
setup_repo() {
echo -e "${BLUE}>>> Setting up OpenHamClock...${NC}"
if [ -d "$INSTALL_DIR" ]; then
echo "Updating existing installation..."
cd "$INSTALL_DIR"
git pull
else
echo "Cloning repository..."
git clone https://github.com/k0cjh/openhamclock.git "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
# Install dependencies
npm install
echo -e "${GREEN}✓ Installation complete${NC}"
}
# Create launcher script
create_launcher() {
cat > "$INSTALL_DIR/run.sh" << EOF
#!/bin/bash
cd "$INSTALL_DIR"
echo "Starting OpenHamClock..."
echo "Open http://localhost:3000 in your browser"
node server.js
EOF
chmod +x "$INSTALL_DIR/run.sh"
}
# Print instructions
print_instructions() {
echo ""
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Installation Complete! ║${NC}"
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e " ${BLUE}To start OpenHamClock:${NC}"
echo ""
echo " cd $INSTALL_DIR && npm start"
echo ""
echo " Or use the launcher: $INSTALL_DIR/run.sh"
echo ""
echo -e " ${BLUE}Then open:${NC} http://localhost:3000"
echo ""
echo -e " ${BLUE}For Electron desktop app:${NC}"
echo " npm run electron"
echo ""
echo -e " ${BLUE}73 de OpenHamClock!${NC}"
echo ""
}
# Main
main() {
check_node
check_git
setup_repo
create_launcher
print_instructions
}
main

@ -0,0 +1,355 @@
#!/bin/bash
#
# OpenHamClock - Raspberry Pi Setup Script
#
# This script configures a Raspberry Pi for kiosk mode operation
# Supports: Pi 3B, 3B+, 4, 5 (32-bit and 64-bit Raspberry Pi OS)
#
# Usage:
# chmod +x setup-pi.sh
# ./setup-pi.sh
#
# Options:
# --kiosk Enable kiosk mode (auto-start on boot)
# --server Install as a server (no GUI)
# --help Show help
#
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
INSTALL_DIR="$HOME/openhamclock"
SERVICE_NAME="openhamclock"
NODE_VERSION="20"
# Print banner
echo -e "${BLUE}"
echo "╔═══════════════════════════════════════════════════════════╗"
echo "║ ║"
echo "║ ██████╗ ██████╗ ███████╗███╗ ██╗ ║"
echo "║ ██╔═══██╗██╔══██╗██╔════╝████╗ ██║ ║"
echo "║ ██║ ██║██████╔╝█████╗ ██╔██╗ ██║ ║"
echo "║ ██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║ ║"
echo "║ ╚██████╔╝██║ ███████╗██║ ╚████║ ║"
echo "║ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ HAM CLOCK ║"
echo "║ ║"
echo "║ Raspberry Pi Setup Script ║"
echo "║ ║"
echo "╚═══════════════════════════════════════════════════════════╝"
echo -e "${NC}"
# Parse arguments
KIOSK_MODE=false
SERVER_MODE=false
while [[ "$#" -gt 0 ]]; do
case $1 in
--kiosk) KIOSK_MODE=true ;;
--server) SERVER_MODE=true ;;
--help)
echo "Usage: ./setup-pi.sh [OPTIONS]"
echo ""
echo "Options:"
echo " --kiosk Enable kiosk mode (fullscreen, auto-start)"
echo " --server Install as headless server only"
echo " --help Show this help message"
exit 0
;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
shift
done
# Check if running on Raspberry Pi
check_raspberry_pi() {
if [ -f /proc/device-tree/model ]; then
MODEL=$(cat /proc/device-tree/model)
echo -e "${GREEN}✓ Detected: $MODEL${NC}"
else
echo -e "${YELLOW}⚠ Warning: This doesn't appear to be a Raspberry Pi${NC}"
read -p "Continue anyway? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
}
# Update system
update_system() {
echo -e "${BLUE}>>> Updating system packages...${NC}"
sudo apt-get update -qq
sudo apt-get upgrade -y -qq
}
# Install Node.js
install_nodejs() {
echo -e "${BLUE}>>> Installing Node.js ${NODE_VERSION}...${NC}"
# Check if Node.js is already installed
if command -v node &> /dev/null; then
CURRENT_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
if [ "$CURRENT_VERSION" -ge "$NODE_VERSION" ]; then
echo -e "${GREEN}✓ Node.js $(node -v) already installed${NC}"
return
fi
fi
# Install Node.js via NodeSource
curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | sudo -E bash -
sudo apt-get install -y nodejs
echo -e "${GREEN}✓ Node.js $(node -v) installed${NC}"
}
# Install dependencies
install_dependencies() {
echo -e "${BLUE}>>> Installing system dependencies...${NC}"
PACKAGES="git"
if [ "$SERVER_MODE" = false ]; then
PACKAGES="$PACKAGES chromium-browser unclutter xdotool x11-xserver-utils"
fi
sudo apt-get install -y -qq $PACKAGES
echo -e "${GREEN}✓ Dependencies installed${NC}"
}
# Clone or update repository
setup_repository() {
echo -e "${BLUE}>>> Setting up OpenHamClock...${NC}"
if [ -d "$INSTALL_DIR" ]; then
echo "Updating existing installation..."
cd "$INSTALL_DIR"
git pull
else
echo "Cloning repository..."
git clone https://github.com/k0cjh/openhamclock.git "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
# Install npm dependencies
npm install --production
echo -e "${GREEN}✓ OpenHamClock installed to $INSTALL_DIR${NC}"
}
# Create systemd service
create_service() {
echo -e "${BLUE}>>> Creating systemd service...${NC}"
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null << EOF
[Unit]
Description=OpenHamClock Server
After=network.target
[Service]
Type=simple
User=$USER
WorkingDirectory=$INSTALL_DIR
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=10
Environment=NODE_ENV=production
Environment=PORT=3000
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable ${SERVICE_NAME}
sudo systemctl start ${SERVICE_NAME}
echo -e "${GREEN}✓ Service created and started${NC}"
}
# Setup kiosk mode
setup_kiosk() {
echo -e "${BLUE}>>> Configuring kiosk mode...${NC}"
# Disable screen blanking
sudo raspi-config nonint do_blanking 1 2>/dev/null || true
# Create autostart directory
mkdir -p "$HOME/.config/autostart"
# Create kiosk launcher script
cat > "$INSTALL_DIR/kiosk.sh" << 'EOF'
#!/bin/bash
# OpenHamClock Kiosk Launcher
# Wait for desktop
sleep 5
# Disable screen saver and power management
xset s off
xset -dpms
xset s noblank
# Hide mouse cursor
unclutter -idle 1 -root &
# Wait for server to be ready
while ! curl -s http://localhost:3000/api/health > /dev/null; do
sleep 1
done
# Launch Chromium in kiosk mode
chromium-browser \
--kiosk \
--noerrdialogs \
--disable-infobars \
--disable-session-crashed-bubble \
--disable-restore-session-state \
--disable-features=TranslateUI \
--check-for-update-interval=31536000 \
--disable-component-update \
--overscroll-history-navigation=0 \
--disable-pinch \
--incognito \
http://localhost:3000
EOF
chmod +x "$INSTALL_DIR/kiosk.sh"
# Create autostart entry
cat > "$HOME/.config/autostart/openhamclock-kiosk.desktop" << EOF
[Desktop Entry]
Type=Application
Name=OpenHamClock Kiosk
Exec=$INSTALL_DIR/kiosk.sh
Hidden=false
X-GNOME-Autostart-enabled=true
EOF
# Configure boot for faster startup
if [ -f /boot/config.txt ]; then
# Disable splash screen for faster boot
if ! grep -q "disable_splash=1" /boot/config.txt; then
echo "disable_splash=1" | sudo tee -a /boot/config.txt > /dev/null
fi
# Allocate more GPU memory
if ! grep -q "gpu_mem=" /boot/config.txt; then
echo "gpu_mem=128" | sudo tee -a /boot/config.txt > /dev/null
fi
fi
echo -e "${GREEN}✓ Kiosk mode configured${NC}"
}
# Create helper scripts
create_scripts() {
echo -e "${BLUE}>>> Creating helper scripts...${NC}"
# Start script
cat > "$INSTALL_DIR/start.sh" << EOF
#!/bin/bash
cd "$INSTALL_DIR"
node server.js
EOF
chmod +x "$INSTALL_DIR/start.sh"
# Stop script
cat > "$INSTALL_DIR/stop.sh" << EOF
#!/bin/bash
sudo systemctl stop ${SERVICE_NAME}
pkill -f chromium-browser 2>/dev/null || true
pkill -f unclutter 2>/dev/null || true
echo "OpenHamClock stopped"
EOF
chmod +x "$INSTALL_DIR/stop.sh"
# Restart script
cat > "$INSTALL_DIR/restart.sh" << EOF
#!/bin/bash
sudo systemctl restart ${SERVICE_NAME}
echo "OpenHamClock restarted"
EOF
chmod +x "$INSTALL_DIR/restart.sh"
# Status script
cat > "$INSTALL_DIR/status.sh" << EOF
#!/bin/bash
echo "=== OpenHamClock Status ==="
sudo systemctl status ${SERVICE_NAME} --no-pager
echo ""
echo "=== Server Health ==="
curl -s http://localhost:3000/api/health | python3 -m json.tool 2>/dev/null || echo "Server not responding"
EOF
chmod +x "$INSTALL_DIR/status.sh"
echo -e "${GREEN}✓ Helper scripts created${NC}"
}
# Print summary
print_summary() {
echo ""
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Installation Complete! ║${NC}"
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e " ${BLUE}Installation Directory:${NC} $INSTALL_DIR"
echo -e " ${BLUE}Web Interface:${NC} http://localhost:3000"
echo ""
echo -e " ${YELLOW}Helper Commands:${NC}"
echo " $INSTALL_DIR/start.sh - Start server manually"
echo " $INSTALL_DIR/stop.sh - Stop everything"
echo " $INSTALL_DIR/restart.sh - Restart server"
echo " $INSTALL_DIR/status.sh - Check status"
echo ""
echo -e " ${YELLOW}Service Commands:${NC}"
echo " sudo systemctl start ${SERVICE_NAME}"
echo " sudo systemctl stop ${SERVICE_NAME}"
echo " sudo systemctl status ${SERVICE_NAME}"
echo " sudo journalctl -u ${SERVICE_NAME} -f"
echo ""
if [ "$KIOSK_MODE" = true ]; then
echo -e " ${GREEN}Kiosk Mode:${NC} Enabled"
echo " OpenHamClock will auto-start on boot in fullscreen"
echo " To disable: rm ~/.config/autostart/openhamclock-kiosk.desktop"
echo ""
fi
echo -e " ${BLUE}73 de OpenHamClock!${NC}"
echo ""
if [ "$KIOSK_MODE" = true ]; then
read -p "Reboot now to start kiosk mode? (y/N) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
sudo reboot
fi
fi
}
# Main installation flow
main() {
check_raspberry_pi
update_system
install_nodejs
install_dependencies
setup_repository
create_service
create_scripts
if [ "$KIOSK_MODE" = true ]; then
setup_kiosk
fi
print_summary
}
# Run main
main

@ -0,0 +1,127 @@
# OpenHamClock - Windows Setup Script
#
# Run in PowerShell as Administrator:
# Set-ExecutionPolicy Bypass -Scope Process -Force
# .\setup-windows.ps1
#
$ErrorActionPreference = "Stop"
Write-Host ""
Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Blue
Write-Host "║ OpenHamClock Windows Setup ║" -ForegroundColor Blue
Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Blue
Write-Host ""
$InstallDir = "$env:USERPROFILE\openhamclock"
# Check for Node.js
function Check-Node {
try {
$nodeVersion = node -v
$versionNumber = [int]($nodeVersion -replace 'v(\d+)\..*', '$1')
if ($versionNumber -lt 18) {
Write-Host "Node.js version 18 or later required. Current: $nodeVersion" -ForegroundColor Yellow
Write-Host "Download from: https://nodejs.org/" -ForegroundColor Yellow
exit 1
}
Write-Host "✓ Node.js $nodeVersion detected" -ForegroundColor Green
}
catch {
Write-Host "Node.js not found. Please install Node.js 18 or later from https://nodejs.org/" -ForegroundColor Yellow
exit 1
}
}
# Check for Git
function Check-Git {
try {
git --version | Out-Null
Write-Host "✓ Git detected" -ForegroundColor Green
}
catch {
Write-Host "Git not found. Please install Git from https://git-scm.com/" -ForegroundColor Yellow
exit 1
}
}
# Setup repository
function Setup-Repository {
Write-Host ">>> Setting up OpenHamClock..." -ForegroundColor Blue
if (Test-Path $InstallDir) {
Write-Host "Updating existing installation..."
Set-Location $InstallDir
git pull
}
else {
Write-Host "Cloning repository..."
git clone https://github.com/k0cjh/openhamclock.git $InstallDir
Set-Location $InstallDir
}
Write-Host "Installing dependencies..."
npm install
Write-Host "✓ Installation complete" -ForegroundColor Green
}
# Create desktop shortcut
function Create-Shortcut {
$WshShell = New-Object -ComObject WScript.Shell
$Shortcut = $WshShell.CreateShortcut("$env:USERPROFILE\Desktop\OpenHamClock.lnk")
$Shortcut.TargetPath = "cmd.exe"
$Shortcut.Arguments = "/c cd /d `"$InstallDir`" && npm start"
$Shortcut.WorkingDirectory = $InstallDir
$Shortcut.Description = "OpenHamClock - Amateur Radio Dashboard"
$Shortcut.Save()
Write-Host "✓ Desktop shortcut created" -ForegroundColor Green
}
# Create batch file launcher
function Create-Launcher {
$batchContent = @"
@echo off
cd /d "$InstallDir"
echo Starting OpenHamClock...
echo Open http://localhost:3000 in your browser
npm start
pause
"@
Set-Content -Path "$InstallDir\start.bat" -Value $batchContent
Write-Host "✓ Launcher created: $InstallDir\start.bat" -ForegroundColor Green
}
# Print instructions
function Print-Instructions {
Write-Host ""
Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Green
Write-Host "║ Installation Complete! ║" -ForegroundColor Green
Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Green
Write-Host ""
Write-Host " To start OpenHamClock:" -ForegroundColor Blue
Write-Host ""
Write-Host " 1. Double-click the desktop shortcut"
Write-Host " 2. Or run: $InstallDir\start.bat"
Write-Host " 3. Or in PowerShell: cd $InstallDir; npm start"
Write-Host ""
Write-Host " Then open: http://localhost:3000" -ForegroundColor Blue
Write-Host ""
Write-Host " For Electron desktop app:" -ForegroundColor Blue
Write-Host " npm run electron"
Write-Host ""
Write-Host " 73 de OpenHamClock!" -ForegroundColor Blue
Write-Host ""
}
# Main
Check-Node
Check-Git
Setup-Repository
Create-Launcher
Create-Shortcut
Print-Instructions

@ -0,0 +1,235 @@
/**
* OpenHamClock Server
*
* Express server that:
* 1. Serves the static web application
* 2. Proxies API requests to avoid CORS issues
* 3. Provides WebSocket support for future real-time features
*
* Usage:
* node server.js
* PORT=8080 node server.js
*/
const express = require('express');
const cors = require('cors');
const path = require('path');
const fetch = require('node-fetch');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Serve static files from public directory
app.use(express.static(path.join(__dirname, 'public')));
// ============================================
// API PROXY ENDPOINTS
// ============================================
// NOAA Space Weather - Solar Flux
app.get('/api/noaa/flux', async (req, res) => {
try {
const response = await fetch('https://services.swpc.noaa.gov/json/f107_cm_flux.json');
const data = await response.json();
res.json(data);
} catch (error) {
console.error('NOAA Flux API error:', error.message);
res.status(500).json({ error: 'Failed to fetch solar flux data' });
}
});
// NOAA Space Weather - K-Index
app.get('/api/noaa/kindex', async (req, res) => {
try {
const response = await fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json');
const data = await response.json();
res.json(data);
} catch (error) {
console.error('NOAA K-Index API error:', error.message);
res.status(500).json({ error: 'Failed to fetch K-index data' });
}
});
// NOAA Space Weather - Sunspots
app.get('/api/noaa/sunspots', async (req, res) => {
try {
const response = await fetch('https://services.swpc.noaa.gov/json/solar-cycle/observed-solar-cycle-indices.json');
const data = await response.json();
res.json(data);
} catch (error) {
console.error('NOAA Sunspots API error:', error.message);
res.status(500).json({ error: 'Failed to fetch sunspot data' });
}
});
// NOAA Space Weather - X-Ray Flux
app.get('/api/noaa/xray', async (req, res) => {
try {
const response = await fetch('https://services.swpc.noaa.gov/json/goes/primary/xrays-7-day.json');
const data = await response.json();
res.json(data);
} catch (error) {
console.error('NOAA X-Ray API error:', error.message);
res.status(500).json({ error: 'Failed to fetch X-ray data' });
}
});
// POTA Spots
app.get('/api/pota/spots', async (req, res) => {
try {
const response = await fetch('https://api.pota.app/spot/activator');
const data = await response.json();
res.json(data);
} catch (error) {
console.error('POTA API error:', error.message);
res.status(500).json({ error: 'Failed to fetch POTA spots' });
}
});
// SOTA Spots
app.get('/api/sota/spots', async (req, res) => {
try {
const response = await fetch('https://api2.sota.org.uk/api/spots/50/all');
const data = await response.json();
res.json(data);
} catch (error) {
console.error('SOTA API error:', error.message);
res.status(500).json({ error: 'Failed to fetch SOTA spots' });
}
});
// HamQSL Band Conditions
app.get('/api/hamqsl/conditions', async (req, res) => {
try {
const response = await fetch('https://www.hamqsl.com/solarxml.php');
const text = await response.text();
res.set('Content-Type', 'application/xml');
res.send(text);
} catch (error) {
console.error('HamQSL API error:', error.message);
res.status(500).json({ error: 'Failed to fetch band conditions' });
}
});
// DX Cluster proxy (for future WebSocket implementation)
app.get('/api/dxcluster/spots', async (req, res) => {
try {
// Try DXWatch first
const response = await fetch('https://www.dxwatch.com/api/spots.json?limit=20', {
headers: { 'User-Agent': 'OpenHamClock/3.0' }
});
if (response.ok) {
const data = await response.json();
res.json(data);
} else {
// Return empty array if API unavailable
res.json([]);
}
} catch (error) {
console.error('DX Cluster API error:', error.message);
res.json([]); // Return empty array on error
}
});
// QRZ Callsign lookup (requires API key)
app.get('/api/qrz/lookup/:callsign', async (req, res) => {
const { callsign } = req.params;
// Note: QRZ requires an API key - this is a placeholder
res.json({
message: 'QRZ lookup requires API key configuration',
callsign: callsign.toUpperCase()
});
});
// ============================================
// HEALTH CHECK
// ============================================
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
version: '3.0.0',
uptime: process.uptime(),
timestamp: new Date().toISOString()
});
});
// ============================================
// CONFIGURATION ENDPOINT
// ============================================
app.get('/api/config', (req, res) => {
res.json({
version: '3.0.0',
features: {
spaceWeather: true,
pota: true,
sota: true,
dxCluster: true,
satellites: false, // Coming soon
contests: false // Coming soon
},
refreshIntervals: {
spaceWeather: 300000,
pota: 60000,
sota: 60000,
dxCluster: 30000
}
});
});
// ============================================
// CATCH-ALL FOR SPA
// ============================================
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// ============================================
// START SERVER
// ============================================
app.listen(PORT, () => {
console.log('');
console.log('╔═══════════════════════════════════════════════════════╗');
console.log('║ ║');
console.log('║ ██████╗ ██████╗ ███████╗███╗ ██╗ ║');
console.log('║ ██╔═══██╗██╔══██╗██╔════╝████╗ ██║ ║');
console.log('║ ██║ ██║██████╔╝█████╗ ██╔██╗ ██║ ║');
console.log('║ ██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║ ║');
console.log('║ ╚██████╔╝██║ ███████╗██║ ╚████║ ║');
console.log('║ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ║');
console.log('║ ║');
console.log('║ ██╗ ██╗ █████╗ ███╗ ███╗ ██████╗██╗ ██╗ ██╗ ║');
console.log('║ ██║ ██║██╔══██╗████╗ ████║██╔════╝██║ ██║ ██╔╝ ║');
console.log('║ ███████║███████║██╔████╔██║██║ ██║ █████╔╝ ║');
console.log('║ ██╔══██║██╔══██║██║╚██╔╝██║██║ ██║ ██╔═██╗ ║');
console.log('║ ██║ ██║██║ ██║██║ ╚═╝ ██║╚██████╗███████╗██║ ██╗ ║');
console.log('║ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ║');
console.log('║ ║');
console.log('╚═══════════════════════════════════════════════════════╝');
console.log('');
console.log(` 🌐 Server running at http://localhost:${PORT}`);
console.log(' 📡 API proxy enabled for NOAA, POTA, SOTA, DX Cluster');
console.log(' 🖥️ Open your browser to start using OpenHamClock');
console.log('');
console.log(' In memory of Elwood Downey, WB0OEW');
console.log(' 73 de OpenHamClock contributors');
console.log('');
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
process.exit(0);
});
process.on('SIGINT', () => {
console.log('\nShutting down...');
process.exit(0);
});
Loading…
Cancel
Save

Powered by TurnKey Linux.