Merge pull request #37 from accius/Modular-Staging

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

@ -2,6 +2,29 @@
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
### Added

@ -1,6 +1,6 @@
{
"name": "openhamclock",
"version": "3.10.0",
"version": "3.11.0",
"description": "Amateur Radio Dashboard - A modern web-based HamClock alternative",
"main": "server.js",
"scripts": {

@ -50,6 +50,19 @@
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;
@ -60,22 +73,20 @@
<body>
<div class="container">
<h1>📻 OpenHamClock</h1>
<p>The frontend needs to be built before running.</p>
<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>Or use the quick start:</p>
<div class="command">
<code>npm install && npm start</code>
</div>
<p class="note">
If you're seeing this page, the build step was skipped.<br>
Running <code>npm start</code> should auto-build if needed.
The classic version works without building.<br>
The modular version requires Node.js 18+ to build.
</p>
</div>
</body>

@ -206,6 +206,8 @@ app.use('/api', (req, res, next) => {
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')) {
@ -1850,6 +1852,272 @@ 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 = 5 * 60 * 1000; // 5 minutes
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
if (pskHttpCache[cacheKey] && (now - pskHttpCache[cacheKey].timestamp) < PSK_HTTP_CACHE_TTL) {
return res.json({ ...pskHttpCache[cacheKey].data, cached: 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) {
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);
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
if (pskHttpCache[cacheKey]) {
return res.json({ ...pskHttpCache[cacheKey].data, cached: true, stale: true });
}
res.json({
callsign,
direction,
count: 0,
reports: [],
error: error.message,
hint: 'Consider using MQTT WebSocket connection for real-time data'
});
}
});
// 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
// ============================================

@ -15,7 +15,8 @@ import {
DXFilterManager,
SolarPanel,
PropagationPanel,
DXpeditionPanel
DXpeditionPanel,
PSKReporterPanel
} from './components';
// Hooks
@ -31,7 +32,8 @@ import {
useMySpots,
useDXpeditions,
useSatellites,
useSolarIndices
useSolarIndices,
usePSKReporter
} from './hooks';
// Utils
@ -100,9 +102,9 @@ const App = () => {
const [mapLayers, setMapLayers] = useState(() => {
try {
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;
} 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(() => {
@ -117,6 +119,7 @@ const App = () => {
const toggleDXLabels = useCallback(() => setMapLayers(prev => ({ ...prev, showDXLabels: !prev.showDXLabels })), []);
const togglePOTA = useCallback(() => setMapLayers(prev => ({ ...prev, showPOTA: !prev.showPOTA })), []);
const toggleSatellites = useCallback(() => setMapLayers(prev => ({ ...prev, showSatellites: !prev.showSatellites })), []);
const togglePSKReporter = useCallback(() => setMapLayers(prev => ({ ...prev, showPSKReporter: !prev.showPSKReporter })), []);
// 12/24 hour format
const [use12Hour, setUse12Hour] = useState(() => {
@ -188,6 +191,7 @@ const App = () => {
const mySpots = useMySpots(config.callsign);
const satellites = useSatellites(config.location);
const localWeather = useLocalWeather(config.location);
const pskReporter = usePSKReporter(config.callsign, { minutes: 15, enabled: config.callsign !== 'N0CALL' });
// Computed values
const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]);
@ -457,11 +461,13 @@ const App = () => {
dxPaths={dxPaths.data}
dxFilters={dxFilters}
satellites={satellites.data}
pskReporterSpots={[...(pskReporter.txReports || []), ...(pskReporter.rxReports || [])]}
showDXPaths={mapLayers.showDXPaths}
showDXLabels={mapLayers.showDXLabels}
onToggleDXLabels={toggleDXLabels}
showPOTA={mapLayers.showPOTA}
showSatellites={mapLayers.showSatellites}
showPSKReporter={mapLayers.showPSKReporter}
onToggleSatellites={toggleSatellites}
hoveredSpot={hoveredSpot}
/>
@ -593,11 +599,13 @@ const App = () => {
dxPaths={dxPaths.data}
dxFilters={dxFilters}
satellites={satellites.data}
pskReporterSpots={[...(pskReporter.txReports || []), ...(pskReporter.rxReports || [])]}
showDXPaths={mapLayers.showDXPaths}
showDXLabels={mapLayers.showDXLabels}
onToggleDXLabels={toggleDXLabels}
showPOTA={mapLayers.showPOTA}
showSatellites={mapLayers.showSatellites}
showPSKReporter={mapLayers.showPSKReporter}
onToggleSatellites={toggleSatellites}
hoveredSpot={hoveredSpot}
/>
@ -617,9 +625,9 @@ const App = () => {
</div>
{/* RIGHT SIDEBAR */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', overflow: 'hidden' }}>
{/* DX Cluster - takes most space */}
<div style={{ flex: '2 1 0', minHeight: '250px', overflow: 'hidden' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', overflow: 'hidden' }}>
{/* DX Cluster - primary panel, takes most space */}
<div style={{ flex: '2 1 auto', minHeight: '180px', overflow: 'hidden' }}>
<DXClusterPanel
data={dxCluster.data}
loading={dxCluster.loading}
@ -634,13 +642,27 @@ const App = () => {
/>
</div>
{/* DXpeditions - smaller */}
<div style={{ flex: '0 0 auto', maxHeight: '140px', overflow: 'hidden' }}>
{/* PSKReporter - digital mode spots */}
<div style={{ flex: '1 1 auto', minHeight: '140px', overflow: 'hidden' }}>
<PSKReporterPanel
callsign={config.callsign}
showOnMap={mapLayers.showPSKReporter}
onToggleMap={togglePSKReporter}
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} />
</div>
{/* POTA - smaller */}
<div style={{ flex: '0 0 auto', maxHeight: '120px', overflow: 'hidden' }}>
{/* POTA */}
<div style={{ flex: '0 0 auto', minHeight: '60px', maxHeight: '90px', overflow: 'hidden' }}>
<POTAPanel
data={potaSpots.data}
loading={potaSpots.loading}
@ -649,8 +671,8 @@ const App = () => {
/>
</div>
{/* Contests - smaller */}
<div style={{ flex: '0 0 auto', maxHeight: '150px', overflow: 'hidden' }}>
{/* Contests - at bottom, compact */}
<div style={{ flex: '0 0 auto', minHeight: '80px', maxHeight: '120px', overflow: 'hidden' }}>
<ContestPanel data={contests.data} loading={contests.loading} />
</div>
</div>

@ -1,6 +1,6 @@
/**
* ContestPanel Component
* Displays upcoming contests with contestcalendar.com credit
* Displays upcoming and active contests with live indicators
*/
import React from 'react';
@ -22,13 +22,100 @@ export const ContestPanel = ({ data, loading }) => {
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 (
<div className="panel" style={{ padding: '8px', height: '100%', display: 'flex', flexDirection: 'column' }}>
<div className="panel-header" style={{
<div style={{
marginBottom: '6px',
fontSize: '11px'
fontSize: '11px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
color: 'var(--accent-primary)',
fontWeight: '700'
}}>
<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'
}}>
🏆 CONTESTS
🔴 {liveCount} LIVE
</span>
)}
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
@ -36,31 +123,61 @@ export const ContestPanel = ({ data, loading }) => {
<div style={{ display: 'flex', justifyContent: 'center', padding: '10px' }}>
<div className="loading-spinner" />
</div>
) : data && data.length > 0 ? (
) : sortedContests.length > 0 ? (
<div style={{ fontSize: '10px', fontFamily: 'JetBrains Mono, monospace' }}>
{data.slice(0, 6).map((contest, i) => (
{sortedContests.slice(0, 4).map((contest, i) => {
const live = isContestLive(contest);
const soon = isStartingSoon(contest);
return (
<div
key={`${contest.name}-${i}`}
style={{
padding: '4px 0',
borderBottom: i < Math.min(data.length, 6) - 1 ? '1px solid var(--border-color)' : 'none'
padding: '5px 6px',
marginBottom: '3px',
borderRadius: '4px',
background: live ? 'rgba(239, 68, 68, 0.15)' : soon ? 'rgba(251, 191, 36, 0.1)' : 'rgba(255,255,255,0.03)',
border: live ? '1px solid rgba(239, 68, 68, 0.4)' : '1px solid transparent'
}}
>
<div style={{
color: 'var(--text-primary)',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}>
{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: '2px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '3px' }}>
<span style={{ color: getModeColor(contest.mode) }}>{contest.mode}</span>
<span style={{ color: 'var(--text-muted)' }}>{formatDate(contest.start)}</span>
<span style={{
color: live ? '#ef4444' : soon ? '#fbbf24' : 'var(--text-muted)',
fontWeight: live ? '600' : '400'
}}>
{getTimeInfo(contest)}
</span>
</div>
</div>
))}
);
})}
</div>
) : (
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '10px', fontSize: '11px' }}>
@ -71,8 +188,8 @@ export const ContestPanel = ({ data, loading }) => {
{/* Contest Calendar Credit */}
<div style={{
marginTop: '6px',
paddingTop: '6px',
marginTop: '4px',
paddingTop: '4px',
borderTop: '1px solid var(--border-color)',
textAlign: 'right'
}}>

@ -0,0 +1,261 @@
/**
* PSKReporter Panel
* Shows where your digital mode signals are being received
* Styled to match DXClusterPanel
*/
import React, { useState } from 'react';
import { usePSKReporter } from '../hooks/usePSKReporter.js';
import { getBandColor } from '../utils/callsign.js';
const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) => {
const [timeWindow, setTimeWindow] = useState(15);
const [activeTab, setActiveTab] = useState('rx'); // Default to 'rx' (Hearing) - more useful
const {
txReports,
txCount,
rxReports,
rxCount,
loading,
error,
refresh
} = usePSKReporter(callsign, {
minutes: timeWindow,
enabled: callsign && callsign !== 'N0CALL'
});
const reports = activeTab === 'tx' ? txReports : rxReports;
const count = activeTab === 'tx' ? txCount : rxCount;
// 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`;
};
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 - matches DX Cluster style */}
<div style={{
fontSize: '12px',
color: 'var(--accent-primary)',
fontWeight: '700',
marginBottom: '6px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<span>📡 PSKReporter</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<select
value={timeWindow}
onChange={(e) => setTimeWindow(parseInt(e.target.value))}
style={{
background: 'rgba(100, 100, 100, 0.3)',
border: '1px solid #666',
color: '#aaa',
padding: '2px 4px',
borderRadius: '4px',
fontSize: '10px',
fontFamily: 'JetBrains Mono',
cursor: 'pointer'
}}
>
<option value={5}>5m</option>
<option value={15}>15m</option>
<option value={30}>30m</option>
<option value={60}>1h</option>
</select>
<button
onClick={refresh}
disabled={loading}
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 - compact style */}
<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 ({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 ({rxCount})
</button>
</div>
{/* Reports list - matches DX Cluster style */}
{error ? (
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
Temporarily unavailable
</div>
) : loading && reports.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px' }}>
<div className="loading-spinner" />
</div>
) : reports.length === 0 ? (
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
No {activeTab === 'tx' ? 'reception reports' : 'stations heard'}
</div>
) : (
<div style={{
flex: 1,
overflow: 'auto',
fontSize: '12px',
fontFamily: 'JetBrains Mono, monospace'
}}>
{reports.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 };

@ -21,11 +21,13 @@ export const WorldMap = ({
dxPaths,
dxFilters,
satellites,
pskReporterSpots,
showDXPaths,
showDXLabels,
onToggleDXLabels,
showPOTA,
showSatellites,
showPSKReporter,
onToggleSatellites,
hoveredSpot
}) => {
@ -44,6 +46,7 @@ export const WorldMap = ({
const dxPathsMarkersRef = useRef([]);
const satMarkersRef = useRef([]);
const satTracksRef = useRef([]);
const pskMarkersRef = useRef([]);
// Load map style from localStorage
const getStoredMapSettings = () => {
@ -416,6 +419,71 @@ export const WorldMap = ({
}
}, [satellites, showSatellites]);
// 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 (
<div style={{ position: 'relative', height: '100%', minHeight: '200px' }}>
<div ref={mapRef} style={{ height: '100%', width: '100%', borderRadius: '8px' }} />

@ -16,3 +16,4 @@ export { DXFilterManager } from './DXFilterManager.jsx';
export { SolarPanel } from './SolarPanel.jsx';
export { PropagationPanel } from './PropagationPanel.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 { useSatellites } from './useSatellites.js';
export { useSolarIndices } from './useSolarIndices.js';
export { usePSKReporter } from './usePSKReporter.js';

@ -0,0 +1,147 @@
/**
* usePSKReporter Hook
* Fetches PSKReporter data showing where your digital mode signals are being received
*
* Uses HTTP API with server-side caching to respect PSKReporter rate limits.
*
* For real-time MQTT updates, see mqtt.pskreporter.info (requires mqtt.js library)
*/
import { useState, useEffect, useCallback } from 'react';
// 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 };
}
export const usePSKReporter = (callsign, options = {}) => {
const {
minutes = 15, // Time window in minutes (default 15)
enabled = true, // Enable/disable fetching
refreshInterval = 300000, // Refresh every 5 minutes (PSKReporter friendly)
maxSpots = 100 // Max spots to display
} = options;
const [txReports, setTxReports] = useState([]);
const [rxReports, setRxReports] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [lastUpdate, setLastUpdate] = useState(null);
const fetchData = useCallback(async () => {
if (!callsign || callsign === 'N0CALL' || !enabled) {
setTxReports([]);
setRxReports([]);
setLoading(false);
return;
}
try {
setError(null);
// Fetch combined endpoint from our server (handles caching)
const response = await fetch(`/api/pskreporter/${encodeURIComponent(callsign)}?minutes=${minutes}`);
if (response.ok) {
const data = await response.json();
// Process TX reports (where I'm being heard)
const txData = data.tx?.reports || [];
const processedTx = txData
.map(r => ({
...r,
// Ensure we have location data
lat: r.lat || (r.receiverGrid ? gridToLatLon(r.receiverGrid)?.lat : null),
lon: r.lon || (r.receiverGrid ? gridToLatLon(r.receiverGrid)?.lon : null),
age: r.age || Math.floor((Date.now() - r.timestamp) / 60000)
}))
.filter(r => r.lat && r.lon)
.slice(0, maxSpots);
// Process RX reports (what I'm hearing)
const rxData = data.rx?.reports || [];
const processedRx = rxData
.map(r => ({
...r,
lat: r.lat || (r.senderGrid ? gridToLatLon(r.senderGrid)?.lat : null),
lon: r.lon || (r.senderGrid ? gridToLatLon(r.senderGrid)?.lon : null),
age: r.age || Math.floor((Date.now() - r.timestamp) / 60000)
}))
.filter(r => r.lat && r.lon)
.slice(0, maxSpots);
setTxReports(processedTx);
setRxReports(processedRx);
setLastUpdate(new Date());
// Check for errors in response
if (data.error || data.tx?.error || data.rx?.error) {
setError(data.error || data.tx?.error || data.rx?.error);
}
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (err) {
console.error('PSKReporter fetch error:', err);
setError(err.message);
} finally {
setLoading(false);
}
}, [callsign, minutes, enabled, maxSpots]);
useEffect(() => {
fetchData();
if (enabled && refreshInterval > 0) {
const interval = setInterval(fetchData, refreshInterval);
return () => clearInterval(interval);
}
}, [fetchData, enabled, refreshInterval]);
// Computed stats
const txBands = [...new Set(txReports.map(r => r.band))].filter(b => b && b !== 'Unknown');
const txModes = [...new Set(txReports.map(r => r.mode))].filter(Boolean);
const stats = {
txCount: txReports.length,
rxCount: rxReports.length,
txBands,
txModes,
bestSnr: txReports.length > 0
? txReports.reduce((max, r) => (r.snr || -99) > (max?.snr || -99) ? r : max, null)
: null
};
return {
txReports,
txCount: txReports.length,
rxReports,
rxCount: rxReports.length,
stats,
loading,
error,
connected: false, // HTTP mode - not real-time connected
source: 'http',
lastUpdate,
refresh: fetchData
};
};
export default usePSKReporter;

@ -187,6 +187,16 @@ export const MAP_STYLES = {
name: 'Gray',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}',
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'
}
};

Loading…
Cancel
Save

Powered by TurnKey Linux.