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 3eb5f52..607815f 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';
@@ -13,6 +13,10 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
const [theme, setTheme] = useState(config?.theme || 'dark');
const [layout, setLayout] = useState(config?.layout || 'modern');
const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy');
+
+ // Layer controls
+ const [layers, setLayers] = useState([]);
+ const [activeTab, setActiveTab] = useState('station');
useEffect(() => {
if (config) {
@@ -22,19 +26,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) {
@@ -44,7 +62,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;
@@ -67,7 +84,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));
@@ -91,11 +107,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,
@@ -136,249 +182,381 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
border: '2px solid var(--accent-amber)',
borderRadius: '12px',
padding: '24px',
- width: '420px',
+ width: '520px',
maxHeight: '90vh',
overflowY: 'auto'
}}>
- β Station Settings
+ β Settings
- {/* First-time setup banner */}
- {(config?.configIncomplete || config?.callsign === 'N0CALL' || !config?.locator) && (
-
-
- π Welcome to OpenHamClock!
-
-
- Please enter your callsign and grid square to get started.
- Your settings will be saved in your browser.
-
-
- π‘ Tip: For permanent config, copy .env.example to .env and set CALLSIGN and LOCATOR
-
-
- )}
-
- {/* Callsign */}
-
-
-
setCallsign(e.target.value.toUpperCase())}
+ {/* Tab Navigation */}
+
+
-
- {/* Grid Square */}
-
-
- handleGridChange(e.target.value)}
- placeholder="FN20nc"
- maxLength={6}
+ >
+ π‘ Station
+
+
- {/* Lat/Lon */}
-
-
-
- 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'
- }}
- />
-
-
-
-
setLon(parseFloat(e.target.value))}
+ {/* Station Settings Tab */}
+ {activeTab === 'station' && (
+ <>
+ {/* 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 */}
+
+
+ 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 */}
+
+
+
+ setLat(parseFloat(e.target.value))}
+ value={isNaN(lat) ? '' : lat}
+ onChange={(e) => 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'
+ }}
+ />
+
+
+
+ setLon(parseFloat(e.target.value))}
+ value={isNaN(lon) ? '' : lon}
+ onChange={(e) => 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'
+ }}
+ />
+
+
+
+
-
-
+ >
+ π Use My Current Location
+
- {/* Use My Location button */}
-
+ {/* Theme */}
+
+
+
+ {['dark', 'light', 'legacy', 'retro'].map((t) => (
+
+ ))}
+
+
+ {themeDescriptions[theme]}
+
+
- {/* Theme */}
-
-
-
- {['dark', 'light', 'legacy', 'retro'].map((t) => (
-
- ))}
-
-
- {themeDescriptions[theme]}
-
-
+ {/* Layout */}
+
+
+
+ {['modern', 'classic'].map((l) => (
+
+ ))}
+
+
+ {layoutDescriptions[layout]}
+
+
- {/* Layout */}
-
-
-
- {['modern', 'classic'].map((l) => (
-
+
+
+
+
+
+
+ β Real-time DX Spider feed via our dedicated proxy service
+
+
+ >
+ )}
- {/* DX Cluster Source */}
-
-
-
-
- β Real-time DX Spider feed via our dedicated proxy service
+ {/* Map Layers Tab */}
+ {activeTab === 'layers' && (
+
+ {layers.length > 0 ? (
+ layers.map(layer => (
+
+
+
+
+ {layer.category}
+
+
+
+ {layer.enabled && (
+
+
+ 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..eaf1d45 100644
--- a/src/components/WorldMap.jsx
+++ b/src/components/WorldMap.jsx
@@ -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,
@@ -44,6 +48,10 @@ export const WorldMap = ({
const dxPathsMarkersRef = useRef([]);
const satMarkersRef = useRef([]);
const satTracksRef = useRef([]);
+
+ const pluginLayersRef = useRef({});
+ const [pluginLayerStates, setPluginLayerStates] = useState({});
+
// Load map style from localStorage
const getStoredMapSettings = () => {
@@ -416,10 +424,106 @@ 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]);
+
+
+// return (
+//
+//
+
return (
+ {/* Render all plugin layers - GENERIC */}
+ {mapInstanceRef.current && getAllLayers().map(layerDef => (
+
+ ))}
+
+
{/* Map style dropdown */}