diff --git a/CHANGELOG.md b/CHANGELOG.md index e0cbc66..a419499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index d7fb76a..049c8a7 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/public/index.html b/public/index.html index 8d6a48c..8df06fb 100644 --- a/public/index.html +++ b/public/index.html @@ -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 @@

πŸ“» OpenHamClock

-

The frontend needs to be built before running.

+

The modular frontend needs to be built first.

+ + Use Classic Version Instead +

Or build the modular version:

npm install npm run build npm start
-

Or use the quick start:

-
- npm install && npm start -
-

- If you're seeing this page, the build step was skipped.
- Running npm start should auto-build if needed. + The classic version works without building.
+ The modular version requires Node.js 18+ to build.

diff --git a/server.js b/server.js index dfc2c4e..ab26023 100644 --- a/server.js +++ b/server.js @@ -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 = /]*>/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 // ============================================ diff --git a/src/App.jsx b/src/App.jsx index 4cdf42b..885e60c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 = () => { {/* RIGHT SIDEBAR */} -
- {/* DX Cluster - takes most space */} -
+
+ {/* DX Cluster - primary panel, takes most space */} +
{ />
- {/* DXpeditions - smaller */} -
+ {/* PSKReporter - digital mode spots */} +
+ { + if (report.lat && report.lon) { + setDxLocation({ lat: report.lat, lon: report.lon, call: report.receiver || report.sender }); + } + }} + /> +
+ + {/* DXpeditions */} +
- {/* POTA - smaller */} -
+ {/* POTA */} +
{ />
- {/* Contests - smaller */} -
+ {/* Contests - at bottom, compact */} +
diff --git a/src/components/ContestPanel.jsx b/src/components/ContestPanel.jsx index 3991a7d..100e207 100644 --- a/src/components/ContestPanel.jsx +++ b/src/components/ContestPanel.jsx @@ -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 (
-
- πŸ† CONTESTS + πŸ† CONTESTS + {liveCount > 0 && ( + + πŸ”΄ {liveCount} LIVE + + )}
@@ -36,31 +123,61 @@ export const ContestPanel = ({ data, loading }) => {
- ) : data && data.length > 0 ? ( + ) : sortedContests.length > 0 ? (
- {data.slice(0, 6).map((contest, i) => ( -
-
- {contest.name} -
-
- {contest.mode} - {formatDate(contest.start)} + {sortedContests.slice(0, 4).map((contest, i) => { + const live = isContestLive(contest); + const soon = isStartingSoon(contest); + + return ( +
+
+ {live && ( + ● + )} + {soon && !live && ( + ◐ + )} + + {contest.name} + +
+
+ {contest.mode} + + {getTimeInfo(contest)} + +
-
- ))} + ); + })}
) : (
@@ -71,8 +188,8 @@ export const ContestPanel = ({ data, loading }) => { {/* Contest Calendar Credit */}
diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx new file mode 100644 index 0000000..651f1e7 --- /dev/null +++ b/src/components/PSKReporterPanel.jsx @@ -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 ( +
+
+ πŸ“‘ PSKReporter +
+
+ Set callsign in Settings +
+
+ ); + } + + return ( +
+ {/* Header - matches DX Cluster style */} +
+ πŸ“‘ PSKReporter +
+ + + {onToggleMap && ( + + )} +
+
+ + {/* Tabs - compact style */} +
+ + +
+ + {/* Reports list - matches DX Cluster style */} + {error ? ( +
+ ⚠️ Temporarily unavailable +
+ ) : loading && reports.length === 0 ? ( +
+
+
+ ) : reports.length === 0 ? ( +
+ No {activeTab === 'tx' ? 'reception reports' : 'stations heard'} +
+ ) : ( +
+ {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 ( +
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'} + > +
+ {freqMHz} +
+
+ {displayCall} + {grid && {grid}} +
+
+ {report.mode} + {report.snr !== null && report.snr !== undefined && ( + = 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316', + fontWeight: '600' + }}> + {report.snr > 0 ? '+' : ''}{report.snr} + + )} + + {formatAge(report.age)} + +
+
+ ); + })} +
+ )} +
+ ); +}; + +export default PSKReporterPanel; + +export { PSKReporterPanel }; diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index 6ad138d..de8181e 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -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(` + ${displayCall}
+ ${spot.mode} @ ${freqMHz} MHz
+ ${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 (
diff --git a/src/components/index.js b/src/components/index.js index 27279cc..7d84316 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -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'; diff --git a/src/hooks/index.js b/src/hooks/index.js index 2f981dc..b07aec3 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -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'; diff --git a/src/hooks/usePSKReporter.js b/src/hooks/usePSKReporter.js new file mode 100644 index 0000000..225201d --- /dev/null +++ b/src/hooks/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; diff --git a/src/utils/config.js b/src/utils/config.js index 42f0024..464617a 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -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: '© Esri' + }, + political: { + name: 'Political', + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', + attribution: '© Esri' + }, + natgeo: { + name: 'Nat Geo', + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}', + attribution: '© Esri, National Geographic' } };