@@ -71,8 +188,8 @@ export const ContestPanel = ({ data, loading }) => {
{/* Contest Calendar Credit */}
diff --git a/src/components/PSKFilterManager.jsx b/src/components/PSKFilterManager.jsx
new file mode 100644
index 0000000..ab5172a
--- /dev/null
+++ b/src/components/PSKFilterManager.jsx
@@ -0,0 +1,405 @@
+/**
+ * PSKFilterManager Component
+ * Filter modal for PSKReporter spots - Bands, Grids, Modes
+ */
+import React, { useState } from 'react';
+
+const BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm'];
+const MODES = ['FT8', 'FT4', 'JS8', 'WSPR', 'JT65', 'JT9', 'MSK144', 'Q65', 'FST4', 'FST4W'];
+
+// Common grid field prefixes by region
+const GRID_REGIONS = [
+ { name: 'North America East', grids: ['FN', 'FM', 'EN', 'EM', 'DN', 'DM'] },
+ { name: 'North America West', grids: ['CN', 'CM', 'DM', 'DN', 'BN', 'BM'] },
+ { name: 'Europe', grids: ['JO', 'JN', 'IO', 'IN', 'KO', 'KN', 'LO', 'LN'] },
+ { name: 'South America', grids: ['GG', 'GH', 'GI', 'FG', 'FH', 'FI', 'FF', 'FE'] },
+ { name: 'Asia', grids: ['PM', 'PL', 'OM', 'OL', 'QL', 'QM', 'NM', 'NL'] },
+ { name: 'Oceania', grids: ['QF', 'QG', 'PF', 'PG', 'RF', 'RG', 'OF', 'OG'] },
+ { name: 'Africa', grids: ['KH', 'KG', 'JH', 'JG', 'IH', 'IG'] },
+];
+
+export const PSKFilterManager = ({ filters, onFilterChange, isOpen, onClose }) => {
+ const [activeTab, setActiveTab] = useState('bands');
+ const [customGrid, setCustomGrid] = useState('');
+
+ if (!isOpen) return null;
+
+ const toggleArrayItem = (key, item) => {
+ const current = filters[key] || [];
+ const newArray = current.includes(item)
+ ? current.filter(x => x !== item)
+ : [...current, item];
+ onFilterChange({ ...filters, [key]: newArray.length ? newArray : undefined });
+ };
+
+ const selectAll = (key, items) => {
+ onFilterChange({ ...filters, [key]: [...items] });
+ };
+
+ const clearFilter = (key) => {
+ const newFilters = { ...filters };
+ delete newFilters[key];
+ onFilterChange(newFilters);
+ };
+
+ const clearAllFilters = () => {
+ onFilterChange({});
+ };
+
+ const addCustomGrid = () => {
+ if (customGrid.trim() && customGrid.length >= 2) {
+ const grid = customGrid.toUpperCase().substring(0, 2);
+ const current = filters?.grids || [];
+ if (!current.includes(grid)) {
+ onFilterChange({ ...filters, grids: [...current, grid] });
+ }
+ setCustomGrid('');
+ }
+ };
+
+ const getActiveFilterCount = () => {
+ let count = 0;
+ if (filters?.bands?.length) count += filters.bands.length;
+ if (filters?.grids?.length) count += filters.grids.length;
+ if (filters?.modes?.length) count += filters.modes.length;
+ return count;
+ };
+
+ const tabStyle = (active) => ({
+ padding: '8px 16px',
+ background: active ? 'var(--bg-tertiary)' : 'transparent',
+ border: 'none',
+ borderBottom: active ? '2px solid var(--accent-cyan)' : '2px solid transparent',
+ color: active ? 'var(--accent-cyan)' : 'var(--text-muted)',
+ fontSize: '13px',
+ cursor: 'pointer',
+ fontFamily: 'inherit'
+ });
+
+ const chipStyle = (selected) => ({
+ padding: '6px 12px',
+ background: selected ? 'rgba(0, 221, 255, 0.2)' : 'var(--bg-tertiary)',
+ border: `1px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
+ borderRadius: '4px',
+ color: selected ? 'var(--accent-cyan)' : 'var(--text-secondary)',
+ fontSize: '12px',
+ cursor: 'pointer',
+ fontFamily: 'JetBrains Mono, monospace'
+ });
+
+ const renderBandsTab = () => (
+
+
+
+ Filter by Band
+
+
+ selectAll('bands', BANDS)}
+ style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', fontSize: '12px', cursor: 'pointer' }}
+ >
+ Select All
+
+ clearFilter('bands')}
+ style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}
+ >
+ Clear
+
+
+
+
+ {BANDS.map(band => (
+ toggleArrayItem('bands', band)}
+ style={chipStyle(filters?.bands?.includes(band))}
+ >
+ {band}
+
+ ))}
+
+
+ {filters?.bands?.length
+ ? `Showing only: ${filters.bands.join(', ')}`
+ : 'Showing all bands (no filter)'}
+
+
+ );
+
+ const renderGridsTab = () => (
+
+
+
+ Filter by Grid Square
+
+ clearFilter('grids')}
+ style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}
+ >
+ Clear All
+
+
+
+ {/* Custom grid input */}
+
+ setCustomGrid(e.target.value.toUpperCase())}
+ maxLength={2}
+ onKeyPress={(e) => e.key === 'Enter' && addCustomGrid()}
+ style={{
+ flex: 1,
+ padding: '8px 12px',
+ background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-color)',
+ borderRadius: '4px',
+ color: 'var(--text-primary)',
+ fontSize: '13px',
+ fontFamily: 'JetBrains Mono'
+ }}
+ />
+
+ Add
+
+
+
+ {/* Selected grids */}
+ {filters?.grids?.length > 0 && (
+
+
+ Active Grid Filters:
+
+
+ {filters.grids.map(grid => (
+ toggleArrayItem('grids', grid)}
+ style={{
+ ...chipStyle(true),
+ display: 'flex',
+ alignItems: 'center',
+ gap: '6px'
+ }}
+ >
+ {grid}
+ Ć
+
+ ))}
+
+
+ )}
+
+ {/* Quick select by region */}
+
+ Quick Select by Region:
+
+ {GRID_REGIONS.map(region => (
+
+
+ {region.name}
+
+
+ {region.grids.map(grid => (
+ toggleArrayItem('grids', grid)}
+ style={{
+ ...chipStyle(filters?.grids?.includes(grid)),
+ padding: '4px 8px',
+ fontSize: '11px'
+ }}
+ >
+ {grid}
+
+ ))}
+
+
+ ))}
+
+ );
+
+ const renderModesTab = () => (
+
+
+
+ Filter by Mode
+
+
+ selectAll('modes', MODES)}
+ style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', fontSize: '12px', cursor: 'pointer' }}
+ >
+ Select All
+
+ clearFilter('modes')}
+ style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}
+ >
+ Clear
+
+
+
+
+ {MODES.map(mode => (
+ toggleArrayItem('modes', mode)}
+ style={chipStyle(filters?.modes?.includes(mode))}
+ >
+ {mode}
+
+ ))}
+
+
+ {filters?.modes?.length
+ ? `Showing only: ${filters.modes.join(', ')}`
+ : 'Showing all modes (no filter)'}
+
+
+ );
+
+ return (
+
e.target === e.currentTarget && onClose()}
+ >
+
+ {/* Header */}
+
+
+
+ š” PSKReporter Filters
+
+
+ {getActiveFilterCount()} filter{getActiveFilterCount() !== 1 ? 's' : ''} active
+
+
+
+ Ć
+
+
+
+ {/* Tabs */}
+
+ setActiveTab('bands')} style={tabStyle(activeTab === 'bands')}>
+ Bands {filters?.bands?.length ? `(${filters.bands.length})` : ''}
+
+ setActiveTab('grids')} style={tabStyle(activeTab === 'grids')}>
+ Grids {filters?.grids?.length ? `(${filters.grids.length})` : ''}
+
+ setActiveTab('modes')} style={tabStyle(activeTab === 'modes')}>
+ Modes {filters?.modes?.length ? `(${filters.modes.length})` : ''}
+
+
+
+ {/* Tab Content */}
+
+ {activeTab === 'bands' && renderBandsTab()}
+ {activeTab === 'grids' && renderGridsTab()}
+ {activeTab === 'modes' && renderModesTab()}
+
+
+ {/* Footer */}
+
+
+ Clear All Filters
+
+
+ Done
+
+
+
+
+ );
+};
+
+export default PSKFilterManager;
diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx
new file mode 100644
index 0000000..d7fff17
--- /dev/null
+++ b/src/components/PSKReporterPanel.jsx
@@ -0,0 +1,324 @@
+/**
+ * PSKReporter Panel
+ * Shows where your digital mode signals are being received
+ * Uses MQTT WebSocket for real-time data
+ */
+import React, { useState, useMemo } from 'react';
+import { usePSKReporter } from '../hooks/usePSKReporter.js';
+import { getBandColor } from '../utils/callsign.js';
+
+const PSKReporterPanel = ({
+ callsign,
+ onShowOnMap,
+ showOnMap,
+ onToggleMap,
+ filters = {},
+ onOpenFilters
+}) => {
+ const [activeTab, setActiveTab] = useState('tx'); // Default to 'tx' (Being Heard)
+
+ const {
+ txReports,
+ txCount,
+ rxReports,
+ rxCount,
+ loading,
+ error,
+ connected,
+ source,
+ refresh
+ } = usePSKReporter(callsign, {
+ minutes: 15,
+ enabled: callsign && callsign !== 'N0CALL'
+ });
+
+ // Filter reports by band, grid, and mode
+ const filterReports = (reports) => {
+ return reports.filter(r => {
+ // Band filter
+ if (filters?.bands?.length && !filters.bands.includes(r.band)) return false;
+
+ // Grid filter (prefix match)
+ if (filters?.grids?.length) {
+ const grid = activeTab === 'tx' ? r.receiverGrid : r.senderGrid;
+ if (!grid) return false;
+ const gridPrefix = grid.substring(0, 2).toUpperCase();
+ if (!filters.grids.includes(gridPrefix)) return false;
+ }
+
+ // Mode filter
+ if (filters?.modes?.length && !filters.modes.includes(r.mode)) return false;
+
+ return true;
+ });
+ };
+
+ const filteredTx = useMemo(() => filterReports(txReports), [txReports, filters, activeTab]);
+ const filteredRx = useMemo(() => filterReports(rxReports), [rxReports, filters, activeTab]);
+ const filteredReports = activeTab === 'tx' ? filteredTx : filteredRx;
+
+ // Count active filters
+ const getActiveFilterCount = () => {
+ let count = 0;
+ if (filters?.bands?.length) count++;
+ if (filters?.grids?.length) count++;
+ if (filters?.modes?.length) count++;
+ return count;
+ };
+ const filterCount = getActiveFilterCount();
+
+ // Get band color from frequency
+ const getFreqColor = (freqMHz) => {
+ if (!freqMHz) return 'var(--text-muted)';
+ const freq = parseFloat(freqMHz);
+ return getBandColor(freq);
+ };
+
+ // Format age
+ const formatAge = (minutes) => {
+ if (minutes < 1) return 'now';
+ if (minutes < 60) return `${minutes}m`;
+ return `${Math.floor(minutes/60)}h`;
+ };
+
+ // Get status indicator
+ const getStatusIndicator = () => {
+ if (connected) {
+ return
ā LIVE ;
+ }
+ if (source === 'connecting' || source === 'reconnecting') {
+ return
ā {source} ;
+ }
+ if (error) {
+ return
ā offline ;
+ }
+ return null;
+ };
+
+ if (!callsign || callsign === 'N0CALL') {
+ return (
+
+
+ š” PSKReporter
+
+
+ Set callsign in Settings
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
š” PSKReporter {getStatusIndicator()}
+
+
+ {filteredReports.length}/{activeTab === 'tx' ? txCount : rxCount}
+
+ 0 ? 'rgba(255, 170, 0, 0.3)' : 'rgba(100, 100, 100, 0.3)',
+ border: `1px solid ${filterCount > 0 ? '#ffaa00' : '#666'}`,
+ color: filterCount > 0 ? '#ffaa00' : '#888',
+ padding: '2px 8px',
+ borderRadius: '4px',
+ fontSize: '10px',
+ fontFamily: 'JetBrains Mono',
+ cursor: 'pointer'
+ }}
+ >
+ š Filters
+
+
+ š
+
+ {onToggleMap && (
+
+ šŗļø {showOnMap ? 'ON' : 'OFF'}
+
+ )}
+
+
+
+ {/* Tabs */}
+
+ setActiveTab('tx')}
+ style={{
+ flex: 1,
+ padding: '4px 6px',
+ background: activeTab === 'tx' ? 'rgba(74, 222, 128, 0.2)' : 'rgba(100, 100, 100, 0.2)',
+ border: `1px solid ${activeTab === 'tx' ? '#4ade80' : '#555'}`,
+ borderRadius: '3px',
+ color: activeTab === 'tx' ? '#4ade80' : '#888',
+ cursor: 'pointer',
+ fontSize: '10px',
+ fontFamily: 'JetBrains Mono'
+ }}
+ >
+ š¤ Being Heard ({filterCount > 0 ? `${filteredTx.length}` : txCount})
+
+ setActiveTab('rx')}
+ style={{
+ flex: 1,
+ padding: '4px 6px',
+ background: activeTab === 'rx' ? 'rgba(96, 165, 250, 0.2)' : 'rgba(100, 100, 100, 0.2)',
+ border: `1px solid ${activeTab === 'rx' ? '#60a5fa' : '#555'}`,
+ borderRadius: '3px',
+ color: activeTab === 'rx' ? '#60a5fa' : '#888',
+ cursor: 'pointer',
+ fontSize: '10px',
+ fontFamily: 'JetBrains Mono'
+ }}
+ >
+ š„ Hearing ({filterCount > 0 ? `${filteredRx.length}` : rxCount})
+
+
+
+ {/* Reports list */}
+ {error && !connected ? (
+
+ ā ļø Connection failed - click š to retry
+
+ ) : loading && filteredReports.length === 0 && filterCount === 0 ? (
+
+
+ Connecting to MQTT...
+
+ ) : !connected && filteredReports.length === 0 && filterCount === 0 ? (
+
+ Waiting for connection...
+
+ ) : filteredReports.length === 0 ? (
+
+ {filterCount > 0
+ ? 'No spots match filters'
+ : activeTab === 'tx'
+ ? 'Waiting for spots... (TX to see reports)'
+ : 'No stations heard yet'}
+
+ ) : (
+
+ {filteredReports.slice(0, 20).map((report, i) => {
+ const freqMHz = report.freqMHz || (report.freq ? (report.freq / 1000000).toFixed(3) : '?');
+ const color = getFreqColor(freqMHz);
+ const displayCall = activeTab === 'tx' ? report.receiver : report.sender;
+ const grid = activeTab === 'tx' ? report.receiverGrid : report.senderGrid;
+
+ return (
+
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/PluginLayer.jsx b/src/components/PluginLayer.jsx
new file mode 100644
index 0000000..ced3f66
--- /dev/null
+++ b/src/components/PluginLayer.jsx
@@ -0,0 +1,16 @@
+/**
+ * PluginLayer Component
+ * Renders a single plugin layer using its hook
+ */
+import React from 'react';
+
+export const PluginLayer = ({ plugin, enabled, opacity, map }) => {
+ // Call the plugin's hook (this is allowed because it's in a component)
+ const result = plugin.hook({ enabled, opacity, map });
+
+ // Plugin hook handles its own rendering to the map
+ // This component doesn't render anything to the DOM
+ return null;
+};
+
+export default PluginLayer;
diff --git a/src/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx
index 656c45b..8566d4c 100644
--- a/src/components/SettingsPanel.jsx
+++ b/src/components/SettingsPanel.jsx
@@ -1,6 +1,6 @@
/**
* SettingsPanel Component
- * Full settings modal matching production version
+ * Full settings modal with map layer controls
*/
import React, { useState, useEffect } from 'react';
import { calculateGridSquare } from '../utils/geo.js';
@@ -15,6 +15,10 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
const [layout, setLayout] = useState(config?.layout || 'modern');
const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy');
const { t } = useTranslation();
+
+ // Layer controls
+ const [layers, setLayers] = useState([]);
+ const [activeTab, setActiveTab] = useState('station');
useEffect(() => {
if (config) {
@@ -24,19 +28,33 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
setTheme(config.theme || 'dark');
setLayout(config.layout || 'modern');
setDxClusterSource(config.dxClusterSource || 'dxspider-proxy');
- // Use locator from config, or calculate from coordinates
- if (config.locator) {
- setGridSquare(config.locator);
- } else if (config.location?.lat && config.location?.lon) {
+ if (config.location?.lat && config.location?.lon) {
setGridSquare(calculateGridSquare(config.location.lat, config.location.lon));
}
}
}, [config, isOpen]);
- // Update lat/lon when grid square changes
+ // Load layers when panel opens
+ useEffect(() => {
+ if (isOpen && window.hamclockLayerControls) {
+ setLayers(window.hamclockLayerControls.layers || []);
+ }
+ }, [isOpen]);
+
+ // Refresh layers periodically
+ useEffect(() => {
+ if (isOpen && activeTab === 'layers') {
+ const interval = setInterval(() => {
+ if (window.hamclockLayerControls) {
+ setLayers([...window.hamclockLayerControls.layers]);
+ }
+ }, 200);
+ return () => clearInterval(interval);
+ }
+ }, [isOpen, activeTab]);
+
const handleGridChange = (grid) => {
setGridSquare(grid.toUpperCase());
- // Parse grid square to lat/lon if valid (6 char)
if (grid.length >= 4) {
const parsed = parseGridSquare(grid);
if (parsed) {
@@ -46,7 +64,6 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
}
};
- // Parse grid square to coordinates
const parseGridSquare = (grid) => {
grid = grid.toUpperCase();
if (grid.length < 4) return null;
@@ -69,7 +86,6 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
return { lat, lon };
};
- // Update grid when lat/lon changes
useEffect(() => {
if (lat && lon) {
setGridSquare(calculateGridSquare(lat, lon));
@@ -93,11 +109,41 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
}
};
+ const handleToggleLayer = (layerId) => {
+ if (window.hamclockLayerControls) {
+ const layer = layers.find(l => l.id === layerId);
+ const newEnabledState = !layer.enabled;
+
+ // Update the control
+ window.hamclockLayerControls.toggleLayer(layerId, newEnabledState);
+
+ // Force immediate UI update
+ setLayers(prevLayers =>
+ prevLayers.map(l =>
+ l.id === layerId ? { ...l, enabled: newEnabledState } : l
+ )
+ );
+
+ // Refresh after a short delay to get the updated state
+ setTimeout(() => {
+ if (window.hamclockLayerControls) {
+ setLayers([...window.hamclockLayerControls.layers]);
+ }
+ }, 100);
+ }
+ };
+
+ const handleOpacityChange = (layerId, opacity) => {
+ if (window.hamclockLayerControls) {
+ window.hamclockLayerControls.setOpacity(layerId, opacity);
+ setLayers([...window.hamclockLayerControls.layers]);
+ }
+ };
+
const handleSave = () => {
onSave({
...config,
callsign: callsign.toUpperCase(),
- locator: gridSquare.toUpperCase(),
location: { lat: parseFloat(lat), lon: parseFloat(lon) },
theme,
layout,
@@ -144,14 +190,14 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
border: '2px solid var(--accent-amber)',
borderRadius: '12px',
padding: '24px',
- width: '480px',
+ width: '520px',
maxHeight: '90vh',
overflowY: 'auto'
}}>
{
{t('station.settings.title')}
- {/* First-time setup banner */}
- {(config?.configIncomplete || config?.callsign === 'N0CALL' || !config?.locator) && (
-
-
- {t("station.settings.welcome")}
-
-
- {t("station.settings.describe")}
-
-
- , env: }} />
-
-
- )}
-
- {/* Callsign */}
-
-
- {t('station.settings.callsign')}
-
-
setCallsign(e.target.value.toUpperCase())}
+ {/* Tab Navigation */}
+
+ setActiveTab('station')}
style={{
- width: '100%',
- padding: '12px',
- background: 'var(--bg-tertiary)',
- border: '1px solid var(--border-color)',
- borderRadius: '6px',
- color: 'var(--accent-amber)',
- fontSize: '18px',
- fontFamily: 'JetBrains Mono, monospace',
- fontWeight: '700',
- boxSizing: 'border-box'
+ flex: 1,
+ padding: '10px',
+ background: activeTab === 'station' ? 'var(--accent-amber)' : 'transparent',
+ border: 'none',
+ borderRadius: '6px 6px 0 0',
+ color: activeTab === 'station' ? '#000' : 'var(--text-secondary)',
+ fontSize: '13px',
+ cursor: 'pointer',
+ fontWeight: activeTab === 'station' ? '700' : '400',
+ fontFamily: 'JetBrains Mono, monospace'
}}
- />
-
-
- {/* Grid Square */}
-
-
- {t('station.settings.locator')}
-
- handleGridChange(e.target.value)}
- placeholder="FN20nc"
- maxLength={6}
+ >
+ š” Station
+
+ setActiveTab('layers')}
style={{
- width: '100%',
- padding: '12px',
- background: 'var(--bg-tertiary)',
- border: '1px solid var(--border-color)',
- borderRadius: '6px',
- color: 'var(--accent-amber)',
- fontSize: '18px',
- fontFamily: 'JetBrains Mono, monospace',
- fontWeight: '700',
- boxSizing: 'border-box'
+ flex: 1,
+ padding: '10px',
+ background: activeTab === 'layers' ? 'var(--accent-amber)' : 'transparent',
+ border: 'none',
+ borderRadius: '6px 6px 0 0',
+ color: activeTab === 'layers' ? '#000' : 'var(--text-secondary)',
+ fontSize: '13px',
+ cursor: 'pointer',
+ fontWeight: activeTab === 'layers' ? '700' : '400',
+ fontFamily: 'JetBrains Mono, monospace'
}}
- />
+ >
+ šŗļø Map Layers
+
- {/* Lat/Lon */}
-
-
-
- {t('station.settings.latitude')}
-
- setLat(parseFloat(e.target.value))}
- style={{
- width: '100%',
- padding: '10px',
- background: 'var(--bg-tertiary)',
- border: '1px solid var(--border-color)',
- borderRadius: '6px',
- color: 'var(--text-primary)',
- fontSize: '14px',
- fontFamily: 'JetBrains Mono, monospace',
- boxSizing: 'border-box'
- }}
- />
-
-
-
- {t('station.settings.longitude')}
-
-
setLon(parseFloat(e.target.value))}
+ {/* Station Settings Tab */}
+ {activeTab === 'station' && (
+ <>
+ {/* First-time setup banner */}
+ {(config?.configIncomplete || config?.callsign === 'N0CALL' || !config?.locator) && (
+
+
+ {t("station.settings.welcome")}
+
+
+ {t("station.settings.describe")}
+
+
+ , env: }} />
+
+
+ )}
+
+ {/* Callsign */}
+
+
+ {t('station.settings.callsign')}
+
+ setCallsign(e.target.value.toUpperCase())}
+ style={{
+ width: '100%',
+ padding: '12px',
+ background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-color)',
+ borderRadius: '6px',
+ color: 'var(--accent-amber)',
+ fontSize: '18px',
+ fontFamily: 'JetBrains Mono, monospace',
+ fontWeight: '700',
+ boxSizing: 'border-box'
+ }}
+ />
+
+
+ {/* Grid Square */}
+
+
+ {t('station.settings.locator')}
+
+ handleGridChange(e.target.value)}
+ placeholder="FN20nc"
+ maxLength={6}
+ style={{
+ width: '100%',
+ padding: '12px',
+ background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-color)',
+ borderRadius: '6px',
+ color: 'var(--accent-amber)',
+ fontSize: '18px',
+ fontFamily: 'JetBrains Mono, monospace',
+ fontWeight: '700',
+ boxSizing: 'border-box'
+ }}
+ />
+
+
+ {/* Lat/Lon */}
+
+
+
+ {t('station.settings.latitude')}
+
+ setLat(parseFloat(e.target.value) || 0)}
+ style={{
+ width: '100%',
+ padding: '10px',
+ background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-color)',
+ borderRadius: '6px',
+ color: 'var(--text-primary)',
+ fontSize: '14px',
+ fontFamily: 'JetBrains Mono, monospace',
+ boxSizing: 'border-box'
+ }}
+ />
+
+
+
+ {t('station.settings.longitude')}
+
+ setLon(parseFloat(e.target.value) || 0)}
+ style={{
+ width: '100%',
+ padding: '10px',
+ background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-color)',
+ borderRadius: '6px',
+ color: 'var(--text-primary)',
+ fontSize: '14px',
+ fontFamily: 'JetBrains Mono, monospace',
+ boxSizing: 'border-box'
+ }}
+ />
+
+
+
+
-
-
+ >
+ {t('station.settings.useLocation')}
+
- {/* Use My Location button */}
-
- {t('station.settings.useLocation')}
-
+ {/* Theme */}
+
+
+ {t('station.settings.theme')}
+
+
+ {['dark', 'light', 'legacy', 'retro'].map((th) => (
+ setTheme(th)}
+ style={{
+ padding: '10px',
+ background: theme === th ? 'var(--accent-amber)' : 'var(--bg-tertiary)',
+ border: `1px solid ${theme === th ? 'var(--accent-amber)' : 'var(--border-color)'}`,
+ borderRadius: '6px',
+ color: theme === th ? '#000' : 'var(--text-secondary)',
+ fontSize: '12px',
+ cursor: 'pointer',
+ fontWeight: theme === th ? '600' : '400'
+ }}
+ >
+ {th === 'dark' ? 'š' : th === 'light' ? 'āļø' : th === 'legacy' ? 'š»' : 'šŖ'} {t('station.settings.theme.' + th)}
+
+ ))}
+
+
+ {themeDescriptions[theme]}
+
+
- {/* Theme */}
-
-
- {t('station.settings.theme')}
-
-
- {['dark', 'light', 'legacy', 'retro'].map((theme) => (
- setTheme(theme)}
- style={{
- padding: '10px',
- background: theme === theme ? 'var(--accent-amber)' : 'var(--bg-tertiary)',
- border: `1px solid ${theme === theme ? 'var(--accent-amber)' : 'var(--border-color)'}`,
- borderRadius: '6px',
- color: theme === theme ? '#000' : 'var(--text-secondary)',
- fontSize: '12px',
- cursor: 'pointer',
- fontWeight: theme === theme ? '600' : '400'
- }}
- >
- {theme === 'dark' ? 'š' : theme === 'light' ? 'āļø' : theme === 'legacy' ? 'š»' : 'šŖ'} {t('station.settings.theme.' + theme)}
-
- ))}
-
-
- {themeDescriptions[theme]}
-
-
+ {/* Layout */}
+
+
+ {t('station.settings.layout')}
+
+
+ {['modern', 'classic'].map((l) => (
+ setLayout(l)}
+ style={{
+ padding: '10px',
+ background: layout === l ? 'var(--accent-amber)' : 'var(--bg-tertiary)',
+ border: `1px solid ${layout === l ? 'var(--accent-amber)' : 'var(--border-color)'}`,
+ borderRadius: '6px',
+ color: layout === l ? '#000' : 'var(--text-secondary)',
+ fontSize: '13px',
+ cursor: 'pointer',
+ fontWeight: layout === l ? '600' : '400'
+ }}
+ >
+ {l === 'modern' ? 'š„ļø' : 'šŗ'} {t('station.settings.layout.' + l)}
+
+ ))}
+
+
+ {layoutDescriptions[layout]}
+
+
- {/* Layout */}
-
-
- {t('station.settings.layout')}
-
-
- {['modern', 'classic'].map((l) => (
-
setLayout(l)}
+ {/* DX Cluster Source */}
+
+
+ {t('station.settings.dx.title')}
+
+ setDxClusterSource(e.target.value)}
style={{
- padding: '10px',
- background: layout === l ? 'var(--accent-amber)' : 'var(--bg-tertiary)',
- border: `1px solid ${layout === l ? 'var(--accent-amber)' : 'var(--border-color)'}`,
+ width: '100%',
+ padding: '12px',
+ background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-color)',
borderRadius: '6px',
- color: layout === l ? '#000' : 'var(--text-secondary)',
- fontSize: '13px',
- cursor: 'pointer',
- fontWeight: layout === l ? '600' : '400'
+ color: 'var(--accent-green)',
+ fontSize: '14px',
+ fontFamily: 'JetBrains Mono, monospace',
+ cursor: 'pointer'
}}
>
- {l === 'modern' ? 'š„ļø' : 'šŗ'} {t('station.settings.layout.' + l)}
-
- ))}
-
-
- {layoutDescriptions[layout]}
-
-
+
{t('station.settings.dx.option1')}
+
{t('station.settings.dx.option2')}
+
{t('station.settings.dx.option3')}
+
{t('station.settings.dx.option4')}
+
+
+ {t('station.settings.dx.describe')}
+
+
+ >
+ )}
- {/* DX Cluster Source */}
-
-
- {t('station.settings.dx.title')}
-
-
setDxClusterSource(e.target.value)}
- style={{
- width: '100%',
- padding: '12px',
- background: 'var(--bg-tertiary)',
- border: '1px solid var(--border-color)',
- borderRadius: '6px',
- color: 'var(--accent-green)',
- fontSize: '14px',
- fontFamily: 'JetBrains Mono, monospace',
- cursor: 'pointer'
- }}
- >
- {t('station.settings.dx.option1')}
- {t('station.settings.dx.option2')}
- {t('station.settings.dx.option3')}
- {t('station.settings.dx.option4')}
-
-
- {t('station.settings.dx.describe')}
+ {/* Map Layers Tab */}
+ {activeTab === 'layers' && (
+
+ {layers.length > 0 ? (
+ layers.map(layer => (
+
+
+
+ handleToggleLayer(layer.id)}
+ style={{
+ width: '18px',
+ height: '18px',
+ cursor: 'pointer'
+ }}
+ />
+ {layer.icon}
+
+
+ {layer.name}
+
+ {layer.description && (
+
+ {layer.description}
+
+ )}
+
+
+
+ {layer.category}
+
+
+
+ {layer.enabled && (
+
+
+ Opacity: {Math.round(layer.opacity * 100)}%
+
+ handleOpacityChange(layer.id, parseFloat(e.target.value) / 100)}
+ style={{
+ width: '100%',
+ cursor: 'pointer'
+ }}
+ />
+
+ )}
+
+ ))
+ ) : (
+
+ No map layers available
+
+ )}
-
+ )}
{/* Buttons */}
diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx
index 6ad138d..b7043e6 100644
--- a/src/components/WorldMap.jsx
+++ b/src/components/WorldMap.jsx
@@ -1,6 +1,6 @@
/**
* WorldMap Component
- * Leaflet map with DE/DX markers, terminator, DX paths, POTA, satellites
+ * Leaflet map with DE/DX markers, terminator, DX paths, POTA, satellites, PSKReporter
*/
import React, { useRef, useEffect, useState } from 'react';
import { MAP_STYLES } from '../utils/config.js';
@@ -12,6 +12,10 @@ import {
} from '../utils/geo.js';
import { filterDXPaths, getBandColor } from '../utils/callsign.js';
+import { getAllLayers } from '../plugins/layerRegistry.js';
+import PluginLayer from './PluginLayer.jsx';
+
+
export const WorldMap = ({
deLocation,
dxLocation,
@@ -21,11 +25,13 @@ export const WorldMap = ({
dxPaths,
dxFilters,
satellites,
+ pskReporterSpots,
showDXPaths,
showDXLabels,
onToggleDXLabels,
showPOTA,
showSatellites,
+ showPSKReporter,
onToggleSatellites,
hoveredSpot
}) => {
@@ -44,6 +50,11 @@ export const WorldMap = ({
const dxPathsMarkersRef = useRef([]);
const satMarkersRef = useRef([]);
const satTracksRef = useRef([]);
+ const pskMarkersRef = useRef([]);
+
+ // Plugin system refs and state
+ const pluginLayersRef = useRef({});
+ const [pluginLayerStates, setPluginLayerStates] = useState({});
// Load map style from localStorage
const getStoredMapSettings = () => {
@@ -416,10 +427,164 @@ export const WorldMap = ({
}
}, [satellites, showSatellites]);
+ // Plugin layer system - properly load saved states
+ useEffect(() => {
+ if (!mapInstanceRef.current) return;
+
+ try {
+ const availableLayers = getAllLayers();
+ const settings = getStoredMapSettings();
+ const savedLayers = settings.layers || {};
+
+ // Build initial states from localStorage
+ const initialStates = {};
+ availableLayers.forEach(layerDef => {
+ // Use saved state if it exists, otherwise use defaults
+ if (savedLayers[layerDef.id]) {
+ initialStates[layerDef.id] = savedLayers[layerDef.id];
+ } else {
+ initialStates[layerDef.id] = {
+ enabled: layerDef.defaultEnabled,
+ opacity: layerDef.defaultOpacity
+ };
+ }
+ });
+
+ // Initialize state ONLY on first mount (when empty)
+ if (Object.keys(pluginLayerStates).length === 0) {
+ console.log('Loading saved layer states:', initialStates);
+ setPluginLayerStates(initialStates);
+ }
+
+ // Expose controls for SettingsPanel
+ window.hamclockLayerControls = {
+ layers: availableLayers.map(l => ({
+ ...l,
+ enabled: pluginLayerStates[l.id]?.enabled ?? initialStates[l.id]?.enabled ?? l.defaultEnabled,
+ opacity: pluginLayerStates[l.id]?.opacity ?? initialStates[l.id]?.opacity ?? l.defaultOpacity
+ })),
+ toggleLayer: (id, enabled) => {
+ console.log(`Toggle layer ${id}:`, enabled);
+ const settings = getStoredMapSettings();
+ const layers = settings.layers || {};
+ layers[id] = {
+ enabled: enabled,
+ opacity: layers[id]?.opacity ?? 0.6
+ };
+ localStorage.setItem('openhamclock_mapSettings', JSON.stringify({ ...settings, layers }));
+ console.log('Saved to localStorage:', layers);
+ setPluginLayerStates(prev => ({
+ ...prev,
+ [id]: {
+ ...prev[id],
+ enabled: enabled
+ }
+ }));
+ },
+ setOpacity: (id, opacity) => {
+ console.log(`Set opacity ${id}:`, opacity);
+ const settings = getStoredMapSettings();
+ const layers = settings.layers || {};
+ layers[id] = {
+ enabled: layers[id]?.enabled ?? false,
+ opacity: opacity
+ };
+ localStorage.setItem('openhamclock_mapSettings', JSON.stringify({ ...settings, layers }));
+ console.log('Saved to localStorage:', layers);
+ setPluginLayerStates(prev => ({
+ ...prev,
+ [id]: {
+ ...prev[id],
+ opacity: opacity
+ }
+ }));
+ }
+ };
+ } catch (err) {
+ console.error('Plugin system error:', err);
+ }
+ }, [pluginLayerStates]);
+
+ // Update PSKReporter markers
+ useEffect(() => {
+ if (!mapInstanceRef.current) return;
+ const map = mapInstanceRef.current;
+
+ pskMarkersRef.current.forEach(m => map.removeLayer(m));
+ pskMarkersRef.current = [];
+
+ // Validate deLocation exists and has valid coordinates
+ const hasValidDE = deLocation &&
+ typeof deLocation.lat === 'number' && !isNaN(deLocation.lat) &&
+ typeof deLocation.lon === 'number' && !isNaN(deLocation.lon);
+
+ if (showPSKReporter && pskReporterSpots && pskReporterSpots.length > 0 && hasValidDE) {
+ pskReporterSpots.forEach(spot => {
+ // Validate spot coordinates are valid numbers
+ const spotLat = parseFloat(spot.lat);
+ const spotLon = parseFloat(spot.lon);
+
+ if (!isNaN(spotLat) && !isNaN(spotLon)) {
+ const displayCall = spot.receiver || spot.sender;
+ const freqMHz = spot.freqMHz || (spot.freq ? (spot.freq / 1000000).toFixed(3) : '?');
+ const bandColor = getBandColor(parseFloat(freqMHz));
+
+ try {
+ // Draw line from DE to spot location
+ const points = getGreatCirclePoints(
+ deLocation.lat, deLocation.lon,
+ spotLat, spotLon,
+ 50
+ );
+
+ // Validate points before creating polyline
+ if (points && points.length > 1 && points.every(p => Array.isArray(p) && !isNaN(p[0]) && !isNaN(p[1]))) {
+ const line = L.polyline(points, {
+ color: bandColor,
+ weight: 1.5,
+ opacity: 0.5,
+ dashArray: '4, 4'
+ }).addTo(map);
+ pskMarkersRef.current.push(line);
+ }
+
+ // Add small dot marker at spot location
+ const circle = L.circleMarker([spotLat, spotLon], {
+ radius: 4,
+ fillColor: bandColor,
+ color: '#fff',
+ weight: 1,
+ opacity: 0.9,
+ fillOpacity: 0.8
+ }).bindPopup(`
+
${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 (
+ {/* Render all plugin layers */}
+ {mapInstanceRef.current && getAllLayers().map(layerDef => (
+
+ ))}
+
{/* Map style dropdown */}
{
// Get retention time from filters, default to 30 minutes
const spotRetentionMs = (filters?.spotRetentionMinutes || 30) * 60 * 1000;
- const pollInterval = 5000; // 5 seconds
+ const pollInterval = 30000; // 30 seconds (was 5 seconds - reduced to save bandwidth)
// Apply filters to spots
const applyFilters = useCallback((spots, filters) => {
diff --git a/src/hooks/useDXPaths.js b/src/hooks/useDXPaths.js
index ffee685..bc3fa3e 100644
--- a/src/hooks/useDXPaths.js
+++ b/src/hooks/useDXPaths.js
@@ -24,7 +24,7 @@ export const useDXPaths = () => {
};
fetchData();
- const interval = setInterval(fetchData, 10000); // 10 seconds
+ const interval = setInterval(fetchData, 30000); // 30 seconds (was 10s)
return () => clearInterval(interval);
}, []);
diff --git a/src/hooks/useMySpots.js b/src/hooks/useMySpots.js
index 5134ff4..513b957 100644
--- a/src/hooks/useMySpots.js
+++ b/src/hooks/useMySpots.js
@@ -30,7 +30,7 @@ export const useMySpots = (callsign) => {
};
fetchMySpots();
- const interval = setInterval(fetchMySpots, 30000); // 30 seconds
+ const interval = setInterval(fetchMySpots, 60000); // 60 seconds (was 30s)
return () => clearInterval(interval);
}, [callsign]);
diff --git a/src/hooks/usePOTASpots.js b/src/hooks/usePOTASpots.js
index 7fef2e4..da58d86 100644
--- a/src/hooks/usePOTASpots.js
+++ b/src/hooks/usePOTASpots.js
@@ -1,9 +1,8 @@
/**
* usePOTASpots Hook
- * Fetches Parks on the Air activations
+ * Fetches Parks on the Air activations via server proxy (for caching)
*/
import { useState, useEffect } from 'react';
-import { DEFAULT_CONFIG } from '../utils/config.js';
export const usePOTASpots = () => {
const [data, setData] = useState([]);
@@ -12,7 +11,8 @@ export const usePOTASpots = () => {
useEffect(() => {
const fetchPOTA = async () => {
try {
- const res = await fetch('https://api.pota.app/spot/activator');
+ // Use server proxy for caching - reduces external API calls
+ const res = await fetch('/api/pota/spots');
if (res.ok) {
const spots = await res.json();
setData(spots.slice(0, 10).map(s => ({
@@ -34,7 +34,7 @@ export const usePOTASpots = () => {
};
fetchPOTA();
- const interval = setInterval(fetchPOTA, DEFAULT_CONFIG.refreshIntervals.pota);
+ const interval = setInterval(fetchPOTA, 2 * 60 * 1000); // 2 minutes
return () => clearInterval(interval);
}, []);
diff --git a/src/hooks/usePSKReporter.js b/src/hooks/usePSKReporter.js
new file mode 100644
index 0000000..e131531
--- /dev/null
+++ b/src/hooks/usePSKReporter.js
@@ -0,0 +1,318 @@
+/**
+ * usePSKReporter Hook
+ * Fetches PSKReporter data via MQTT WebSocket connection
+ *
+ * Uses real-time MQTT feed from mqtt.pskreporter.info for live spots
+ * No HTTP API calls - direct WebSocket connection from browser
+ */
+import { useState, useEffect, useCallback, useRef } from 'react';
+import mqtt from 'mqtt';
+
+// Convert grid square to lat/lon
+function gridToLatLon(grid) {
+ if (!grid || grid.length < 4) return null;
+
+ const g = grid.toUpperCase();
+ const lon = (g.charCodeAt(0) - 65) * 20 - 180;
+ const lat = (g.charCodeAt(1) - 65) * 10 - 90;
+ const lonMin = parseInt(g[2]) * 2;
+ const latMin = parseInt(g[3]) * 1;
+
+ let finalLon = lon + lonMin + 1;
+ let finalLat = lat + latMin + 0.5;
+
+ if (grid.length >= 6) {
+ const lonSec = (g.charCodeAt(4) - 65) * (2/24);
+ const latSec = (g.charCodeAt(5) - 65) * (1/24);
+ finalLon = lon + lonMin + lonSec + (1/24);
+ finalLat = lat + latMin + latSec + (0.5/24);
+ }
+
+ return { lat: finalLat, lon: finalLon };
+}
+
+// Get band name from frequency in Hz
+function getBandFromHz(freqHz) {
+ const freqMHz = freqHz / 1000000;
+ if (freqMHz >= 1.8 && freqMHz <= 2) return '160m';
+ if (freqMHz >= 3.5 && freqMHz <= 4) return '80m';
+ if (freqMHz >= 5.3 && freqMHz <= 5.4) return '60m';
+ if (freqMHz >= 7 && freqMHz <= 7.3) return '40m';
+ if (freqMHz >= 10.1 && freqMHz <= 10.15) return '30m';
+ if (freqMHz >= 14 && freqMHz <= 14.35) return '20m';
+ if (freqMHz >= 18.068 && freqMHz <= 18.168) return '17m';
+ if (freqMHz >= 21 && freqMHz <= 21.45) return '15m';
+ if (freqMHz >= 24.89 && freqMHz <= 24.99) return '12m';
+ if (freqMHz >= 28 && freqMHz <= 29.7) return '10m';
+ if (freqMHz >= 50 && freqMHz <= 54) return '6m';
+ if (freqMHz >= 144 && freqMHz <= 148) return '2m';
+ if (freqMHz >= 420 && freqMHz <= 450) return '70cm';
+ return 'Unknown';
+}
+
+export const usePSKReporter = (callsign, options = {}) => {
+ const {
+ minutes = 15, // Time window to keep spots
+ enabled = true, // Enable/disable fetching
+ maxSpots = 100 // Max spots to keep
+ } = options;
+
+ const [txReports, setTxReports] = useState([]);
+ const [rxReports, setRxReports] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [connected, setConnected] = useState(false);
+ const [lastUpdate, setLastUpdate] = useState(null);
+ const [source, setSource] = useState('connecting');
+
+ const clientRef = useRef(null);
+ const txReportsRef = useRef([]);
+ const rxReportsRef = useRef([]);
+ const mountedRef = useRef(true);
+
+ // Clean old spots (older than specified minutes)
+ const cleanOldSpots = useCallback((spots, maxAgeMinutes) => {
+ const cutoff = Date.now() - (maxAgeMinutes * 60 * 1000);
+ return spots.filter(s => s.timestamp > cutoff).slice(0, maxSpots);
+ }, [maxSpots]);
+
+ // Process incoming MQTT message
+ const processMessage = useCallback((topic, message) => {
+ if (!mountedRef.current) return;
+
+ try {
+ const data = JSON.parse(message.toString());
+
+ // PSKReporter MQTT message format
+ // sa=sender callsign, sl=sender locator, ra=receiver callsign, rl=receiver locator
+ // f=frequency, md=mode, rp=snr (report), t=timestamp
+ const {
+ sa: senderCallsign,
+ sl: senderLocator,
+ ra: receiverCallsign,
+ rl: receiverLocator,
+ f: frequency,
+ md: mode,
+ rp: snr,
+ t: timestamp
+ } = data;
+
+ if (!senderCallsign || !receiverCallsign) return;
+
+ const senderLoc = gridToLatLon(senderLocator);
+ const receiverLoc = gridToLatLon(receiverLocator);
+ const freq = parseInt(frequency) || 0;
+ const now = Date.now();
+
+ const report = {
+ sender: senderCallsign,
+ senderGrid: senderLocator,
+ receiver: receiverCallsign,
+ receiverGrid: receiverLocator,
+ freq,
+ freqMHz: freq ? (freq / 1000000).toFixed(3) : '?',
+ band: getBandFromHz(freq),
+ mode: mode || 'Unknown',
+ snr: snr !== undefined ? parseInt(snr) : null,
+ timestamp: timestamp ? timestamp * 1000 : now,
+ age: 0,
+ lat: null,
+ lon: null
+ };
+
+ const upperCallsign = callsign?.toUpperCase();
+ if (!upperCallsign) return;
+
+ // If I'm the sender, this is a TX report (someone heard me)
+ if (senderCallsign.toUpperCase() === upperCallsign) {
+ report.lat = receiverLoc?.lat;
+ report.lon = receiverLoc?.lon;
+
+ // Add to front, dedupe by receiver+freq, limit size
+ txReportsRef.current = [report, ...txReportsRef.current]
+ .filter((r, i, arr) =>
+ i === arr.findIndex(x => x.receiver === r.receiver && Math.abs(x.freq - r.freq) < 1000)
+ )
+ .slice(0, maxSpots);
+
+ setTxReports(cleanOldSpots([...txReportsRef.current], minutes));
+ setLastUpdate(new Date());
+ }
+
+ // If I'm the receiver, this is an RX report (I heard someone)
+ if (receiverCallsign.toUpperCase() === upperCallsign) {
+ report.lat = senderLoc?.lat;
+ report.lon = senderLoc?.lon;
+
+ rxReportsRef.current = [report, ...rxReportsRef.current]
+ .filter((r, i, arr) =>
+ i === arr.findIndex(x => x.sender === r.sender && Math.abs(x.freq - r.freq) < 1000)
+ )
+ .slice(0, maxSpots);
+
+ setRxReports(cleanOldSpots([...rxReportsRef.current], minutes));
+ setLastUpdate(new Date());
+ }
+
+ } catch (err) {
+ // Silently ignore parse errors - malformed messages happen
+ }
+ }, [callsign, minutes, maxSpots, cleanOldSpots]);
+
+ // Connect to MQTT
+ useEffect(() => {
+ mountedRef.current = true;
+
+ if (!callsign || callsign === 'N0CALL' || !enabled) {
+ setTxReports([]);
+ setRxReports([]);
+ setLoading(false);
+ setSource('disabled');
+ setConnected(false);
+ return;
+ }
+
+ const upperCallsign = callsign.toUpperCase();
+
+ // Clear old data
+ txReportsRef.current = [];
+ rxReportsRef.current = [];
+ setTxReports([]);
+ setRxReports([]);
+ setLoading(true);
+ setError(null);
+ setSource('connecting');
+
+ console.log(`[PSKReporter MQTT] Connecting for ${upperCallsign}...`);
+
+ // Connect to PSKReporter MQTT via WebSocket
+ const client = mqtt.connect('wss://mqtt.pskreporter.info:1886/mqtt', {
+ clientId: `ohc_${upperCallsign}_${Math.random().toString(16).substr(2, 6)}`,
+ clean: true,
+ connectTimeout: 15000,
+ reconnectPeriod: 60000,
+ keepalive: 60
+ });
+
+ clientRef.current = client;
+
+ client.on('connect', () => {
+ if (!mountedRef.current) return;
+
+ console.log('[PSKReporter MQTT] Connected!');
+ setConnected(true);
+ setLoading(false);
+ setSource('mqtt');
+ setError(null);
+
+ // Subscribe to spots where we are the sender (being heard by others)
+ // Topic format: pskr/filter/v2/{mode}/{band}/{senderCall}/{senderLoc}/{rxCall}/{rxLoc}/{freq}/{snr}
+ const txTopic = `pskr/filter/v2/+/+/${upperCallsign}/#`;
+ client.subscribe(txTopic, { qos: 0 }, (err) => {
+ if (err) {
+ console.error('[PSKReporter MQTT] TX subscribe error:', err);
+ } else {
+ console.log(`[PSKReporter MQTT] Subscribed TX: ${txTopic}`);
+ }
+ });
+
+ // Subscribe to spots where we are the receiver (hearing others)
+ const rxTopic = `pskr/filter/v2/+/+/+/+/${upperCallsign}/#`;
+ client.subscribe(rxTopic, { qos: 0 }, (err) => {
+ if (err) {
+ console.error('[PSKReporter MQTT] RX subscribe error:', err);
+ } else {
+ console.log(`[PSKReporter MQTT] Subscribed RX: ${rxTopic}`);
+ }
+ });
+ });
+
+ client.on('message', processMessage);
+
+ client.on('error', (err) => {
+ if (!mountedRef.current) return;
+ console.error('[PSKReporter MQTT] Error:', err.message);
+ setError('Connection error');
+ setConnected(false);
+ setLoading(false);
+ });
+
+ client.on('close', () => {
+ if (!mountedRef.current) return;
+ console.log('[PSKReporter MQTT] Disconnected');
+ setConnected(false);
+ });
+
+ client.on('offline', () => {
+ if (!mountedRef.current) return;
+ console.log('[PSKReporter MQTT] Offline');
+ setConnected(false);
+ setSource('offline');
+ });
+
+ client.on('reconnect', () => {
+ if (!mountedRef.current) return;
+ console.log('[PSKReporter MQTT] Reconnecting...');
+ setSource('reconnecting');
+ });
+
+ // Cleanup on unmount or callsign change
+ return () => {
+ mountedRef.current = false;
+ if (client) {
+ console.log('[PSKReporter MQTT] Cleaning up...');
+ client.end(true);
+ }
+ };
+ }, [callsign, enabled, processMessage]);
+
+ // Periodically clean old spots and update ages
+ useEffect(() => {
+ if (!enabled) return;
+
+ const interval = setInterval(() => {
+ // Update ages and clean old spots
+ const now = Date.now();
+
+ setTxReports(prev => prev.map(r => ({
+ ...r,
+ age: Math.floor((now - r.timestamp) / 60000)
+ })).filter(r => r.age <= minutes));
+
+ setRxReports(prev => prev.map(r => ({
+ ...r,
+ age: Math.floor((now - r.timestamp) / 60000)
+ })).filter(r => r.age <= minutes));
+
+ }, 30000); // Every 30 seconds
+
+ return () => clearInterval(interval);
+ }, [enabled, minutes]);
+
+ // Manual refresh - force reconnect
+ const refresh = useCallback(() => {
+ if (clientRef.current) {
+ clientRef.current.end(true);
+ clientRef.current = null;
+ }
+ setConnected(false);
+ setLoading(true);
+ setSource('reconnecting');
+ // useEffect will reconnect due to state change
+ }, []);
+
+ return {
+ txReports,
+ txCount: txReports.length,
+ rxReports,
+ rxCount: rxReports.length,
+ loading,
+ error,
+ connected,
+ source,
+ lastUpdate,
+ refresh
+ };
+};
+
+export default usePSKReporter;
diff --git a/src/plugins/OpenHamClock-Plugin-Guide.md b/src/plugins/OpenHamClock-Plugin-Guide.md
new file mode 100644
index 0000000..b37d0f4
--- /dev/null
+++ b/src/plugins/OpenHamClock-Plugin-Guide.md
@@ -0,0 +1,1204 @@
+# OpenHamClock Map Layer Plugin System
+
+**Complete Developer Guide**
+
+Version 1.0.0 | February 2, 2026
+
+---
+
+## Table of Contents
+
+1. [Overview](#overview)
+2. [Architecture](#architecture)
+3. [Quick Start](#quick-start)
+4. [Creating Your First Plugin](#creating-your-first-plugin)
+5. [Plugin Types & Examples](#plugin-types--examples)
+6. [Best Practices](#best-practices)
+7. [Testing](#testing)
+8. [Troubleshooting](#troubleshooting)
+9. [Advanced Features](#advanced-features)
+10. [API Reference](#api-reference)
+
+---
+
+## Overview
+
+The OpenHamClock plugin system allows developers to easily add custom map layers without modifying core application code. Plugins are self-contained modules that handle their own data fetching, rendering, and lifecycle management.
+
+### Key Features
+
+- ā
**Zero Core Modification** - Add layers without touching WorldMap.jsx
+- ā
**Hot Reload** - Changes appear immediately during development
+- ā
**Persistent Settings** - User preferences saved in localStorage
+- ā
**React Hooks Based** - Modern, clean API
+- ā
**Full Leaflet Access** - Direct access to map instance
+- ā
**Category Organization** - Group related plugins
+- ā
**Opacity Control** - Built-in transparency slider
+- ā
**Enable/Disable Toggle** - Easy on/off switching
+
+---
+
+## Architecture
+
+### System Components
+
+```
+src/plugins/
+āāā layerRegistry.js # Central plugin registration
+āāā layers/ # Individual plugin implementations
+ā āāā useWXRadar.js # Weather radar example
+ā āāā useEarthquakes.js # Earthquake data example
+ā āāā useYourPlugin.js # Your custom plugin
+āāā README.md # Documentation
+āāā QUICKSTART.md # Quick reference guide
+
+src/components/
+āāā WorldMap.jsx # Minimal plugin integration (3 additions)
+āāā PluginLayer.jsx # React wrapper for plugin hooks
+āāā SettingsPanel.jsx # UI controls for plugins
+```
+
+### Data Flow
+
+```
+User toggles layer in Settings
+ ā
+Settings updates localStorage
+ ā
+WorldMap reads localStorage ā updates pluginLayerStates
+ ā
+PluginLayer component renders with new state
+ ā
+Plugin's useLayer hook called with {enabled, opacity, map}
+ ā
+Plugin adds/removes/updates Leaflet layers on map
+```
+
+### Integration Points
+
+**WorldMap.jsx** (3 small additions):
+1. Import `getAllLayers` from registry
+2. Add `pluginLayersRef` and `pluginLayerStates` state
+3. Render `` components in JSX
+
+**No other core files modified!**
+
+---
+
+## Quick Start
+
+### 5-Minute Plugin Creation
+
+#### Step 1: Create Plugin File
+
+Create `src/plugins/layers/useMyLayer.js`:
+
+```javascript
+import { useState, useEffect } from 'react';
+
+export const metadata = {
+ id: 'mylayer',
+ name: 'My Custom Layer',
+ description: 'Brief description of what this layer shows',
+ icon: 'šØ',
+ category: 'custom',
+ defaultEnabled: false,
+ defaultOpacity: 0.7,
+ version: '1.0.0'
+};
+
+export function useLayer({ enabled, opacity, map }) {
+ const [layerRef, setLayerRef] = useState(null);
+
+ useEffect(() => {
+ if (!map || typeof L === 'undefined') return;
+
+ if (enabled && !layerRef) {
+ // Add your layer
+ const layer = L.tileLayer('https://example.com/{z}/{x}/{y}.png', {
+ opacity: opacity
+ });
+ layer.addTo(map);
+ setLayerRef(layer);
+ } else if (!enabled && layerRef) {
+ // Remove layer
+ map.removeLayer(layerRef);
+ setLayerRef(null);
+ } else if (layerRef) {
+ // Update opacity
+ layerRef.setOpacity(opacity);
+ }
+
+ return () => {
+ if (layerRef) map.removeLayer(layerRef);
+ };
+ }, [enabled, opacity, map]);
+
+ return { layer: layerRef };
+}
+```
+
+#### Step 2: Register Plugin
+
+Edit `src/plugins/layerRegistry.js`:
+
+```javascript
+import * as MyLayerPlugin from './layers/useMyLayer.js';
+
+const layerPlugins = [
+ WXRadarPlugin,
+ EarthquakesPlugin,
+ MyLayerPlugin, // ā Add your plugin here
+];
+```
+
+#### Step 3: Test
+
+```bash
+npm run dev
+```
+
+Open **Settings ā Map Layers** and toggle your layer!
+
+---
+
+## Creating Your First Plugin
+
+### Complete Example: Lightning Strikes
+
+Let's build a plugin that shows recent lightning strikes:
+
+```javascript
+/**
+ * Lightning Strikes Plugin
+ * Shows real-time lightning strike data
+ */
+import { useState, useEffect } from 'react';
+
+export const metadata = {
+ id: 'lightning',
+ name: 'Lightning Strikes',
+ description: 'Real-time lightning detection (last 30 minutes)',
+ icon: 'ā”',
+ category: 'weather',
+ defaultEnabled: false,
+ defaultOpacity: 0.8,
+ version: '1.0.0'
+};
+
+export function useLayer({ enabled = false, opacity = 0.8, map = null }) {
+ const [markers, setMarkers] = useState([]);
+ const [strikes, setStrikes] = useState([]);
+
+ // Fetch lightning data
+ useEffect(() => {
+ if (!enabled) return;
+
+ const fetchStrikes = async () => {
+ try {
+ const response = await fetch(
+ 'https://api.example.com/lightning?minutes=30'
+ );
+ const data = await response.json();
+ setStrikes(data.strikes || []);
+ } catch (err) {
+ console.error('Lightning data error:', err);
+ }
+ };
+
+ fetchStrikes();
+
+ // Refresh every 1 minute
+ const interval = setInterval(fetchStrikes, 60000);
+ return () => clearInterval(interval);
+ }, [enabled]);
+
+ // Render markers
+ useEffect(() => {
+ if (!map || typeof L === 'undefined') return;
+
+ // Clear old markers
+ markers.forEach(m => {
+ try {
+ map.removeLayer(m);
+ } catch (e) {
+ // Already removed
+ }
+ });
+ setMarkers([]);
+
+ if (!enabled || strikes.length === 0) return;
+
+ const newMarkers = [];
+
+ strikes.forEach(strike => {
+ // Create marker
+ const marker = L.circleMarker([strike.lat, strike.lon], {
+ radius: 6,
+ fillColor: '#ffff00',
+ color: '#ff6600',
+ weight: 2,
+ fillOpacity: opacity,
+ opacity: opacity
+ });
+
+ // Add popup
+ const time = new Date(strike.timestamp);
+ marker.bindPopup(`
+ ā” Lightning Strike
+ Time: ${time.toLocaleTimeString()}
+ Intensity: ${strike.intensity} kA
+ `);
+
+ marker.addTo(map);
+ newMarkers.push(marker);
+ });
+
+ setMarkers(newMarkers);
+
+ return () => {
+ newMarkers.forEach(m => {
+ try {
+ map.removeLayer(m);
+ } catch (e) {
+ // Already removed
+ }
+ });
+ };
+ }, [enabled, strikes, map, opacity]);
+
+ return {
+ markers,
+ strikeCount: strikes.length
+ };
+}
+```
+
+### What's Happening?
+
+1. **Metadata Export** - Defines plugin properties for UI
+2. **useLayer Hook** - Main logic, receives {enabled, opacity, map}
+3. **Data Fetching** - useEffect fetches when enabled, refreshes periodically
+4. **Rendering** - useEffect adds/removes markers based on state
+5. **Cleanup** - Return functions remove layers when unmounting
+
+---
+
+## Plugin Types & Examples
+
+### Type 1: Tile Layer (Raster Overlay)
+
+**Use for:** Weather radar, satellite imagery, heat maps
+
+```javascript
+export function useLayer({ enabled, opacity, map }) {
+ const [layerRef, setLayerRef] = useState(null);
+
+ useEffect(() => {
+ if (!map || typeof L === 'undefined') return;
+
+ if (enabled && !layerRef) {
+ // WMS tile layer
+ const layer = L.tileLayer.wms(
+ 'https://mesonet.agron.iastate.edu/cgi-bin/wms/nexrad/n0r.cgi',
+ {
+ layers: 'nexrad-n0r-900913',
+ format: 'image/png',
+ transparent: true,
+ opacity: opacity,
+ zIndex: 200,
+ attribution: 'Ā© Data Provider'
+ }
+ );
+ layer.addTo(map);
+ setLayerRef(layer);
+ } else if (!enabled && layerRef) {
+ map.removeLayer(layerRef);
+ setLayerRef(null);
+ } else if (layerRef) {
+ layerRef.setOpacity(opacity);
+ }
+
+ return () => {
+ if (layerRef && map) {
+ try {
+ map.removeLayer(layerRef);
+ } catch (e) {}
+ }
+ };
+ }, [enabled, opacity, map]);
+
+ return { layer: layerRef };
+}
+```
+
+### Type 2: Marker Layer (Point Data)
+
+**Use for:** Earthquakes, stations, events, POIs
+
+```javascript
+export function useLayer({ enabled, opacity, map }) {
+ const [markers, setMarkers] = useState([]);
+ const [data, setData] = useState([]);
+
+ // Fetch data
+ useEffect(() => {
+ if (!enabled) return;
+
+ const fetchData = async () => {
+ try {
+ const response = await fetch('https://api.example.com/points');
+ const json = await response.json();
+ setData(json.features || []);
+ } catch (err) {
+ console.error('Fetch error:', err);
+ }
+ };
+
+ fetchData();
+ const interval = setInterval(fetchData, 300000); // 5 min
+ return () => clearInterval(interval);
+ }, [enabled]);
+
+ // Render markers
+ useEffect(() => {
+ if (!map || typeof L === 'undefined') return;
+
+ // Clear old
+ markers.forEach(m => map.removeLayer(m));
+ setMarkers([]);
+
+ if (!enabled || data.length === 0) return;
+
+ const newMarkers = [];
+
+ data.forEach(point => {
+ const marker = L.circleMarker([point.lat, point.lon], {
+ radius: 8,
+ fillColor: point.color || '#ff0000',
+ color: '#fff',
+ weight: 2,
+ fillOpacity: opacity,
+ opacity: opacity
+ });
+
+ marker.bindPopup(`
+ ${point.name}
+ ${point.description}
+ `);
+
+ marker.addTo(map);
+ newMarkers.push(marker);
+ });
+
+ setMarkers(newMarkers);
+
+ return () => {
+ newMarkers.forEach(m => map.removeLayer(m));
+ };
+ }, [enabled, data, map, opacity]);
+
+ return { markers, count: data.length };
+}
+```
+
+### Type 3: Vector Layer (Lines/Polygons)
+
+**Use for:** Boundaries, routes, zones, areas
+
+```javascript
+export function useLayer({ enabled, opacity, map }) {
+ const [layerRef, setLayerRef] = useState(null);
+ const [geoData, setGeoData] = useState(null);
+
+ // Fetch GeoJSON
+ useEffect(() => {
+ if (!enabled) return;
+
+ fetch('https://api.example.com/boundaries.geojson')
+ .then(r => r.json())
+ .then(setGeoData);
+ }, [enabled]);
+
+ // Render GeoJSON
+ useEffect(() => {
+ if (!map || typeof L === 'undefined' || !geoData) return;
+
+ if (layerRef) {
+ map.removeLayer(layerRef);
+ }
+
+ if (enabled) {
+ const layer = L.geoJSON(geoData, {
+ style: {
+ color: '#0000ff',
+ weight: 2,
+ opacity: opacity,
+ fillOpacity: opacity * 0.3
+ },
+ onEachFeature: (feature, layer) => {
+ if (feature.properties.name) {
+ layer.bindPopup(`${feature.properties.name} `);
+ }
+ }
+ });
+ layer.addTo(map);
+ setLayerRef(layer);
+ } else {
+ setLayerRef(null);
+ }
+
+ return () => {
+ if (layerRef) map.removeLayer(layerRef);
+ };
+ }, [enabled, geoData, map, opacity]);
+
+ return { layer: layerRef };
+}
+```
+
+---
+
+## Best Practices
+
+### 1. Always Check Dependencies
+
+```javascript
+useEffect(() => {
+ // ā
GOOD: Check before using
+ if (!map || typeof L === 'undefined') return;
+
+ // Now safe to use map and L
+ const layer = L.marker([...]).addTo(map);
+}, [map]);
+```
+
+```javascript
+useEffect(() => {
+ // ā BAD: No checks, will crash
+ const layer = L.marker([...]).addTo(map);
+}, [map]);
+```
+
+### 2. Proper Cleanup
+
+```javascript
+useEffect(() => {
+ const layer = L.marker([...]).addTo(map);
+
+ // ā
GOOD: Cleanup function
+ return () => {
+ if (layer && map) {
+ try {
+ map.removeLayer(layer);
+ } catch (e) {
+ // Layer may already be removed
+ }
+ }
+ };
+}, [map]);
+```
+
+### 3. Error Handling
+
+```javascript
+useEffect(() => {
+ const fetchData = async () => {
+ try {
+ const response = await fetch(url);
+ if (!response.ok) throw new Error('Fetch failed');
+ const data = await response.json();
+ setData(data);
+ } catch (err) {
+ // ā
GOOD: Log errors, don't crash
+ console.error('Plugin data error:', err);
+ }
+ };
+
+ if (enabled) fetchData();
+}, [enabled]);
+```
+
+### 4. Memory Management
+
+```javascript
+useEffect(() => {
+ if (!enabled) return;
+
+ const interval = setInterval(fetchData, 60000);
+ const markers = [];
+
+ // ā
GOOD: Clean up intervals and markers
+ return () => {
+ clearInterval(interval);
+ markers.forEach(m => map.removeLayer(m));
+ };
+}, [enabled]);
+```
+
+### 5. Performance Optimization
+
+```javascript
+// ā
GOOD: Reasonable refresh interval
+const interval = setInterval(fetchData, 300000); // 5 minutes
+
+// ā BAD: Too frequent, wastes resources
+const interval = setInterval(fetchData, 1000); // 1 second
+```
+
+```javascript
+// ā
GOOD: Limit marker count
+const limitedData = data.slice(0, 1000);
+
+// ā BAD: Render thousands of markers
+const markers = data.map(createMarker); // data has 50000 items
+```
+
+---
+
+## Testing
+
+### Manual Testing Checklist
+
+- [ ] **Enable layer** - Appears on map correctly
+- [ ] **Disable layer** - Completely removed from map
+- [ ] **Adjust opacity** - Changes transparency in real-time
+- [ ] **Page refresh** - Settings persist (stays enabled/disabled)
+- [ ] **Rapid toggle** - No errors when toggling quickly
+- [ ] **Console clean** - No React warnings or errors
+- [ ] **Popup functionality** - Clicking markers shows info
+- [ ] **Data refresh** - Auto-updates if applicable
+- [ ] **Multiple plugins** - Works alongside other layers
+
+### Browser Console Debugging
+
+```javascript
+// Check plugin registration
+window.hamclockLayerControls.layers
+
+// Check localStorage
+JSON.parse(localStorage.getItem('openhamclock_mapSettings')).layers
+
+// Manually toggle (for debugging)
+window.hamclockLayerControls.toggleLayer('mylayer', true)
+
+// Check layer state
+window.hamclockLayerControls.layers.find(l => l.id === 'mylayer')
+```
+
+### Common Test Scenarios
+
+**Test 1: Fresh Install**
+1. Clear localStorage: `localStorage.clear()`
+2. Refresh page
+3. Plugin should be at defaultEnabled state
+4. Toggle on/off should work
+
+**Test 2: State Persistence**
+1. Enable plugin, set opacity to 50%
+2. Refresh page (F5)
+3. Plugin should still be enabled at 50% opacity
+
+**Test 3: Multiple Plugins**
+1. Enable weather radar
+2. Enable earthquakes
+3. Both should display simultaneously
+4. Toggling one shouldn't affect the other
+
+---
+
+## Troubleshooting
+
+### Problem: Layer doesn't appear when enabled
+
+**Possible Causes:**
+- Map instance not ready
+- Leaflet not loaded
+- API/data fetch failed
+- Invalid coordinates
+
+**Solution:**
+```javascript
+useEffect(() => {
+ // Add debug logging
+ console.log('Plugin state:', { enabled, map, data: data.length });
+
+ if (!map) {
+ console.warn('Map not ready');
+ return;
+ }
+
+ if (typeof L === 'undefined') {
+ console.error('Leaflet not loaded');
+ return;
+ }
+
+ if (data.length === 0) {
+ console.warn('No data to display');
+ return;
+ }
+
+ // Continue with rendering...
+}, [enabled, map, data]);
+```
+
+### Problem: Layer shows when disabled
+
+**Cause:** Missing cleanup or not checking enabled state
+
+**Solution:**
+```javascript
+useEffect(() => {
+ if (!map) return;
+
+ // Remove layer if disabled
+ if (!enabled && layerRef) {
+ map.removeLayer(layerRef);
+ setLayerRef(null);
+ return; // Exit early
+ }
+
+ // Only add if enabled
+ if (enabled && !layerRef) {
+ const layer = createLayer();
+ setLayerRef(layer);
+ }
+}, [enabled, map]);
+```
+
+### Problem: Settings don't persist after refresh
+
+**Cause:** localStorage not saving correctly
+
+**Solution:**
+```javascript
+// Check if data is being saved
+window.hamclockLayerControls.toggleLayer('mylayer', true);
+
+// Then check localStorage
+console.log(
+ JSON.parse(localStorage.getItem('openhamclock_mapSettings')).layers
+);
+
+// Should show: { mylayer: { enabled: true, opacity: 0.7 } }
+```
+
+### Problem: Plugin not in Settings panel
+
+**Causes:**
+1. Not registered in layerRegistry.js
+2. Missing metadata export
+3. Syntax error in plugin file
+
+**Solution:**
+```bash
+# Check for syntax errors
+npm run dev
+# Look for errors in console
+
+# Verify registration
+grep -n "MyLayerPlugin" src/plugins/layerRegistry.js
+
+# Verify metadata
+grep -n "export const metadata" src/plugins/layers/useMyLayer.js
+```
+
+### Problem: React warning "Cannot update during render"
+
+**Cause:** Calling state setter during render
+
+**Solution:**
+```javascript
+// ā BAD: State update during render
+if (enabled && !layerRef) {
+ const layer = createLayer();
+ setLayerRef(layer); // Called during render!
+}
+
+// ā
GOOD: State update in useEffect
+useEffect(() => {
+ if (enabled && !layerRef) {
+ const layer = createLayer();
+ setLayerRef(layer); // Called in effect
+ }
+}, [enabled]);
+```
+
+---
+
+## Advanced Features
+
+### Custom Map Controls
+
+Add interactive buttons to the map:
+
+```javascript
+export function useLayer({ enabled, opacity, map }) {
+ useEffect(() => {
+ if (!enabled || !map) return;
+
+ // Create custom control
+ const RefreshControl = L.Control.extend({
+ options: { position: 'topright' },
+
+ onAdd: function(map) {
+ const container = L.DomUtil.create('div', 'leaflet-bar');
+ const button = L.DomUtil.create('a', '', container);
+ button.innerHTML = 'š';
+ button.title = 'Refresh Data';
+ button.style.cursor = 'pointer';
+ button.style.padding = '5px 10px';
+ button.style.background = '#fff';
+
+ button.onclick = function(e) {
+ e.preventDefault();
+ fetchData(); // Trigger refresh
+ };
+
+ return container;
+ }
+ });
+
+ const control = new RefreshControl();
+ map.addControl(control);
+
+ return () => {
+ map.removeControl(control);
+ };
+ }, [enabled, map]);
+}
+```
+
+### Custom Marker Icons
+
+Create styled markers:
+
+```javascript
+const createCustomIcon = (color, label) => {
+ return L.divIcon({
+ className: 'custom-marker',
+ html: `
+
+ ${label}
+
+ `,
+ iconSize: null,
+ iconAnchor: [0, 0]
+ });
+};
+
+// Usage
+const marker = L.marker([lat, lon], {
+ icon: createCustomIcon('#ff0000', 'ALERT')
+});
+```
+
+### Animated Layers
+
+Fade in/out effect:
+
+```javascript
+export function useLayer({ enabled, opacity, map }) {
+ const [layerRef, setLayerRef] = useState(null);
+ const [currentOpacity, setCurrentOpacity] = useState(0);
+
+ // Animate opacity changes
+ useEffect(() => {
+ if (!layerRef) return;
+
+ let animationFrame;
+ const targetOpacity = enabled ? opacity : 0;
+
+ const animate = () => {
+ setCurrentOpacity(prev => {
+ const diff = targetOpacity - prev;
+ if (Math.abs(diff) < 0.01) return targetOpacity;
+ return prev + diff * 0.1; // Ease towards target
+ });
+
+ if (Math.abs(currentOpacity - targetOpacity) > 0.01) {
+ animationFrame = requestAnimationFrame(animate);
+ }
+ };
+
+ animate();
+
+ return () => {
+ if (animationFrame) cancelAnimationFrame(animationFrame);
+ };
+ }, [enabled, opacity, layerRef]);
+
+ // Apply animated opacity
+ useEffect(() => {
+ if (layerRef) {
+ layerRef.setOpacity(currentOpacity);
+ }
+ }, [currentOpacity, layerRef]);
+
+ // ... rest of plugin
+}
+```
+
+---
+
+## API Reference
+
+### Metadata Object
+
+```typescript
+export const metadata: {
+ id: string; // Unique identifier (lowercase, no spaces)
+ name: string; // Display name in UI
+ description: string; // Brief description (shown in Settings)
+ icon: string; // Emoji icon (single character)
+ category: string; // Category for grouping (weather, geology, etc.)
+ defaultEnabled: boolean; // Initial enabled state
+ defaultOpacity: number; // Initial opacity (0.0 to 1.0)
+ version: string; // Plugin version (semver)
+};
+```
+
+### useLayer Hook
+
+```typescript
+export function useLayer(params: {
+ enabled: boolean; // Current enabled state
+ opacity: number; // Current opacity (0.0 to 1.0)
+ map: L.Map | null; // Leaflet map instance (may be null initially)
+}): any; // Optional return value (for debugging/monitoring)
+```
+
+**Parameters:**
+- `enabled` - Boolean indicating if layer should be visible
+- `opacity` - Number from 0.0 (transparent) to 1.0 (opaque)
+- `map` - Leaflet map instance or null if not ready
+
+**Return Value (Optional):**
+Return any data you want for debugging. Common returns:
+- `{ layer: layerRef }` - Reference to Leaflet layer
+- `{ markers: markersArray }` - Array of markers
+- `{ count: dataLength }` - Number of items displayed
+
+### Available React Hooks
+
+```javascript
+import { useState, useEffect, useRef, useCallback } from 'react';
+
+// useState - Manage component state
+const [value, setValue] = useState(initialValue);
+
+// useEffect - Side effects (fetch, render, cleanup)
+useEffect(() => {
+ // Effect logic
+ return () => {
+ // Cleanup logic
+ };
+}, [dependencies]);
+
+// useRef - Mutable reference
+const ref = useRef(initialValue);
+
+// useCallback - Memoized function
+const memoizedFn = useCallback(() => {
+ // Function logic
+}, [dependencies]);
+```
+
+### Leaflet API Essentials
+
+**Map Methods:**
+```javascript
+map.addLayer(layer) // Add layer to map
+map.removeLayer(layer) // Remove layer from map
+map.hasLayer(layer) // Check if layer exists
+map.getCenter() // Get center [lat, lon]
+map.getZoom() // Get zoom level
+map.getBounds() // Get visible bounds
+map.panTo([lat, lon]) // Pan to coordinates
+map.setView([lat, lon], zoom) // Set center and zoom
+```
+
+**Layer Types:**
+```javascript
+// Tile layer
+L.tileLayer(url, options)
+L.tileLayer.wms(url, options)
+
+// Markers
+L.marker([lat, lon], options)
+L.circleMarker([lat, lon], options)
+
+// Shapes
+L.circle([lat, lon], options)
+L.polygon(latlngs, options)
+L.polyline(latlngs, options)
+
+// GeoJSON
+L.geoJSON(geojsonData, options)
+
+// Layer groups
+L.layerGroup(layers)
+L.featureGroup(layers)
+```
+
+**Popup/Tooltip:**
+```javascript
+marker.bindPopup(content, options)
+marker.bindTooltip(content, options)
+marker.openPopup()
+marker.closePopup()
+```
+
+### Fetch API
+
+```javascript
+// GET request
+const response = await fetch(url);
+const data = await response.json();
+
+// With options
+const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json'
+ }
+});
+
+// Error handling
+try {
+ const response = await fetch(url);
+ if (!response.ok) throw new Error('Fetch failed');
+ const data = await response.json();
+} catch (err) {
+ console.error('Error:', err);
+}
+```
+
+---
+
+## Example Plugins Walkthrough
+
+### Example 1: Weather Radar (WMS Tile Layer)
+
+**File:** `src/plugins/layers/useWXRadar.js`
+
+**Features:**
+- WMS tile overlay
+- Auto-refresh every 2 minutes
+- Opacity control
+- NEXRAD radar data
+
+**Key Code:**
+```javascript
+const layer = L.tileLayer.wms(
+ 'https://mesonet.agron.iastate.edu/cgi-bin/wms/nexrad/n0r.cgi',
+ {
+ layers: 'nexrad-n0r-900913',
+ format: 'image/png',
+ transparent: true,
+ opacity: opacity,
+ zIndex: 200
+ }
+);
+```
+
+**Learn From:**
+- Simple tile layer implementation
+- Auto-refresh pattern
+- WMS configuration
+
+### Example 2: Earthquakes (Marker Layer)
+
+**File:** `src/plugins/layers/useEarthquakes.js`
+
+**Features:**
+- USGS GeoJSON API
+- Circle markers scaled by magnitude
+- Color-coded by severity
+- Detailed popups
+- Auto-refresh every 5 minutes
+
+**Key Code:**
+```javascript
+const size = Math.min(Math.max(mag * 4, 8), 40);
+
+const marker = L.circleMarker([lat, lon], {
+ radius: size / 2,
+ fillColor: color,
+ color: '#fff',
+ weight: 2,
+ fillOpacity: opacity
+});
+
+marker.bindPopup(`
+ M${mag} Earthquake
+ ${location}
+ ${timeStr}
+`);
+```
+
+**Learn From:**
+- API data fetching
+- Dynamic marker sizing
+- Color-coding logic
+- Popup formatting
+
+---
+
+## Plugin Ideas
+
+### Beginner Level
+
+1. **ISS Tracker** - Show International Space Station position
+2. **Sun/Moon Position** - Mark subsolar/sublunar points
+3. **Timezone Boundaries** - Display timezone polygons
+4. **City Labels** - Show major city names
+5. **Country Borders** - Highlight country boundaries
+
+### Intermediate Level
+
+1. **Hurricane Tracker** - Current tropical storms
+2. **Wildfire Map** - Active fire perimeters
+3. **Air Quality Index** - AQI data by location
+4. **Flight Tracker** - Live aircraft positions
+5. **Ship Tracker** - AIS maritime data
+
+### Advanced Level
+
+1. **Satellite Footprints** - Amateur radio satellite coverage
+2. **Propagation Map** - HF band propagation predictions
+3. **Solar Wind** - Geomagnetic storm visualization
+4. **Meteor Showers** - Radiant points during meteor events
+5. **Aurora Oval** - Current aurora visibility prediction
+
+---
+
+## Resources
+
+### Data Sources
+
+**Weather:**
+- NOAA NEXRAD Radar: https://mesonet.agron.iastate.edu/
+- OpenWeatherMap: https://openweathermap.org/api
+- Weather.gov API: https://www.weather.gov/documentation/services-web-api
+
+**Geology:**
+- USGS Earthquakes: https://earthquake.usgs.gov/fdsnws/event/1/
+- USGS Volcanoes: https://volcanoes.usgs.gov/vhp/data_api.html
+
+**Astronomy:**
+- NASA APIs: https://api.nasa.gov/
+- Space Weather: https://services.swpc.noaa.gov/
+
+**Amateur Radio:**
+- Reverse Beacon Network: https://www.reversebeacon.net/
+- PSK Reporter: https://pskreporter.info/
+- APRS-IS: http://www.aprs-is.net/
+
+**General:**
+- OpenStreetMap: https://wiki.openstreetmap.org/wiki/API
+- Natural Earth Data: https://www.naturalearthdata.com/
+
+### Libraries
+
+**Leaflet Plugins:**
+- Marker Clustering: https://github.com/Leaflet/Leaflet.markercluster
+- Heatmaps: https://github.com/Leaflet/Leaflet.heat
+- Animated Markers: https://github.com/openplans/Leaflet.AnimatedMarker
+- Draw Tools: https://github.com/Leaflet/Leaflet.draw
+
+**React Resources:**
+- React Hooks Docs: https://react.dev/reference/react
+- useEffect Guide: https://react.dev/reference/react/useEffect
+
+### Documentation
+
+- **Leaflet Docs:** https://leafletjs.com/reference.html
+- **React Docs:** https://react.dev/
+- **MDN Web Docs:** https://developer.mozilla.org/
+
+---
+
+## Contributing
+
+### Submitting Your Plugin
+
+1. **Test thoroughly** - Follow testing checklist
+2. **Document data sources** - Include attribution
+3. **Add comments** - Explain complex logic
+4. **Include example screenshot** - Visual preview
+5. **Update CHANGELOG** - Note new plugin
+6. **Submit PR** - Pull request to main repo
+
+### Code Style
+
+- Use ES6+ syntax (const/let, arrow functions, async/await)
+- Include JSDoc comments for exported functions
+- Follow existing plugin structure
+- Use descriptive variable names
+- Handle errors gracefully
+
+### Plugin Checklist
+
+Before submitting:
+
+- [ ] Metadata complete and accurate
+- [ ] useLayer hook properly implemented
+- [ ] Cleanup functions included
+- [ ] Error handling added
+- [ ] Attribution included
+- [ ] Comments added for complex logic
+- [ ] Tested enable/disable
+- [ ] Tested opacity changes
+- [ ] Tested page refresh (persistence)
+- [ ] No console errors
+- [ ] README updated if needed
+
+---
+
+## Support
+
+**Questions? Issues? Ideas?**
+
+- **GitHub Issues:** https://github.com/yourusername/openhamclock/issues
+- **Documentation:** https://github.com/yourusername/openhamclock/wiki
+- **Example Plugins:** `src/plugins/layers/`
+
+**Community:**
+
+- Share your plugins in GitHub Discussions
+- Help other developers in Issues
+- Contribute improvements via Pull Requests
+
+---
+
+## Changelog
+
+### Version 1.0.0 (February 2026)
+
+- Initial plugin system release
+- Weather radar plugin
+- Earthquake data plugin
+- Settings panel integration
+- Persistent state management
+- Comprehensive documentation
+
+---
+
+**Happy Plugin Development! š**
+
+Questions? Found a bug? Have an idea? Open an issue on GitHub!
+
+---
+
+*Last Updated: February 2, 2026*
\ No newline at end of file
diff --git a/src/plugins/layerRegistry.js b/src/plugins/layerRegistry.js
new file mode 100644
index 0000000..ccaace2
--- /dev/null
+++ b/src/plugins/layerRegistry.js
@@ -0,0 +1,32 @@
+/**
+ * Layer Plugin Registry
+ * Only Weather Radar for now
+ */
+
+import * as WXRadarPlugin from './layers/useWXRadar.js';
+import * as EarthquakesPlugin from './layers/useEarthquakes.js';
+
+const layerPlugins = [
+ WXRadarPlugin,
+ EarthquakesPlugin,
+];
+
+export function getAllLayers() {
+ return layerPlugins
+ .filter(plugin => plugin.metadata && plugin.useLayer)
+ .map(plugin => ({
+ id: plugin.metadata.id,
+ name: plugin.metadata.name,
+ description: plugin.metadata.description,
+ icon: plugin.metadata.icon,
+ defaultEnabled: plugin.metadata.defaultEnabled || false,
+ defaultOpacity: plugin.metadata.defaultOpacity || 0.6,
+ category: plugin.metadata.category || 'overlay',
+ hook: plugin.useLayer
+ }));
+}
+
+export function getLayerById(layerId) {
+ const layers = getAllLayers();
+ return layers.find(layer => layer.id === layerId) || null;
+}
diff --git a/src/plugins/layers/useEarthquakes.js b/src/plugins/layers/useEarthquakes.js
new file mode 100644
index 0000000..29a06f5
--- /dev/null
+++ b/src/plugins/layers/useEarthquakes.js
@@ -0,0 +1,145 @@
+import { useState, useEffect } from 'react';
+
+//Scaled markers - Bigger circles for stronger quakes
+//Color-coded by magnitude:
+//Yellow: M2.5-3 (minor)
+//Orange: M3-4 (light)
+//Deep Orange: M4-5 (moderate)
+//Red: M5-6 (strong)
+//Dark Red: M6-7 (major)
+//Very Dark Red: M7+ (great)
+
+export const metadata = {
+ id: 'earthquakes',
+ name: 'Earthquakes',
+ description: 'Live USGS earthquake data (M2.5+ from last 24 hours)',
+ icon: 'š',
+ category: 'geology',
+ defaultEnabled: false,
+ defaultOpacity: 0.9,
+ version: '1.0.0'
+};
+
+export function useLayer({ enabled = false, opacity = 0.9, map = null }) {
+ const [markersRef, setMarkersRef] = useState([]);
+ const [earthquakeData, setEarthquakeData] = useState([]);
+
+ // Fetch earthquake data
+ useEffect(() => {
+ if (!enabled) return;
+
+ const fetchEarthquakes = async () => {
+ try {
+ // USGS GeoJSON feed - M2.5+ from last day
+ const response = await fetch(
+ 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson'
+ );
+ const data = await response.json();
+ setEarthquakeData(data.features || []);
+ } catch (err) {
+ console.error('Earthquake data fetch error:', err);
+ }
+ };
+
+ fetchEarthquakes();
+ // Refresh every 5 minutes
+ const interval = setInterval(fetchEarthquakes, 300000);
+
+ return () => clearInterval(interval);
+ }, [enabled]);
+
+ // Add/remove markers
+ useEffect(() => {
+ if (!map || typeof L === 'undefined') return;
+
+ // Clear old markers
+ markersRef.forEach(marker => {
+ try {
+ map.removeLayer(marker);
+ } catch (e) {
+ // Already removed
+ }
+ });
+ setMarkersRef([]);
+
+ if (!enabled || earthquakeData.length === 0) return;
+
+ const newMarkers = [];
+
+ earthquakeData.forEach(quake => {
+ const coords = quake.geometry.coordinates;
+ const props = quake.properties;
+ const mag = props.mag;
+ const lat = coords[1];
+ const lon = coords[0];
+ const depth = coords[2];
+
+ // Skip if invalid coordinates
+ if (!lat || !lon || isNaN(lat) || isNaN(lon)) return;
+
+ // Calculate marker size based on magnitude (M2.5 = 8px, M7+ = 40px)
+ const size = Math.min(Math.max(mag * 4, 8), 40);
+
+ // Color based on magnitude
+ let color;
+ if (mag < 3) color = '#ffff00'; // Yellow - minor
+ else if (mag < 4) color = '#ffaa00'; // Orange - light
+ else if (mag < 5) color = '#ff6600'; // Deep orange - moderate
+ else if (mag < 6) color = '#ff3300'; // Red - strong
+ else if (mag < 7) color = '#cc0000'; // Dark red - major
+ else color = '#990000'; // Very dark red - great
+
+ // Create circle marker
+ const circle = L.circleMarker([lat, lon], {
+ radius: size / 2,
+ fillColor: color,
+ color: '#fff',
+ weight: 2,
+ opacity: opacity,
+ fillOpacity: opacity * 0.7
+ });
+
+ // Format time
+ const time = new Date(props.time);
+ const timeStr = time.toLocaleString();
+
+ // Add popup with details
+ circle.bindPopup(`
+
+
+ M${mag.toFixed(1)} ${props.type === 'earthquake' ? 'š' : 'ā”'}
+
+
+ Location: ${props.place || 'Unknown'}
+ Time: ${timeStr}
+ Depth: ${depth.toFixed(1)} km
+ Magnitude: ${mag.toFixed(1)}
+ Status: ${props.status || 'automatic'}
+ ${props.tsunami ? 'ā ļø TSUNAMI WARNING ' : ''}
+
+ ${props.url ? `
View Details ā ` : ''}
+
+ `);
+
+ circle.addTo(map);
+ newMarkers.push(circle);
+ });
+
+ setMarkersRef(newMarkers);
+
+ return () => {
+ newMarkers.forEach(marker => {
+ try {
+ map.removeLayer(marker);
+ } catch (e) {
+ // Already removed
+ }
+ });
+ };
+ }, [enabled, earthquakeData, map, opacity]);
+
+ return {
+ markers: markersRef,
+ earthquakeCount: earthquakeData.length
+ };
+}
diff --git a/src/plugins/layers/useWXRadar.js b/src/plugins/layers/useWXRadar.js
new file mode 100644
index 0000000..0282402
--- /dev/null
+++ b/src/plugins/layers/useWXRadar.js
@@ -0,0 +1,88 @@
+import { useState, useEffect } from 'react';
+
+export const metadata = {
+ id: 'wxradar',
+ name: 'Weather Radar',
+ description: 'NEXRAD weather radar overlay for North America',
+ icon: 'āļø',
+ category: 'weather',
+ defaultEnabled: false,
+ defaultOpacity: 0.6,
+ version: '1.0.0'
+};
+
+export function useLayer({ enabled = false, opacity = 0.6, map = null }) {
+ const [layerRef, setLayerRef] = useState(null);
+ const [radarTimestamp, setRadarTimestamp] = useState(Date.now());
+
+ const wmsConfig = {
+ url: 'https://mesonet.agron.iastate.edu/cgi-bin/wms/nexrad/n0r.cgi',
+ options: {
+ layers: 'nexrad-n0r-900913',
+ format: 'image/png',
+ transparent: true,
+ attribution: 'Weather data Ā© Iowa State University Mesonet',
+ opacity: opacity,
+ zIndex: 200
+ }
+ };
+
+ // Add/remove layer
+ useEffect(() => {
+ if (!map || typeof L === 'undefined') return;
+
+ if (enabled && !layerRef) {
+ try {
+ const layer = L.tileLayer.wms(wmsConfig.url, wmsConfig.options);
+ layer.addTo(map);
+ setLayerRef(layer);
+ } catch (err) {
+ console.error('WXRadar error:', err);
+ }
+ } else if (!enabled && layerRef) {
+ map.removeLayer(layerRef);
+ setLayerRef(null);
+ }
+
+ return () => {
+ if (layerRef && map) {
+ try {
+ map.removeLayer(layerRef);
+ } catch (e) {
+ // Layer already removed
+ }
+ }
+ };
+ }, [enabled, map]);
+
+ // Update opacity
+ useEffect(() => {
+ if (layerRef) {
+ layerRef.setOpacity(opacity);
+ }
+ }, [opacity, layerRef]);
+
+ // Auto-refresh every 2 minutes
+ useEffect(() => {
+ if (!enabled) return;
+
+ const interval = setInterval(() => {
+ setRadarTimestamp(Date.now());
+ }, 120000);
+
+ return () => clearInterval(interval);
+ }, [enabled]);
+
+ // Force refresh
+ useEffect(() => {
+ if (layerRef && enabled) {
+ layerRef.setParams({ t: radarTimestamp }, false);
+ layerRef.redraw();
+ }
+ }, [radarTimestamp, layerRef, enabled]);
+
+ return {
+ layer: layerRef,
+ refresh: () => setRadarTimestamp(Date.now())
+ };
+}
diff --git a/src/utils/config.js b/src/utils/config.js
index 7e18af5..464617a 100644
--- a/src/utils/config.js
+++ b/src/utils/config.js
@@ -21,11 +21,11 @@ export const DEFAULT_CONFIG = {
showPota: true,
showDxPaths: true,
refreshIntervals: {
- spaceWeather: 300000,
- bandConditions: 300000,
- pota: 60000,
- dxCluster: 30000,
- terminator: 60000
+ spaceWeather: 300000, // 5 minutes
+ bandConditions: 300000, // 5 minutes
+ pota: 120000, // 2 minutes (was 1 min)
+ dxCluster: 30000, // 30 seconds (was 5 sec)
+ terminator: 60000 // 1 minute
}
};
@@ -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'
}
};
diff --git a/vite.config.mjs b/vite.config.mjs
index a7bf8e7..e41bbb0 100644
--- a/vite.config.mjs
+++ b/vite.config.mjs
@@ -22,6 +22,13 @@ export default defineConfig({
'@styles': path.resolve(__dirname, './src/styles')
}
},
+ define: {
+ // mqtt.js needs these for browser
+ global: 'globalThis',
+ },
+ optimizeDeps: {
+ include: ['mqtt']
+ },
build: {
outDir: 'dist',
sourcemap: false,
@@ -29,7 +36,8 @@ export default defineConfig({
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
- satellite: ['satellite.js']
+ satellite: ['satellite.js'],
+ mqtt: ['mqtt']
}
}
}