Fix map not visible when both sidebars are hidden

- Add dynamic grid columns that adjust based on which sidebars are visible
- Add panel visibility settings in Settings panel to toggle individual panels
- Add panels config to default config and config loader
- Add invalidateSize() call to map when panels toggle (monolithic version)
- Add Playwright as dev dependency for testing
pull/103/head
Brian Keating 3 days ago
parent a3f753c0dd
commit 7dc0fd99ec

@ -27,6 +27,7 @@
"ws": "^8.14.2"
},
"devDependencies": {
"@playwright/test": "^1.58.1",
"@vitejs/plugin-react": "^4.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -44,4 +45,4 @@
],
"author": "K0CJH",
"license": "MIT"
}
}

@ -2289,7 +2289,7 @@
// ============================================
// LEAFLET MAP COMPONENT
// ============================================
const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots, mySpots, dxPaths, dxFilters, satellites, showDXPaths, showDXLabels, onToggleDXLabels, showPOTA, showSatellites, onToggleSatellites, hoveredSpot }) => {
const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots, mySpots, dxPaths, dxFilters, satellites, showDXPaths, showDXLabels, onToggleDXLabels, showPOTA, showSatellites, onToggleSatellites, hoveredSpot, showLeftPanel, showRightPanel }) => {
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
const tileLayerRef = useRef(null);
@ -2420,6 +2420,16 @@
}
}, [mapStyle]);
useEffect(() => {
if (!mapInstanceRef.current) return;
const timer = setTimeout(() => {
if (mapInstanceRef.current) {
mapInstanceRef.current.invalidateSize();
}
}, 100);
return () => clearTimeout(timer);
}, [showLeftPanel, showRightPanel]);
// Update markers and path
useEffect(() => {
if (!mapInstanceRef.current) return;
@ -5072,7 +5082,34 @@
const toggleDXLabels = useCallback(() => setMapLayers(prev => ({ ...prev, showDXLabels: !prev.showDXLabels })), []);
const togglePOTA = useCallback(() => setMapLayers(prev => ({ ...prev, showPOTA: !prev.showPOTA })), []);
const toggleSatellites = useCallback(() => setMapLayers(prev => ({ ...prev, showSatellites: !prev.showSatellites })), []);
const [showLeftPanel, setShowLeftPanel] = useState(() => {
try {
const stored = localStorage.getItem('openhamclock_showLeftPanel');
return stored === 'true';
} catch (e) { return true; }
});
const [showRightPanel, setShowRightPanel] = useState(() => {
try {
const stored = localStorage.getItem('openhamclock_showRightPanel');
return stored === 'true';
} catch (e) { return true; }
});
useEffect(() => {
try {
localStorage.setItem('openhamclock_showLeftPanel', showLeftPanel.toString());
} catch (e) { console.error('Failed to save left panel visibility:', e); }
}, [showLeftPanel]);
useEffect(() => {
try {
localStorage.setItem('openhamclock_showRightPanel', showRightPanel.toString());
} catch (e) { console.error('Failed to save right panel visibility:', e); }
}, [showRightPanel]);
const toggleLeftPanel = useCallback(() => setShowLeftPanel(prev => !prev), []);
const toggleRightPanel = useCallback(() => setShowRightPanel(prev => !prev), []);
// 12/24 hour format preference with localStorage persistence
const [use12Hour, setUse12Hour] = useState(() => {
try {
@ -5298,7 +5335,7 @@
transform: `scale(${scale})`,
transformOrigin: 'center center',
display: 'grid',
gridTemplateColumns: '280px 1fr 280px',
gridTemplateColumns: `${showLeftPanel ? '280px' : '0'} 1fr ${showRightPanel ? '280px' : '0'}`,
gridTemplateRows: '50px 1fr',
gap: '8px',
padding: '8px',
@ -5359,9 +5396,35 @@
<div><span style={{ color: 'var(--text-muted)' }}>K </span><span style={{ color: spaceWeather.data?.kIndex >= 4 ? 'var(--accent-red)' : 'var(--accent-green)', fontWeight: '600' }}>{spaceWeather.data?.kIndex ?? '--'}</span></div>
<div><span style={{ color: 'var(--text-muted)' }}>SSN </span><span style={{ color: 'var(--accent-cyan)', fontWeight: '600' }}>{spaceWeather.data?.sunspotNumber || '--'}</span></div>
</div>
{/* Settings & Fullscreen Buttons */}
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={toggleLeftPanel}
style={{
background: showLeftPanel ? 'rgba(0, 255, 136, 0.15)' : 'var(--bg-tertiary)',
border: `1px solid ${showLeftPanel ? 'var(--accent-green)' : 'var(--border-color)'}`,
padding: '6px 12px', borderRadius: '4px',
color: showLeftPanel ? 'var(--accent-green)' : 'var(--text-secondary)',
fontSize: '13px', cursor: 'pointer'
}}
title={showLeftPanel ? "Hide Left Panel" : "Show Left Panel"}
>
◀ L
</button>
<button
onClick={toggleRightPanel}
style={{
background: showRightPanel ? 'rgba(0, 255, 136, 0.15)' : 'var(--bg-tertiary)',
border: `1px solid ${showRightPanel ? 'var(--accent-green)' : 'var(--border-color)'}`,
padding: '6px 12px', borderRadius: '4px',
color: showRightPanel ? 'var(--accent-green)' : 'var(--text-secondary)',
fontSize: '13px', cursor: 'pointer'
}}
title={showRightPanel ? "Hide Right Panel" : "Show Right Panel"}
>
R ▶
</button>
<a
href="https://buymeacoffee.com/k0cjh"
target="_blank"
@ -5401,7 +5464,7 @@
</div>
{/* LEFT SIDEBAR */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', overflowY: 'auto', overflowX: 'hidden' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', overflowY: 'auto', overflowX: 'hidden', visibility: showLeftPanel ? 'visible' : 'hidden' }}>
{/* DE Location */}
<div className="panel" style={{ padding: '12px', flex: '0 0 auto' }}>
<div style={{ fontSize: '13px', color: 'var(--accent-cyan)', fontWeight: '700', marginBottom: '8px' }}>📍 DE - YOUR LOCATION</div>
@ -5441,12 +5504,12 @@
{/* CENTER - MAP */}
<div style={{ position: 'relative', borderRadius: '6px', overflow: 'hidden' }}>
<WorldMap
deLocation={config.location}
dxLocation={dxLocation}
onDXChange={handleDXChange}
potaSpots={potaSpots.data}
mySpots={mySpots.data}
<WorldMap
deLocation={config.location}
dxLocation={dxLocation}
onDXChange={handleDXChange}
potaSpots={potaSpots.data}
mySpots={mySpots.data}
dxPaths={dxPaths.data}
dxFilters={dxFilters}
satellites={satellites.positions}
@ -5457,6 +5520,8 @@
showSatellites={mapLayers.showSatellites}
onToggleSatellites={toggleSatellites}
hoveredSpot={hoveredSpot}
showLeftPanel={showLeftPanel}
showRightPanel={showRightPanel}
/>
<div style={{ position: 'absolute', bottom: '8px', left: '50%', transform: 'translateX(-50%)', fontSize: '13px', color: 'var(--text-muted)', background: 'rgba(0,0,0,0.7)', padding: '2px 8px', borderRadius: '4px' }}>
Click map to set DX • 73 de {config.callsign}
@ -5464,7 +5529,7 @@
</div>
{/* RIGHT SIDEBAR */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', overflow: 'hidden' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', overflow: 'hidden', visibility: showRightPanel ? 'visible' : 'hidden' }}>
{/* DX Cluster - Compact with filters */}
<div className="panel" style={{ padding: '10px', flex: '1 1 auto', overflow: 'hidden', minHeight: 0 }}>
<div style={{ fontSize: '12px', color: 'var(--accent-green)', fontWeight: '700', marginBottom: '6px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>

@ -278,6 +278,33 @@ const App = () => {
const localTime = currentTime.toLocaleTimeString('en-US', localTimeOpts);
const localDate = currentTime.toLocaleDateString('en-US', localDateOpts);
// Calculate sidebar visibility for responsive grid
const leftSidebarVisible = config.panels?.deLocation?.visible !== false ||
config.panels?.dxLocation?.visible !== false ||
config.panels?.solar?.visible !== false ||
config.panels?.propagation?.visible !== false;
const rightSidebarVisible = config.panels?.dxCluster?.visible !== false ||
config.panels?.pskReporter?.visible !== false ||
config.panels?.dxpeditions?.visible !== false ||
config.panels?.pota?.visible !== false ||
config.panels?.contests?.visible !== false;
const leftSidebarWidth = leftSidebarVisible ? '270px' : '0px';
const rightSidebarWidth = rightSidebarVisible ? '300px' : '0px';
// Dynamic grid columns - adjust based on which sidebars are visible
const getGridTemplateColumns = () => {
if (!leftSidebarVisible && !rightSidebarVisible) {
return '1fr'; // Only map visible - single column
}
if (!leftSidebarVisible) {
return `1fr ${rightSidebarWidth}`; // Only right sidebar
}
if (!rightSidebarVisible) {
return `${leftSidebarWidth} 1fr`; // Only left sidebar
}
return `${leftSidebarWidth} 1fr ${rightSidebarWidth}`; // Both sidebars
};
// Scale for small screens
const [scale, setScale] = useState(1);
useEffect(() => {
@ -580,10 +607,10 @@ const App = () => {
transform: `scale(${scale})`,
transformOrigin: 'center center',
display: 'grid',
gridTemplateColumns: '270px 1fr 300px',
gridTemplateColumns: getGridTemplateColumns(),
gridTemplateRows: '55px 1fr',
gap: '8px',
padding: '8px',
gap: leftSidebarVisible || rightSidebarVisible ? '8px' : '0',
padding: leftSidebarVisible || rightSidebarVisible ? '8px' : '0',
overflow: 'hidden',
boxSizing: 'border-box'
}}>
@ -604,217 +631,227 @@ const App = () => {
/>
{/* LEFT SIDEBAR */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', overflowY: 'auto', overflowX: 'hidden' }}>
{/* DE Location + Weather */}
<div className="panel" style={{ padding: '14px', flex: '0 0 auto' }}>
<div style={{ fontSize: '14px', color: 'var(--accent-cyan)', fontWeight: '700', marginBottom: '10px' }}>📍 DE - YOUR LOCATION</div>
<div style={{ fontFamily: 'JetBrains Mono', fontSize: '14px' }}>
<div style={{ color: 'var(--accent-amber)', fontSize: '22px', fontWeight: '700', letterSpacing: '1px' }}>{deGrid}</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', marginTop: '4px' }}>{config.location.lat.toFixed(4)}°, {config.location.lon.toFixed(4)}°</div>
<div style={{ marginTop: '8px', fontSize: '13px' }}>
<span style={{ color: 'var(--text-secondary)' }}> </span>
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{deSunTimes.sunrise}</span>
<span style={{ color: 'var(--text-secondary)' }}> </span>
<span style={{ color: 'var(--accent-purple)', fontWeight: '600' }}>{deSunTimes.sunset}</span>
</div>
</div>
{/* Local Weather — compact by default, click to expand */}
{localWeather.data && (() => {
const w = localWeather.data;
const deg = `°${w.tempUnit || tempUnit}`;
const wind = w.windUnit || 'mph';
const vis = w.visUnit || 'mi';
return (
<div style={{ marginTop: '12px', borderTop: '1px solid var(--border-color)', paddingTop: '10px' }}>
{/* Compact summary row — always visible */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
onClick={() => { const next = !weatherExpanded; setWeatherExpanded(next); try { localStorage.setItem('openhamclock_weatherExpanded', next.toString()); } catch {} }}
style={{
display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer',
userSelect: 'none', flex: 1, minWidth: 0,
}}
>
<span style={{ fontSize: '20px', lineHeight: 1 }}>{w.icon}</span>
<span style={{ fontSize: '18px', fontWeight: '700', color: 'var(--text-primary)', fontFamily: 'Orbitron, monospace' }}>
{w.temp}{deg}
</span>
<span style={{ fontSize: '11px', color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{w.description}</span>
<span style={{ fontSize: '11px', color: 'var(--text-muted)', fontFamily: 'JetBrains Mono, monospace' }}>
💨{w.windSpeed}
</span>
<span style={{
fontSize: '10px', color: 'var(--text-muted)',
transform: weatherExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s',
}}></span>
</div>
{/* F/C toggle */}
<button
onClick={(e) => {
e.stopPropagation();
const next = tempUnit === 'F' ? 'C' : 'F';
setTempUnit(next);
try { localStorage.setItem('openhamclock_tempUnit', next); } catch {}
}}
style={{
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
color: 'var(--text-secondary)',
fontSize: '10px',
padding: '1px 5px',
borderRadius: '3px',
cursor: 'pointer',
fontFamily: 'JetBrains Mono, monospace',
fontWeight: '600',
flexShrink: 0,
}}
title={`Switch to °${tempUnit === 'F' ? 'C' : 'F'}`}
>
°{tempUnit === 'F' ? 'C' : 'F'}
</button>
{leftSidebarVisible && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', overflowY: 'auto', overflowX: 'hidden' }}>
{/* DE Location + Weather */}
{config.panels?.deLocation?.visible !== false && (
<div className="panel" style={{ padding: '14px', flex: '0 0 auto' }}>
<div style={{ fontSize: '14px', color: 'var(--accent-cyan)', fontWeight: '700', marginBottom: '10px' }}>📍 DE - YOUR LOCATION</div>
<div style={{ fontFamily: 'JetBrains Mono', fontSize: '14px' }}>
<div style={{ color: 'var(--accent-amber)', fontSize: '22px', fontWeight: '700', letterSpacing: '1px' }}>{deGrid}</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', marginTop: '4px' }}>{config.location.lat.toFixed(4)}°, {config.location.lon.toFixed(4)}°</div>
<div style={{ marginTop: '8px', fontSize: '13px' }}>
<span style={{ color: 'var(--text-secondary)' }}> </span>
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{deSunTimes.sunrise}</span>
<span style={{ color: 'var(--text-secondary)' }}> </span>
<span style={{ color: 'var(--accent-purple)', fontWeight: '600' }}>{deSunTimes.sunset}</span>
</div>
{/* Expanded details */}
{weatherExpanded && (
<div style={{ marginTop: '10px' }}>
{/* Feels like + hi/lo */}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '11px', marginBottom: '8px', fontFamily: 'JetBrains Mono, monospace' }}>
{w.feelsLike !== w.temp && (
<span style={{ color: 'var(--text-muted)' }}>Feels like {w.feelsLike}{deg}</span>
)}
{w.todayHigh != null && (
<span style={{ color: 'var(--text-muted)', marginLeft: 'auto' }}>
<span style={{ color: 'var(--accent-amber)' }}>{w.todayHigh}°</span>
{' '}
<span style={{ color: 'var(--accent-blue)' }}>{w.todayLow}°</span>
</span>
)}
</div>
{/* Local Weather — compact by default, click to expand */}
{localWeather.data && (() => {
const w = localWeather.data;
const deg = `°${w.tempUnit || tempUnit}`;
const wind = w.windUnit || 'mph';
const vis = w.visUnit || 'mi';
return (
<div style={{ marginTop: '12px', borderTop: '1px solid var(--border-color)', paddingTop: '10px' }}>
{/* Compact summary row — always visible */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
onClick={() => { const next = !weatherExpanded; setWeatherExpanded(next); try { localStorage.setItem('openhamclock_weatherExpanded', next.toString()); } catch {} }}
style={{
display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer',
userSelect: 'none', flex: 1, minWidth: 0,
}}
>
<span style={{ fontSize: '20px', lineHeight: 1 }}>{w.icon}</span>
<span style={{ fontSize: '18px', fontWeight: '700', color: 'var(--text-primary)', fontFamily: 'Orbitron, monospace' }}>
{w.temp}{deg}
</span>
<span style={{ fontSize: '11px', color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{w.description}</span>
<span style={{ fontSize: '11px', color: 'var(--text-muted)', fontFamily: 'JetBrains Mono, monospace' }}>
💨{w.windSpeed}
</span>
<span style={{
fontSize: '10px', color: 'var(--text-muted)',
transform: weatherExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s',
}}></span>
</div>
{/* Detail grid */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '6px 12px',
fontSize: '11px',
fontFamily: 'JetBrains Mono, monospace',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}>💨 Wind</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.windDir} {w.windSpeed} {wind}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}>💧 Humidity</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.humidity}%</span>
{/* F/C toggle */}
<button
onClick={(e) => {
e.stopPropagation();
const next = tempUnit === 'F' ? 'C' : 'F';
setTempUnit(next);
try { localStorage.setItem('openhamclock_tempUnit', next); } catch {}
}}
style={{
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
color: 'var(--text-secondary)',
fontSize: '10px',
padding: '1px 5px',
borderRadius: '3px',
cursor: 'pointer',
fontFamily: 'JetBrains Mono, monospace',
fontWeight: '600',
flexShrink: 0,
}}
title={`Switch to °${tempUnit === 'F' ? 'C' : 'F'}`}
>
°{tempUnit === 'F' ? 'C' : 'F'}
</button>
</div>
{/* Expanded details */}
{weatherExpanded && (
<div style={{ marginTop: '10px' }}>
{/* Feels like + hi/lo */}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '11px', marginBottom: '8px', fontFamily: 'JetBrains Mono, monospace' }}>
{w.feelsLike !== w.temp && (
<span style={{ color: 'var(--text-muted)' }}>Feels like {w.feelsLike}{deg}</span>
)}
{w.todayHigh != null && (
<span style={{ color: 'var(--text-muted)', marginLeft: 'auto' }}>
<span style={{ color: 'var(--accent-amber)' }}>{w.todayHigh}°</span>
{' '}
<span style={{ color: 'var(--accent-blue)' }}>{w.todayLow}°</span>
</span>
)}
</div>
{w.windGusts > w.windSpeed + 5 && (
{/* Detail grid */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '6px 12px',
fontSize: '11px',
fontFamily: 'JetBrains Mono, monospace',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}>🌬 Gusts</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.windGusts} {wind}</span>
<span style={{ color: 'var(--text-muted)' }}>💨 Wind</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.windDir} {w.windSpeed} {wind}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}>🌡 Dew Pt</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.dewPoint}{deg}</span>
</div>
{w.pressure && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}>🔵 Pressure</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.pressure} hPa</span>
<span style={{ color: 'var(--text-muted)' }}>💧 Humidity</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.humidity}%</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}> Clouds</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.cloudCover}%</span>
</div>
{w.visibility && (
{w.windGusts > w.windSpeed + 5 && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}>🌬 Gusts</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.windGusts} {wind}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}>👁 Vis</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.visibility} {vis}</span>
<span style={{ color: 'var(--text-muted)' }}>🌡 Dew Pt</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.dewPoint}{deg}</span>
</div>
)}
{w.uvIndex > 0 && (
{w.pressure && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}>🔵 Pressure</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.pressure} hPa</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}> UV</span>
<span style={{ color: w.uvIndex >= 8 ? '#ef4444' : w.uvIndex >= 6 ? '#f97316' : w.uvIndex >= 3 ? '#eab308' : 'var(--text-secondary)' }}>
{w.uvIndex.toFixed(1)}
</span>
<span style={{ color: 'var(--text-muted)' }}> Clouds</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.cloudCover}%</span>
</div>
)}
</div>
{/* 3-Day Forecast */}
{w.daily?.length > 0 && (
<div style={{
marginTop: '10px',
paddingTop: '8px',
borderTop: '1px solid var(--border-color)',
}}>
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginBottom: '6px', fontWeight: '600' }}>FORECAST</div>
<div style={{ display: 'flex', gap: '4px' }}>
{w.daily.map((day, i) => (
<div key={i} style={{
flex: 1,
textAlign: 'center',
padding: '6px 2px',
background: 'var(--bg-tertiary)',
borderRadius: '4px',
fontSize: '10px',
}}>
<div style={{ color: 'var(--text-muted)', fontWeight: '600', marginBottom: '2px' }}>{i === 0 ? 'Today' : day.date}</div>
<div style={{ fontSize: '16px', lineHeight: 1.2 }}>{day.icon}</div>
<div style={{ fontFamily: 'JetBrains Mono, monospace', marginTop: '2px' }}>
<span style={{ color: 'var(--accent-amber)' }}>{day.high}°</span>
<span style={{ color: 'var(--text-muted)' }}>/</span>
<span style={{ color: 'var(--accent-blue)' }}>{day.low}°</span>
</div>
{day.precipProb > 0 && (
<div style={{ color: 'var(--accent-blue)', fontSize: '9px', marginTop: '1px' }}>
💧{day.precipProb}%
{w.visibility && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}>👁 Vis</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.visibility} {vis}</span>
</div>
)}
{w.uvIndex > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}> UV</span>
<span style={{ color: w.uvIndex >= 8 ? '#ef4444' : w.uvIndex >= 6 ? '#f97316' : w.uvIndex >= 3 ? '#eab308' : 'var(--text-secondary)' }}>
{w.uvIndex.toFixed(1)}
</span>
</div>
)}
</div>
{/* 3-Day Forecast */}
{w.daily?.length > 0 && (
<div style={{
marginTop: '10px',
paddingTop: '8px',
borderTop: '1px solid var(--border-color)',
}}>
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginBottom: '6px', fontWeight: '600' }}>FORECAST</div>
<div style={{ display: 'flex', gap: '4px' }}>
{w.daily.map((day, i) => (
<div key={i} style={{
flex: 1,
textAlign: 'center',
padding: '6px 2px',
background: 'var(--bg-tertiary)',
borderRadius: '4px',
fontSize: '10px',
}}>
<div style={{ color: 'var(--text-muted)', fontWeight: '600', marginBottom: '2px' }}>{i === 0 ? 'Today' : day.date}</div>
<div style={{ fontSize: '16px', lineHeight: 1.2 }}>{day.icon}</div>
<div style={{ fontFamily: 'JetBrains Mono, monospace', marginTop: '2px' }}>
<span style={{ color: 'var(--accent-amber)' }}>{day.high}°</span>
<span style={{ color: 'var(--text-muted)' }}>/</span>
<span style={{ color: 'var(--accent-blue)' }}>{day.low}°</span>
</div>
)}
</div>
))}
{day.precipProb > 0 && (
<div style={{ color: 'var(--accent-blue)', fontSize: '9px', marginTop: '1px' }}>
💧{day.precipProb}%
</div>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
</div>
);
})()}
</div>
)}
</div>
)}
</div>
);
})()}
</div>
)}
{/* DX Location */}
<div className="panel" style={{ padding: '14px', flex: '0 0 auto' }}>
<div style={{ fontSize: '14px', color: 'var(--accent-green)', fontWeight: '700', marginBottom: '10px' }}>🎯 DX - TARGET</div>
<div style={{ fontFamily: 'JetBrains Mono', fontSize: '14px' }}>
<div style={{ color: 'var(--accent-amber)', fontSize: '22px', fontWeight: '700', letterSpacing: '1px' }}>{dxGrid}</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', marginTop: '4px' }}>{dxLocation.lat.toFixed(4)}°, {dxLocation.lon.toFixed(4)}°</div>
<div style={{ marginTop: '8px', fontSize: '13px' }}>
<span style={{ color: 'var(--text-secondary)' }}> </span>
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{dxSunTimes.sunrise}</span>
<span style={{ color: 'var(--text-secondary)' }}> </span>
<span style={{ color: 'var(--accent-purple)', fontWeight: '600' }}>{dxSunTimes.sunset}</span>
{config.panels?.dxLocation?.visible !== false && (
<div className="panel" style={{ padding: '14px', flex: '0 0 auto' }}>
<div style={{ fontSize: '14px', color: 'var(--accent-green)', fontWeight: '700', marginBottom: '10px' }}>🎯 DX - TARGET</div>
<div style={{ fontFamily: 'JetBrains Mono', fontSize: '14px' }}>
<div style={{ color: 'var(--accent-amber)', fontSize: '22px', fontWeight: '700', letterSpacing: '1px' }}>{dxGrid}</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', marginTop: '4px' }}>{dxLocation.lat.toFixed(4)}°, {dxLocation.lon.toFixed(4)}°</div>
<div style={{ marginTop: '8px', fontSize: '13px' }}>
<span style={{ color: 'var(--text-secondary)' }}> </span>
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{dxSunTimes.sunrise}</span>
<span style={{ color: 'var(--text-secondary)' }}> </span>
<span style={{ color: 'var(--accent-purple)', fontWeight: '600' }}>{dxSunTimes.sunset}</span>
</div>
</div>
</div>
</div>
)}
{/* Solar Panel */}
<SolarPanel solarIndices={solarIndices} />
{config.panels?.solar?.visible !== false && (
<SolarPanel solarIndices={solarIndices} />
)}
{/* VOACAP/Propagation Panel */}
<PropagationPanel
propagation={propagation.data}
loading={propagation.loading}
bandConditions={bandConditions}
/>
{config.panels?.propagation?.visible !== false && (
<PropagationPanel
propagation={propagation.data}
loading={propagation.loading}
bandConditions={bandConditions}
/>
)}
</div>
)}
{/* CENTER - MAP */}
<div style={{ position: 'relative', borderRadius: '6px', overflow: 'hidden' }}>
<div style={{ position: 'relative', borderRadius: '6px', overflow: 'hidden', width: '100%', height: '100%', minWidth: 0 }}>
<WorldMap
deLocation={config.location}
dxLocation={dxLocation}
@ -852,71 +889,83 @@ const App = () => {
</div>
{/* RIGHT SIDEBAR */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', overflow: 'hidden' }}>
{/* DX Cluster - primary panel, takes most space */}
<div style={{ flex: '2 1 auto', minHeight: '180px', overflow: 'hidden' }}>
<DXClusterPanel
data={dxCluster.data}
loading={dxCluster.loading}
totalSpots={dxCluster.totalSpots}
filters={dxFilters}
onFilterChange={setDxFilters}
onOpenFilters={() => setShowDXFilters(true)}
onHoverSpot={setHoveredSpot}
hoveredSpot={hoveredSpot}
showOnMap={mapLayers.showDXPaths}
onToggleMap={toggleDXPaths}
/>
</div>
{/* PSKReporter + WSJT-X - digital mode spots */}
<div style={{ flex: '1 1 auto', minHeight: '140px', overflow: 'hidden' }}>
<PSKReporterPanel
callsign={config.callsign}
showOnMap={mapLayers.showPSKReporter}
onToggleMap={togglePSKReporter}
filters={pskFilters}
onOpenFilters={() => setShowPSKFilters(true)}
onShowOnMap={(report) => {
if (report.lat && report.lon) {
setDxLocation({ lat: report.lat, lon: report.lon, call: report.receiver || report.sender });
}
}}
wsjtxDecodes={wsjtx.decodes}
wsjtxClients={wsjtx.clients}
wsjtxQsos={wsjtx.qsos}
wsjtxStats={wsjtx.stats}
wsjtxLoading={wsjtx.loading}
wsjtxEnabled={wsjtx.enabled}
wsjtxPort={wsjtx.port}
wsjtxRelayEnabled={wsjtx.relayEnabled}
wsjtxRelayConnected={wsjtx.relayConnected}
wsjtxSessionId={wsjtx.sessionId}
showWSJTXOnMap={mapLayers.showWSJTX}
onToggleWSJTXMap={toggleWSJTX}
/>
</div>
{/* DXpeditions */}
<div style={{ flex: '0 0 auto', minHeight: '70px', maxHeight: '100px', overflow: 'hidden' }}>
<DXpeditionPanel data={dxpeditions.data} loading={dxpeditions.loading} />
</div>
{/* POTA */}
<div style={{ flex: '0 0 auto', minHeight: '60px', maxHeight: '90px', overflow: 'hidden' }}>
<POTAPanel
data={potaSpots.data}
loading={potaSpots.loading}
showOnMap={mapLayers.showPOTA}
onToggleMap={togglePOTA}
/>
</div>
{/* Contests - at bottom, compact */}
<div style={{ flex: '0 0 auto', minHeight: '80px', maxHeight: '120px', overflow: 'hidden' }}>
<ContestPanel data={contests.data} loading={contests.loading} />
{rightSidebarVisible && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', overflow: 'hidden' }}>
{/* DX Cluster - primary panel, takes most space */}
{config.panels?.dxCluster?.visible !== false && (
<div style={{ flex: `${config.panels.dxCluster.size || 2} 1 auto`, minHeight: '180px', overflow: 'hidden' }}>
<DXClusterPanel
data={dxCluster.data}
loading={dxCluster.loading}
totalSpots={dxCluster.totalSpots}
filters={dxFilters}
onFilterChange={setDxFilters}
onOpenFilters={() => setShowDXFilters(true)}
onHoverSpot={setHoveredSpot}
hoveredSpot={hoveredSpot}
showOnMap={mapLayers.showDXPaths}
onToggleMap={toggleDXPaths}
/>
</div>
)}
{/* PSKReporter + WSJT-X - digital mode spots */}
{config.panels?.pskReporter?.visible !== false && (
<div style={{ flex: `${config.panels.pskReporter.size || 1} 1 auto`, minHeight: '140px', overflow: 'hidden' }}>
<PSKReporterPanel
callsign={config.callsign}
showOnMap={mapLayers.showPSKReporter}
onToggleMap={togglePSKReporter}
filters={pskFilters}
onOpenFilters={() => setShowPSKFilters(true)}
onShowOnMap={(report) => {
if (report.lat && report.lon) {
setDxLocation({ lat: report.lat, lon: report.lon, call: report.receiver || report.sender });
}
}}
wsjtxDecodes={wsjtx.decodes}
wsjtxClients={wsjtx.clients}
wsjtxQsos={wsjtx.qsos}
wsjtxStats={wsjtx.stats}
wsjtxLoading={wsjtx.loading}
wsjtxEnabled={wsjtx.enabled}
wsjtxPort={wsjtx.port}
wsjtxRelayEnabled={wsjtx.relayEnabled}
wsjtxRelayConnected={wsjtx.relayConnected}
wsjtxSessionId={wsjtx.sessionId}
showWSJTXOnMap={mapLayers.showWSJTX}
onToggleWSJTXMap={toggleWSJTX}
/>
</div>
)}
{/* DXpeditions */}
{config.panels?.dxpeditions?.visible !== false && (
<div style={{ flex: `${config.panels.dxpeditions?.size || 1} 0 auto`, minHeight: '70px', maxHeight: '100px', overflow: 'hidden' }}>
<DXpeditionPanel data={dxpeditions.data} loading={dxpeditions.loading} />
</div>
)}
{/* POTA */}
{config.panels?.pota?.visible !== false && (
<div style={{ flex: `${config.panels.pota?.size || 1} 0 auto`, minHeight: '60px', maxHeight: '90px', overflow: 'hidden' }}>
<POTAPanel
data={potaSpots.data}
loading={potaSpots.loading}
showOnMap={mapLayers.showPOTA}
onToggleMap={togglePOTA}
/>
</div>
)}
{/* Contests - at bottom, compact */}
{config.panels?.contests?.visible !== false && (
<div style={{ flex: `${config.panels.contests?.size || 1} 0 auto`, minHeight: '80px', maxHeight: '120px', overflow: 'hidden' }}>
<ContestPanel data={contests.data} loading={contests.loading} />
</div>
)}
</div>
</div>
)}
</div>
)}

@ -23,6 +23,19 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
const [layers, setLayers] = useState([]);
const [activeTab, setActiveTab] = useState('station');
// Panel settings
const [panels, setPanels] = useState(config?.panels || {
deLocation: { visible: true, size: 1.0 },
dxLocation: { visible: true, size: 1.0 },
solar: { visible: true, size: 1.0 },
propagation: { visible: true, size: 1.0 },
dxCluster: { visible: true, size: 2.0 },
pskReporter: { visible: true, size: 1.0 },
dxpeditions: { visible: true, size: 1.0 },
pota: { visible: true, size: 1.0 },
contests: { visible: true, size: 1.0 }
});
useEffect(() => {
if (config) {
setCallsign(config.callsign || '');
@ -33,6 +46,17 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
setLayout(config.layout || 'modern');
setTimezone(config.timezone || '');
setDxClusterSource(config.dxClusterSource || 'dxspider-proxy');
setPanels(config.panels || {
deLocation: { visible: true, size: 1.0 },
dxLocation: { visible: true, size: 1.0 },
solar: { visible: true, size: 1.0 },
propagation: { visible: true, size: 1.0 },
dxCluster: { visible: true, size: 2.0 },
pskReporter: { visible: true, size: 1.0 },
dxpeditions: { visible: true, size: 1.0 },
pota: { visible: true, size: 1.0 },
contests: { visible: true, size: 1.0 }
});
if (config.location?.lat && config.location?.lon) {
setGridSquare(calculateGridSquare(config.location.lat, config.location.lon));
}
@ -145,6 +169,26 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
}
};
const handleTogglePanel = (panelId) => {
setPanels(prev => ({
...prev,
[panelId]: {
...prev[panelId],
visible: !prev[panelId].visible
}
}));
};
const handlePanelSizeChange = (panelId, size) => {
setPanels(prev => ({
...prev,
[panelId]: {
...prev[panelId],
size: parseFloat(size)
}
}));
};
const handleSave = () => {
onSave({
...config,
@ -154,7 +198,8 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
theme,
layout,
timezone,
dxClusterSource
dxClusterSource,
panels
});
onClose();
};
@ -237,6 +282,23 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
>
Station
</button>
<button
onClick={() => setActiveTab('panels')}
style={{
flex: 1,
padding: '10px',
background: activeTab === 'panels' ? 'var(--accent-amber)' : 'transparent',
border: 'none',
borderRadius: '6px 6px 0 0',
color: activeTab === 'panels' ? '#000' : 'var(--text-secondary)',
fontSize: '13px',
cursor: 'pointer',
fontWeight: activeTab === 'panels' ? '700' : '400',
fontFamily: 'JetBrains Mono, monospace'
}}
>
Panels
</button>
<button
onClick={() => setActiveTab('layers')}
style={{
@ -657,6 +719,153 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
</>
)}
{/* Panels Tab */}
{activeTab === 'panels' && (
<div>
<div style={{ marginBottom: '16px', fontSize: '12px', color: 'var(--text-muted)' }}>
Toggle panels on/off and adjust their relative sizes
</div>
{/* Left Sidebar Panels */}
<div style={{ marginBottom: '20px' }}>
<div style={{ fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '8px', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '1px' }}>
LEFT SIDEBAR
</div>
{[
{ id: 'deLocation', name: '📍 DE Location', description: 'Your station location and weather' },
{ id: 'dxLocation', name: '🎯 DX Target', description: 'Target location for DXing' },
{ id: 'solar', name: '☀️ Solar Indices', description: 'Sunspot numbers and solar flux' },
{ id: 'propagation', name: '📡 Propagation', description: 'Band conditions and forecasts' }
].map(panel => (
<div key={panel.id} style={{
background: 'var(--bg-tertiary)',
border: `1px solid ${panels[panel.id]?.visible !== false ? 'var(--accent-amber)' : 'var(--border-color)'}`,
borderRadius: '8px',
padding: '12px',
marginBottom: '8px'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: 'pointer',
flex: 1
}}>
<input
type="checkbox"
checked={panels[panel.id]?.visible !== false}
onChange={() => handleTogglePanel(panel.id)}
style={{
width: '18px',
height: '18px',
cursor: 'pointer'
}}
/>
<div>
<div style={{
color: panels[panel.id]?.visible !== false ? 'var(--accent-amber)' : 'var(--text-primary)',
fontSize: '13px',
fontWeight: '600',
fontFamily: 'JetBrains Mono, monospace'
}}>
{panel.name}
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
{panel.description}
</div>
</div>
</label>
</div>
</div>
))}
</div>
{/* Right Sidebar Panels */}
<div>
<div style={{ fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '8px', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '1px' }}>
RIGHT SIDEBAR
</div>
{[
{ id: 'dxCluster', name: '📻 DX Cluster', description: 'Live DX spots from cluster', hasSize: true, defaultSize: 2.0 },
{ id: 'pskReporter', name: '📡 PSK Reporter', description: 'Digital mode spots and WSJT-X', hasSize: true, defaultSize: 1.0 },
{ id: 'dxpeditions', name: '🏝️ DXpeditions', description: 'Upcoming DXpeditions', hasSize: true, defaultSize: 1.0 },
{ id: 'pota', name: '🏕️ POTA', description: 'Parks on the Air activators', hasSize: true, defaultSize: 1.0 },
{ id: 'contests', name: '🏆 Contests', description: 'Upcoming and active contests', hasSize: true, defaultSize: 1.0 }
].map(panel => (
<div key={panel.id} style={{
background: 'var(--bg-tertiary)',
border: `1px solid ${panels[panel.id]?.visible !== false ? 'var(--accent-amber)' : 'var(--border-color)'}`,
borderRadius: '8px',
padding: '12px',
marginBottom: '8px'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: 'pointer',
flex: 1
}}>
<input
type="checkbox"
checked={panels[panel.id]?.visible !== false}
onChange={() => handleTogglePanel(panel.id)}
style={{
width: '18px',
height: '18px',
cursor: 'pointer'
}}
/>
<div>
<div style={{
color: panels[panel.id]?.visible !== false ? 'var(--accent-amber)' : 'var(--text-primary)',
fontSize: '13px',
fontWeight: '600',
fontFamily: 'JetBrains Mono, monospace'
}}>
{panel.name}
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
{panel.description}
</div>
</div>
</label>
</div>
{panels[panel.id]?.visible !== false && panel.hasSize && (
<div style={{ paddingLeft: '28px', marginTop: '8px' }}>
<label style={{
display: 'block',
fontSize: '11px',
color: 'var(--text-muted)',
marginBottom: '6px',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
Size: {panels[panel.id]?.size || panel.defaultSize}x
</label>
<input
type="range"
min="0.5"
max="3.0"
step="0.1"
value={panels[panel.id]?.size || panel.defaultSize}
onChange={(e) => handlePanelSizeChange(panel.id, e.target.value)}
style={{
width: '100%',
cursor: 'pointer'
}}
/>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Map Layers Tab */}
{activeTab === 'layers' && (
<div>

@ -22,6 +22,19 @@ export const DEFAULT_CONFIG = {
showSatellites: true,
showPota: true,
showDxPaths: true,
panels: {
// Left sidebar panels
deLocation: { visible: true, size: 1.0 },
dxLocation: { visible: true, size: 1.0 },
solar: { visible: true, size: 1.0 },
propagation: { visible: true, size: 1.0 },
// Right sidebar panels
dxCluster: { visible: true, size: 2.0 },
pskReporter: { visible: true, size: 1.0 },
dxpeditions: { visible: true, size: 1.0 },
pota: { visible: true, size: 1.0 },
contests: { visible: true, size: 1.0 }
},
refreshIntervals: {
spaceWeather: 300000, // 5 minutes
bandConditions: 300000, // 5 minutes
@ -83,6 +96,7 @@ export const loadConfig = () => {
// Ensure nested objects are properly merged
location: localConfig.location || config.location,
defaultDX: localConfig.defaultDX || config.defaultDX,
panels: { ...config.panels, ...localConfig.panels },
refreshIntervals: { ...config.refreshIntervals, ...localConfig.refreshIntervals }
};
}
@ -92,7 +106,7 @@ export const loadConfig = () => {
// But only if they have real values (not N0CALL)
config = {
...config,
callsign: (serverConfig.callsign && serverConfig.callsign !== 'N0CALL')
callsign: (serverConfig.callsign && serverConfig.callsign !== 'N0CALL')
? serverConfig.callsign : config.callsign,
locator: serverConfig.locator || config.locator,
location: {
@ -110,7 +124,8 @@ export const loadConfig = () => {
use12Hour: serverConfig.timeFormat === '12',
showSatellites: serverConfig.showSatellites ?? config.showSatellites,
showPota: serverConfig.showPota ?? config.showPota,
showDxPaths: serverConfig.showDxPaths ?? config.showDxPaths
showDxPaths: serverConfig.showDxPaths ?? config.showDxPaths,
panels: { ...config.panels, ...serverConfig.panels }
};
}

Loading…
Cancel
Save

Powered by TurnKey Linux.