Merge branch 'main' into PR_TranslateProposal

pull/27/head
accius 2 days ago committed by GitHub
commit e04cc40b53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -16,8 +16,8 @@ CALLSIGN=N0CALL
LOCATOR=FN31 LOCATOR=FN31
# Your station coordinates (optional - calculated from LOCATOR if not set) # Your station coordinates (optional - calculated from LOCATOR if not set)
LATITUDE= # LATITUDE=40.7128
LONGITUDE= # LONGITUDE=-74.0060
# =========================================== # ===========================================
# SERVER SETTINGS # SERVER SETTINGS
@ -53,14 +53,16 @@ LAYOUT=modern
# =========================================== # ===========================================
# ITURHFProp service URL (for advanced propagation predictions) # ITURHFProp service URL (for advanced propagation predictions)
ITURHFPROP_URL= # Only uncomment if you have your own ITURHFProp service running
# ITURHFPROP_URL=https://your-iturhfprop-service.com
# DX Spider Proxy URL (for DX cluster spots) # DX Spider Proxy URL (for DX cluster spots)
DXSPIDER_PROXY_URL= # Only uncomment if you have your own proxy running
# DXSPIDER_PROXY_URL=https://your-dxspider-proxy.com
# OpenWeatherMap API key (for local weather display) # OpenWeatherMap API key (for local weather display)
# Get a free key at https://openweathermap.org/api # Get a free key at https://openweathermap.org/api
OPENWEATHER_API_KEY= # OPENWEATHER_API_KEY=your_api_key_here
# =========================================== # ===========================================
# FEATURE TOGGLES # FEATURE TOGGLES
@ -80,7 +82,7 @@ SHOW_DX_PATHS=true
# =========================================== # ===========================================
# Your callsign for DX cluster login (default: CALLSIGN-56) # Your callsign for DX cluster login (default: CALLSIGN-56)
DX_CLUSTER_CALLSIGN= # DX_CLUSTER_CALLSIGN=N0CALL-56
# Spot retention time in minutes (5-30) # Spot retention time in minutes (5-30)
SPOT_RETENTION_MINUTES=30 SPOT_RETENTION_MINUTES=30

@ -2,6 +2,29 @@
All notable changes to OpenHamClock will be documented in this file. All notable changes to OpenHamClock will be documented in this file.
## [3.11.0] - 2025-02-02
### Added
- **PSKReporter Integration** - See where your digital mode signals are being received
- New PSKReporter panel shows stations hearing you and stations you're hearing
- Supports FT8, FT4, JS8, and other digital modes
- Configurable time window (5, 15, 30 min, 1 hour)
- Shows band, mode, SNR, and age of each report
- Click on a report to center map on that location
### Changed
- **Bandwidth Optimization** - Reduced network egress by ~85%
- Added GZIP compression (70-90% smaller responses)
- Server-side caching for all external API calls
- Reduced client polling intervals (DX Cluster: 5s→30s, POTA: 60s→120s)
- Added HTTP Cache-Control headers
- POTA now uses server proxy instead of direct API calls
### Fixed
- Empty ITURHFPROP_URL causing "Only absolute URLs supported" error
- Satellite TLE fetch timeout errors now handled silently
- Reduced console log spam for network errors
## [3.10.0] - 2025-02-02 ## [3.10.0] - 2025-02-02
### Added ### Added
@ -9,6 +32,7 @@ All notable changes to OpenHamClock will be documented in this file.
- `.env` is auto-created from `.env.example` on first run - `.env` is auto-created from `.env.example` on first run
- Settings won't be overwritten by git updates - Settings won't be overwritten by git updates
- Supports: CALLSIGN, LOCATOR, PORT, HOST, UNITS, TIME_FORMAT, THEME, LAYOUT - Supports: CALLSIGN, LOCATOR, PORT, HOST, UNITS, TIME_FORMAT, THEME, LAYOUT
- **Auto-build on start** - `npm start` automatically builds the React frontend if needed
- **Update script** - Easy updates for local/Pi installations (`./scripts/update.sh`) - **Update script** - Easy updates for local/Pi installations (`./scripts/update.sh`)
- Backs up config, pulls latest, rebuilds, preserves settings - Backs up config, pulls latest, rebuilds, preserves settings
- **Network access configuration** - Set `HOST=0.0.0.0` to access from other devices - **Network access configuration** - Set `HOST=0.0.0.0` to access from other devices

@ -8,7 +8,7 @@ A modern, modular amateur radio dashboard built with React and Vite. This is the
# Install dependencies # Install dependencies
npm install npm install
# Start the server (auto-creates .env on first run) # Start the server (auto-builds frontend and creates .env on first run)
npm start npm start
# Edit .env with your callsign and grid locator # Edit .env with your callsign and grid locator
@ -18,14 +18,17 @@ npm start
# Open http://localhost:3000 # Open http://localhost:3000
``` ```
**That's it!** On first run, the server automatically creates a `.env` file from `.env.example`. Just edit it with your callsign and locator. **That's it!** On first run:
- Frontend is automatically built (React app compiled to `dist/`)
- `.env` file is created from `.env.example`
- Just edit `.env` with your callsign and locator
For development with hot reload: For development with hot reload:
```bash ```bash
# Terminal 1: Backend API server # Terminal 1: Backend API server
node server.js node server.js
# Terminal 2: Frontend dev server # Terminal 2: Frontend dev server with hot reload
npm run dev npm run dev
``` ```

@ -1,23 +1,26 @@
{ {
"name": "openhamclock", "name": "openhamclock",
"version": "3.7.0", "version": "3.12.0",
"description": "Amateur Radio Dashboard - A modern web-based HamClock alternative", "description": "Amateur Radio Dashboard - A modern web-based HamClock alternative",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"prestart": "node -e \"const fs=require('fs'); if(!fs.existsSync('dist/index.html')){console.log('Building frontend...'); require('child_process').execSync('npm run build',{stdio:'inherit'})}\"",
"start": "node server.js", "start": "node server.js",
"server": "node server.js", "server": "node server.js",
"test": "echo \"Tests passing\" && exit 0" "test": "echo \"Tests passing\" && exit 0"
}, },
"dependencies": { "dependencies": {
"axios": "^1.6.2", "axios": "^1.6.2",
"compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"i18next": "^25.8.0", "i18next": "^25.8.0",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"mqtt": "^5.3.4",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",
"satellite.js": "^5.0.0", "satellite.js": "^5.0.0",
@ -36,7 +39,8 @@
"dx-cluster", "dx-cluster",
"propagation", "propagation",
"pota", "pota",
"satellite-tracking" "satellite-tracking",
"pskreporter"
], ],
"author": "K0CJH", "author": "K0CJH",
"license": "MIT" "license": "MIT"

@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenHamClock - Build Required</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
text-align: center;
padding: 40px;
max-width: 600px;
}
h1 {
color: #fbbf24;
font-size: 2.5rem;
margin-bottom: 20px;
}
p {
font-size: 1.1rem;
line-height: 1.6;
margin-bottom: 15px;
color: #aaa;
}
code {
background: #2d2d44;
padding: 4px 10px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
color: #4ade80;
}
.command {
background: #2d2d44;
padding: 20px;
border-radius: 8px;
margin: 30px 0;
text-align: left;
}
.command code {
display: block;
background: none;
padding: 5px 0;
}
a.button {
display: inline-block;
background: #4ade80;
color: #1a1a2e;
padding: 12px 24px;
border-radius: 6px;
text-decoration: none;
font-weight: bold;
margin: 20px 0;
}
a.button:hover {
background: #22c55e;
}
.note {
font-size: 0.9rem;
color: #888;
margin-top: 30px;
}
</style>
</head>
<body>
<div class="container">
<h1>📻 OpenHamClock</h1>
<p>The modular frontend needs to be built first.</p>
<a href="/index-monolithic.html" class="button">Use Classic Version Instead</a>
<p style="margin-top: 30px;">Or build the modular version:</p>
<div class="command">
<code>npm install</code>
<code>npm run build</code>
<code>npm start</code>
</div>
<p class="note">
The classic version works without building.<br>
The modular version requires Node.js 18+ to build.
</p>
</div>
</body>
</html>

@ -18,6 +18,7 @@
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const compression = require('compression');
const path = require('path'); const path = require('path');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const net = require('net'); const net = require('net');
@ -156,7 +157,10 @@ if (configMissing) {
} }
// ITURHFProp service URL (optional - enables hybrid mode) // ITURHFProp service URL (optional - enables hybrid mode)
const ITURHFPROP_URL = process.env.ITURHFPROP_URL || null; // Must be a full URL like https://iturhfprop.example.com
const ITURHFPROP_URL = process.env.ITURHFPROP_URL && process.env.ITURHFPROP_URL.trim().startsWith('http')
? process.env.ITURHFPROP_URL.trim()
: null;
// Log configuration // Log configuration
console.log(`[Config] Station: ${CONFIG.callsign} @ ${CONFIG.gridSquare || 'No grid'}`); console.log(`[Config] Station: ${CONFIG.callsign} @ ${CONFIG.gridSquare || 'No grid'}`);
@ -172,6 +176,49 @@ if (ITURHFPROP_URL) {
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
// GZIP compression - reduces response sizes by 70-90%
// This is critical for reducing bandwidth/egress costs
app.use(compression({
level: 6, // Balanced compression level (1-9)
threshold: 1024, // Only compress responses > 1KB
filter: (req, res) => {
// Compress everything except already-compressed formats
if (req.headers['x-no-compression']) return false;
return compression.filter(req, res);
}
}));
// API response caching middleware
// Sets Cache-Control headers based on endpoint to reduce client polling
app.use('/api', (req, res, next) => {
// Determine cache duration based on endpoint
let cacheDuration = 30; // Default: 30 seconds
const path = req.path.toLowerCase();
if (path.includes('/satellites/tle')) {
cacheDuration = 3600; // 1 hour (TLE data is static)
} else if (path.includes('/contests') || path.includes('/dxpeditions')) {
cacheDuration = 1800; // 30 minutes (contests/expeditions change slowly)
} else if (path.includes('/solar-indices') || path.includes('/noaa')) {
cacheDuration = 300; // 5 minutes (space weather updates every 5 min)
} else if (path.includes('/propagation')) {
cacheDuration = 600; // 10 minutes
} else if (path.includes('/pota') || path.includes('/sota')) {
cacheDuration = 120; // 2 minutes
} else if (path.includes('/pskreporter')) {
cacheDuration = 300; // 5 minutes (PSKReporter rate limits aggressively)
} else if (path.includes('/dxcluster') || path.includes('/myspots')) {
cacheDuration = 30; // 30 seconds (DX spots need to be relatively fresh)
} else if (path.includes('/config')) {
cacheDuration = 3600; // 1 hour (config rarely changes)
}
res.setHeader('Cache-Control', `public, max-age=${cacheDuration}`);
res.setHeader('Vary', 'Accept-Encoding');
next();
});
// ============================================ // ============================================
// RATE-LIMITED LOGGING // RATE-LIMITED LOGGING
// ============================================ // ============================================
@ -192,29 +239,69 @@ function logErrorOnce(category, message) {
return false; return false;
} }
// Serve static files - use 'dist' in production (Vite build), 'public' in development // Serve static files
const staticDir = process.env.NODE_ENV === 'production' // dist/ contains the built React app (from npm run build)
? path.join(__dirname, 'dist') // public/ contains the fallback page if build hasn't run
: path.join(__dirname, 'public'); const distDir = path.join(__dirname, 'dist');
app.use(express.static(staticDir)); const publicDir = path.join(__dirname, 'public');
// Check if dist/ exists (has index.html from build)
const distExists = fs.existsSync(path.join(distDir, 'index.html'));
// Static file caching options
const staticOptions = {
maxAge: '1d', // Cache static files for 1 day
etag: true,
lastModified: true
};
// Long-term caching for hashed assets (Vite adds hash to filenames)
const assetOptions = {
maxAge: '1y', // Cache hashed assets for 1 year
immutable: true
};
// Also serve public folder for any additional assets if (distExists) {
if (process.env.NODE_ENV === 'production') { // Serve built React app from dist/
app.use(express.static(path.join(__dirname, 'public'))); // Hashed assets (with content hash in filename) can be cached forever
app.use('/assets', express.static(path.join(distDir, 'assets'), assetOptions));
app.use(express.static(distDir, staticOptions));
console.log('[Server] Serving React app from dist/');
} else {
// No build found - serve placeholder from public/
console.log('[Server] ⚠️ No build found! Run: npm run build');
} }
// Always serve public folder (for fallback and assets)
app.use(express.static(publicDir, staticOptions));
// ============================================ // ============================================
// API PROXY ENDPOINTS // API PROXY ENDPOINTS
// ============================================ // ============================================
// Centralized cache for NOAA data (5-minute cache)
const noaaCache = {
flux: { data: null, timestamp: 0 },
kindex: { data: null, timestamp: 0 },
sunspots: { data: null, timestamp: 0 },
xray: { data: null, timestamp: 0 },
solarIndices: { data: null, timestamp: 0 }
};
const NOAA_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// NOAA Space Weather - Solar Flux // NOAA Space Weather - Solar Flux
app.get('/api/noaa/flux', async (req, res) => { app.get('/api/noaa/flux', async (req, res) => {
try { try {
if (noaaCache.flux.data && (Date.now() - noaaCache.flux.timestamp) < NOAA_CACHE_TTL) {
return res.json(noaaCache.flux.data);
}
const response = await fetch('https://services.swpc.noaa.gov/json/f107_cm_flux.json'); const response = await fetch('https://services.swpc.noaa.gov/json/f107_cm_flux.json');
const data = await response.json(); const data = await response.json();
noaaCache.flux = { data, timestamp: Date.now() };
res.json(data); res.json(data);
} catch (error) { } catch (error) {
console.error('NOAA Flux API error:', error.message); console.error('NOAA Flux API error:', error.message);
if (noaaCache.flux.data) return res.json(noaaCache.flux.data);
res.status(500).json({ error: 'Failed to fetch solar flux data' }); res.status(500).json({ error: 'Failed to fetch solar flux data' });
} }
}); });
@ -222,11 +309,16 @@ app.get('/api/noaa/flux', async (req, res) => {
// NOAA Space Weather - K-Index // NOAA Space Weather - K-Index
app.get('/api/noaa/kindex', async (req, res) => { app.get('/api/noaa/kindex', async (req, res) => {
try { try {
if (noaaCache.kindex.data && (Date.now() - noaaCache.kindex.timestamp) < NOAA_CACHE_TTL) {
return res.json(noaaCache.kindex.data);
}
const response = await fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json'); const response = await fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json');
const data = await response.json(); const data = await response.json();
noaaCache.kindex = { data, timestamp: Date.now() };
res.json(data); res.json(data);
} catch (error) { } catch (error) {
console.error('NOAA K-Index API error:', error.message); console.error('NOAA K-Index API error:', error.message);
if (noaaCache.kindex.data) return res.json(noaaCache.kindex.data);
res.status(500).json({ error: 'Failed to fetch K-index data' }); res.status(500).json({ error: 'Failed to fetch K-index data' });
} }
}); });
@ -234,11 +326,16 @@ app.get('/api/noaa/kindex', async (req, res) => {
// NOAA Space Weather - Sunspots // NOAA Space Weather - Sunspots
app.get('/api/noaa/sunspots', async (req, res) => { app.get('/api/noaa/sunspots', async (req, res) => {
try { try {
if (noaaCache.sunspots.data && (Date.now() - noaaCache.sunspots.timestamp) < NOAA_CACHE_TTL) {
return res.json(noaaCache.sunspots.data);
}
const response = await fetch('https://services.swpc.noaa.gov/json/solar-cycle/observed-solar-cycle-indices.json'); const response = await fetch('https://services.swpc.noaa.gov/json/solar-cycle/observed-solar-cycle-indices.json');
const data = await response.json(); const data = await response.json();
noaaCache.sunspots = { data, timestamp: Date.now() };
res.json(data); res.json(data);
} catch (error) { } catch (error) {
console.error('NOAA Sunspots API error:', error.message); console.error('NOAA Sunspots API error:', error.message);
if (noaaCache.sunspots.data) return res.json(noaaCache.sunspots.data);
res.status(500).json({ error: 'Failed to fetch sunspot data' }); res.status(500).json({ error: 'Failed to fetch sunspot data' });
} }
}); });
@ -246,6 +343,11 @@ app.get('/api/noaa/sunspots', async (req, res) => {
// Solar Indices with History and Kp Forecast // Solar Indices with History and Kp Forecast
app.get('/api/solar-indices', async (req, res) => { app.get('/api/solar-indices', async (req, res) => {
try { try {
// Check cache first
if (noaaCache.solarIndices.data && (Date.now() - noaaCache.solarIndices.timestamp) < NOAA_CACHE_TTL) {
return res.json(noaaCache.solarIndices.data);
}
const [fluxRes, kIndexRes, kForecastRes, sunspotRes] = await Promise.allSettled([ const [fluxRes, kIndexRes, kForecastRes, sunspotRes] = await Promise.allSettled([
fetch('https://services.swpc.noaa.gov/json/f107_cm_flux.json'), 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/products/noaa-planetary-k-index.json'),
@ -314,9 +416,14 @@ app.get('/api/solar-indices', async (req, res) => {
} }
} }
// Cache the result
noaaCache.solarIndices = { data: result, timestamp: Date.now() };
res.json(result); res.json(result);
} catch (error) { } catch (error) {
console.error('Solar Indices API error:', error.message); console.error('Solar Indices API error:', error.message);
// Return stale cache on error
if (noaaCache.solarIndices.data) return res.json(noaaCache.solarIndices.data);
res.status(500).json({ error: 'Failed to fetch solar indices' }); res.status(500).json({ error: 'Failed to fetch solar indices' });
} }
}); });
@ -573,38 +680,85 @@ app.get('/api/noaa/xray', async (req, res) => {
}); });
// POTA Spots // POTA Spots
// POTA cache (2 minutes)
let potaCache = { data: null, timestamp: 0 };
const POTA_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
app.get('/api/pota/spots', async (req, res) => { app.get('/api/pota/spots', async (req, res) => {
try { try {
// Return cached data if fresh
if (potaCache.data && (Date.now() - potaCache.timestamp) < POTA_CACHE_TTL) {
return res.json(potaCache.data);
}
const response = await fetch('https://api.pota.app/spot/activator'); const response = await fetch('https://api.pota.app/spot/activator');
const data = await response.json(); const data = await response.json();
// Cache the response
potaCache = { data, timestamp: Date.now() };
res.json(data); res.json(data);
} catch (error) { } catch (error) {
console.error('POTA API error:', error.message); console.error('POTA API error:', error.message);
// Return stale cache on error
if (potaCache.data) return res.json(potaCache.data);
res.status(500).json({ error: 'Failed to fetch POTA spots' }); res.status(500).json({ error: 'Failed to fetch POTA spots' });
} }
}); });
// SOTA cache (2 minutes)
let sotaCache = { data: null, timestamp: 0 };
const SOTA_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
// SOTA Spots // SOTA Spots
app.get('/api/sota/spots', async (req, res) => { app.get('/api/sota/spots', async (req, res) => {
try { try {
// Return cached data if fresh
if (sotaCache.data && (Date.now() - sotaCache.timestamp) < SOTA_CACHE_TTL) {
return res.json(sotaCache.data);
}
const response = await fetch('https://api2.sota.org.uk/api/spots/50/all'); const response = await fetch('https://api2.sota.org.uk/api/spots/50/all');
const data = await response.json(); const data = await response.json();
// Cache the response
sotaCache = { data, timestamp: Date.now() };
res.json(data); res.json(data);
} catch (error) { } catch (error) {
console.error('SOTA API error:', error.message); console.error('SOTA API error:', error.message);
if (sotaCache.data) return res.json(sotaCache.data);
res.status(500).json({ error: 'Failed to fetch SOTA spots' }); res.status(500).json({ error: 'Failed to fetch SOTA spots' });
} }
}); });
// HamQSL cache (5 minutes)
let hamqslCache = { data: null, timestamp: 0 };
const HAMQSL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// HamQSL Band Conditions // HamQSL Band Conditions
app.get('/api/hamqsl/conditions', async (req, res) => { app.get('/api/hamqsl/conditions', async (req, res) => {
try { try {
// Return cached data if fresh
if (hamqslCache.data && (Date.now() - hamqslCache.timestamp) < HAMQSL_CACHE_TTL) {
res.set('Content-Type', 'application/xml');
return res.send(hamqslCache.data);
}
const response = await fetch('https://www.hamqsl.com/solarxml.php'); const response = await fetch('https://www.hamqsl.com/solarxml.php');
const text = await response.text(); const text = await response.text();
// Cache the response
hamqslCache = { data: text, timestamp: Date.now() };
res.set('Content-Type', 'application/xml'); res.set('Content-Type', 'application/xml');
res.send(text); res.send(text);
} catch (error) { } catch (error) {
console.error('HamQSL API error:', error.message); console.error('HamQSL API error:', error.message);
if (hamqslCache.data) {
res.set('Content-Type', 'application/xml');
return res.send(hamqslCache.data);
}
res.status(500).json({ error: 'Failed to fetch band conditions' }); res.status(500).json({ error: 'Failed to fetch band conditions' });
} }
}); });
@ -1698,6 +1852,295 @@ app.get('/api/myspots/:callsign', async (req, res) => {
} }
}); });
// ============================================
// PSKREPORTER API (MQTT-based for real-time)
// ============================================
// PSKReporter MQTT feed at mqtt.pskreporter.info provides real-time spots
// WebSocket endpoints: 1885 (ws), 1886 (wss)
// Topic format: pskr/filter/v2/{band}/{mode}/{sendercall}/{receivercall}/{senderlocator}/{receiverlocator}/{sendercountry}/{receivercountry}
// Cache for PSKReporter data - stores recent spots from MQTT
const pskReporterSpots = {
tx: new Map(), // Map of callsign -> spots where they're being heard
rx: new Map(), // Map of callsign -> spots they're receiving
maxAge: 60 * 60 * 1000 // Keep spots for 1 hour max
};
// Clean up old spots periodically
setInterval(() => {
const cutoff = Date.now() - pskReporterSpots.maxAge;
for (const [call, spots] of pskReporterSpots.tx) {
const filtered = spots.filter(s => s.timestamp > cutoff);
if (filtered.length === 0) {
pskReporterSpots.tx.delete(call);
} else {
pskReporterSpots.tx.set(call, filtered);
}
}
for (const [call, spots] of pskReporterSpots.rx) {
const filtered = spots.filter(s => s.timestamp > cutoff);
if (filtered.length === 0) {
pskReporterSpots.rx.delete(call);
} else {
pskReporterSpots.rx.set(call, filtered);
}
}
}, 5 * 60 * 1000); // Clean every 5 minutes
// Convert grid square to lat/lon
function gridToLatLonSimple(grid) {
if (!grid || grid.length < 4) return null;
const g = grid.toUpperCase();
const lon = (g.charCodeAt(0) - 65) * 20 - 180;
const lat = (g.charCodeAt(1) - 65) * 10 - 90;
const lonMin = parseInt(g[2]) * 2;
const latMin = parseInt(g[3]) * 1;
let finalLon = lon + lonMin + 1;
let finalLat = lat + latMin + 0.5;
// If 6-character grid, add more precision
if (grid.length >= 6) {
const lonSec = (g.charCodeAt(4) - 65) * (2/24);
const latSec = (g.charCodeAt(5) - 65) * (1/24);
finalLon = lon + lonMin + lonSec + (1/24);
finalLat = lat + latMin + latSec + (0.5/24);
}
return { lat: finalLat, lon: finalLon };
}
// Get band name from frequency in Hz
function getBandFromHz(freqHz) {
const freq = freqHz / 1000000; // Convert to MHz
if (freq >= 1.8 && freq <= 2) return '160m';
if (freq >= 3.5 && freq <= 4) return '80m';
if (freq >= 5.3 && freq <= 5.4) return '60m';
if (freq >= 7 && freq <= 7.3) return '40m';
if (freq >= 10.1 && freq <= 10.15) return '30m';
if (freq >= 14 && freq <= 14.35) return '20m';
if (freq >= 18.068 && freq <= 18.168) return '17m';
if (freq >= 21 && freq <= 21.45) return '15m';
if (freq >= 24.89 && freq <= 24.99) return '12m';
if (freq >= 28 && freq <= 29.7) return '10m';
if (freq >= 50 && freq <= 54) return '6m';
if (freq >= 144 && freq <= 148) return '2m';
if (freq >= 420 && freq <= 450) return '70cm';
return 'Unknown';
}
// PSKReporter endpoint - returns MQTT connection info for frontend
// The frontend connects directly to MQTT via WebSocket for real-time updates
app.get('/api/pskreporter/config', (req, res) => {
res.json({
mqtt: {
host: 'mqtt.pskreporter.info',
wsPort: 1885, // WebSocket
wssPort: 1886, // WebSocket + TLS (recommended)
topicPrefix: 'pskr/filter/v2'
},
// Topic format: pskr/filter/v2/{band}/{mode}/{sendercall}/{receivercall}/{senderlocator}/{receiverlocator}/{sendercountry}/{receivercountry}
// Use + for single-level wildcard, # for multi-level
// Example for TX (being heard): pskr/filter/v2/+/+/{CALLSIGN}/#
// Example for RX (hearing): Subscribe and filter client-side
info: 'Connect via WebSocket to mqtt.pskreporter.info:1886 (wss) for real-time spots'
});
});
// Fallback HTTP endpoint for when MQTT isn't available
// Uses the traditional retrieve API with caching
let pskHttpCache = {};
const PSK_HTTP_CACHE_TTL = 10 * 60 * 1000; // 10 minutes - PSKReporter rate limits aggressively
let psk503Backoff = 0; // Timestamp when we can try again after 503
app.get('/api/pskreporter/http/:callsign', async (req, res) => {
const callsign = req.params.callsign.toUpperCase();
const minutes = parseInt(req.query.minutes) || 15;
const direction = req.query.direction || 'tx'; // tx or rx
// flowStartSeconds must be NEGATIVE for "last N seconds"
const flowStartSeconds = -Math.abs(minutes * 60);
const cacheKey = `${direction}:${callsign}:${minutes}`;
const now = Date.now();
// Check cache first
if (pskHttpCache[cacheKey] && (now - pskHttpCache[cacheKey].timestamp) < PSK_HTTP_CACHE_TTL) {
return res.json({ ...pskHttpCache[cacheKey].data, cached: true });
}
// If we're in 503 backoff period, return cached data or empty result
if (psk503Backoff > now) {
console.log(`[PSKReporter HTTP] In backoff period, ${Math.round((psk503Backoff - now) / 1000)}s remaining`);
if (pskHttpCache[cacheKey]) {
return res.json({ ...pskHttpCache[cacheKey].data, cached: true, stale: true });
}
return res.json({
callsign,
direction,
count: 0,
reports: [],
backoff: true
});
}
try {
const param = direction === 'tx' ? 'senderCallsign' : 'receiverCallsign';
// Add appcontact parameter as requested by PSKReporter developer docs
const url = `https://retrieve.pskreporter.info/query?${param}=${encodeURIComponent(callsign)}&flowStartSeconds=${flowStartSeconds}&rronly=1&appcontact=openhamclock`;
console.log(`[PSKReporter HTTP] Fetching ${direction} for ${callsign} (last ${minutes} min)`);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000);
const response = await fetch(url, {
headers: {
'User-Agent': 'OpenHamClock/3.11 (Amateur Radio Dashboard)',
'Accept': '*/*'
},
signal: controller.signal
});
clearTimeout(timeout);
if (!response.ok) {
// On 503, set backoff period (15 minutes) to avoid hammering
if (response.status === 503) {
psk503Backoff = Date.now() + (15 * 60 * 1000);
console.log(`[PSKReporter HTTP] Got 503, backing off for 15 minutes`);
}
throw new Error(`HTTP ${response.status}`);
}
const xml = await response.text();
const reports = [];
// Parse XML response
const reportRegex = /<receptionReport[^>]*>/g;
let match;
while ((match = reportRegex.exec(xml)) !== null) {
const report = match[0];
const getAttr = (name) => {
const m = report.match(new RegExp(`${name}="([^"]*)"`));
return m ? m[1] : null;
};
const receiverCallsign = getAttr('receiverCallsign');
const receiverLocator = getAttr('receiverLocator');
const senderCallsign = getAttr('senderCallsign');
const senderLocator = getAttr('senderLocator');
const frequency = getAttr('frequency');
const mode = getAttr('mode');
const flowStartSecs = getAttr('flowStartSeconds');
const sNR = getAttr('sNR');
if (receiverCallsign && senderCallsign) {
const locator = direction === 'tx' ? receiverLocator : senderLocator;
const loc = locator ? gridToLatLonSimple(locator) : null;
reports.push({
sender: senderCallsign,
senderGrid: senderLocator,
receiver: receiverCallsign,
receiverGrid: receiverLocator,
freq: frequency ? parseInt(frequency) : null,
freqMHz: frequency ? (parseInt(frequency) / 1000000).toFixed(3) : null,
band: frequency ? getBandFromHz(parseInt(frequency)) : 'Unknown',
mode: mode || 'Unknown',
timestamp: flowStartSecs ? parseInt(flowStartSecs) * 1000 : Date.now(),
snr: sNR ? parseInt(sNR) : null,
lat: loc?.lat,
lon: loc?.lon,
age: flowStartSecs ? Math.floor((Date.now() / 1000 - parseInt(flowStartSecs)) / 60) : 0
});
}
}
// Sort by timestamp (newest first)
reports.sort((a, b) => b.timestamp - a.timestamp);
// Clear backoff on success
psk503Backoff = 0;
const result = {
callsign,
direction,
count: reports.length,
reports: reports.slice(0, 100),
timestamp: new Date().toISOString(),
source: 'http'
};
// Cache it
pskHttpCache[cacheKey] = { data: result, timestamp: now };
console.log(`[PSKReporter HTTP] Found ${reports.length} ${direction} reports for ${callsign}`);
res.json(result);
} catch (error) {
logErrorOnce('PSKReporter HTTP', error.message);
// Return cached data if available (without error flag)
if (pskHttpCache[cacheKey]) {
return res.json({ ...pskHttpCache[cacheKey].data, cached: true, stale: true });
}
// Return empty result without error flag for 503s (rate limiting is expected)
res.json({
callsign,
direction,
count: 0,
reports: []
});
}
});
// Combined endpoint that tries MQTT cache first, falls back to HTTP
app.get('/api/pskreporter/:callsign', async (req, res) => {
const callsign = req.params.callsign.toUpperCase();
const minutes = parseInt(req.query.minutes) || 15;
// For now, redirect to HTTP endpoint since MQTT requires client-side connection
// The frontend should connect directly to MQTT for real-time updates
try {
const [txRes, rxRes] = await Promise.allSettled([
fetch(`http://localhost:${PORT}/api/pskreporter/http/${callsign}?minutes=${minutes}&direction=tx`),
fetch(`http://localhost:${PORT}/api/pskreporter/http/${callsign}?minutes=${minutes}&direction=rx`)
]);
let txData = { count: 0, reports: [] };
let rxData = { count: 0, reports: [] };
if (txRes.status === 'fulfilled' && txRes.value.ok) {
txData = await txRes.value.json();
}
if (rxRes.status === 'fulfilled' && rxRes.value.ok) {
rxData = await rxRes.value.json();
}
res.json({
callsign,
tx: txData,
rx: rxData,
timestamp: new Date().toISOString(),
mqtt: {
available: true,
host: 'wss://mqtt.pskreporter.info:1886',
hint: 'Connect via WebSocket for real-time updates'
}
});
} catch (error) {
logErrorOnce('PSKReporter', error.message);
res.json({
callsign,
tx: { count: 0, reports: [] },
rx: { count: 0, reports: [] },
error: error.message
});
}
});
// ============================================ // ============================================
// SATELLITE TRACKING API // SATELLITE TRACKING API
// ============================================ // ============================================
@ -1770,17 +2213,16 @@ let tleCache = { data: null, timestamp: 0 };
const TLE_CACHE_DURATION = 6 * 60 * 60 * 1000; // 6 hours const TLE_CACHE_DURATION = 6 * 60 * 60 * 1000; // 6 hours
app.get('/api/satellites/tle', async (req, res) => { app.get('/api/satellites/tle', async (req, res) => {
console.log('[Satellites] Fetching TLE data...');
try { try {
const now = Date.now(); const now = Date.now();
// Return cached data if fresh // Return cached data if fresh
if (tleCache.data && (now - tleCache.timestamp) < TLE_CACHE_DURATION) { if (tleCache.data && (now - tleCache.timestamp) < TLE_CACHE_DURATION) {
console.log('[Satellites] Returning cached TLE data');
return res.json(tleCache.data); return res.json(tleCache.data);
} }
console.log('[Satellites] Fetching fresh TLE data...');
// Fetch fresh TLE data from CelesTrak // Fetch fresh TLE data from CelesTrak
const tleData = {}; const tleData = {};
@ -1819,7 +2261,6 @@ app.get('/api/satellites/tle', async (req, res) => {
tle1: line1, tle1: line1,
tle2: line2 tle2: line2
}; };
console.log('[Satellites] Found TLE for:', key, noradId);
} }
} }
} }
@ -1829,10 +2270,18 @@ app.get('/api/satellites/tle', async (req, res) => {
// Also try to get ISS specifically (it's in the stations group) // Also try to get ISS specifically (it's in the stations group)
if (!tleData['ISS']) { if (!tleData['ISS']) {
try { try {
const issController = new AbortController();
const issTimeout = setTimeout(() => issController.abort(), 10000);
const issResponse = await fetch( const issResponse = await fetch(
'https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=tle', 'https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=tle',
{ headers: { 'User-Agent': 'OpenHamClock/3.3' } } {
headers: { 'User-Agent': 'OpenHamClock/3.3' },
signal: issController.signal
}
); );
clearTimeout(issTimeout);
if (issResponse.ok) { if (issResponse.ok) {
const issText = await issResponse.text(); const issText = await issResponse.text();
const issLines = issText.trim().split('\n'); const issLines = issText.trim().split('\n');
@ -1846,7 +2295,9 @@ app.get('/api/satellites/tle', async (req, res) => {
} }
} }
} catch (e) { } catch (e) {
console.log('[Satellites] Could not fetch ISS TLE:', e.message); if (e.name !== 'AbortError') {
logErrorOnce('Satellites', `ISS TLE fetch: ${e.message}`);
}
} }
} }
@ -1857,7 +2308,10 @@ app.get('/api/satellites/tle', async (req, res) => {
res.json(tleData); res.json(tleData);
} catch (error) { } catch (error) {
console.error('[Satellites] TLE fetch error:', error.message); // Don't spam logs for timeouts (AbortError) or network issues
if (error.name !== 'AbortError') {
logErrorOnce('Satellites', `TLE fetch error: ${error.message}`);
}
// Return cached data even if stale, or empty object // Return cached data even if stale, or empty object
res.json(tleCache.data || {}); res.json(tleCache.data || {});
} }
@ -3083,9 +3537,11 @@ app.get('/api/config', (req, res) => {
// ============================================ // ============================================
app.get('*', (req, res) => { app.get('*', (req, res) => {
const indexPath = process.env.NODE_ENV === 'production' // Try dist first (built React app), fallback to public (monolithic)
? path.join(__dirname, 'dist', 'index.html') const distIndex = path.join(__dirname, 'dist', 'index.html');
: path.join(__dirname, 'public', 'index.html'); const publicIndex = path.join(__dirname, 'public', 'index.html');
const indexPath = fs.existsSync(distIndex) ? distIndex : publicIndex;
res.sendFile(indexPath); res.sendFile(indexPath);
}); });

@ -13,9 +13,11 @@ import {
ContestPanel, ContestPanel,
SettingsPanel, SettingsPanel,
DXFilterManager, DXFilterManager,
PSKFilterManager,
SolarPanel, SolarPanel,
PropagationPanel, PropagationPanel,
DXpeditionPanel DXpeditionPanel,
PSKReporterPanel
} from './components'; } from './components';
// Hooks // Hooks
@ -31,7 +33,8 @@ import {
useMySpots, useMySpots,
useDXpeditions, useDXpeditions,
useSatellites, useSatellites,
useSolarIndices useSolarIndices,
usePSKReporter
} from './hooks'; } from './hooks';
// Utils // Utils
@ -94,15 +97,16 @@ const App = () => {
// UI state // UI state
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [showDXFilters, setShowDXFilters] = useState(false); const [showDXFilters, setShowDXFilters] = useState(false);
const [showPSKFilters, setShowPSKFilters] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
// Map layer visibility // Map layer visibility
const [mapLayers, setMapLayers] = useState(() => { const [mapLayers, setMapLayers] = useState(() => {
try { try {
const stored = localStorage.getItem('openhamclock_mapLayers'); const stored = localStorage.getItem('openhamclock_mapLayers');
const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false }; const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true };
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults; return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
} catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false }; } } catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true }; }
}); });
useEffect(() => { useEffect(() => {
@ -117,6 +121,7 @@ const App = () => {
const toggleDXLabels = useCallback(() => setMapLayers(prev => ({ ...prev, showDXLabels: !prev.showDXLabels })), []); const toggleDXLabels = useCallback(() => setMapLayers(prev => ({ ...prev, showDXLabels: !prev.showDXLabels })), []);
const togglePOTA = useCallback(() => setMapLayers(prev => ({ ...prev, showPOTA: !prev.showPOTA })), []); const togglePOTA = useCallback(() => setMapLayers(prev => ({ ...prev, showPOTA: !prev.showPOTA })), []);
const toggleSatellites = useCallback(() => setMapLayers(prev => ({ ...prev, showSatellites: !prev.showSatellites })), []); const toggleSatellites = useCallback(() => setMapLayers(prev => ({ ...prev, showSatellites: !prev.showSatellites })), []);
const togglePSKReporter = useCallback(() => setMapLayers(prev => ({ ...prev, showPSKReporter: !prev.showPSKReporter })), []);
// 12/24 hour format // 12/24 hour format
const [use12Hour, setUse12Hour] = useState(() => { const [use12Hour, setUse12Hour] = useState(() => {
@ -180,6 +185,20 @@ const App = () => {
} catch (e) {} } catch (e) {}
}, [dxFilters]); }, [dxFilters]);
// PSKReporter Filters
const [pskFilters, setPskFilters] = useState(() => {
try {
const stored = localStorage.getItem('openhamclock_pskFilters');
return stored ? JSON.parse(stored) : {};
} catch (e) { return {}; }
});
useEffect(() => {
try {
localStorage.setItem('openhamclock_pskFilters', JSON.stringify(pskFilters));
} catch (e) {}
}, [pskFilters]);
const dxCluster = useDXCluster(config.dxClusterSource || 'auto', dxFilters); const dxCluster = useDXCluster(config.dxClusterSource || 'auto', dxFilters);
const dxPaths = useDXPaths(); const dxPaths = useDXPaths();
const dxpeditions = useDXpeditions(); const dxpeditions = useDXpeditions();
@ -188,6 +207,26 @@ const App = () => {
const mySpots = useMySpots(config.callsign); const mySpots = useMySpots(config.callsign);
const satellites = useSatellites(config.location); const satellites = useSatellites(config.location);
const localWeather = useLocalWeather(config.location); const localWeather = useLocalWeather(config.location);
const pskReporter = usePSKReporter(config.callsign, { minutes: 15, enabled: config.callsign !== 'N0CALL' });
// Filter PSKReporter spots for map display
const filteredPskSpots = useMemo(() => {
const allSpots = [...(pskReporter.txReports || []), ...(pskReporter.rxReports || [])];
if (!pskFilters?.bands?.length && !pskFilters?.grids?.length && !pskFilters?.modes?.length) {
return allSpots;
}
return allSpots.filter(spot => {
if (pskFilters?.bands?.length && !pskFilters.bands.includes(spot.band)) return false;
if (pskFilters?.modes?.length && !pskFilters.modes.includes(spot.mode)) return false;
if (pskFilters?.grids?.length) {
const grid = spot.receiverGrid || spot.senderGrid;
if (!grid) return false;
const gridPrefix = grid.substring(0, 2).toUpperCase();
if (!pskFilters.grids.includes(gridPrefix)) return false;
}
return true;
});
}, [pskReporter.txReports, pskReporter.rxReports, pskFilters]);
// Computed values // Computed values
const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]); const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]);
@ -457,11 +496,13 @@ const App = () => {
dxPaths={dxPaths.data} dxPaths={dxPaths.data}
dxFilters={dxFilters} dxFilters={dxFilters}
satellites={satellites.data} satellites={satellites.data}
pskReporterSpots={filteredPskSpots}
showDXPaths={mapLayers.showDXPaths} showDXPaths={mapLayers.showDXPaths}
showDXLabels={mapLayers.showDXLabels} showDXLabels={mapLayers.showDXLabels}
onToggleDXLabels={toggleDXLabels} onToggleDXLabels={toggleDXLabels}
showPOTA={mapLayers.showPOTA} showPOTA={mapLayers.showPOTA}
showSatellites={mapLayers.showSatellites} showSatellites={mapLayers.showSatellites}
showPSKReporter={mapLayers.showPSKReporter}
onToggleSatellites={toggleSatellites} onToggleSatellites={toggleSatellites}
hoveredSpot={hoveredSpot} hoveredSpot={hoveredSpot}
/> />
@ -593,11 +634,13 @@ const App = () => {
dxPaths={dxPaths.data} dxPaths={dxPaths.data}
dxFilters={dxFilters} dxFilters={dxFilters}
satellites={satellites.data} satellites={satellites.data}
pskReporterSpots={filteredPskSpots}
showDXPaths={mapLayers.showDXPaths} showDXPaths={mapLayers.showDXPaths}
showDXLabels={mapLayers.showDXLabels} showDXLabels={mapLayers.showDXLabels}
onToggleDXLabels={toggleDXLabels} onToggleDXLabels={toggleDXLabels}
showPOTA={mapLayers.showPOTA} showPOTA={mapLayers.showPOTA}
showSatellites={mapLayers.showSatellites} showSatellites={mapLayers.showSatellites}
showPSKReporter={mapLayers.showPSKReporter}
onToggleSatellites={toggleSatellites} onToggleSatellites={toggleSatellites}
hoveredSpot={hoveredSpot} hoveredSpot={hoveredSpot}
/> />
@ -617,9 +660,9 @@ const App = () => {
</div> </div>
{/* RIGHT SIDEBAR */} {/* RIGHT SIDEBAR */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', overflow: 'hidden' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '4px', overflow: 'hidden' }}>
{/* DX Cluster - takes most space */} {/* DX Cluster - primary panel, takes most space */}
<div style={{ flex: '2 1 0', minHeight: '250px', overflow: 'hidden' }}> <div style={{ flex: '2 1 auto', minHeight: '180px', overflow: 'hidden' }}>
<DXClusterPanel <DXClusterPanel
data={dxCluster.data} data={dxCluster.data}
loading={dxCluster.loading} loading={dxCluster.loading}
@ -634,13 +677,29 @@ const App = () => {
/> />
</div> </div>
{/* DXpeditions - smaller */} {/* PSKReporter - digital mode spots */}
<div style={{ flex: '0 0 auto', maxHeight: '140px', overflow: 'hidden' }}> <div style={{ flex: '1 1 auto', minHeight: '140px', overflow: 'hidden' }}>
<PSKReporterPanel
callsign={config.callsign}
showOnMap={mapLayers.showPSKReporter}
onToggleMap={togglePSKReporter}
filters={pskFilters}
onOpenFilters={() => setShowPSKFilters(true)}
onShowOnMap={(report) => {
if (report.lat && report.lon) {
setDxLocation({ lat: report.lat, lon: report.lon, call: report.receiver || report.sender });
}
}}
/>
</div>
{/* DXpeditions */}
<div style={{ flex: '0 0 auto', minHeight: '70px', maxHeight: '100px', overflow: 'hidden' }}>
<DXpeditionPanel data={dxpeditions.data} loading={dxpeditions.loading} /> <DXpeditionPanel data={dxpeditions.data} loading={dxpeditions.loading} />
</div> </div>
{/* POTA - smaller */} {/* POTA */}
<div style={{ flex: '0 0 auto', maxHeight: '120px', overflow: 'hidden' }}> <div style={{ flex: '0 0 auto', minHeight: '60px', maxHeight: '90px', overflow: 'hidden' }}>
<POTAPanel <POTAPanel
data={potaSpots.data} data={potaSpots.data}
loading={potaSpots.loading} loading={potaSpots.loading}
@ -649,8 +708,8 @@ const App = () => {
/> />
</div> </div>
{/* Contests - smaller */} {/* Contests - at bottom, compact */}
<div style={{ flex: '0 0 auto', maxHeight: '150px', overflow: 'hidden' }}> <div style={{ flex: '0 0 auto', minHeight: '80px', maxHeight: '120px', overflow: 'hidden' }}>
<ContestPanel data={contests.data} loading={contests.loading} /> <ContestPanel data={contests.data} loading={contests.loading} />
</div> </div>
</div> </div>
@ -670,6 +729,12 @@ const App = () => {
isOpen={showDXFilters} isOpen={showDXFilters}
onClose={() => setShowDXFilters(false)} onClose={() => setShowDXFilters(false)}
/> />
<PSKFilterManager
filters={pskFilters}
onFilterChange={setPskFilters}
isOpen={showPSKFilters}
onClose={() => setShowPSKFilters(false)}
/>
</div> </div>
); );
}; };

@ -1,6 +1,6 @@
/** /**
* ContestPanel Component * ContestPanel Component
* Displays upcoming contests with contestcalendar.com credit * Displays upcoming and active contests with live indicators
*/ */
import React from 'react'; import React from 'react';
@ -22,13 +22,100 @@ export const ContestPanel = ({ data, loading }) => {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}; };
const formatTime = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) + 'z';
};
// Check if contest is live (happening now)
const isContestLive = (contest) => {
if (!contest.start || !contest.end) return false;
const now = new Date();
const start = new Date(contest.start);
const end = new Date(contest.end);
return now >= start && now <= end;
};
// Check if contest starts within 24 hours
const isStartingSoon = (contest) => {
if (!contest.start) return false;
const now = new Date();
const start = new Date(contest.start);
const hoursUntil = (start - now) / (1000 * 60 * 60);
return hoursUntil > 0 && hoursUntil <= 24;
};
// Get time remaining or time until start
const getTimeInfo = (contest) => {
if (!contest.start || !contest.end) return formatDate(contest.start);
const now = new Date();
const start = new Date(contest.start);
const end = new Date(contest.end);
if (now >= start && now <= end) {
// Contest is live - show time remaining
const hoursLeft = Math.floor((end - now) / (1000 * 60 * 60));
const minsLeft = Math.floor(((end - now) % (1000 * 60 * 60)) / (1000 * 60));
if (hoursLeft > 0) {
return `${hoursLeft}h ${minsLeft}m left`;
}
return `${minsLeft}m left`;
} else if (now < start) {
// Contest hasn't started
const hoursUntil = Math.floor((start - now) / (1000 * 60 * 60));
if (hoursUntil < 24) {
return `Starts in ${hoursUntil}h`;
}
return formatDate(contest.start);
}
return formatDate(contest.start);
};
// Sort contests: live first, then starting soon, then by date
const sortedContests = data ? [...data].sort((a, b) => {
const aLive = isContestLive(a);
const bLive = isContestLive(b);
const aSoon = isStartingSoon(a);
const bSoon = isStartingSoon(b);
if (aLive && !bLive) return -1;
if (!aLive && bLive) return 1;
if (aSoon && !bSoon) return -1;
if (!aSoon && bSoon) return 1;
return new Date(a.start) - new Date(b.start);
}) : [];
// Count live contests
const liveCount = sortedContests.filter(isContestLive).length;
return ( return (
<div className="panel" style={{ padding: '8px', height: '100%', display: 'flex', flexDirection: 'column' }}> <div className="panel" style={{ padding: '8px', height: '100%', display: 'flex', flexDirection: 'column' }}>
<div className="panel-header" style={{ <div style={{
marginBottom: '6px', marginBottom: '6px',
fontSize: '11px' fontSize: '11px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
color: 'var(--accent-primary)',
fontWeight: '700'
}}> }}>
🏆 CONTESTS <span>🏆 CONTESTS</span>
{liveCount > 0 && (
<span style={{
background: 'rgba(239, 68, 68, 0.3)',
color: '#ef4444',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '9px',
fontWeight: '700',
border: '1px solid #ef4444'
}}>
🔴 {liveCount} LIVE
</span>
)}
</div> </div>
<div style={{ flex: 1, overflowY: 'auto' }}> <div style={{ flex: 1, overflowY: 'auto' }}>
@ -36,31 +123,61 @@ export const ContestPanel = ({ data, loading }) => {
<div style={{ display: 'flex', justifyContent: 'center', padding: '10px' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '10px' }}>
<div className="loading-spinner" /> <div className="loading-spinner" />
</div> </div>
) : data && data.length > 0 ? ( ) : sortedContests.length > 0 ? (
<div style={{ fontSize: '10px', fontFamily: 'JetBrains Mono, monospace' }}> <div style={{ fontSize: '10px', fontFamily: 'JetBrains Mono, monospace' }}>
{data.slice(0, 6).map((contest, i) => ( {sortedContests.slice(0, 4).map((contest, i) => {
<div const live = isContestLive(contest);
key={`${contest.name}-${i}`} const soon = isStartingSoon(contest);
style={{
padding: '4px 0', return (
borderBottom: i < Math.min(data.length, 6) - 1 ? '1px solid var(--border-color)' : 'none' <div
}} key={`${contest.name}-${i}`}
> style={{
<div style={{ padding: '5px 6px',
color: 'var(--text-primary)', marginBottom: '3px',
fontWeight: '600', borderRadius: '4px',
whiteSpace: 'nowrap', background: live ? 'rgba(239, 68, 68, 0.15)' : soon ? 'rgba(251, 191, 36, 0.1)' : 'rgba(255,255,255,0.03)',
overflow: 'hidden', border: live ? '1px solid rgba(239, 68, 68, 0.4)' : '1px solid transparent'
textOverflow: 'ellipsis' }}
}}> >
{contest.name} <div style={{
</div> display: 'flex',
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '2px' }}> alignItems: 'center',
<span style={{ color: getModeColor(contest.mode) }}>{contest.mode}</span> gap: '6px'
<span style={{ color: 'var(--text-muted)' }}>{formatDate(contest.start)}</span> }}>
{live && (
<span style={{
color: '#ef4444',
fontSize: '8px',
animation: 'pulse 1.5s infinite'
}}></span>
)}
{soon && !live && (
<span style={{ color: '#fbbf24', fontSize: '8px' }}></span>
)}
<span style={{
color: live ? '#ef4444' : 'var(--text-primary)',
fontWeight: '600',
flex: 1,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{contest.name}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '3px' }}>
<span style={{ color: getModeColor(contest.mode) }}>{contest.mode}</span>
<span style={{
color: live ? '#ef4444' : soon ? '#fbbf24' : 'var(--text-muted)',
fontWeight: live ? '600' : '400'
}}>
{getTimeInfo(contest)}
</span>
</div>
</div> </div>
</div> );
))} })}
</div> </div>
) : ( ) : (
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '10px', fontSize: '11px' }}> <div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '10px', fontSize: '11px' }}>
@ -71,8 +188,8 @@ export const ContestPanel = ({ data, loading }) => {
{/* Contest Calendar Credit */} {/* Contest Calendar Credit */}
<div style={{ <div style={{
marginTop: '6px', marginTop: '4px',
paddingTop: '6px', paddingTop: '4px',
borderTop: '1px solid var(--border-color)', borderTop: '1px solid var(--border-color)',
textAlign: 'right' textAlign: 'right'
}}> }}>

@ -0,0 +1,405 @@
/**
* PSKFilterManager Component
* Filter modal for PSKReporter spots - Bands, Grids, Modes
*/
import React, { useState } from 'react';
const BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm'];
const MODES = ['FT8', 'FT4', 'JS8', 'WSPR', 'JT65', 'JT9', 'MSK144', 'Q65', 'FST4', 'FST4W'];
// Common grid field prefixes by region
const GRID_REGIONS = [
{ name: 'North America East', grids: ['FN', 'FM', 'EN', 'EM', 'DN', 'DM'] },
{ name: 'North America West', grids: ['CN', 'CM', 'DM', 'DN', 'BN', 'BM'] },
{ name: 'Europe', grids: ['JO', 'JN', 'IO', 'IN', 'KO', 'KN', 'LO', 'LN'] },
{ name: 'South America', grids: ['GG', 'GH', 'GI', 'FG', 'FH', 'FI', 'FF', 'FE'] },
{ name: 'Asia', grids: ['PM', 'PL', 'OM', 'OL', 'QL', 'QM', 'NM', 'NL'] },
{ name: 'Oceania', grids: ['QF', 'QG', 'PF', 'PG', 'RF', 'RG', 'OF', 'OG'] },
{ name: 'Africa', grids: ['KH', 'KG', 'JH', 'JG', 'IH', 'IG'] },
];
export const PSKFilterManager = ({ filters, onFilterChange, isOpen, onClose }) => {
const [activeTab, setActiveTab] = useState('bands');
const [customGrid, setCustomGrid] = useState('');
if (!isOpen) return null;
const toggleArrayItem = (key, item) => {
const current = filters[key] || [];
const newArray = current.includes(item)
? current.filter(x => x !== item)
: [...current, item];
onFilterChange({ ...filters, [key]: newArray.length ? newArray : undefined });
};
const selectAll = (key, items) => {
onFilterChange({ ...filters, [key]: [...items] });
};
const clearFilter = (key) => {
const newFilters = { ...filters };
delete newFilters[key];
onFilterChange(newFilters);
};
const clearAllFilters = () => {
onFilterChange({});
};
const addCustomGrid = () => {
if (customGrid.trim() && customGrid.length >= 2) {
const grid = customGrid.toUpperCase().substring(0, 2);
const current = filters?.grids || [];
if (!current.includes(grid)) {
onFilterChange({ ...filters, grids: [...current, grid] });
}
setCustomGrid('');
}
};
const getActiveFilterCount = () => {
let count = 0;
if (filters?.bands?.length) count += filters.bands.length;
if (filters?.grids?.length) count += filters.grids.length;
if (filters?.modes?.length) count += filters.modes.length;
return count;
};
const tabStyle = (active) => ({
padding: '8px 16px',
background: active ? 'var(--bg-tertiary)' : 'transparent',
border: 'none',
borderBottom: active ? '2px solid var(--accent-cyan)' : '2px solid transparent',
color: active ? 'var(--accent-cyan)' : 'var(--text-muted)',
fontSize: '13px',
cursor: 'pointer',
fontFamily: 'inherit'
});
const chipStyle = (selected) => ({
padding: '6px 12px',
background: selected ? 'rgba(0, 221, 255, 0.2)' : 'var(--bg-tertiary)',
border: `1px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
borderRadius: '4px',
color: selected ? 'var(--accent-cyan)' : 'var(--text-secondary)',
fontSize: '12px',
cursor: 'pointer',
fontFamily: 'JetBrains Mono, monospace'
});
const renderBandsTab = () => (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
<span style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)' }}>
Filter by Band
</span>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={() => selectAll('bands', BANDS)}
style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', fontSize: '12px', cursor: 'pointer' }}
>
Select All
</button>
<button
onClick={() => clearFilter('bands')}
style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}
>
Clear
</button>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{BANDS.map(band => (
<button
key={band}
onClick={() => toggleArrayItem('bands', band)}
style={chipStyle(filters?.bands?.includes(band))}
>
{band}
</button>
))}
</div>
<div style={{ marginTop: '15px', fontSize: '11px', color: 'var(--text-muted)' }}>
{filters?.bands?.length
? `Showing only: ${filters.bands.join(', ')}`
: 'Showing all bands (no filter)'}
</div>
</div>
);
const renderGridsTab = () => (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
<span style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)' }}>
Filter by Grid Square
</span>
<button
onClick={() => clearFilter('grids')}
style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}
>
Clear All
</button>
</div>
{/* Custom grid input */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
<input
type="text"
placeholder="Add grid (e.g. FN)"
value={customGrid}
onChange={(e) => setCustomGrid(e.target.value.toUpperCase())}
maxLength={2}
onKeyPress={(e) => e.key === 'Enter' && addCustomGrid()}
style={{
flex: 1,
padding: '8px 12px',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: '4px',
color: 'var(--text-primary)',
fontSize: '13px',
fontFamily: 'JetBrains Mono'
}}
/>
<button
onClick={addCustomGrid}
style={{
padding: '8px 16px',
background: 'var(--accent-cyan)',
border: 'none',
borderRadius: '4px',
color: '#000',
fontSize: '12px',
cursor: 'pointer',
fontWeight: '600'
}}
>
Add
</button>
</div>
{/* Selected grids */}
{filters?.grids?.length > 0 && (
<div style={{ marginBottom: '20px' }}>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '8px' }}>
Active Grid Filters:
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{filters.grids.map(grid => (
<button
key={grid}
onClick={() => toggleArrayItem('grids', grid)}
style={{
...chipStyle(true),
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
{grid}
<span style={{ color: 'var(--accent-red)', fontWeight: '700' }}>×</span>
</button>
))}
</div>
</div>
)}
{/* Quick select by region */}
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '10px' }}>
Quick Select by Region:
</div>
{GRID_REGIONS.map(region => (
<div key={region.name} style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '6px' }}>
{region.name}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{region.grids.map(grid => (
<button
key={grid}
onClick={() => toggleArrayItem('grids', grid)}
style={{
...chipStyle(filters?.grids?.includes(grid)),
padding: '4px 8px',
fontSize: '11px'
}}
>
{grid}
</button>
))}
</div>
</div>
))}
</div>
);
const renderModesTab = () => (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
<span style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)' }}>
Filter by Mode
</span>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={() => selectAll('modes', MODES)}
style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', fontSize: '12px', cursor: 'pointer' }}
>
Select All
</button>
<button
onClick={() => clearFilter('modes')}
style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}
>
Clear
</button>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{MODES.map(mode => (
<button
key={mode}
onClick={() => toggleArrayItem('modes', mode)}
style={chipStyle(filters?.modes?.includes(mode))}
>
{mode}
</button>
))}
</div>
<div style={{ marginTop: '15px', fontSize: '11px', color: 'var(--text-muted)' }}>
{filters?.modes?.length
? `Showing only: ${filters.modes.join(', ')}`
: 'Showing all modes (no filter)'}
</div>
</div>
);
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000
}}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div style={{
background: 'var(--bg-primary)',
border: '1px solid var(--border-color)',
borderRadius: '8px',
width: '500px',
maxWidth: '95vw',
maxHeight: '85vh',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5)'
}}>
{/* Header */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px 20px',
borderBottom: '1px solid var(--border-color)'
}}>
<div>
<h3 style={{ margin: 0, fontSize: '16px', color: 'var(--text-primary)' }}>
📡 PSKReporter Filters
</h3>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
{getActiveFilterCount()} filter{getActiveFilterCount() !== 1 ? 's' : ''} active
</span>
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
color: 'var(--text-muted)',
fontSize: '24px',
cursor: 'pointer',
lineHeight: 1
}}
>
×
</button>
</div>
{/* Tabs */}
<div style={{
display: 'flex',
borderBottom: '1px solid var(--border-color)',
background: 'var(--bg-secondary)'
}}>
<button onClick={() => setActiveTab('bands')} style={tabStyle(activeTab === 'bands')}>
Bands {filters?.bands?.length ? `(${filters.bands.length})` : ''}
</button>
<button onClick={() => setActiveTab('grids')} style={tabStyle(activeTab === 'grids')}>
Grids {filters?.grids?.length ? `(${filters.grids.length})` : ''}
</button>
<button onClick={() => setActiveTab('modes')} style={tabStyle(activeTab === 'modes')}>
Modes {filters?.modes?.length ? `(${filters.modes.length})` : ''}
</button>
</div>
{/* Tab Content */}
<div style={{
flex: 1,
overflow: 'auto',
padding: '20px'
}}>
{activeTab === 'bands' && renderBandsTab()}
{activeTab === 'grids' && renderGridsTab()}
{activeTab === 'modes' && renderModesTab()}
</div>
{/* Footer */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
padding: '16px 20px',
borderTop: '1px solid var(--border-color)',
background: 'var(--bg-secondary)'
}}>
<button
onClick={clearAllFilters}
style={{
padding: '8px 16px',
background: 'transparent',
border: '1px solid var(--accent-red)',
borderRadius: '4px',
color: 'var(--accent-red)',
fontSize: '13px',
cursor: 'pointer'
}}
>
Clear All Filters
</button>
<button
onClick={onClose}
style={{
padding: '8px 24px',
background: 'var(--accent-cyan)',
border: 'none',
borderRadius: '4px',
color: '#000',
fontSize: '13px',
cursor: 'pointer',
fontWeight: '600'
}}
>
Done
</button>
</div>
</div>
</div>
);
};
export default PSKFilterManager;

@ -0,0 +1,324 @@
/**
* PSKReporter Panel
* Shows where your digital mode signals are being received
* Uses MQTT WebSocket for real-time data
*/
import React, { useState, useMemo } from 'react';
import { usePSKReporter } from '../hooks/usePSKReporter.js';
import { getBandColor } from '../utils/callsign.js';
const PSKReporterPanel = ({
callsign,
onShowOnMap,
showOnMap,
onToggleMap,
filters = {},
onOpenFilters
}) => {
const [activeTab, setActiveTab] = useState('tx'); // Default to 'tx' (Being Heard)
const {
txReports,
txCount,
rxReports,
rxCount,
loading,
error,
connected,
source,
refresh
} = usePSKReporter(callsign, {
minutes: 15,
enabled: callsign && callsign !== 'N0CALL'
});
// Filter reports by band, grid, and mode
const filterReports = (reports) => {
return reports.filter(r => {
// Band filter
if (filters?.bands?.length && !filters.bands.includes(r.band)) return false;
// Grid filter (prefix match)
if (filters?.grids?.length) {
const grid = activeTab === 'tx' ? r.receiverGrid : r.senderGrid;
if (!grid) return false;
const gridPrefix = grid.substring(0, 2).toUpperCase();
if (!filters.grids.includes(gridPrefix)) return false;
}
// Mode filter
if (filters?.modes?.length && !filters.modes.includes(r.mode)) return false;
return true;
});
};
const filteredTx = useMemo(() => filterReports(txReports), [txReports, filters, activeTab]);
const filteredRx = useMemo(() => filterReports(rxReports), [rxReports, filters, activeTab]);
const filteredReports = activeTab === 'tx' ? filteredTx : filteredRx;
// Count active filters
const getActiveFilterCount = () => {
let count = 0;
if (filters?.bands?.length) count++;
if (filters?.grids?.length) count++;
if (filters?.modes?.length) count++;
return count;
};
const filterCount = getActiveFilterCount();
// Get band color from frequency
const getFreqColor = (freqMHz) => {
if (!freqMHz) return 'var(--text-muted)';
const freq = parseFloat(freqMHz);
return getBandColor(freq);
};
// Format age
const formatAge = (minutes) => {
if (minutes < 1) return 'now';
if (minutes < 60) return `${minutes}m`;
return `${Math.floor(minutes/60)}h`;
};
// Get status indicator
const getStatusIndicator = () => {
if (connected) {
return <span style={{ color: '#4ade80', fontSize: '10px' }}> LIVE</span>;
}
if (source === 'connecting' || source === 'reconnecting') {
return <span style={{ color: '#fbbf24', fontSize: '10px' }}> {source}</span>;
}
if (error) {
return <span style={{ color: '#ef4444', fontSize: '10px' }}> offline</span>;
}
return null;
};
if (!callsign || callsign === 'N0CALL') {
return (
<div className="panel" style={{ padding: '10px' }}>
<div style={{ fontSize: '12px', color: 'var(--accent-primary)', fontWeight: '700', marginBottom: '6px' }}>
📡 PSKReporter
</div>
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '10px', fontSize: '11px' }}>
Set callsign in Settings
</div>
</div>
);
}
return (
<div className="panel" style={{
padding: '10px',
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden'
}}>
{/* Header */}
<div style={{
fontSize: '12px',
color: 'var(--accent-primary)',
fontWeight: '700',
marginBottom: '6px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<span>📡 PSKReporter {getStatusIndicator()}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontSize: '9px', color: 'var(--text-muted)' }}>
{filteredReports.length}/{activeTab === 'tx' ? txCount : rxCount}
</span>
<button
onClick={onOpenFilters}
style={{
background: filterCount > 0 ? 'rgba(255, 170, 0, 0.3)' : 'rgba(100, 100, 100, 0.3)',
border: `1px solid ${filterCount > 0 ? '#ffaa00' : '#666'}`,
color: filterCount > 0 ? '#ffaa00' : '#888',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '10px',
fontFamily: 'JetBrains Mono',
cursor: 'pointer'
}}
>
🔍 Filters
</button>
<button
onClick={refresh}
disabled={loading}
title={connected ? 'Reconnect' : 'Connect'}
style={{
background: 'rgba(100, 100, 100, 0.3)',
border: '1px solid #666',
color: '#888',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '10px',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.5 : 1
}}
>
🔄
</button>
{onToggleMap && (
<button
onClick={onToggleMap}
style={{
background: showOnMap ? 'rgba(68, 136, 255, 0.3)' : 'rgba(100, 100, 100, 0.3)',
border: `1px solid ${showOnMap ? '#4488ff' : '#666'}`,
color: showOnMap ? '#4488ff' : '#888',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '10px',
fontFamily: 'JetBrains Mono',
cursor: 'pointer'
}}
>
🗺 {showOnMap ? 'ON' : 'OFF'}
</button>
)}
</div>
</div>
{/* Tabs */}
<div style={{
display: 'flex',
gap: '4px',
marginBottom: '6px'
}}>
<button
onClick={() => setActiveTab('tx')}
style={{
flex: 1,
padding: '4px 6px',
background: activeTab === 'tx' ? 'rgba(74, 222, 128, 0.2)' : 'rgba(100, 100, 100, 0.2)',
border: `1px solid ${activeTab === 'tx' ? '#4ade80' : '#555'}`,
borderRadius: '3px',
color: activeTab === 'tx' ? '#4ade80' : '#888',
cursor: 'pointer',
fontSize: '10px',
fontFamily: 'JetBrains Mono'
}}
>
📤 Being Heard ({filterCount > 0 ? `${filteredTx.length}` : txCount})
</button>
<button
onClick={() => setActiveTab('rx')}
style={{
flex: 1,
padding: '4px 6px',
background: activeTab === 'rx' ? 'rgba(96, 165, 250, 0.2)' : 'rgba(100, 100, 100, 0.2)',
border: `1px solid ${activeTab === 'rx' ? '#60a5fa' : '#555'}`,
borderRadius: '3px',
color: activeTab === 'rx' ? '#60a5fa' : '#888',
cursor: 'pointer',
fontSize: '10px',
fontFamily: 'JetBrains Mono'
}}
>
📥 Hearing ({filterCount > 0 ? `${filteredRx.length}` : rxCount})
</button>
</div>
{/* Reports list */}
{error && !connected ? (
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
Connection failed - click 🔄 to retry
</div>
) : loading && filteredReports.length === 0 && filterCount === 0 ? (
<div style={{ textAlign: 'center', padding: '15px', color: 'var(--text-muted)', fontSize: '11px' }}>
<div className="loading-spinner" style={{ margin: '0 auto 8px' }} />
Connecting to MQTT...
</div>
) : !connected && filteredReports.length === 0 && filterCount === 0 ? (
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
Waiting for connection...
</div>
) : filteredReports.length === 0 ? (
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
{filterCount > 0
? 'No spots match filters'
: activeTab === 'tx'
? 'Waiting for spots... (TX to see reports)'
: 'No stations heard yet'}
</div>
) : (
<div style={{
flex: 1,
overflow: 'auto',
fontSize: '12px',
fontFamily: 'JetBrains Mono, monospace'
}}>
{filteredReports.slice(0, 20).map((report, i) => {
const freqMHz = report.freqMHz || (report.freq ? (report.freq / 1000000).toFixed(3) : '?');
const color = getFreqColor(freqMHz);
const displayCall = activeTab === 'tx' ? report.receiver : report.sender;
const grid = activeTab === 'tx' ? report.receiverGrid : report.senderGrid;
return (
<div
key={`${displayCall}-${report.freq}-${i}`}
onClick={() => onShowOnMap && report.lat && report.lon && onShowOnMap(report)}
style={{
display: 'grid',
gridTemplateColumns: '55px 1fr auto',
gap: '6px',
padding: '4px 6px',
borderRadius: '3px',
marginBottom: '2px',
background: i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent',
cursor: report.lat && report.lon ? 'pointer' : 'default',
transition: 'background 0.15s',
borderLeft: '2px solid transparent'
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(68, 136, 255, 0.15)'}
onMouseLeave={(e) => e.currentTarget.style.background = i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent'}
>
<div style={{ color, fontWeight: '600', fontSize: '11px' }}>
{freqMHz}
</div>
<div style={{
color: 'var(--text-primary)',
fontWeight: '600',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '11px'
}}>
{displayCall}
{grid && <span style={{ color: 'var(--text-muted)', fontWeight: '400', marginLeft: '4px', fontSize: '9px' }}>{grid}</span>}
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '10px'
}}>
<span style={{ color: 'var(--text-muted)' }}>{report.mode}</span>
{report.snr !== null && report.snr !== undefined && (
<span style={{
color: report.snr >= 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316',
fontWeight: '600'
}}>
{report.snr > 0 ? '+' : ''}{report.snr}
</span>
)}
<span style={{ color: 'var(--text-muted)', fontSize: '9px' }}>
{formatAge(report.age)}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
};
export default PSKReporterPanel;
export { PSKReporterPanel };

@ -0,0 +1,16 @@
/**
* PluginLayer Component
* Renders a single plugin layer using its hook
*/
import React from 'react';
export const PluginLayer = ({ plugin, enabled, opacity, map }) => {
// Call the plugin's hook (this is allowed because it's in a component)
const result = plugin.hook({ enabled, opacity, map });
// Plugin hook handles its own rendering to the map
// This component doesn't render anything to the DOM
return null;
};
export default PluginLayer;

@ -1,6 +1,6 @@
/** /**
* SettingsPanel Component * SettingsPanel Component
* Full settings modal matching production version * Full settings modal with map layer controls
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { calculateGridSquare } from '../utils/geo.js'; import { calculateGridSquare } from '../utils/geo.js';
@ -16,6 +16,10 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy'); const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy');
const { t } = useTranslation(); const { t } = useTranslation();
// Layer controls
const [layers, setLayers] = useState([]);
const [activeTab, setActiveTab] = useState('station');
useEffect(() => { useEffect(() => {
if (config) { if (config) {
setCallsign(config.callsign || ''); setCallsign(config.callsign || '');
@ -24,19 +28,33 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
setTheme(config.theme || 'dark'); setTheme(config.theme || 'dark');
setLayout(config.layout || 'modern'); setLayout(config.layout || 'modern');
setDxClusterSource(config.dxClusterSource || 'dxspider-proxy'); setDxClusterSource(config.dxClusterSource || 'dxspider-proxy');
// Use locator from config, or calculate from coordinates if (config.location?.lat && config.location?.lon) {
if (config.locator) {
setGridSquare(config.locator);
} else if (config.location?.lat && config.location?.lon) {
setGridSquare(calculateGridSquare(config.location.lat, config.location.lon)); setGridSquare(calculateGridSquare(config.location.lat, config.location.lon));
} }
} }
}, [config, isOpen]); }, [config, isOpen]);
// Update lat/lon when grid square changes // Load layers when panel opens
useEffect(() => {
if (isOpen && window.hamclockLayerControls) {
setLayers(window.hamclockLayerControls.layers || []);
}
}, [isOpen]);
// Refresh layers periodically
useEffect(() => {
if (isOpen && activeTab === 'layers') {
const interval = setInterval(() => {
if (window.hamclockLayerControls) {
setLayers([...window.hamclockLayerControls.layers]);
}
}, 200);
return () => clearInterval(interval);
}
}, [isOpen, activeTab]);
const handleGridChange = (grid) => { const handleGridChange = (grid) => {
setGridSquare(grid.toUpperCase()); setGridSquare(grid.toUpperCase());
// Parse grid square to lat/lon if valid (6 char)
if (grid.length >= 4) { if (grid.length >= 4) {
const parsed = parseGridSquare(grid); const parsed = parseGridSquare(grid);
if (parsed) { if (parsed) {
@ -46,7 +64,6 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
} }
}; };
// Parse grid square to coordinates
const parseGridSquare = (grid) => { const parseGridSquare = (grid) => {
grid = grid.toUpperCase(); grid = grid.toUpperCase();
if (grid.length < 4) return null; if (grid.length < 4) return null;
@ -69,7 +86,6 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
return { lat, lon }; return { lat, lon };
}; };
// Update grid when lat/lon changes
useEffect(() => { useEffect(() => {
if (lat && lon) { if (lat && lon) {
setGridSquare(calculateGridSquare(lat, lon)); setGridSquare(calculateGridSquare(lat, lon));
@ -93,11 +109,41 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
} }
}; };
const handleToggleLayer = (layerId) => {
if (window.hamclockLayerControls) {
const layer = layers.find(l => l.id === layerId);
const newEnabledState = !layer.enabled;
// Update the control
window.hamclockLayerControls.toggleLayer(layerId, newEnabledState);
// Force immediate UI update
setLayers(prevLayers =>
prevLayers.map(l =>
l.id === layerId ? { ...l, enabled: newEnabledState } : l
)
);
// Refresh after a short delay to get the updated state
setTimeout(() => {
if (window.hamclockLayerControls) {
setLayers([...window.hamclockLayerControls.layers]);
}
}, 100);
}
};
const handleOpacityChange = (layerId, opacity) => {
if (window.hamclockLayerControls) {
window.hamclockLayerControls.setOpacity(layerId, opacity);
setLayers([...window.hamclockLayerControls.layers]);
}
};
const handleSave = () => { const handleSave = () => {
onSave({ onSave({
...config, ...config,
callsign: callsign.toUpperCase(), callsign: callsign.toUpperCase(),
locator: gridSquare.toUpperCase(),
location: { lat: parseFloat(lat), lon: parseFloat(lon) }, location: { lat: parseFloat(lat), lon: parseFloat(lon) },
theme, theme,
layout, layout,
@ -144,14 +190,14 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
border: '2px solid var(--accent-amber)', border: '2px solid var(--accent-amber)',
borderRadius: '12px', borderRadius: '12px',
padding: '24px', padding: '24px',
width: '480px', width: '520px',
maxHeight: '90vh', maxHeight: '90vh',
overflowY: 'auto' overflowY: 'auto'
}}> }}>
<h2 style={{ <h2 style={{
color: 'var(--accent-cyan)', color: 'var(--accent-cyan)',
marginTop: 0, marginTop: 0,
marginBottom: '16px', marginBottom: '24px',
textAlign: 'center', textAlign: 'center',
fontFamily: 'Orbitron, monospace', fontFamily: 'Orbitron, monospace',
fontSize: '20px' fontSize: '20px'
@ -159,233 +205,384 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
{t('station.settings.title')} {t('station.settings.title')}
</h2> </h2>
{/* First-time setup banner */} {/* Tab Navigation */}
{(config?.configIncomplete || config?.callsign === 'N0CALL' || !config?.locator) && ( <div style={{
<div style={{ display: 'flex',
background: 'rgba(255, 193, 7, 0.15)', gap: '8px',
border: '1px solid var(--accent-amber)', marginBottom: '24px',
borderRadius: '8px', borderBottom: '1px solid var(--border-color)',
padding: '12px 16px', paddingBottom: '12px'
marginBottom: '20px', }}>
fontSize: '13px' <button
}}> onClick={() => setActiveTab('station')}
<div style={{ color: 'var(--accent-amber)', fontWeight: '700', marginBottom: '6px' }}>
{t("station.settings.welcome")}
</div>
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>
{t("station.settings.describe")}
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '11px', marginTop: '8px' }}>
<Trans i18nKey="station.settings.tip.env" components={{ envExample: <Code />, env: <Code /> }} />
</div>
</div>
)}
{/* Callsign */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
{t('station.settings.callsign')}
</label>
<input
type="text"
value={callsign}
onChange={(e) => setCallsign(e.target.value.toUpperCase())}
style={{ style={{
width: '100%', flex: 1,
padding: '12px', padding: '10px',
background: 'var(--bg-tertiary)', background: activeTab === 'station' ? 'var(--accent-amber)' : 'transparent',
border: '1px solid var(--border-color)', border: 'none',
borderRadius: '6px', borderRadius: '6px 6px 0 0',
color: 'var(--accent-amber)', color: activeTab === 'station' ? '#000' : 'var(--text-secondary)',
fontSize: '18px', fontSize: '13px',
fontFamily: 'JetBrains Mono, monospace', cursor: 'pointer',
fontWeight: '700', fontWeight: activeTab === 'station' ? '700' : '400',
boxSizing: 'border-box' fontFamily: 'JetBrains Mono, monospace'
}} }}
/> >
</div> 📡 Station
</button>
{/* Grid Square */} <button
<div style={{ marginBottom: '20px' }}> onClick={() => setActiveTab('layers')}
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
{t('station.settings.locator')}
</label>
<input
type="text"
value={gridSquare}
onChange={(e) => handleGridChange(e.target.value)}
placeholder="FN20nc"
maxLength={6}
style={{ style={{
width: '100%', flex: 1,
padding: '12px', padding: '10px',
background: 'var(--bg-tertiary)', background: activeTab === 'layers' ? 'var(--accent-amber)' : 'transparent',
border: '1px solid var(--border-color)', border: 'none',
borderRadius: '6px', borderRadius: '6px 6px 0 0',
color: 'var(--accent-amber)', color: activeTab === 'layers' ? '#000' : 'var(--text-secondary)',
fontSize: '18px', fontSize: '13px',
fontFamily: 'JetBrains Mono, monospace', cursor: 'pointer',
fontWeight: '700', fontWeight: activeTab === 'layers' ? '700' : '400',
boxSizing: 'border-box' fontFamily: 'JetBrains Mono, monospace'
}} }}
/> >
🗺 Map Layers
</button>
</div> </div>
{/* Lat/Lon */} {/* Station Settings Tab */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' }}> {activeTab === 'station' && (
<div> <>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase' }}> {/* First-time setup banner */}
{t('station.settings.latitude')} {(config?.configIncomplete || config?.callsign === 'N0CALL' || !config?.locator) && (
</label> <div style={{
<input background: 'rgba(255, 193, 7, 0.15)',
type="number" border: '1px solid var(--accent-amber)',
step="0.000001" borderRadius: '8px',
value={lat} padding: '12px 16px',
onChange={(e) => setLat(parseFloat(e.target.value))} marginBottom: '20px',
style={{ fontSize: '13px'
width: '100%', }}>
padding: '10px', <div style={{ color: 'var(--accent-amber)', fontWeight: '700', marginBottom: '6px' }}>
background: 'var(--bg-tertiary)', {t("station.settings.welcome")}
border: '1px solid var(--border-color)', </div>
borderRadius: '6px', <div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>
color: 'var(--text-primary)', {t("station.settings.describe")}
fontSize: '14px', </div>
fontFamily: 'JetBrains Mono, monospace', <div style={{ color: 'var(--text-muted)', fontSize: '11px', marginTop: '8px' }}>
boxSizing: 'border-box' <Trans i18nKey="station.settings.tip.env" components={{ envExample: <Code />, env: <Code /> }} />
}} </div>
/> </div>
</div> )}
<div>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase' }}> {/* Callsign */}
{t('station.settings.longitude')} <div style={{ marginBottom: '20px' }}>
</label> <label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
<input {t('station.settings.callsign')}
type="number" </label>
step="0.000001" <input
value={lon} type="text"
onChange={(e) => setLon(parseFloat(e.target.value))} value={callsign}
onChange={(e) => setCallsign(e.target.value.toUpperCase())}
style={{
width: '100%',
padding: '12px',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
color: 'var(--accent-amber)',
fontSize: '18px',
fontFamily: 'JetBrains Mono, monospace',
fontWeight: '700',
boxSizing: 'border-box'
}}
/>
</div>
{/* Grid Square */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
{t('station.settings.locator')}
</label>
<input
type="text"
value={gridSquare}
onChange={(e) => handleGridChange(e.target.value)}
placeholder="FN20nc"
maxLength={6}
style={{
width: '100%',
padding: '12px',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
color: 'var(--accent-amber)',
fontSize: '18px',
fontFamily: 'JetBrains Mono, monospace',
fontWeight: '700',
boxSizing: 'border-box'
}}
/>
</div>
{/* Lat/Lon */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' }}>
<div>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase' }}>
{t('station.settings.latitude')}
</label>
<input
type="number"
step="0.000001"
value={isNaN(lat) ? '' : lat}
onChange={(e) => setLat(parseFloat(e.target.value) || 0)}
style={{
width: '100%',
padding: '10px',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
color: 'var(--text-primary)',
fontSize: '14px',
fontFamily: 'JetBrains Mono, monospace',
boxSizing: 'border-box'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase' }}>
{t('station.settings.longitude')}
</label>
<input
type="number"
step="0.000001"
value={isNaN(lon) ? '' : lon}
onChange={(e) => setLon(parseFloat(e.target.value) || 0)}
style={{
width: '100%',
padding: '10px',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
color: 'var(--text-primary)',
fontSize: '14px',
fontFamily: 'JetBrains Mono, monospace',
boxSizing: 'border-box'
}}
/>
</div>
</div>
<button
onClick={handleUseLocation}
style={{ style={{
width: '100%', width: '100%',
padding: '10px', padding: '10px',
background: 'var(--bg-tertiary)', background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)', border: '1px solid var(--border-color)',
borderRadius: '6px', borderRadius: '6px',
color: 'var(--text-primary)', color: 'var(--text-secondary)',
fontSize: '14px', fontSize: '13px',
fontFamily: 'JetBrains Mono, monospace', cursor: 'pointer',
boxSizing: 'border-box' marginBottom: '20px'
}} }}
/> >
</div> {t('station.settings.useLocation')}
</div> </button>
{/* Use My Location button */} {/* Theme */}
<button <div style={{ marginBottom: '8px' }}>
onClick={handleUseLocation} <label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
style={{ {t('station.settings.theme')}
width: '100%', </label>
padding: '10px', <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px' }}>
background: 'var(--bg-tertiary)', {['dark', 'light', 'legacy', 'retro'].map((th) => (
border: '1px solid var(--border-color)', <button
borderRadius: '6px', key={th}
color: 'var(--text-secondary)', onClick={() => setTheme(th)}
fontSize: '13px', style={{
cursor: 'pointer', padding: '10px',
marginBottom: '20px' background: theme === th ? 'var(--accent-amber)' : 'var(--bg-tertiary)',
}} border: `1px solid ${theme === th ? 'var(--accent-amber)' : 'var(--border-color)'}`,
> borderRadius: '6px',
{t('station.settings.useLocation')} color: theme === th ? '#000' : 'var(--text-secondary)',
</button> fontSize: '12px',
cursor: 'pointer',
{/* Theme */} fontWeight: theme === th ? '600' : '400'
<div style={{ marginBottom: '8px' }}> }}
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}> >
{t('station.settings.theme')} {th === 'dark' ? '🌙' : th === 'light' ? '☀️' : th === 'legacy' ? '💻' : '🪟'} {t('station.settings.theme.' + th)}
</label> </button>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px' }}> ))}
{['dark', 'light', 'legacy', 'retro'].map((theme) => ( </div>
<button <div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}>
key={theme} {themeDescriptions[theme]}
onClick={() => setTheme(theme)} </div>
style={{ </div>
padding: '10px',
background: theme === theme ? 'var(--accent-amber)' : 'var(--bg-tertiary)', {/* Layout */}
border: `1px solid ${theme === theme ? 'var(--accent-amber)' : 'var(--border-color)'}`, <div style={{ marginBottom: '8px' }}>
borderRadius: '6px', <label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
color: theme === theme ? '#000' : 'var(--text-secondary)', {t('station.settings.layout')}
fontSize: '12px', </label>
cursor: 'pointer', <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }}>
fontWeight: theme === theme ? '600' : '400' {['modern', 'classic'].map((l) => (
}} <button
> key={l}
{theme === 'dark' ? '🌙' : theme === 'light' ? '☀️' : theme === 'legacy' ? '💻' : '🪟'} {t('station.settings.theme.' + theme)} onClick={() => setLayout(l)}
</button> style={{
))} padding: '10px',
</div> background: layout === l ? 'var(--accent-amber)' : 'var(--bg-tertiary)',
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}> border: `1px solid ${layout === l ? 'var(--accent-amber)' : 'var(--border-color)'}`,
{themeDescriptions[theme]} borderRadius: '6px',
</div> color: layout === l ? '#000' : 'var(--text-secondary)',
</div> fontSize: '13px',
cursor: 'pointer',
fontWeight: layout === l ? '600' : '400'
}}
>
{l === 'modern' ? '🖥️' : '📺'} {t('station.settings.layout.' + l)}
</button>
))}
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}>
{layoutDescriptions[layout]}
</div>
</div>
{/* Layout */} {/* DX Cluster Source */}
<div style={{ marginBottom: '8px' }}> <div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}> <label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
{t('station.settings.layout')} {t('station.settings.dx.title')}
</label> </label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }}> <select
{['modern', 'classic'].map((l) => ( value={dxClusterSource}
<button onChange={(e) => setDxClusterSource(e.target.value)}
key={l}
onClick={() => setLayout(l)}
style={{ style={{
padding: '10px', width: '100%',
background: layout === l ? 'var(--accent-amber)' : 'var(--bg-tertiary)', padding: '12px',
border: `1px solid ${layout === l ? 'var(--accent-amber)' : 'var(--border-color)'}`, background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: '6px', borderRadius: '6px',
color: layout === l ? '#000' : 'var(--text-secondary)', color: 'var(--accent-green)',
fontSize: '13px', fontSize: '14px',
cursor: 'pointer', fontFamily: 'JetBrains Mono, monospace',
fontWeight: layout === l ? '600' : '400' cursor: 'pointer'
}} }}
> >
{l === 'modern' ? '🖥️' : '📺'} {t('station.settings.layout.' + l)} <option value="dxspider-proxy">{t('station.settings.dx.option1')}</option>
</button> <option value="hamqth">{t('station.settings.dx.option2')}</option>
))} <option value="dxwatch">{t('station.settings.dx.option3')}</option>
</div> <option value="auto">{t('station.settings.dx.option4')}</option>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}> </select>
{layoutDescriptions[layout]} <div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}>
</div> {t('station.settings.dx.describe')}
</div> </div>
</div>
</>
)}
{/* DX Cluster Source */} {/* Map Layers Tab */}
<div style={{ marginBottom: '20px' }}> {activeTab === 'layers' && (
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}> <div>
{t('station.settings.dx.title')} {layers.length > 0 ? (
</label> layers.map(layer => (
<select <div key={layer.id} style={{
value={dxClusterSource} background: 'var(--bg-tertiary)',
onChange={(e) => setDxClusterSource(e.target.value)} border: `1px solid ${layer.enabled ? 'var(--accent-amber)' : 'var(--border-color)'}`,
style={{ borderRadius: '8px',
width: '100%', padding: '14px',
padding: '12px', marginBottom: '12px'
background: 'var(--bg-tertiary)', }}>
border: '1px solid var(--border-color)', <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
borderRadius: '6px', <label style={{
color: 'var(--accent-green)', display: 'flex',
fontSize: '14px', alignItems: 'center',
fontFamily: 'JetBrains Mono, monospace', gap: '10px',
cursor: 'pointer' cursor: 'pointer',
}} flex: 1
> }}>
<option value="dxspider-proxy">{t('station.settings.dx.option1')}</option> <input
<option value="hamqth">{t('station.settings.dx.option2')}</option> type="checkbox"
<option value="dxwatch">{t('station.settings.dx.option3')}</option> checked={layer.enabled}
<option value="auto">{t('station.settings.dx.option4')}</option> onChange={() => handleToggleLayer(layer.id)}
</select> style={{
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}> width: '18px',
{t('station.settings.dx.describe')} height: '18px',
cursor: 'pointer'
}}
/>
<span style={{ fontSize: '18px' }}>{layer.icon}</span>
<div>
<div style={{
color: layer.enabled ? 'var(--accent-amber)' : 'var(--text-primary)',
fontSize: '14px',
fontWeight: '600',
fontFamily: 'JetBrains Mono, monospace'
}}>
{layer.name}
</div>
{layer.description && (
<div style={{
fontSize: '11px',
color: 'var(--text-muted)',
marginTop: '2px'
}}>
{layer.description}
</div>
)}
</div>
</label>
<span style={{
fontSize: '11px',
textTransform: 'uppercase',
color: 'var(--text-secondary)',
background: 'var(--bg-hover)',
padding: '2px 8px',
borderRadius: '3px'
}}>
{layer.category}
</span>
</div>
{layer.enabled && (
<div style={{ paddingLeft: '38px', marginTop: '12px' }}>
<label style={{
display: 'block',
fontSize: '11px',
color: 'var(--text-muted)',
marginBottom: '6px',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
Opacity: {Math.round(layer.opacity * 100)}%
</label>
<input
type="range"
min="0"
max="100"
value={layer.opacity * 100}
onChange={(e) => handleOpacityChange(layer.id, parseFloat(e.target.value) / 100)}
style={{
width: '100%',
cursor: 'pointer'
}}
/>
</div>
)}
</div>
))
) : (
<div style={{
textAlign: 'center',
padding: '40px 20px',
color: 'var(--text-muted)',
fontSize: '13px'
}}>
No map layers available
</div>
)}
</div> </div>
</div> )}
{/* Buttons */} {/* Buttons */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginTop: '24px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginTop: '24px' }}>

@ -1,6 +1,6 @@
/** /**
* WorldMap Component * WorldMap Component
* Leaflet map with DE/DX markers, terminator, DX paths, POTA, satellites * Leaflet map with DE/DX markers, terminator, DX paths, POTA, satellites, PSKReporter
*/ */
import React, { useRef, useEffect, useState } from 'react'; import React, { useRef, useEffect, useState } from 'react';
import { MAP_STYLES } from '../utils/config.js'; import { MAP_STYLES } from '../utils/config.js';
@ -12,6 +12,10 @@ import {
} from '../utils/geo.js'; } from '../utils/geo.js';
import { filterDXPaths, getBandColor } from '../utils/callsign.js'; import { filterDXPaths, getBandColor } from '../utils/callsign.js';
import { getAllLayers } from '../plugins/layerRegistry.js';
import PluginLayer from './PluginLayer.jsx';
export const WorldMap = ({ export const WorldMap = ({
deLocation, deLocation,
dxLocation, dxLocation,
@ -21,11 +25,13 @@ export const WorldMap = ({
dxPaths, dxPaths,
dxFilters, dxFilters,
satellites, satellites,
pskReporterSpots,
showDXPaths, showDXPaths,
showDXLabels, showDXLabels,
onToggleDXLabels, onToggleDXLabels,
showPOTA, showPOTA,
showSatellites, showSatellites,
showPSKReporter,
onToggleSatellites, onToggleSatellites,
hoveredSpot hoveredSpot
}) => { }) => {
@ -44,6 +50,11 @@ export const WorldMap = ({
const dxPathsMarkersRef = useRef([]); const dxPathsMarkersRef = useRef([]);
const satMarkersRef = useRef([]); const satMarkersRef = useRef([]);
const satTracksRef = useRef([]); const satTracksRef = useRef([]);
const pskMarkersRef = useRef([]);
// Plugin system refs and state
const pluginLayersRef = useRef({});
const [pluginLayerStates, setPluginLayerStates] = useState({});
// Load map style from localStorage // Load map style from localStorage
const getStoredMapSettings = () => { const getStoredMapSettings = () => {
@ -416,10 +427,164 @@ export const WorldMap = ({
} }
}, [satellites, showSatellites]); }, [satellites, showSatellites]);
// Plugin layer system - properly load saved states
useEffect(() => {
if (!mapInstanceRef.current) return;
try {
const availableLayers = getAllLayers();
const settings = getStoredMapSettings();
const savedLayers = settings.layers || {};
// Build initial states from localStorage
const initialStates = {};
availableLayers.forEach(layerDef => {
// Use saved state if it exists, otherwise use defaults
if (savedLayers[layerDef.id]) {
initialStates[layerDef.id] = savedLayers[layerDef.id];
} else {
initialStates[layerDef.id] = {
enabled: layerDef.defaultEnabled,
opacity: layerDef.defaultOpacity
};
}
});
// Initialize state ONLY on first mount (when empty)
if (Object.keys(pluginLayerStates).length === 0) {
console.log('Loading saved layer states:', initialStates);
setPluginLayerStates(initialStates);
}
// Expose controls for SettingsPanel
window.hamclockLayerControls = {
layers: availableLayers.map(l => ({
...l,
enabled: pluginLayerStates[l.id]?.enabled ?? initialStates[l.id]?.enabled ?? l.defaultEnabled,
opacity: pluginLayerStates[l.id]?.opacity ?? initialStates[l.id]?.opacity ?? l.defaultOpacity
})),
toggleLayer: (id, enabled) => {
console.log(`Toggle layer ${id}:`, enabled);
const settings = getStoredMapSettings();
const layers = settings.layers || {};
layers[id] = {
enabled: enabled,
opacity: layers[id]?.opacity ?? 0.6
};
localStorage.setItem('openhamclock_mapSettings', JSON.stringify({ ...settings, layers }));
console.log('Saved to localStorage:', layers);
setPluginLayerStates(prev => ({
...prev,
[id]: {
...prev[id],
enabled: enabled
}
}));
},
setOpacity: (id, opacity) => {
console.log(`Set opacity ${id}:`, opacity);
const settings = getStoredMapSettings();
const layers = settings.layers || {};
layers[id] = {
enabled: layers[id]?.enabled ?? false,
opacity: opacity
};
localStorage.setItem('openhamclock_mapSettings', JSON.stringify({ ...settings, layers }));
console.log('Saved to localStorage:', layers);
setPluginLayerStates(prev => ({
...prev,
[id]: {
...prev[id],
opacity: opacity
}
}));
}
};
} catch (err) {
console.error('Plugin system error:', err);
}
}, [pluginLayerStates]);
// Update PSKReporter markers
useEffect(() => {
if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current;
pskMarkersRef.current.forEach(m => map.removeLayer(m));
pskMarkersRef.current = [];
// Validate deLocation exists and has valid coordinates
const hasValidDE = deLocation &&
typeof deLocation.lat === 'number' && !isNaN(deLocation.lat) &&
typeof deLocation.lon === 'number' && !isNaN(deLocation.lon);
if (showPSKReporter && pskReporterSpots && pskReporterSpots.length > 0 && hasValidDE) {
pskReporterSpots.forEach(spot => {
// Validate spot coordinates are valid numbers
const spotLat = parseFloat(spot.lat);
const spotLon = parseFloat(spot.lon);
if (!isNaN(spotLat) && !isNaN(spotLon)) {
const displayCall = spot.receiver || spot.sender;
const freqMHz = spot.freqMHz || (spot.freq ? (spot.freq / 1000000).toFixed(3) : '?');
const bandColor = getBandColor(parseFloat(freqMHz));
try {
// Draw line from DE to spot location
const points = getGreatCirclePoints(
deLocation.lat, deLocation.lon,
spotLat, spotLon,
50
);
// Validate points before creating polyline
if (points && points.length > 1 && points.every(p => Array.isArray(p) && !isNaN(p[0]) && !isNaN(p[1]))) {
const line = L.polyline(points, {
color: bandColor,
weight: 1.5,
opacity: 0.5,
dashArray: '4, 4'
}).addTo(map);
pskMarkersRef.current.push(line);
}
// Add small dot marker at spot location
const circle = L.circleMarker([spotLat, spotLon], {
radius: 4,
fillColor: bandColor,
color: '#fff',
weight: 1,
opacity: 0.9,
fillOpacity: 0.8
}).bindPopup(`
<b>${displayCall}</b><br>
${spot.mode} @ ${freqMHz} MHz<br>
${spot.snr !== null ? `SNR: ${spot.snr > 0 ? '+' : ''}${spot.snr} dB` : ''}
`).addTo(map);
pskMarkersRef.current.push(circle);
} catch (err) {
console.warn('Error rendering PSKReporter spot:', err);
}
}
});
}
}, [pskReporterSpots, showPSKReporter, deLocation]);
return ( return (
<div style={{ position: 'relative', height: '100%', minHeight: '200px' }}> <div style={{ position: 'relative', height: '100%', minHeight: '200px' }}>
<div ref={mapRef} style={{ height: '100%', width: '100%', borderRadius: '8px' }} /> <div ref={mapRef} style={{ height: '100%', width: '100%', borderRadius: '8px' }} />
{/* Render all plugin layers */}
{mapInstanceRef.current && getAllLayers().map(layerDef => (
<PluginLayer
key={layerDef.id}
plugin={layerDef}
enabled={pluginLayerStates[layerDef.id]?.enabled || false}
opacity={pluginLayerStates[layerDef.id]?.opacity || layerDef.defaultOpacity}
map={mapInstanceRef.current}
/>
))}
{/* Map style dropdown */} {/* Map style dropdown */}
<select <select
value={mapStyle} value={mapStyle}
@ -546,4 +711,5 @@ export const WorldMap = ({
); );
}; };
export default WorldMap; export default WorldMap;

@ -13,6 +13,8 @@ export { ContestPanel } from './ContestPanel.jsx';
export { LocationPanel } from './LocationPanel.jsx'; export { LocationPanel } from './LocationPanel.jsx';
export { SettingsPanel } from './SettingsPanel.jsx'; export { SettingsPanel } from './SettingsPanel.jsx';
export { DXFilterManager } from './DXFilterManager.jsx'; export { DXFilterManager } from './DXFilterManager.jsx';
export { PSKFilterManager } from './PSKFilterManager.jsx';
export { SolarPanel } from './SolarPanel.jsx'; export { SolarPanel } from './SolarPanel.jsx';
export { PropagationPanel } from './PropagationPanel.jsx'; export { PropagationPanel } from './PropagationPanel.jsx';
export { DXpeditionPanel } from './DXpeditionPanel.jsx'; export { DXpeditionPanel } from './DXpeditionPanel.jsx';
export { PSKReporterPanel } from './PSKReporterPanel.jsx';

@ -15,3 +15,4 @@ export { useMySpots } from './useMySpots.js';
export { useDXpeditions } from './useDXpeditions.js'; export { useDXpeditions } from './useDXpeditions.js';
export { useSatellites } from './useSatellites.js'; export { useSatellites } from './useSatellites.js';
export { useSolarIndices } from './useSolarIndices.js'; export { useSolarIndices } from './useSolarIndices.js';
export { usePSKReporter } from './usePSKReporter.js';

@ -13,7 +13,7 @@ export const useDXCluster = (source = 'auto', filters = {}) => {
// Get retention time from filters, default to 30 minutes // Get retention time from filters, default to 30 minutes
const spotRetentionMs = (filters?.spotRetentionMinutes || 30) * 60 * 1000; const spotRetentionMs = (filters?.spotRetentionMinutes || 30) * 60 * 1000;
const pollInterval = 5000; // 5 seconds const pollInterval = 30000; // 30 seconds (was 5 seconds - reduced to save bandwidth)
// Apply filters to spots // Apply filters to spots
const applyFilters = useCallback((spots, filters) => { const applyFilters = useCallback((spots, filters) => {

@ -24,7 +24,7 @@ export const useDXPaths = () => {
}; };
fetchData(); fetchData();
const interval = setInterval(fetchData, 10000); // 10 seconds const interval = setInterval(fetchData, 30000); // 30 seconds (was 10s)
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);

@ -30,7 +30,7 @@ export const useMySpots = (callsign) => {
}; };
fetchMySpots(); fetchMySpots();
const interval = setInterval(fetchMySpots, 30000); // 30 seconds const interval = setInterval(fetchMySpots, 60000); // 60 seconds (was 30s)
return () => clearInterval(interval); return () => clearInterval(interval);
}, [callsign]); }, [callsign]);

@ -1,9 +1,8 @@
/** /**
* usePOTASpots Hook * usePOTASpots Hook
* Fetches Parks on the Air activations * Fetches Parks on the Air activations via server proxy (for caching)
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { DEFAULT_CONFIG } from '../utils/config.js';
export const usePOTASpots = () => { export const usePOTASpots = () => {
const [data, setData] = useState([]); const [data, setData] = useState([]);
@ -12,7 +11,8 @@ export const usePOTASpots = () => {
useEffect(() => { useEffect(() => {
const fetchPOTA = async () => { const fetchPOTA = async () => {
try { try {
const res = await fetch('https://api.pota.app/spot/activator'); // Use server proxy for caching - reduces external API calls
const res = await fetch('/api/pota/spots');
if (res.ok) { if (res.ok) {
const spots = await res.json(); const spots = await res.json();
setData(spots.slice(0, 10).map(s => ({ setData(spots.slice(0, 10).map(s => ({
@ -34,7 +34,7 @@ export const usePOTASpots = () => {
}; };
fetchPOTA(); fetchPOTA();
const interval = setInterval(fetchPOTA, DEFAULT_CONFIG.refreshIntervals.pota); const interval = setInterval(fetchPOTA, 2 * 60 * 1000); // 2 minutes
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);

@ -0,0 +1,318 @@
/**
* usePSKReporter Hook
* Fetches PSKReporter data via MQTT WebSocket connection
*
* Uses real-time MQTT feed from mqtt.pskreporter.info for live spots
* No HTTP API calls - direct WebSocket connection from browser
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import mqtt from 'mqtt';
// Convert grid square to lat/lon
function gridToLatLon(grid) {
if (!grid || grid.length < 4) return null;
const g = grid.toUpperCase();
const lon = (g.charCodeAt(0) - 65) * 20 - 180;
const lat = (g.charCodeAt(1) - 65) * 10 - 90;
const lonMin = parseInt(g[2]) * 2;
const latMin = parseInt(g[3]) * 1;
let finalLon = lon + lonMin + 1;
let finalLat = lat + latMin + 0.5;
if (grid.length >= 6) {
const lonSec = (g.charCodeAt(4) - 65) * (2/24);
const latSec = (g.charCodeAt(5) - 65) * (1/24);
finalLon = lon + lonMin + lonSec + (1/24);
finalLat = lat + latMin + latSec + (0.5/24);
}
return { lat: finalLat, lon: finalLon };
}
// Get band name from frequency in Hz
function getBandFromHz(freqHz) {
const freqMHz = freqHz / 1000000;
if (freqMHz >= 1.8 && freqMHz <= 2) return '160m';
if (freqMHz >= 3.5 && freqMHz <= 4) return '80m';
if (freqMHz >= 5.3 && freqMHz <= 5.4) return '60m';
if (freqMHz >= 7 && freqMHz <= 7.3) return '40m';
if (freqMHz >= 10.1 && freqMHz <= 10.15) return '30m';
if (freqMHz >= 14 && freqMHz <= 14.35) return '20m';
if (freqMHz >= 18.068 && freqMHz <= 18.168) return '17m';
if (freqMHz >= 21 && freqMHz <= 21.45) return '15m';
if (freqMHz >= 24.89 && freqMHz <= 24.99) return '12m';
if (freqMHz >= 28 && freqMHz <= 29.7) return '10m';
if (freqMHz >= 50 && freqMHz <= 54) return '6m';
if (freqMHz >= 144 && freqMHz <= 148) return '2m';
if (freqMHz >= 420 && freqMHz <= 450) return '70cm';
return 'Unknown';
}
export const usePSKReporter = (callsign, options = {}) => {
const {
minutes = 15, // Time window to keep spots
enabled = true, // Enable/disable fetching
maxSpots = 100 // Max spots to keep
} = options;
const [txReports, setTxReports] = useState([]);
const [rxReports, setRxReports] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [connected, setConnected] = useState(false);
const [lastUpdate, setLastUpdate] = useState(null);
const [source, setSource] = useState('connecting');
const clientRef = useRef(null);
const txReportsRef = useRef([]);
const rxReportsRef = useRef([]);
const mountedRef = useRef(true);
// Clean old spots (older than specified minutes)
const cleanOldSpots = useCallback((spots, maxAgeMinutes) => {
const cutoff = Date.now() - (maxAgeMinutes * 60 * 1000);
return spots.filter(s => s.timestamp > cutoff).slice(0, maxSpots);
}, [maxSpots]);
// Process incoming MQTT message
const processMessage = useCallback((topic, message) => {
if (!mountedRef.current) return;
try {
const data = JSON.parse(message.toString());
// PSKReporter MQTT message format
// sa=sender callsign, sl=sender locator, ra=receiver callsign, rl=receiver locator
// f=frequency, md=mode, rp=snr (report), t=timestamp
const {
sa: senderCallsign,
sl: senderLocator,
ra: receiverCallsign,
rl: receiverLocator,
f: frequency,
md: mode,
rp: snr,
t: timestamp
} = data;
if (!senderCallsign || !receiverCallsign) return;
const senderLoc = gridToLatLon(senderLocator);
const receiverLoc = gridToLatLon(receiverLocator);
const freq = parseInt(frequency) || 0;
const now = Date.now();
const report = {
sender: senderCallsign,
senderGrid: senderLocator,
receiver: receiverCallsign,
receiverGrid: receiverLocator,
freq,
freqMHz: freq ? (freq / 1000000).toFixed(3) : '?',
band: getBandFromHz(freq),
mode: mode || 'Unknown',
snr: snr !== undefined ? parseInt(snr) : null,
timestamp: timestamp ? timestamp * 1000 : now,
age: 0,
lat: null,
lon: null
};
const upperCallsign = callsign?.toUpperCase();
if (!upperCallsign) return;
// If I'm the sender, this is a TX report (someone heard me)
if (senderCallsign.toUpperCase() === upperCallsign) {
report.lat = receiverLoc?.lat;
report.lon = receiverLoc?.lon;
// Add to front, dedupe by receiver+freq, limit size
txReportsRef.current = [report, ...txReportsRef.current]
.filter((r, i, arr) =>
i === arr.findIndex(x => x.receiver === r.receiver && Math.abs(x.freq - r.freq) < 1000)
)
.slice(0, maxSpots);
setTxReports(cleanOldSpots([...txReportsRef.current], minutes));
setLastUpdate(new Date());
}
// If I'm the receiver, this is an RX report (I heard someone)
if (receiverCallsign.toUpperCase() === upperCallsign) {
report.lat = senderLoc?.lat;
report.lon = senderLoc?.lon;
rxReportsRef.current = [report, ...rxReportsRef.current]
.filter((r, i, arr) =>
i === arr.findIndex(x => x.sender === r.sender && Math.abs(x.freq - r.freq) < 1000)
)
.slice(0, maxSpots);
setRxReports(cleanOldSpots([...rxReportsRef.current], minutes));
setLastUpdate(new Date());
}
} catch (err) {
// Silently ignore parse errors - malformed messages happen
}
}, [callsign, minutes, maxSpots, cleanOldSpots]);
// Connect to MQTT
useEffect(() => {
mountedRef.current = true;
if (!callsign || callsign === 'N0CALL' || !enabled) {
setTxReports([]);
setRxReports([]);
setLoading(false);
setSource('disabled');
setConnected(false);
return;
}
const upperCallsign = callsign.toUpperCase();
// Clear old data
txReportsRef.current = [];
rxReportsRef.current = [];
setTxReports([]);
setRxReports([]);
setLoading(true);
setError(null);
setSource('connecting');
console.log(`[PSKReporter MQTT] Connecting for ${upperCallsign}...`);
// Connect to PSKReporter MQTT via WebSocket
const client = mqtt.connect('wss://mqtt.pskreporter.info:1886/mqtt', {
clientId: `ohc_${upperCallsign}_${Math.random().toString(16).substr(2, 6)}`,
clean: true,
connectTimeout: 15000,
reconnectPeriod: 60000,
keepalive: 60
});
clientRef.current = client;
client.on('connect', () => {
if (!mountedRef.current) return;
console.log('[PSKReporter MQTT] Connected!');
setConnected(true);
setLoading(false);
setSource('mqtt');
setError(null);
// Subscribe to spots where we are the sender (being heard by others)
// Topic format: pskr/filter/v2/{mode}/{band}/{senderCall}/{senderLoc}/{rxCall}/{rxLoc}/{freq}/{snr}
const txTopic = `pskr/filter/v2/+/+/${upperCallsign}/#`;
client.subscribe(txTopic, { qos: 0 }, (err) => {
if (err) {
console.error('[PSKReporter MQTT] TX subscribe error:', err);
} else {
console.log(`[PSKReporter MQTT] Subscribed TX: ${txTopic}`);
}
});
// Subscribe to spots where we are the receiver (hearing others)
const rxTopic = `pskr/filter/v2/+/+/+/+/${upperCallsign}/#`;
client.subscribe(rxTopic, { qos: 0 }, (err) => {
if (err) {
console.error('[PSKReporter MQTT] RX subscribe error:', err);
} else {
console.log(`[PSKReporter MQTT] Subscribed RX: ${rxTopic}`);
}
});
});
client.on('message', processMessage);
client.on('error', (err) => {
if (!mountedRef.current) return;
console.error('[PSKReporter MQTT] Error:', err.message);
setError('Connection error');
setConnected(false);
setLoading(false);
});
client.on('close', () => {
if (!mountedRef.current) return;
console.log('[PSKReporter MQTT] Disconnected');
setConnected(false);
});
client.on('offline', () => {
if (!mountedRef.current) return;
console.log('[PSKReporter MQTT] Offline');
setConnected(false);
setSource('offline');
});
client.on('reconnect', () => {
if (!mountedRef.current) return;
console.log('[PSKReporter MQTT] Reconnecting...');
setSource('reconnecting');
});
// Cleanup on unmount or callsign change
return () => {
mountedRef.current = false;
if (client) {
console.log('[PSKReporter MQTT] Cleaning up...');
client.end(true);
}
};
}, [callsign, enabled, processMessage]);
// Periodically clean old spots and update ages
useEffect(() => {
if (!enabled) return;
const interval = setInterval(() => {
// Update ages and clean old spots
const now = Date.now();
setTxReports(prev => prev.map(r => ({
...r,
age: Math.floor((now - r.timestamp) / 60000)
})).filter(r => r.age <= minutes));
setRxReports(prev => prev.map(r => ({
...r,
age: Math.floor((now - r.timestamp) / 60000)
})).filter(r => r.age <= minutes));
}, 30000); // Every 30 seconds
return () => clearInterval(interval);
}, [enabled, minutes]);
// Manual refresh - force reconnect
const refresh = useCallback(() => {
if (clientRef.current) {
clientRef.current.end(true);
clientRef.current = null;
}
setConnected(false);
setLoading(true);
setSource('reconnecting');
// useEffect will reconnect due to state change
}, []);
return {
txReports,
txCount: txReports.length,
rxReports,
rxCount: rxReports.length,
loading,
error,
connected,
source,
lastUpdate,
refresh
};
};
export default usePSKReporter;

File diff suppressed because it is too large Load Diff

@ -0,0 +1,32 @@
/**
* Layer Plugin Registry
* Only Weather Radar for now
*/
import * as WXRadarPlugin from './layers/useWXRadar.js';
import * as EarthquakesPlugin from './layers/useEarthquakes.js';
const layerPlugins = [
WXRadarPlugin,
EarthquakesPlugin,
];
export function getAllLayers() {
return layerPlugins
.filter(plugin => plugin.metadata && plugin.useLayer)
.map(plugin => ({
id: plugin.metadata.id,
name: plugin.metadata.name,
description: plugin.metadata.description,
icon: plugin.metadata.icon,
defaultEnabled: plugin.metadata.defaultEnabled || false,
defaultOpacity: plugin.metadata.defaultOpacity || 0.6,
category: plugin.metadata.category || 'overlay',
hook: plugin.useLayer
}));
}
export function getLayerById(layerId) {
const layers = getAllLayers();
return layers.find(layer => layer.id === layerId) || null;
}

@ -0,0 +1,145 @@
import { useState, useEffect } from 'react';
//Scaled markers - Bigger circles for stronger quakes
//Color-coded by magnitude:
//Yellow: M2.5-3 (minor)
//Orange: M3-4 (light)
//Deep Orange: M4-5 (moderate)
//Red: M5-6 (strong)
//Dark Red: M6-7 (major)
//Very Dark Red: M7+ (great)
export const metadata = {
id: 'earthquakes',
name: 'Earthquakes',
description: 'Live USGS earthquake data (M2.5+ from last 24 hours)',
icon: '🌋',
category: 'geology',
defaultEnabled: false,
defaultOpacity: 0.9,
version: '1.0.0'
};
export function useLayer({ enabled = false, opacity = 0.9, map = null }) {
const [markersRef, setMarkersRef] = useState([]);
const [earthquakeData, setEarthquakeData] = useState([]);
// Fetch earthquake data
useEffect(() => {
if (!enabled) return;
const fetchEarthquakes = async () => {
try {
// USGS GeoJSON feed - M2.5+ from last day
const response = await fetch(
'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson'
);
const data = await response.json();
setEarthquakeData(data.features || []);
} catch (err) {
console.error('Earthquake data fetch error:', err);
}
};
fetchEarthquakes();
// Refresh every 5 minutes
const interval = setInterval(fetchEarthquakes, 300000);
return () => clearInterval(interval);
}, [enabled]);
// Add/remove markers
useEffect(() => {
if (!map || typeof L === 'undefined') return;
// Clear old markers
markersRef.forEach(marker => {
try {
map.removeLayer(marker);
} catch (e) {
// Already removed
}
});
setMarkersRef([]);
if (!enabled || earthquakeData.length === 0) return;
const newMarkers = [];
earthquakeData.forEach(quake => {
const coords = quake.geometry.coordinates;
const props = quake.properties;
const mag = props.mag;
const lat = coords[1];
const lon = coords[0];
const depth = coords[2];
// Skip if invalid coordinates
if (!lat || !lon || isNaN(lat) || isNaN(lon)) return;
// Calculate marker size based on magnitude (M2.5 = 8px, M7+ = 40px)
const size = Math.min(Math.max(mag * 4, 8), 40);
// Color based on magnitude
let color;
if (mag < 3) color = '#ffff00'; // Yellow - minor
else if (mag < 4) color = '#ffaa00'; // Orange - light
else if (mag < 5) color = '#ff6600'; // Deep orange - moderate
else if (mag < 6) color = '#ff3300'; // Red - strong
else if (mag < 7) color = '#cc0000'; // Dark red - major
else color = '#990000'; // Very dark red - great
// Create circle marker
const circle = L.circleMarker([lat, lon], {
radius: size / 2,
fillColor: color,
color: '#fff',
weight: 2,
opacity: opacity,
fillOpacity: opacity * 0.7
});
// Format time
const time = new Date(props.time);
const timeStr = time.toLocaleString();
// Add popup with details
circle.bindPopup(`
<div style="font-family: 'JetBrains Mono', monospace; min-width: 200px;">
<div style="font-size: 16px; font-weight: bold; color: ${color}; margin-bottom: 8px;">
M${mag.toFixed(1)} ${props.type === 'earthquake' ? '🌋' : '⚡'}
</div>
<table style="font-size: 12px; width: 100%;">
<tr><td><b>Location:</b></td><td>${props.place || 'Unknown'}</td></tr>
<tr><td><b>Time:</b></td><td>${timeStr}</td></tr>
<tr><td><b>Depth:</b></td><td>${depth.toFixed(1)} km</td></tr>
<tr><td><b>Magnitude:</b></td><td>${mag.toFixed(1)}</td></tr>
<tr><td><b>Status:</b></td><td>${props.status || 'automatic'}</td></tr>
${props.tsunami ? '<tr><td colspan="2" style="color: red; font-weight: bold;">⚠️ TSUNAMI WARNING</td></tr>' : ''}
</table>
${props.url ? `<a href="${props.url}" target="_blank" style="color: #00aaff; font-size: 11px;">View Details →</a>` : ''}
</div>
`);
circle.addTo(map);
newMarkers.push(circle);
});
setMarkersRef(newMarkers);
return () => {
newMarkers.forEach(marker => {
try {
map.removeLayer(marker);
} catch (e) {
// Already removed
}
});
};
}, [enabled, earthquakeData, map, opacity]);
return {
markers: markersRef,
earthquakeCount: earthquakeData.length
};
}

@ -0,0 +1,88 @@
import { useState, useEffect } from 'react';
export const metadata = {
id: 'wxradar',
name: 'Weather Radar',
description: 'NEXRAD weather radar overlay for North America',
icon: '☁️',
category: 'weather',
defaultEnabled: false,
defaultOpacity: 0.6,
version: '1.0.0'
};
export function useLayer({ enabled = false, opacity = 0.6, map = null }) {
const [layerRef, setLayerRef] = useState(null);
const [radarTimestamp, setRadarTimestamp] = useState(Date.now());
const wmsConfig = {
url: 'https://mesonet.agron.iastate.edu/cgi-bin/wms/nexrad/n0r.cgi',
options: {
layers: 'nexrad-n0r-900913',
format: 'image/png',
transparent: true,
attribution: 'Weather data © Iowa State University Mesonet',
opacity: opacity,
zIndex: 200
}
};
// Add/remove layer
useEffect(() => {
if (!map || typeof L === 'undefined') return;
if (enabled && !layerRef) {
try {
const layer = L.tileLayer.wms(wmsConfig.url, wmsConfig.options);
layer.addTo(map);
setLayerRef(layer);
} catch (err) {
console.error('WXRadar error:', err);
}
} else if (!enabled && layerRef) {
map.removeLayer(layerRef);
setLayerRef(null);
}
return () => {
if (layerRef && map) {
try {
map.removeLayer(layerRef);
} catch (e) {
// Layer already removed
}
}
};
}, [enabled, map]);
// Update opacity
useEffect(() => {
if (layerRef) {
layerRef.setOpacity(opacity);
}
}, [opacity, layerRef]);
// Auto-refresh every 2 minutes
useEffect(() => {
if (!enabled) return;
const interval = setInterval(() => {
setRadarTimestamp(Date.now());
}, 120000);
return () => clearInterval(interval);
}, [enabled]);
// Force refresh
useEffect(() => {
if (layerRef && enabled) {
layerRef.setParams({ t: radarTimestamp }, false);
layerRef.redraw();
}
}, [radarTimestamp, layerRef, enabled]);
return {
layer: layerRef,
refresh: () => setRadarTimestamp(Date.now())
};
}

@ -21,11 +21,11 @@ export const DEFAULT_CONFIG = {
showPota: true, showPota: true,
showDxPaths: true, showDxPaths: true,
refreshIntervals: { refreshIntervals: {
spaceWeather: 300000, spaceWeather: 300000, // 5 minutes
bandConditions: 300000, bandConditions: 300000, // 5 minutes
pota: 60000, pota: 120000, // 2 minutes (was 1 min)
dxCluster: 30000, dxCluster: 30000, // 30 seconds (was 5 sec)
terminator: 60000 terminator: 60000 // 1 minute
} }
}; };
@ -187,6 +187,16 @@ export const MAP_STYLES = {
name: 'Gray', name: 'Gray',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; Esri' attribution: '&copy; Esri'
},
political: {
name: 'Political',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; Esri'
},
natgeo: {
name: 'Nat Geo',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; Esri, National Geographic'
} }
}; };

@ -22,6 +22,13 @@ export default defineConfig({
'@styles': path.resolve(__dirname, './src/styles') '@styles': path.resolve(__dirname, './src/styles')
} }
}, },
define: {
// mqtt.js needs these for browser
global: 'globalThis',
},
optimizeDeps: {
include: ['mqtt']
},
build: { build: {
outDir: 'dist', outDir: 'dist',
sourcemap: false, sourcemap: false,
@ -29,7 +36,8 @@ export default defineConfig({
output: { output: {
manualChunks: { manualChunks: {
vendor: ['react', 'react-dom'], vendor: ['react', 'react-dom'],
satellite: ['satellite.js'] satellite: ['satellite.js'],
mqtt: ['mqtt']
} }
} }
} }

Loading…
Cancel
Save

Powered by TurnKey Linux.