PR Merge backwards

pull/40/head
accius 2 days ago
parent 4ef5d84bbf
commit df6fd0c7ff

@ -18,8 +18,11 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"i18next": "^25.8.0",
"i18next-browser-languagedetector": "^8.2.0",
"mqtt": "^5.3.4", "mqtt": "^5.3.4",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"react-i18next": "^16.5.4",
"satellite.js": "^5.0.0", "satellite.js": "^5.0.0",
"ws": "^8.14.2" "ws": "^8.14.2"
}, },
@ -41,4 +44,4 @@
], ],
"author": "K0CJH", "author": "K0CJH",
"license": "MIT" "license": "MIT"
} }

@ -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;

@ -1,14 +1,11 @@
/** /**
* SettingsPanel Component * SettingsPanel Component
* Full settings modal matching production version * Full settings modal with map layer controls
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { calculateGridSquare } from '../utils/geo.js'; import { calculateGridSquare } from '../utils/geo.js';
<<<<<<< Updated upstream
=======
import { useTranslation, Trans } from 'react-i18next'; import { useTranslation, Trans } from 'react-i18next';
import { LANGUAGES } from '../lang/i18n.js'; import { LANGUAGES } from '../lang/i18n.js';
>>>>>>> Stashed changes
export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
const [callsign, setCallsign] = useState(config?.callsign || ''); const [callsign, setCallsign] = useState(config?.callsign || '');
@ -18,14 +15,11 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
const [theme, setTheme] = useState(config?.theme || 'dark'); const [theme, setTheme] = useState(config?.theme || 'dark');
const [layout, setLayout] = useState(config?.layout || 'modern'); const [layout, setLayout] = useState(config?.layout || 'modern');
const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy'); const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy');
<<<<<<< Updated upstream
=======
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
// Layer controls // Layer controls
const [layers, setLayers] = useState([]); const [layers, setLayers] = useState([]);
const [activeTab, setActiveTab] = useState('station'); const [activeTab, setActiveTab] = useState('station');
>>>>>>> Stashed changes
useEffect(() => { useEffect(() => {
if (config) { if (config) {
@ -35,19 +29,33 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
setTheme(config.theme || 'dark'); setTheme(config.theme || 'dark');
setLayout(config.layout || 'modern'); setLayout(config.layout || 'modern');
setDxClusterSource(config.dxClusterSource || 'dxspider-proxy'); setDxClusterSource(config.dxClusterSource || 'dxspider-proxy');
// Use locator from config, or calculate from coordinates if (config.location?.lat && config.location?.lon) {
if (config.locator) {
setGridSquare(config.locator);
} else if (config.location?.lat && config.location?.lon) {
setGridSquare(calculateGridSquare(config.location.lat, config.location.lon)); setGridSquare(calculateGridSquare(config.location.lat, config.location.lon));
} }
} }
}, [config, isOpen]); }, [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) => { const handleGridChange = (grid) => {
setGridSquare(grid.toUpperCase()); setGridSquare(grid.toUpperCase());
// Parse grid square to lat/lon if valid (6 char)
if (grid.length >= 4) { if (grid.length >= 4) {
const parsed = parseGridSquare(grid); const parsed = parseGridSquare(grid);
if (parsed) { if (parsed) {
@ -57,7 +65,6 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
} }
}; };
// Parse grid square to coordinates
const parseGridSquare = (grid) => { const parseGridSquare = (grid) => {
grid = grid.toUpperCase(); grid = grid.toUpperCase();
if (grid.length < 4) return null; if (grid.length < 4) return null;
@ -80,7 +87,6 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
return { lat, lon }; return { lat, lon };
}; };
// Update grid when lat/lon changes
useEffect(() => { useEffect(() => {
if (lat && lon) { if (lat && lon) {
setGridSquare(calculateGridSquare(lat, lon)); setGridSquare(calculateGridSquare(lat, lon));
@ -96,11 +102,42 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
}, },
(error) => { (error) => {
console.error('Geolocation error:', error); console.error('Geolocation error:', error);
alert('Unable to get location. Please enter manually.'); alert(t('station.settings.useLocation.error1'));
} }
); );
} else { } else {
alert('Geolocation not supported by your browser.'); alert(t('station.settings.useLocation.error2'));
}
};
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]);
} }
}; };
@ -108,7 +145,6 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
onSave({ onSave({
...config, ...config,
callsign: callsign.toUpperCase(), callsign: callsign.toUpperCase(),
locator: gridSquare.toUpperCase(),
location: { lat: parseFloat(lat), lon: parseFloat(lon) }, location: { lat: parseFloat(lat), lon: parseFloat(lon) },
theme, theme,
layout, layout,
@ -119,16 +155,22 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
if (!isOpen) return null; if (!isOpen) return null;
const Code = ({ children }) => (
<code style={{ background: 'var(--bg-tertiary)', padding: '2px 4px', borderRadius: '3px' }}>
{children}
</code>
);
const themeDescriptions = { const themeDescriptions = {
dark: '→ Modern dark theme (default)', dark: t('station.settings.theme.dark.describe'),
light: '→ Light theme for daytime use', light: t('station.settings.theme.light.describe'),
legacy: '→ Green terminal CRT style', legacy: t('station.settings.theme.legacy.describe'),
retro: '→ 90s Windows retro style' retro: t('station.settings.theme.retro.describe')
}; };
const layoutDescriptions = { const layoutDescriptions = {
modern: '→ Modern responsive grid layout', modern: t('station.settings.layout.modern.describe'),
classic: '→ Original HamClock-style layout' classic: t('station.settings.layout.classic.describe')
}; };
return ( return (
@ -149,186 +191,285 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
border: '2px solid var(--accent-amber)', border: '2px solid var(--accent-amber)',
borderRadius: '12px', borderRadius: '12px',
padding: '24px', padding: '24px',
width: '420px', width: '520px',
maxHeight: '90vh', maxHeight: '90vh',
overflowY: 'auto' overflowY: 'auto'
}}> }}>
<h2 style={{ <h2 style={{
color: 'var(--accent-cyan)', color: 'var(--accent-cyan)',
marginTop: 0, marginTop: 0,
marginBottom: '16px', marginBottom: '24px',
textAlign: 'center', textAlign: 'center',
fontFamily: 'Orbitron, monospace', fontFamily: 'Orbitron, monospace',
fontSize: '20px' fontSize: '20px'
}}> }}>
Station Settings {t('station.settings.title')}
</h2> </h2>
{/* First-time setup banner */} {/* Tab Navigation */}
{(config?.configIncomplete || config?.callsign === 'N0CALL' || !config?.locator) && ( <div style={{
<div style={{ display: 'flex',
background: 'rgba(255, 193, 7, 0.15)', gap: '8px',
border: '1px solid var(--accent-amber)', marginBottom: '24px',
borderRadius: '8px', borderBottom: '1px solid var(--border-color)',
padding: '12px 16px', paddingBottom: '12px'
marginBottom: '20px', }}>
fontSize: '13px' <button
}}> onClick={() => setActiveTab('station')}
<div style={{ color: 'var(--accent-amber)', fontWeight: '700', marginBottom: '6px' }}>
👋 Welcome to OpenHamClock!
</div>
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>
Please enter your callsign and grid square to get started.
Your settings will be saved in your browser.
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '11px', marginTop: '8px' }}>
💡 Tip: For permanent config, copy <code style={{ background: 'var(--bg-tertiary)', padding: '2px 4px', borderRadius: '3px' }}>.env.example</code> to <code style={{ background: 'var(--bg-tertiary)', padding: '2px 4px', borderRadius: '3px' }}>.env</code> and set CALLSIGN and LOCATOR
</div>
</div>
)}
{/* Callsign */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
Your Callsign
</label>
<input
type="text"
value={callsign}
onChange={(e) => setCallsign(e.target.value.toUpperCase())}
style={{ style={{
width: '100%', flex: 1,
padding: '12px', padding: '10px',
background: 'var(--bg-tertiary)', background: activeTab === 'station' ? 'var(--accent-amber)' : 'transparent',
border: '1px solid var(--border-color)', border: 'none',
borderRadius: '6px', borderRadius: '6px 6px 0 0',
color: 'var(--accent-amber)', color: activeTab === 'station' ? '#000' : 'var(--text-secondary)',
fontSize: '18px', fontSize: '13px',
fontFamily: 'JetBrains Mono, monospace', cursor: 'pointer',
fontWeight: '700', fontWeight: activeTab === 'station' ? '700' : '400',
boxSizing: 'border-box' fontFamily: 'JetBrains Mono, monospace'
}} }}
/> >
</div> 📡 Station
</button>
{/* Grid Square */} <button
<div style={{ marginBottom: '20px' }}> onClick={() => setActiveTab('layers')}
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
Grid Square (or enter Lat/Lon below)
</label>
<input
type="text"
value={gridSquare}
onChange={(e) => handleGridChange(e.target.value)}
placeholder="FN20nc"
maxLength={6}
style={{ style={{
width: '100%', flex: 1,
padding: '12px', padding: '10px',
background: 'var(--bg-tertiary)', background: activeTab === 'layers' ? 'var(--accent-amber)' : 'transparent',
border: '1px solid var(--border-color)', border: 'none',
borderRadius: '6px', borderRadius: '6px 6px 0 0',
color: 'var(--accent-amber)', color: activeTab === 'layers' ? '#000' : 'var(--text-secondary)',
fontSize: '18px', fontSize: '13px',
fontFamily: 'JetBrains Mono, monospace', cursor: 'pointer',
fontWeight: '700', fontWeight: activeTab === 'layers' ? '700' : '400',
boxSizing: 'border-box' fontFamily: 'JetBrains Mono, monospace'
}} }}
/> >
🗺 Map Layers
</button>
</div> </div>
{/* Lat/Lon */} {/* Station Settings Tab */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' }}> {activeTab === 'station' && (
<div> <>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase' }}> {/* First-time setup banner */}
Latitude {(config?.configIncomplete || config?.callsign === 'N0CALL' || !config?.locator) && (
</label> <div style={{
<input background: 'rgba(255, 193, 7, 0.15)',
type="number" border: '1px solid var(--accent-amber)',
step="0.000001" borderRadius: '8px',
value={lat} padding: '12px 16px',
onChange={(e) => setLat(parseFloat(e.target.value))} marginBottom: '20px',
style={{ fontSize: '13px'
width: '100%', }}>
padding: '10px', <div style={{ color: 'var(--accent-amber)', fontWeight: '700', marginBottom: '6px' }}>
background: 'var(--bg-tertiary)', {t("station.settings.welcome")}
border: '1px solid var(--border-color)', </div>
borderRadius: '6px', <div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>
color: 'var(--text-primary)', {t("station.settings.describe")}
fontSize: '14px', </div>
fontFamily: 'JetBrains Mono, monospace', <div style={{ color: 'var(--text-muted)', fontSize: '11px', marginTop: '8px' }}>
boxSizing: 'border-box' <Trans i18nKey="station.settings.tip.env" components={{ envExample: <Code />, env: <Code /> }} />
}} </div>
/> </div>
</div> )}
<div>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase' }}> {/* Callsign */}
Longitude <div style={{ marginBottom: '20px' }}>
</label> <label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
<input {t('station.settings.callsign')}
type="number" </label>
step="0.000001" <input
value={lon} type="text"
onChange={(e) => setLon(parseFloat(e.target.value))} value={callsign}
onChange={(e) => 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'
}}
/>
</div>
{/* Grid Square */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
{t('station.settings.locator')}
</label>
<input
type="text"
value={gridSquare}
onChange={(e) => 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'
}}
/>
</div>
{/* Lat/Lon */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' }}>
<div>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase' }}>
{t('station.settings.latitude')}
</label>
<input
type="number"
step="0.000001"
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'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase' }}>
{t('station.settings.longitude')}
</label>
<input
type="number"
step="0.000001"
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'
}}
/>
</div>
</div>
<button
onClick={handleUseLocation}
style={{ style={{
width: '100%', width: '100%',
padding: '10px', padding: '10px',
background: 'var(--bg-tertiary)', background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)', border: '1px solid var(--border-color)',
borderRadius: '6px', borderRadius: '6px',
color: 'var(--text-primary)', color: 'var(--text-secondary)',
fontSize: '14px', fontSize: '13px',
fontFamily: 'JetBrains Mono, monospace', cursor: 'pointer',
boxSizing: 'border-box' marginBottom: '20px'
}} }}
/> >
</div> {t('station.settings.useLocation')}
</div> </button>
{/* Use My Location button */} {/* Theme */}
<button <div style={{ marginBottom: '8px' }}>
onClick={handleUseLocation} <label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
style={{ {t('station.settings.theme')}
width: '100%', </label>
padding: '10px', <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px' }}>
background: 'var(--bg-tertiary)', {['dark', 'light', 'legacy', 'retro'].map((th) => (
border: '1px solid var(--border-color)', <button
borderRadius: '6px', key={th}
color: 'var(--text-secondary)', onClick={() => setTheme(th)}
fontSize: '13px', style={{
cursor: 'pointer', padding: '10px',
marginBottom: '20px' background: theme === th ? 'var(--accent-amber)' : 'var(--bg-tertiary)',
}} border: `1px solid ${theme === th ? 'var(--accent-amber)' : 'var(--border-color)'}`,
> borderRadius: '6px',
📍 Use My Current Location color: theme === th ? '#000' : 'var(--text-secondary)',
</button> fontSize: '12px',
cursor: 'pointer',
fontWeight: theme === th ? '600' : '400'
}}
>
{th === 'dark' ? '🌙' : th === 'light' ? '☀️' : th === 'legacy' ? '💻' : '🪟'} {t('station.settings.theme.' + th)}
</button>
))}
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}>
{themeDescriptions[theme]}
</div>
</div>
{/* Theme */} {/* Layout */}
<div style={{ marginBottom: '8px' }}> <div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}> <label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
Theme {t('station.settings.layout')}
</label> </label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }}>
{['dark', 'light', 'legacy', 'retro'].map((t) => ( {['modern', 'classic'].map((l) => (
<button <button
key={t} key={l}
onClick={() => setTheme(t)} onClick={() => 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)}
</button>
))}
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}>
{layoutDescriptions[layout]}
</div>
</div>
{/* DX Cluster Source */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
{t('station.settings.dx.title')}
</label>
<select
value={dxClusterSource}
onChange={(e) => setDxClusterSource(e.target.value)}
style={{ style={{
padding: '10px', width: '100%',
background: theme === t ? 'var(--accent-amber)' : 'var(--bg-tertiary)', padding: '12px',
border: `1px solid ${theme === t ? 'var(--accent-amber)' : 'var(--border-color)'}`, background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: '6px', borderRadius: '6px',
color: theme === t ? '#000' : 'var(--text-secondary)', color: 'var(--accent-green)',
fontSize: '12px', fontSize: '14px',
cursor: 'pointer', fontFamily: 'JetBrains Mono, monospace',
fontWeight: theme === t ? '600' : '400' cursor: 'pointer'
}} }}
> >
<<<<<<< Updated upstream
{t === 'dark' ? '🌙' : t === 'light' ? '☀️' : t === 'legacy' ? '💻' : '🪟'} {t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
=======
<option value="dxspider-proxy">{t('station.settings.dx.option1')}</option> <option value="dxspider-proxy">{t('station.settings.dx.option1')}</option>
<option value="hamqth">{t('station.settings.dx.option2')}</option> <option value="hamqth">{t('station.settings.dx.option2')}</option>
<option value="dxwatch">{t('station.settings.dx.option3')}</option> <option value="dxwatch">{t('station.settings.dx.option3')}</option>
@ -475,72 +616,8 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
No map layers available No map layers available
</div> </div>
)} )}
>>>>>>> Stashed changes
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}>
{themeDescriptions[theme]}
</div>
</div>
{/* Layout */}
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
Layout
</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }}>
{['modern', 'classic'].map((l) => (
<button
key={l}
onClick={() => 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' ? '🖥️' : '📺'} {l.charAt(0).toUpperCase() + l.slice(1)}
</button>
))}
</div> </div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}> )}
{layoutDescriptions[layout]}
</div>
</div>
{/* DX Cluster Source */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
DX Cluster Source
</label>
<select
value={dxClusterSource}
onChange={(e) => 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'
}}
>
<option value="dxspider-proxy"> DX Spider Proxy (Recommended)</option>
<option value="hamqth">HamQTH Cluster</option>
<option value="dxwatch">DXWatch</option>
<option value="auto">Auto (try all sources)</option>
</select>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}>
Real-time DX Spider feed via our dedicated proxy service
</div>
</div>
{/* Buttons */} {/* Buttons */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginTop: '24px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginTop: '24px' }}>
@ -556,7 +633,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
cursor: 'pointer' cursor: 'pointer'
}} }}
> >
Cancel {t('cancel')}
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
@ -571,12 +648,12 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
cursor: 'pointer' cursor: 'pointer'
}} }}
> >
Save Settings {t('station.settings.button.save')}
</button> </button>
</div> </div>
<div style={{ textAlign: 'center', marginTop: '16px', fontSize: '11px', color: 'var(--text-muted)' }}> <div style={{ textAlign: 'center', marginTop: '16px', fontSize: '11px', color: 'var(--text-muted)' }}>
Settings are saved in your browser {t('station.settings.button.save.confirm')}
</div> </div>
</div> </div>
</div> </div>

@ -1,6 +1,6 @@
/** /**
* WorldMap Component * 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 React, { useRef, useEffect, useState } from 'react';
import { MAP_STYLES } from '../utils/config.js'; import { MAP_STYLES } from '../utils/config.js';
@ -12,6 +12,10 @@ import {
} from '../utils/geo.js'; } from '../utils/geo.js';
import { filterDXPaths, getBandColor } from '../utils/callsign.js'; import { filterDXPaths, getBandColor } from '../utils/callsign.js';
import { getAllLayers } from '../plugins/layerRegistry.js';
import PluginLayer from './PluginLayer.jsx';
export const WorldMap = ({ export const WorldMap = ({
deLocation, deLocation,
dxLocation, dxLocation,
@ -47,6 +51,10 @@ export const WorldMap = ({
const satMarkersRef = useRef([]); const satMarkersRef = useRef([]);
const satTracksRef = useRef([]); const satTracksRef = useRef([]);
const pskMarkersRef = useRef([]); const pskMarkersRef = useRef([]);
// Plugin system refs and state
const pluginLayersRef = useRef({});
const [pluginLayerStates, setPluginLayerStates] = useState({});
// Load map style from localStorage // Load map style from localStorage
const getStoredMapSettings = () => { const getStoredMapSettings = () => {
@ -419,6 +427,84 @@ export const WorldMap = ({
} }
}, [satellites, showSatellites]); }, [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 // Update PSKReporter markers
useEffect(() => { useEffect(() => {
if (!mapInstanceRef.current) return; if (!mapInstanceRef.current) return;
@ -488,6 +574,17 @@ export const WorldMap = ({
<div style={{ position: 'relative', height: '100%', minHeight: '200px' }}> <div style={{ position: 'relative', height: '100%', minHeight: '200px' }}>
<div ref={mapRef} style={{ height: '100%', width: '100%', borderRadius: '8px' }} /> <div ref={mapRef} style={{ height: '100%', width: '100%', borderRadius: '8px' }} />
{/* Render all plugin layers */}
{mapInstanceRef.current && getAllLayers().map(layerDef => (
<PluginLayer
key={layerDef.id}
plugin={layerDef}
enabled={pluginLayerStates[layerDef.id]?.enabled || false}
opacity={pluginLayerStates[layerDef.id]?.opacity || layerDef.defaultOpacity}
map={mapInstanceRef.current}
/>
))}
{/* Map style dropdown */} {/* Map style dropdown */}
<select <select
value={mapStyle} value={mapStyle}
@ -614,4 +711,5 @@ export const WorldMap = ({
); );
}; };
export default WorldMap; export default WorldMap;

@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import './styles/main.css'; import './styles/main.css';
import './lang/i18n';
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>

File diff suppressed because it is too large Load Diff

@ -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;
}

@ -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(`
<div style="font-family: 'JetBrains Mono', monospace; min-width: 200px;">
<div style="font-size: 16px; font-weight: bold; color: ${color}; margin-bottom: 8px;">
M${mag.toFixed(1)} ${props.type === 'earthquake' ? '🌋' : '⚡'}
</div>
<table style="font-size: 12px; width: 100%;">
<tr><td><b>Location:</b></td><td>${props.place || 'Unknown'}</td></tr>
<tr><td><b>Time:</b></td><td>${timeStr}</td></tr>
<tr><td><b>Depth:</b></td><td>${depth.toFixed(1)} km</td></tr>
<tr><td><b>Magnitude:</b></td><td>${mag.toFixed(1)}</td></tr>
<tr><td><b>Status:</b></td><td>${props.status || 'automatic'}</td></tr>
${props.tsunami ? '<tr><td colspan="2" style="color: red; font-weight: bold;">⚠️ TSUNAMI WARNING</td></tr>' : ''}
</table>
${props.url ? `<a href="${props.url}" target="_blank" style="color: #00aaff; font-size: 11px;">View Details →</a>` : ''}
</div>
`);
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
};
}

@ -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())
};
}
Loading…
Cancel
Save

Powered by TurnKey Linux.