From 10a900386bf28b1f57e5bcacadaedd5fa2f001cb Mon Sep 17 00:00:00 2001 From: Laura Batalha <5883822+lbatalha@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:38:04 +0100 Subject: [PATCH 1/4] feat: add PSK Reporter Band Activity overlay Compact horizontal bar chart showing PSKReporter spot counts per HF band. Features: - Client-side counting from usePSKReporter data (filtered by time window) - Real-time updates via custom events (no polling) - Fixed panel size, all 15 HF bands always visible - Inactive bands dimmed with reduced opacity - Minimizeable with footer that shows total spots and time window - Draggable panel with saved position - Styled with band colors and consistent with other overlays --- psk-reporter-band-activity-plan.md | 160 +++++++++++++ server/routes/pskreporter.js | 21 ++ src/hooks/usePSKReporter.js | 14 ++ src/plugins/layerRegistry.js | 3 + .../layers/usePSKReporterBandActivity.js | 224 ++++++++++++++++++ 5 files changed, 422 insertions(+) create mode 100644 psk-reporter-band-activity-plan.md create mode 100644 src/plugins/layers/usePSKReporterBandActivity.js diff --git a/psk-reporter-band-activity-plan.md b/psk-reporter-band-activity-plan.md new file mode 100644 index 00000000..b07d8339 --- /dev/null +++ b/psk-reporter-band-activity-plan.md @@ -0,0 +1,160 @@ +# Implementation Plan: PSKReporter Band Activity Map Overlay + +## Goal + +A compact map overlay panel showing spot counts per HF band from PSKReporter, using the same time window as the PSKReporter panel. + +## Files to create / modify + +| # | Action | Path | +| --- | ------ | -------------------------------------------------- | +| 1 | Create | `src/plugins/layers/usePSKReporterBandActivity.js` | +| 2 | Edit | `src/plugins/layerRegistry.js` โ€” import + register | +| 3 | Edit | `server/routes/pskreporter.js` โ€” add API endpoint | + +--- + +## 1. Backend: `/api/pskreporter/band-activity` endpoint + +**File**: `server/routes/pskreporter.js` (append inside the `module.exports` function) + +```js +app.get('/api/pskreporter/band-activity', (req, res) => { + const minutes = parseInt(req.query.minutes) || 15; + const cutoff = Date.now() - minutes * 60 * 1000; + const counts = {}; + + for (const [, spots] of pskMqtt.recentSpots) { + for (const spot of spots) { + if (spot.timestamp < cutoff) continue; + const band = spot.band; + if (!band || band === 'Unknown') continue; + counts[band] = (counts[band] || 0) + 1; + } + } + + const bands = Object.entries(counts).sort((a, b) => b[1] - a[1]); + + res.json({ bands, minutes, timestamp: new Date().toISOString() }); +}); +``` + +- No cache needed โ€” `pskMqtt.recentSpots` is in-memory, iteration is fast for <500 entries +- Respects user's `minutes` parameter (comes from `ohc_psk_age`) + +--- + +## 2. Frontend: Layer plugin + +**File**: `src/plugins/layers/usePSKReporterBandActivity.js` + +**Metadata:** + +```js +export const metadata = { + id: 'psk-band-activity', + name: 'PSK Reporter Band Activity', + description: 'Spot counts per HF band', + icon: '๐Ÿ“ก', + category: 'propagation', + defaultEnabled: false, + defaultOpacity: 0.85, + version: '1.0.0', +}; +``` + +**Data flow:** + +- On mount + when enabled: read `localStorage.getItem('ohc_psk_age')` (default 15) +- Listen for `ohc-psk-age-changed` on `window` โ†’ recalculate when time window changes +- Fetch `GET /api/pskreporter/band-activity?minutes=${pskAge}` every 60s (120s in low-memory mode) + +**State:** + +```js +const [bandCounts, setBandCounts] = useState([]); // [[band, count], ...] +const [pskAge, setPskAge] = useState(15); +``` + +**Panel rendering** (single L.Control, topright): + +Compact horizontal bar chart: + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ๐Ÿ“ก PSK Reporter Band Activity โ–ถ โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ 20m [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘] 142 โ”‚ +โ”‚ 40m [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 98 โ”‚ +โ”‚ 15m [โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 73 โ”‚ +โ”‚ 10m [โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 51 โ”‚ +โ”‚ 80m [โ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 34 โ”‚ +โ”‚ 30m [โ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 12 โ”‚ +โ”‚ 17m [โ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 8 โ”‚ +โ”‚ 60m [โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 2 โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Total: 420 ยท Last 15 min โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Design details:** + +- Band bar width = `(count / maxCount) * 100%` โ€” scales to the most active band +- Band colors from `DEFAULT_BAND_COLORS` (consistent with other overlays) +- Only show bands with `count > 0` +- Sort descending by count +- Mono font, `min-width: 180px`, same panel styling as all existing overlays +- Draggable + minimizable via `makeDraggable()` + `addMinimizeToggle()` +- No map markers โ€” stats-only panel + +**Events:** + +```js +window.addEventListener('ohc-psk-age-changed', () => { + try { + setPskAge(parseInt(localStorage.getItem('ohc_psk_age')) || 15); + } catch {} +}); +``` + +**Fetch cycle:** + +```js +// On mount + when enabled +fetch(`/api/pskreporter/band-activity?minutes=${pskAge}`) + .then((r) => r.json()) + .then((data) => setBandCounts(data.bands || [])); + +// Poll every 60s (120s low-memory) +setInterval(fetch, 60000); +``` + +--- + +## 3. Registration + +**File**: `src/plugins/layerRegistry.js` + +```js +// Add import near the top +import * as PSKReporterBandActivityPlugin from './layers/usePSKReporterBandActivity.js'; + +// Add to layerPlugins array (alphabetical-ish placement under 'propagation' category) +PSKReporterBandActivityPlugin, + +// Optional: add to PINNED_SHORTCUTS (pick an unused letter) +'psk-band-activity': 'b', // or whatever letter is available +``` + +--- + +## Summary of all changes + +| Change | Lines of code | +| ------------------------------------------- | -------------- | +| New file `usePSKReporterBandActivity.js` | ~120 lines | +| Edit `layerRegistry.js` (import + register) | 2 lines | +| Edit `pskreporter.js` (API endpoint) | ~15 lines | +| **Total** | **~140 lines** | + +No CSS changes needed โ€” reuses existing `.panel-wrapper`, `floating-panel-header`, etc. from `main.css`. diff --git a/server/routes/pskreporter.js b/server/routes/pskreporter.js index 0378102b..c4c819f8 100644 --- a/server/routes/pskreporter.js +++ b/server/routes/pskreporter.js @@ -206,6 +206,27 @@ module.exports = function (app, ctx) { }); }); + // Band activity โ€” counts of recent spots per band. + // Reads directly from pskMqtt.recentSpots (no cache needed โ€” in-memory iteration). + app.get('/api/pskreporter/band-activity', (req, res) => { + const minutes = parseInt(req.query.minutes) || 15; + const cutoff = Date.now() - minutes * 60 * 1000; + const counts = {}; + + for (const [, spots] of pskMqtt.recentSpots) { + for (const spot of spots) { + if (spot.timestamp < cutoff) continue; + const band = spot.band; + if (!band || band === 'Unknown') continue; + counts[band] = (counts[band] || 0) + 1; + } + } + + const bands = Object.entries(counts).sort((a, b) => b[1] - a[1]); + + res.json({ bands, minutes, timestamp: new Date().toISOString() }); + }); + // Combined endpoint - returns stream info (live spots via SSE, no HTTP backfill) app.get('/api/pskreporter/:callsign', async (req, res) => { const callsign = req.params.callsign.toUpperCase(); diff --git a/src/hooks/usePSKReporter.js b/src/hooks/usePSKReporter.js index 09a8a1c2..44282728 100644 --- a/src/hooks/usePSKReporter.js +++ b/src/hooks/usePSKReporter.js @@ -96,6 +96,20 @@ export const usePSKReporter = (callsign, options = {}) => { if (txChanged || rxChanged) { setLastUpdate(new Date()); } + + // Compute band counts filtered by the user's time window and broadcast + const now = Date.now(); + const cutoff = now - minutes * 60 * 1000; + const counts = {}; + const allReports = [...txReportsRef.current, ...rxReportsRef.current].filter((r) => r.timestamp > cutoff); + for (const report of allReports) { + const band = report.band; + if (!band || band === 'Unknown') continue; + counts[band] = (counts[band] || 0) + 1; + } + const bands = Object.entries(counts).sort((a, b) => b[1] - a[1]); + const total = bands.reduce((sum, [, c]) => sum + c, 0); + window.dispatchEvent(new CustomEvent('psk-band-activity-changed', { detail: { bands, total } })); }, [identifier, minutes, maxSpots, cleanOldSpots], ); diff --git a/src/plugins/layerRegistry.js b/src/plugins/layerRegistry.js index 43db15e4..ea928b9c 100644 --- a/src/plugins/layerRegistry.js +++ b/src/plugins/layerRegistry.js @@ -30,6 +30,7 @@ import * as IBPLayerPlugin from './layers/useIBPLayer.js'; import * as WinlinkGatewaysPlugin from './layers/useWinlinkGateways.js'; import * as AircraftPlugin from './layers/useAircraft.js'; import * as ATCSectorsPlugin from './layers/useATCSectors.js'; +import * as PSKReporterBandActivityPlugin from './layers/usePSKReporterBandActivity.js'; // Auto-discover local/custom plugins (gitignored โ€” survive updates) const localPluginModules = import.meta.glob('./local/*.js', { eager: true }); @@ -73,6 +74,7 @@ const layerPlugins = [ WinlinkGatewaysPlugin, AircraftPlugin, ATCSectorsPlugin, + PSKReporterBandActivityPlugin, ...localPlugins, ]; @@ -105,6 +107,7 @@ const PINNED_SHORTCUTS = { 'winlink-gateways': 'k', aircraft: 'x', 'atc-sectors': 'z', + 'psk-band-activity': 'b', }; export function getAllLayers() { diff --git a/src/plugins/layers/usePSKReporterBandActivity.js b/src/plugins/layers/usePSKReporterBandActivity.js new file mode 100644 index 00000000..afbe2dd4 --- /dev/null +++ b/src/plugins/layers/usePSKReporterBandActivity.js @@ -0,0 +1,224 @@ +import { useEffect, useRef, useState } from 'react'; +import { esc } from '../../utils/escapeHtml.js'; +import { DEFAULT_BAND_COLORS } from '../../utils/bandColors.js'; +import { addMinimizeToggle } from './addMinimizeToggle.js'; +import { makeDraggable } from './makeDraggable.js'; + +/** + * PSK Reporter Band Activity Overlay + * + * Shows a compact horizontal bar chart of spot counts per HF band. + * Receives live data from usePSKReporter via 'psk-band-activity-changed' event, + * which already filters by the user's time window (ohc_psk_age). + * + * Data source: usePSKReporter hook (txReports + rxReports) + * Update: real-time via event (no polling needed) + */ + +export const metadata = { + id: 'psk-band-activity', + name: 'PSKR Band Activity', + description: 'Spot counts per HF band from PSKReporter', + icon: '๐Ÿ“ก', + category: 'propagation', + defaultEnabled: false, + defaultOpacity: 0.85, + version: '1.0.0', +}; + +const BAND_ORDER = [ + '160m', + '80m', + '60m', + '40m', + '30m', + '20m', + '17m', + '15m', + '12m', + '10m', + '8m', + '6m', + '4m', + '2m', + '70cm', +]; + +export function useLayer({ enabled = false, map = null }) { + const [bandCounts, setBandCounts] = useState([]); // [[band, count], ...] + const [pskAge, setPskAge] = useState(() => { + try { + return parseInt(localStorage.getItem('ohc_psk_age')) || 15; + } catch { + return 15; + } + }); + const [totalSpots, setTotalSpots] = useState(0); + + const controlRef = useRef(null); + const updateTimeoutRef = useRef(null); + const latestRef = useRef({ total: 0, age: 15 }); + + // Read time window from localStorage and listen for changes + useEffect(() => { + const sync = () => { + try { + const v = parseInt(localStorage.getItem('ohc_psk_age')); + if (Number.isFinite(v) && v > 0) setPskAge(v); + } catch {} + }; + window.addEventListener('ohc-psk-age-changed', sync); + return () => { + window.removeEventListener('ohc-psk-age-changed', sync); + }; + }, []); + + // Listen for band activity data from usePSKReporter + useEffect(() => { + if (!enabled) return; + + const handler = (e) => { + const { bands, total } = e.detail || {}; + if (bands) setBandCounts(bands); + if (total !== undefined) setTotalSpots(total); + }; + + window.addEventListener('psk-band-activity-changed', handler); + return () => { + window.removeEventListener('psk-band-activity-changed', handler); + }; + }, [enabled]); + + // Update the panel content when data changes (no recreation) + useEffect(() => { + if (!enabled) return; + updateTimeoutRef.current = setTimeout(() => { + const container = controlRef.current?.getContainer() || document.querySelector('.psk-band-activity'); + if (!container) return; + + const maxCount = bandCounts.length > 0 ? bandCounts[0][1] : 0; + const countsMap = new Map(bandCounts); + + const rows = BAND_ORDER.map((band) => { + const count = countsMap.get(band) || 0; + const active = count > 0; + const color = DEFAULT_BAND_COLORS[band] || '#888888'; + const barWidth = maxCount > 0 ? Math.max((count / maxCount) * 100, 1.5) : 0; + return ` +
+ ${esc(band)} +
+
+
+ ${count} +
+ `; + }).join(''); + + // Inject into psk-band-content (our unique content div) + let contentTarget = container.querySelector('.psk-band-content'); + if (!contentTarget) { + contentTarget = document.createElement('div'); + contentTarget.className = 'psk-band-content'; + container.insertBefore(contentTarget, container.querySelector('.psk-band-footer')); + } + contentTarget.innerHTML = `
${rows}
`; + }, 30); + + return () => { + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current); + updateTimeoutRef.current = null; + } + }; + }, [enabled, bandCounts, totalSpots]); + + // Keep ref in sync with state (so footer creation reads latest values) + useEffect(() => { + latestRef.current = { total: totalSpots, age: pskAge }; + }, [totalSpots, pskAge]); + + // Update footer independently (runs on totalSpots or pskAge change) + useEffect(() => { + if (!enabled) return; + const footer = document.querySelector('.psk-band-activity .psk-band-footer'); + if (footer) { + const span = footer.querySelector('#psk-band-footer-text'); + if (span) span.textContent = `Total: ${totalSpots} ยท Last ${pskAge} min`; + } + }, [enabled, totalSpots, pskAge]); + + // Create the control panel + useEffect(() => { + if (!enabled || !map || controlRef.current) return; + + const Control = L.Control.extend({ + options: { position: 'topright' }, + onAdd: function () { + const panelWrapper = L.DomUtil.create('div', 'panel-wrapper'); + const div = L.DomUtil.create('div', 'psk-band-activity', panelWrapper); + div.style.cssText = 'min-width: 180px; max-width: 240px;'; + div.innerHTML = ` +
๐Ÿ“ก PSKR Band Activity
+
+ + `; + + L.DomEvent.disableClickPropagation(div); + L.DomEvent.disableScrollPropagation(div); + + return panelWrapper; + }, + }); + + const control = new Control(); + map.addControl(control); + controlRef.current = control; + + // Make draggable and minimizable + setTimeout(() => { + const container = document.querySelector('.psk-band-activity'); + if (container) { + const saved = localStorage.getItem('psk-band-activity-position'); + if (saved) { + try { + const { top, left } = JSON.parse(saved); + container.style.position = 'fixed'; + container.style.top = top + 'px'; + container.style.left = left + 'px'; + container.style.right = 'auto'; + container.style.bottom = 'auto'; + } catch {} + } + + makeDraggable(container, 'psk-band-activity-position', { snap: 5 }); + addMinimizeToggle(container, 'psk-band-activity-position', { + contentClassName: 'psk-panel-content', + buttonClassName: 'psk-minimize-btn', + }); + } + }, 150); + + return () => { + if (controlRef.current) { + map.removeControl(controlRef.current); + controlRef.current = null; + } + }; + }, [enabled, map]); + + // Cleanup on disable + useEffect(() => { + if (!enabled) { + if (controlRef.current) { + map.removeControl(controlRef.current); + controlRef.current = null; + } + } + }, [enabled, map]); + + return null; +} From 4b940b5e6037898af222c71f45fda03bae6b7336 Mon Sep 17 00:00:00 2001 From: Laura Batalha <5883822+lbatalha@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:12:57 +0100 Subject: [PATCH 2/4] remove implementation plan --- psk-reporter-band-activity-plan.md | 160 ----------------------------- 1 file changed, 160 deletions(-) delete mode 100644 psk-reporter-band-activity-plan.md diff --git a/psk-reporter-band-activity-plan.md b/psk-reporter-band-activity-plan.md deleted file mode 100644 index b07d8339..00000000 --- a/psk-reporter-band-activity-plan.md +++ /dev/null @@ -1,160 +0,0 @@ -# Implementation Plan: PSKReporter Band Activity Map Overlay - -## Goal - -A compact map overlay panel showing spot counts per HF band from PSKReporter, using the same time window as the PSKReporter panel. - -## Files to create / modify - -| # | Action | Path | -| --- | ------ | -------------------------------------------------- | -| 1 | Create | `src/plugins/layers/usePSKReporterBandActivity.js` | -| 2 | Edit | `src/plugins/layerRegistry.js` โ€” import + register | -| 3 | Edit | `server/routes/pskreporter.js` โ€” add API endpoint | - ---- - -## 1. Backend: `/api/pskreporter/band-activity` endpoint - -**File**: `server/routes/pskreporter.js` (append inside the `module.exports` function) - -```js -app.get('/api/pskreporter/band-activity', (req, res) => { - const minutes = parseInt(req.query.minutes) || 15; - const cutoff = Date.now() - minutes * 60 * 1000; - const counts = {}; - - for (const [, spots] of pskMqtt.recentSpots) { - for (const spot of spots) { - if (spot.timestamp < cutoff) continue; - const band = spot.band; - if (!band || band === 'Unknown') continue; - counts[band] = (counts[band] || 0) + 1; - } - } - - const bands = Object.entries(counts).sort((a, b) => b[1] - a[1]); - - res.json({ bands, minutes, timestamp: new Date().toISOString() }); -}); -``` - -- No cache needed โ€” `pskMqtt.recentSpots` is in-memory, iteration is fast for <500 entries -- Respects user's `minutes` parameter (comes from `ohc_psk_age`) - ---- - -## 2. Frontend: Layer plugin - -**File**: `src/plugins/layers/usePSKReporterBandActivity.js` - -**Metadata:** - -```js -export const metadata = { - id: 'psk-band-activity', - name: 'PSK Reporter Band Activity', - description: 'Spot counts per HF band', - icon: '๐Ÿ“ก', - category: 'propagation', - defaultEnabled: false, - defaultOpacity: 0.85, - version: '1.0.0', -}; -``` - -**Data flow:** - -- On mount + when enabled: read `localStorage.getItem('ohc_psk_age')` (default 15) -- Listen for `ohc-psk-age-changed` on `window` โ†’ recalculate when time window changes -- Fetch `GET /api/pskreporter/band-activity?minutes=${pskAge}` every 60s (120s in low-memory mode) - -**State:** - -```js -const [bandCounts, setBandCounts] = useState([]); // [[band, count], ...] -const [pskAge, setPskAge] = useState(15); -``` - -**Panel rendering** (single L.Control, topright): - -Compact horizontal bar chart: - -``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ ๐Ÿ“ก PSK Reporter Band Activity โ–ถ โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ 20m [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘] 142 โ”‚ -โ”‚ 40m [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 98 โ”‚ -โ”‚ 15m [โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 73 โ”‚ -โ”‚ 10m [โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 51 โ”‚ -โ”‚ 80m [โ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 34 โ”‚ -โ”‚ 30m [โ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 12 โ”‚ -โ”‚ 17m [โ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 8 โ”‚ -โ”‚ 60m [โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 2 โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ Total: 420 ยท Last 15 min โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` - -**Design details:** - -- Band bar width = `(count / maxCount) * 100%` โ€” scales to the most active band -- Band colors from `DEFAULT_BAND_COLORS` (consistent with other overlays) -- Only show bands with `count > 0` -- Sort descending by count -- Mono font, `min-width: 180px`, same panel styling as all existing overlays -- Draggable + minimizable via `makeDraggable()` + `addMinimizeToggle()` -- No map markers โ€” stats-only panel - -**Events:** - -```js -window.addEventListener('ohc-psk-age-changed', () => { - try { - setPskAge(parseInt(localStorage.getItem('ohc_psk_age')) || 15); - } catch {} -}); -``` - -**Fetch cycle:** - -```js -// On mount + when enabled -fetch(`/api/pskreporter/band-activity?minutes=${pskAge}`) - .then((r) => r.json()) - .then((data) => setBandCounts(data.bands || [])); - -// Poll every 60s (120s low-memory) -setInterval(fetch, 60000); -``` - ---- - -## 3. Registration - -**File**: `src/plugins/layerRegistry.js` - -```js -// Add import near the top -import * as PSKReporterBandActivityPlugin from './layers/usePSKReporterBandActivity.js'; - -// Add to layerPlugins array (alphabetical-ish placement under 'propagation' category) -PSKReporterBandActivityPlugin, - -// Optional: add to PINNED_SHORTCUTS (pick an unused letter) -'psk-band-activity': 'b', // or whatever letter is available -``` - ---- - -## Summary of all changes - -| Change | Lines of code | -| ------------------------------------------- | -------------- | -| New file `usePSKReporterBandActivity.js` | ~120 lines | -| Edit `layerRegistry.js` (import + register) | 2 lines | -| Edit `pskreporter.js` (API endpoint) | ~15 lines | -| **Total** | **~140 lines** | - -No CSS changes needed โ€” reuses existing `.panel-wrapper`, `floating-panel-header`, etc. from `main.css`. From 3320c422ce2c57db9cf59b23502d45bcbb54d8d1 Mon Sep 17 00:00:00 2001 From: Laura Batalha <5883822+lbatalha@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:54:23 +0100 Subject: [PATCH 3/4] refactor: remove dead /api/pskreporter/band-activity endpoint Overlay now uses client-side events exclusively, no server fetch needed. --- server/routes/pskreporter.js | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/server/routes/pskreporter.js b/server/routes/pskreporter.js index c4c819f8..0378102b 100644 --- a/server/routes/pskreporter.js +++ b/server/routes/pskreporter.js @@ -206,27 +206,6 @@ module.exports = function (app, ctx) { }); }); - // Band activity โ€” counts of recent spots per band. - // Reads directly from pskMqtt.recentSpots (no cache needed โ€” in-memory iteration). - app.get('/api/pskreporter/band-activity', (req, res) => { - const minutes = parseInt(req.query.minutes) || 15; - const cutoff = Date.now() - minutes * 60 * 1000; - const counts = {}; - - for (const [, spots] of pskMqtt.recentSpots) { - for (const spot of spots) { - if (spot.timestamp < cutoff) continue; - const band = spot.band; - if (!band || band === 'Unknown') continue; - counts[band] = (counts[band] || 0) + 1; - } - } - - const bands = Object.entries(counts).sort((a, b) => b[1] - a[1]); - - res.json({ bands, minutes, timestamp: new Date().toISOString() }); - }); - // Combined endpoint - returns stream info (live spots via SSE, no HTTP backfill) app.get('/api/pskreporter/:callsign', async (req, res) => { const callsign = req.params.callsign.toUpperCase(); From de37203041f7e0956cfedafabb0fca8b12a8f29f Mon Sep 17 00:00:00 2001 From: Laura Batalha <5883822+lbatalha@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:37:46 +0100 Subject: [PATCH 4/4] feat: add PSK Reporter Band Activity as a dockable panel --- src/DockableApp.jsx | 8 +- .../PSKReporterBandActivityPanel.jsx | 166 ++++++++++++++++++ src/components/index.js | 1 + 3 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 src/components/PSKReporterBandActivityPanel.jsx diff --git a/src/DockableApp.jsx b/src/DockableApp.jsx index 0e12bb0f..d9fdaae9 100644 --- a/src/DockableApp.jsx +++ b/src/DockableApp.jsx @@ -22,6 +22,7 @@ import { RotatorPanel, DXpeditionPanel, PSKReporterPanel, + PSKReporterBandActivityPanel, APRSPanel, MapDataListView, MeshComPanel, @@ -442,7 +443,8 @@ export const DockableApp = ({ 'propagation-bars': { name: 'VOACAP Bars', icon: '๐Ÿ“Š', group: 'Propagation' }, 'band-conditions': { name: 'Band Conditions', icon: '๐Ÿ“ถ', group: 'Propagation' }, 'band-health': { name: 'Band Health', icon: '๐Ÿ“ถ' }, - 'band-activity': { name: 'Band Activity', icon: '๐Ÿ”ฅ' }, + 'band-activity': { name: 'Band Activity (Continent)', icon: '๐Ÿ”ฅ' }, + 'psk-bands': { name: 'Band Activity (PSKR)', icon: '๐Ÿ“ก' }, ibp: { name: 'IBP Beacons', icon: '๐Ÿ“ก', group: 'Propagation' }, 'dx-cluster': { name: 'DX Cluster', icon: '๐Ÿ“ป' }, 'psk-reporter': { name: 'PSK Reporter', icon: '๐Ÿ“ก' }, @@ -904,6 +906,10 @@ export const DockableApp = ({ content = ; break; + case 'psk-bands': + content = ; + break; + case 'dx-cluster': content = ( { + const [pskAge, setPskAge] = useState(() => { + try { + return parseInt(localStorage.getItem('ohc_psk_age')) || 15; + } catch { + return 15; + } + }); + + // Listen for localStorage changes to pskAge + useEffect(() => { + const sync = () => { + try { + const v = parseInt(localStorage.getItem('ohc_psk_age')); + if (Number.isFinite(v) && v > 0) setPskAge(v); + } catch {} + }; + window.addEventListener('ohc-psk-age-changed', sync); + return () => window.removeEventListener('ohc-psk-age-changed', sync); + }, []); + + const { txReports = [], rxReports = [] } = pskReporter; + + // Compute band counts from reports, filtered by pskAge + const { bandCounts, totalSpots, maxCount } = useMemo(() => { + const cutoff = Date.now() - pskAge * 60 * 1000; + const counts = {}; + let total = 0; + + const allReports = [...txReports, ...rxReports]; + for (const report of allReports) { + if (report.timestamp <= cutoff) continue; + const band = report.band; + if (!band || band === 'Unknown') continue; + counts[band] = (counts[band] || 0) + 1; + } + + const entries = Object.entries(counts); + entries.forEach(([, c]) => { + total += c; + }); + const sorted = entries.sort((a, b) => b[1] - a[1]); + const max = sorted.length > 0 ? sorted[0][1] : 0; + + return { bandCounts: sorted, totalSpots: total, maxCount: max }; + }, [txReports, rxReports, pskAge]); + + const countsMap = new Map(bandCounts); + + return ( +
+
+ {BAND_ORDER.map((band) => { + const count = countsMap.get(band) || 0; + const active = count > 0; + const color = DEFAULT_BAND_COLORS[band] || '#888888'; + const barWidth = maxCount > 0 ? Math.max((count / maxCount) * 100, 1.5) : 0; + return ( +
+ + {band} + +
+
+
+ + {count} + +
+ ); + })} +
+
+
+ Total: {totalSpots} ยท Last {pskAge} min +
+
+ ); +}; + +export default PSKReporterBandActivityPanel; diff --git a/src/components/index.js b/src/components/index.js index e214c953..430bbc16 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -27,6 +27,7 @@ export { SolarPanel } from './SolarPanel.jsx'; export { PropagationPanel } from './PropagationPanel.jsx'; export { DXpeditionPanel } from './DXpeditionPanel.jsx'; export { PSKReporterPanel } from './PSKReporterPanel.jsx'; +export { default as PSKReporterBandActivityPanel } from './PSKReporterBandActivityPanel.jsx'; export { DXNewsTicker } from './DXNewsTicker.jsx'; export { WeatherPanel } from './WeatherPanel.jsx'; export { AnalogClockPanel } from './AnalogClockPanel.jsx';