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
|
||||
@ -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*
|
||||

|
||||
[](LICENSE)
|
||||
[](https://nodejs.org/)
|
||||
[](CONTRIBUTING.md)
|
||||
|
||||
---
|
||||
**A modern, open-source amateur radio dashboard with real-time space weather, band conditions, DX cluster, and interactive world maps.**
|
||||
|
||||
## 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
|
||||

|
||||
|
||||
### 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**
|
||||
- UTC time (large, prominent display)
|
||||
- Local time with date
|
||||
- Uptime counter
|
||||
## 📡 About
|
||||
|
||||
- **Location Information**
|
||||
- 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
|
||||
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.
|
||||
|
||||
- **Space Weather Panel**
|
||||
- Solar Flux Index (SFI)
|
||||
- Sunspot Number
|
||||
- K-Index and A-Index
|
||||
- X-Ray flux
|
||||
- Overall conditions assessment
|
||||
### Why OpenHamClock?
|
||||
|
||||
- **Band Conditions**
|
||||
- Visual display for all HF bands
|
||||
- Color-coded conditions (Good/Fair/Poor)
|
||||
- VHF band status
|
||||
- **Open Source**: MIT licensed, community-driven development
|
||||
- **Cross-Platform**: Runs on Windows, macOS, Linux, and Raspberry Pi
|
||||
- **Modern Stack**: Built with web technologies for easy customization
|
||||
- **Real Maps**: Actual satellite/terrain imagery, not approximations
|
||||
- **Live Data**: Real-time feeds from NOAA, POTA, SOTA, and DX clusters
|
||||
- **Self-Hosted**: Run locally or deploy to your own server
|
||||
|
||||
- **DX Cluster Feed**
|
||||
- Live spot display (placeholder for API integration)
|
||||
- Frequency, callsign, comment, and time
|
||||
---
|
||||
|
||||
- **POTA Activity**
|
||||
- Parks on the Air activator tracking
|
||||
- Reference, frequency, and mode display
|
||||
## ✨ Features
|
||||
|
||||
### 🗺️ Interactive World Map
|
||||
- **8 map styles**: Dark, Satellite, Terrain, Streets, Topo, Ocean, NatGeo, Gray
|
||||
- **Real-time day/night terminator** (gray line)
|
||||
- **Great circle paths** between DE and DX
|
||||
- **Click anywhere** to set DX location
|
||||
- **POTA activators** displayed on map
|
||||
- **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)
|
||||
- [ ] 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
|
||||
|
||||
## 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
|
||||
# Clone or download the files
|
||||
firefox index.html
|
||||
# or
|
||||
chromium-browser index.html
|
||||
# Start the server
|
||||
npm start
|
||||
|
||||
# Open http://localhost:3000 in your browser
|
||||
```
|
||||
|
||||
### Option 2: Raspberry Pi Kiosk Mode
|
||||
### One-Line Install
|
||||
|
||||
1. **Install Raspberry Pi OS** (Desktop version recommended)
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/k0cjh/openhamclock/main/scripts/setup-linux.sh | bash
|
||||
```
|
||||
|
||||
2. **Copy OpenHamClock files**
|
||||
```bash
|
||||
mkdir ~/openhamclock
|
||||
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**
|
||||
```bash
|
||||
chmod +x setup-pi.sh
|
||||
./setup-pi.sh
|
||||
```
|
||||
### 🍓 Raspberry Pi
|
||||
|
||||
4. **Manual Kiosk Setup** (alternative)
|
||||
```bash
|
||||
# Install unclutter to hide mouse cursor
|
||||
sudo apt-get install unclutter
|
||||
```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
|
||||
|
||||
# Standard installation
|
||||
./setup-pi.sh
|
||||
|
||||
# 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
|
||||
```
|
||||
# Or with kiosk mode (fullscreen, auto-start on boot)
|
||||
./setup-pi.sh --kiosk
|
||||
```
|
||||
|
||||
### 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
|
||||
# Python 3
|
||||
cd openhamclock
|
||||
python3 -m http.server 8080
|
||||
# Development
|
||||
npm run electron
|
||||
|
||||
# Build for your platform
|
||||
npm run electron:build
|
||||
|
||||
# Then open http://localhost:8080
|
||||
# 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
|
||||
// Your callsign
|
||||
const [callsign, setCallsign] = useState('YOUR_CALL');
|
||||
### ☁️ Deploy to Railway
|
||||
|
||||
// Your location (lat, lon)
|
||||
const [deLocation, setDeLocation] = useState({ lat: 39.7392, lon: -104.9903 });
|
||||
[](https://railway.app/template/openhamclock)
|
||||
|
||||
// Default DX location
|
||||
const [dxLocation, setDxLocation] = useState({ lat: 35.6762, lon: 139.6503 });
|
||||
```
|
||||
Or manually:
|
||||
1. Fork this repository
|
||||
2. Create a new project on [Railway](https://railway.app)
|
||||
3. Connect your GitHub repository
|
||||
4. Deploy!
|
||||
|
||||
### Future Configuration File
|
||||
---
|
||||
|
||||
A separate `config.js` will be provided for easier configuration:
|
||||
## ⚙️ Configuration
|
||||
|
||||
Edit your callsign and location in `public/index.html`:
|
||||
|
||||
```javascript
|
||||
// config.js (coming soon)
|
||||
export default {
|
||||
callsign: 'K0CJH',
|
||||
location: {
|
||||
lat: 39.7392,
|
||||
lon: -104.9903
|
||||
},
|
||||
theme: 'dark',
|
||||
panels: ['clock', 'map', 'weather', 'dx', 'bands'],
|
||||
// ... more options
|
||||
const CONFIG = {
|
||||
callsign: 'YOUR_CALL',
|
||||
location: { lat: YOUR_LAT, lon: YOUR_LON },
|
||||
defaultDX: { lat: 35.6762, lon: 139.6503 },
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
## Display Resolutions
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `3000` | Server port |
|
||||
| `NODE_ENV` | `development` | Environment mode |
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Map Styles
|
||||
|
||||
OpenHamClock is responsive and works at various resolutions:
|
||||
| Style | Provider | Best For |
|
||||
|-------|----------|----------|
|
||||
| **Dark** | CartoDB | Night use, low-light shacks |
|
||||
| **Satellite** | ESRI | Terrain visualization |
|
||||
| **Terrain** | OpenTopoMap | SOTA operations |
|
||||
| **Streets** | OpenStreetMap | Urban navigation |
|
||||
| **Topo** | ESRI | Detailed terrain |
|
||||
| **Ocean** | ESRI | Maritime operations |
|
||||
| **NatGeo** | ESRI | Classic cartography |
|
||||
| **Gray** | ESRI | Minimal, distraction-free |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone https://github.com/k0cjh/openhamclock.git
|
||||
cd openhamclock
|
||||
npm install
|
||||
|
||||
| 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 |
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
## API Data Sources (Planned)
|
||||
# Run Electron in dev mode
|
||||
npm run electron
|
||||
```
|
||||
|
||||
| Data | Source | Status |
|
||||
|------|--------|--------|
|
||||
| Space Weather | NOAA SWPC | Planned |
|
||||
| Band Conditions | hamqsl.com | Planned |
|
||||
| DX Cluster | Various Telnet nodes | Planned |
|
||||
| POTA | pota.app API | Planned |
|
||||
| SOTA | sotawatch.org | Planned |
|
||||
| Satellites | N2YO, CelesTrak | Planned |
|
||||
### Project Structure
|
||||
|
||||
## Technical Details
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
### Architecture
|
||||
---
|
||||
|
||||
- **Frontend**: React 18 (single-file, no build required)
|
||||
- **Styling**: CSS-in-JS with CSS variables for theming
|
||||
- **Maps**: SVG-based rendering (no external tiles)
|
||||
- **Data**: Currently static, API integration planned
|
||||
## 🤝 Contributing
|
||||
|
||||
### Browser Support
|
||||
We welcome contributions from the amateur radio community! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
- Chrome/Chromium 80+
|
||||
- Firefox 75+
|
||||
- Safari 13+
|
||||
- Edge 80+
|
||||
### Priority Areas
|
||||
|
||||
### Dependencies
|
||||
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
|
||||
|
||||
None! OpenHamClock loads React and Babel from CDN for simplicity.
|
||||
### How to Contribute
|
||||
|
||||
For offline/airgapped deployments, download these files:
|
||||
- react.production.min.js
|
||||
- react-dom.production.min.js
|
||||
- babel.min.js
|
||||
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
|
||||
|
||||
## Contributing
|
||||
---
|
||||
|
||||
Contributions are welcome! Areas where help is needed:
|
||||
## 📜 License
|
||||
|
||||
1. **API Integrations** - Connect to live data sources
|
||||
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
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## License
|
||||
---
|
||||
|
||||
MIT License - Free for personal and commercial use.
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
## 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
|
||||
|
||||
- **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
|
||||
## 📞 Contact
|
||||
|
||||
- **GitHub**: [https://github.com/accius/openhamclock.git](https://github.com/accius/openhamclock.git)
|
||||
- **Email**: chris@cjhlighting.com
|
||||
- **GitHub Issues**: [Report bugs or request features](https://github.com/k0cjh/openhamclock/issues)
|
||||
- **Discussions**: [Join the conversation](https://github.com/k0cjh/openhamclock/discussions)
|
||||
|
||||
---
|
||||
|
||||
**73 de K0CJH**
|
||||
<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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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: '© <a href="https://www.esri.com/">Esri</a>'
|
||||
},
|
||||
terrain: {
|
||||
name: 'Terrain',
|
||||
url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://opentopomap.org">OpenTopoMap</a>'
|
||||
},
|
||||
streets: {
|
||||
name: 'Streets',
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
},
|
||||
topo: {
|
||||
name: 'Topo',
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: '© <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: '© <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: '© <a href="https://www.esri.com/">Esri</a> © National Geographic'
|
||||
},
|
||||
gray: {
|
||||
name: 'Gray',
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: '© <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…
Reference in new issue