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'; 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; +}